feat(Storybook): in-page, state-preserving hot reloading
In comparison to generic page-wise hot reloading, this technique requires using dedicated component within a storybook page but provides greater flexibility e.g. preserving component state across source reloads. Closes: #7975
This commit is contained in:
parent
8981b8615a
commit
dfc5db27d5
|
@ -1,7 +1,5 @@
|
|||
#include <QGuiApplication>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQmlContext>
|
||||
#include <QQmlEngine>
|
||||
|
||||
#include <cachecleaner.h>
|
||||
#include <directorieswatcher.h>
|
||||
|
@ -18,7 +16,7 @@ int main(int argc, char *argv[])
|
|||
|
||||
QQmlApplicationEngine engine;
|
||||
|
||||
QStringList additionalImportPaths {
|
||||
const QStringList additionalImportPaths {
|
||||
SRC_DIR + QStringLiteral("/../ui/StatusQ/src"),
|
||||
SRC_DIR + QStringLiteral("/../ui/app"),
|
||||
SRC_DIR + QStringLiteral("/../ui/imports"),
|
||||
|
|
|
@ -19,10 +19,11 @@ ApplicationWindow {
|
|||
font.pixelSize: 13
|
||||
|
||||
HotReloader {
|
||||
loader: viewLoader
|
||||
enabled: hotReloadingCheckBox.checked
|
||||
id: reloader
|
||||
|
||||
onReloaded: reloadingAnimation.restart()
|
||||
loader: viewLoader
|
||||
enabled: hotReloaderControls.enabled
|
||||
onReloaded: hotReloaderControls.notifyReload()
|
||||
}
|
||||
|
||||
ListModel {
|
||||
|
@ -31,6 +32,9 @@ ApplicationWindow {
|
|||
ListElement {
|
||||
title: "CommunitiesPortalLayout"
|
||||
}
|
||||
ListElement {
|
||||
title: "StatusCommunityCard"
|
||||
}
|
||||
ListElement {
|
||||
title: "LoginView"
|
||||
}
|
||||
|
@ -70,29 +74,12 @@ ApplicationWindow {
|
|||
}
|
||||
}
|
||||
|
||||
CheckBox {
|
||||
id: hotReloadingCheckBox
|
||||
HotReloaderControls {
|
||||
id: hotReloaderControls
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
text: "Hot reloading"
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
border.color: "red"
|
||||
border.width: 2
|
||||
opacity: 0
|
||||
|
||||
OpacityAnimator on opacity {
|
||||
id: reloadingAnimation
|
||||
|
||||
running: false
|
||||
from: 1
|
||||
to: 0
|
||||
duration: 500
|
||||
easing.type: Easing.InQuad
|
||||
}
|
||||
}
|
||||
onForceReloadClicked: reloader.forceReload()
|
||||
}
|
||||
|
||||
Pane {
|
||||
|
@ -134,6 +121,12 @@ ApplicationWindow {
|
|||
anchors.centerIn: parent
|
||||
visible: viewLoader.status === Loader.Loading
|
||||
}
|
||||
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
visible: viewLoader.status === Loader.Error
|
||||
text: "Loading page failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -141,6 +134,6 @@ ApplicationWindow {
|
|||
property alias currentPage: root.currentPage
|
||||
property alias loadAsynchronously: loadAsyncCheckBox.checked
|
||||
property alias darkMode: darkModeCheckBox.checked
|
||||
property alias hotReloading: hotReloadingCheckBox.checked
|
||||
property alias hotReloading: hotReloaderControls.enabled
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,7 +51,7 @@ ListView {
|
|||
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
text: "community id: " + model.communityId
|
||||
text: "community id: " + model.id
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
import QtQuick 2.14
|
||||
import QtQuick.Controls 2.14
|
||||
import QtQuick.Layouts 1.14
|
||||
|
||||
import Storybook 1.0
|
||||
|
||||
SplitView {
|
||||
id: root
|
||||
|
||||
orientation: Qt.Vertical
|
||||
|
||||
readonly property string source: `
|
||||
import QtQml 2.14
|
||||
import StatusQ.Components 0.1
|
||||
Component {
|
||||
StatusCommunityCard {
|
||||
name: nameTextField.text
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
Logs { id: logs }
|
||||
|
||||
Item {
|
||||
SplitView.fillWidth: true
|
||||
SplitView.fillHeight: true
|
||||
|
||||
HotLoader {
|
||||
id: loader
|
||||
|
||||
anchors.centerIn: parent
|
||||
source: sourceCodeBox.sourceCode
|
||||
|
||||
Connections {
|
||||
target: loader.item
|
||||
|
||||
function onClicked() {
|
||||
logs.logEvent("StatusCommunityCard::clicked",
|
||||
["communityId"], arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Pane {
|
||||
anchors.fill: parent
|
||||
visible: !!loader.errors
|
||||
|
||||
CompilationErrorsBox {
|
||||
anchors.fill: parent
|
||||
errors: loader.errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LogsAndControlsPanel {
|
||||
id: logsAndControlsPanel
|
||||
|
||||
SplitView.minimumHeight: 100
|
||||
SplitView.preferredHeight: 200
|
||||
|
||||
logsView.logText: logs.logText
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
|
||||
TextField {
|
||||
id: nameTextField
|
||||
text: "Card name!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SourceCodeBox {
|
||||
id: sourceCodeBox
|
||||
|
||||
Layout.preferredWidth: root.width / 2
|
||||
Layout.fillHeight: true
|
||||
|
||||
sourceCode: root.source
|
||||
hasErrors: !!loader.errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import QtQuick 2.14
|
||||
import QtQuick.Controls 2.14
|
||||
|
||||
GroupBox {
|
||||
title: "compilation errors"
|
||||
|
||||
property var errors // QQmlError
|
||||
|
||||
ScrollView {
|
||||
id: scrollView
|
||||
|
||||
anchors.fill: parent
|
||||
visible: !!loader.errors
|
||||
clip: true
|
||||
|
||||
TextEdit {
|
||||
id: errorsTextEdit
|
||||
|
||||
width: scrollView.width
|
||||
font.family: "courier"
|
||||
selectByMouse: true
|
||||
readOnly: true
|
||||
|
||||
text: !!errors ? JSON.stringify(errors.qmlErrors, null, 2) : ""
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import QtQuick 2.14
|
||||
|
||||
import Storybook 1.0
|
||||
|
||||
QtObject {
|
||||
id: root
|
||||
|
||||
property string source
|
||||
property bool rethrowErrors: true
|
||||
readonly property alias component: d.component
|
||||
readonly property alias errors: d.errors // QQmlError
|
||||
|
||||
onSourceChanged: d.createComponent()
|
||||
|
||||
readonly property Connections _d: Connections {
|
||||
id: d
|
||||
|
||||
target: SourceWatcher
|
||||
|
||||
property Component component
|
||||
property var errors: null
|
||||
|
||||
function createComponent() {
|
||||
if (component) {
|
||||
component.destroy()
|
||||
component = null
|
||||
}
|
||||
|
||||
try {
|
||||
component = Qt.createQmlObject(root.source,
|
||||
this,
|
||||
"HotComponentFromSource_dynamicSnippet"
|
||||
)
|
||||
d.errors = null
|
||||
} catch (e) {
|
||||
d.errors = e
|
||||
|
||||
if (root.rethrowErrors)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function onChanged() {
|
||||
CacheCleaner.clearComponentCache()
|
||||
createComponent()
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.source)
|
||||
d.createComponent()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import QtQuick 2.14
|
||||
|
||||
Loader {
|
||||
sourceComponent: hotComponent.component
|
||||
|
||||
property alias source: hotComponent.source
|
||||
property alias rethrowErrors: hotComponent.rethrowErrors
|
||||
readonly property alias errors: hotComponent.errors
|
||||
|
||||
HotComponentFromSource {
|
||||
id: hotComponent
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
import QtQml 2.14
|
||||
import QtQuick 2.14
|
||||
|
||||
import Storybook 1.0
|
||||
|
||||
QtObject {
|
||||
id: root
|
||||
|
||||
/*required*/ property Loader loader
|
||||
property bool enabled: false
|
||||
|
||||
signal reloaded
|
||||
|
||||
function forceReload() {
|
||||
// clearing component cache right after removing
|
||||
// source from async loader causes undefined behavior
|
||||
// and app crashes on Qt 5.14.2. For that reason
|
||||
// asynchronous is set to false first and restored
|
||||
// to original value after clearing.
|
||||
d.asyncBlocker.when = true
|
||||
d.sourceBlocker.when = true
|
||||
CacheCleaner.clearComponentCache()
|
||||
d.asyncBlocker.when = false
|
||||
d.sourceBlocker.when = false
|
||||
|
||||
reloaded()
|
||||
}
|
||||
|
||||
readonly property Connections _d: Connections {
|
||||
id: d
|
||||
|
||||
target: SourceWatcher
|
||||
|
||||
readonly property Binding asyncBlocker: Binding {
|
||||
target: root.loader
|
||||
property: "asynchronous"
|
||||
value: false
|
||||
when: false
|
||||
}
|
||||
|
||||
readonly property Binding sourceBlocker: Binding {
|
||||
target: root.loader
|
||||
property: "source"
|
||||
value: ""
|
||||
when: false
|
||||
}
|
||||
|
||||
function onChanged() {
|
||||
if (!root.enabled)
|
||||
return
|
||||
|
||||
forceReload()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import QtQuick 2.14
|
||||
import QtQuick.Controls 2.14
|
||||
import QtQuick.Layouts 1.14
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
implicitHeight: row.implicitHeight
|
||||
|
||||
property alias enabled: hotReloadingCheckBox.checked
|
||||
|
||||
signal forceReloadClicked
|
||||
|
||||
function notifyReload() {
|
||||
reloadingAnimation.restart()
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: row
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
|
||||
CheckBox {
|
||||
id: hotReloadingCheckBox
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
text: "Hot reloading"
|
||||
}
|
||||
|
||||
Button {
|
||||
Layout.rightMargin: 5
|
||||
|
||||
text: "Reload now"
|
||||
|
||||
onClicked: root.forceReloadClicked()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
border.color: "red"
|
||||
border.width: 2
|
||||
color: "transparent"
|
||||
opacity: 0
|
||||
|
||||
OpacityAnimator on opacity {
|
||||
id: reloadingAnimation
|
||||
|
||||
running: false
|
||||
from: 1
|
||||
to: 0
|
||||
duration: 500
|
||||
easing.type: Easing.InQuad
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import QtQuick 2.14
|
||||
import QtQuick.Controls 2.14
|
||||
|
||||
GroupBox {
|
||||
id: root
|
||||
|
||||
title: "source code"
|
||||
|
||||
property string sourceCode
|
||||
property bool hasErrors: false
|
||||
|
||||
ScrollView {
|
||||
id: scrollView
|
||||
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
|
||||
implicitHeight: 0
|
||||
implicitWidth: 0
|
||||
|
||||
contentHeight: sourceTextEdit.implicitHeight
|
||||
contentWidth: scrollView.width
|
||||
|
||||
TextEdit {
|
||||
id: sourceTextEdit
|
||||
|
||||
width: scrollView.width
|
||||
font.family: "courier"
|
||||
text: StorybookUtils.formatQmlCode(root.sourceCode)
|
||||
color: root.hasErrors ? "darkred" : "black"
|
||||
selectByMouse: true
|
||||
wrapMode: Text.Wrap
|
||||
|
||||
onTextChanged: root.sourceCode = text
|
||||
}
|
||||
}
|
||||
}
|
|
@ -21,4 +21,23 @@ QtObject {
|
|||
const onlyUnique = (value, index, self) => self.indexOf(value) === index
|
||||
return values.filter(onlyUnique)
|
||||
}
|
||||
|
||||
function formatQmlCode(code) {
|
||||
code = code.replace(/^\n+/, "")
|
||||
code = code.replace(/\s+$/, "")
|
||||
|
||||
const match = code.match(/^[ \t]*(?=\S)/gm)
|
||||
|
||||
if (!match)
|
||||
return code
|
||||
|
||||
const minIndent = match.reduce((r, a) => Math.min(r, a.length),
|
||||
Number.POSITIVE_INFINITY)
|
||||
|
||||
if (minIndent === 0)
|
||||
return code
|
||||
|
||||
const regex = new RegExp(`^[ \\t]{${minIndent}}`, "gm")
|
||||
return code.replace(regex, "")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
CompilationErrorsBox 1.0 CompilationErrorsBox.qml
|
||||
HotComponentFromSource 1.0 HotComponentFromSource.qml
|
||||
HotLoader 1.0 HotLoader.qml
|
||||
HotReloader 1.0 HotReloader.qml
|
||||
HotReloaderControls 1.0 HotReloaderControls.qml
|
||||
ImageSelectPopup 1.0 ImageSelectPopup.qml
|
||||
Logs 1.0 Logs.qml
|
||||
LogsAndControlsPanel 1.0 LogsAndControlsPanel.qml
|
||||
LogsView 1.0 LogsView.qml
|
||||
PagesList 1.0 PagesList.qml
|
||||
SourceCodeBox 1.0 SourceCodeBox.qml
|
||||
singleton StorybookUtils 1.0 StorybookUtils.qml
|
||||
|
|
Loading…
Reference in New Issue