feat(Storybook): add basic Figma integration

After setting Figma private token, Figma desings related
to a given Storybook page can be browsed directly via the
Storybook app.

Closes: #8188
This commit is contained in:
Michał Cieślak 2022-11-21 11:55:16 +01:00 committed by Michał
parent ff9c678a1e
commit ec38dca735
12 changed files with 577 additions and 34 deletions

View File

@ -31,10 +31,40 @@ ApplicationWindow {
ListElement { ListElement {
title: "ProfileDialogView" title: "ProfileDialogView"
figma: [
ListElement {
link: "https://www.figma.com/file/ibJOTPlNtIxESwS96vJb06/%F0%9F%91%A4-Profile-%7C-Desktop?node-id=733%3A12552"
},
ListElement {
link: "https://www.figma.com/file/ibJOTPlNtIxESwS96vJb06/%F0%9F%91%A4-Profile-%7C-Desktop?node-id=682%3A15078"
},
ListElement {
link: "https://www.figma.com/file/ibJOTPlNtIxESwS96vJb06/%F0%9F%91%A4-Profile-%7C-Desktop?node-id=682%3A17655"
},
ListElement {
link: "https://www.figma.com/file/ibJOTPlNtIxESwS96vJb06/%F0%9F%91%A4-Profile-%7C-Desktop?node-id=682%3A17087"
},
ListElement {
link: "https://www.figma.com/file/ibJOTPlNtIxESwS96vJb06/%F0%9F%91%A4-Profile-%7C-Desktop?node-id=4%3A23525"
},
ListElement {
link: "https://www.figma.com/file/ibJOTPlNtIxESwS96vJb06/%F0%9F%91%A4-Profile-%7C-Desktop?node-id=4%3A23932"
}
]
section: "Views" section: "Views"
} }
ListElement { ListElement {
title: "CommunitiesPortalLayout" title: "CommunitiesPortalLayout"
figma: [
ListElement {
link: "https://www.figma.com/file/17fc13UBFvInrLgNUKJJg5/Kuba%E2%8E%9CDesktop?node-id=8159%3A415655"
},
ListElement {
link: "https://www.figma.com/file/17fc13UBFvInrLgNUKJJg5/Kuba%E2%8E%9CDesktop?node-id=8159%3A415935"
}
]
section: "Views" section: "Views"
} }
ListElement { ListElement {
@ -55,6 +85,15 @@ ApplicationWindow {
} }
ListElement { ListElement {
title: "StatusCommunityCard" title: "StatusCommunityCard"
figma: [
ListElement {
link: "https://www.figma.com/file/17fc13UBFvInrLgNUKJJg5/Kuba%E2%8E%9CDesktop?node-id=8159%3A416159"
},
ListElement {
link: "https://www.figma.com/file/17fc13UBFvInrLgNUKJJg5/Kuba%E2%8E%9CDesktop?node-id=8159%3A416160"
}
]
section: "Panels" section: "Panels"
} }
ListElement { ListElement {
@ -87,48 +126,54 @@ ApplicationWindow {
SplitView { SplitView {
anchors.fill: parent anchors.fill: parent
ColumnLayout { Pane {
SplitView.preferredWidth: 240 SplitView.preferredWidth: 270
CheckBox { ColumnLayout {
id: loadAsyncCheckBox width: parent.width
height: parent.height
Layout.fillWidth: true Button {
Layout.fillWidth: true
text: "Load asynchronously" text: "Settings"
}
CheckBox { onClicked: settingsPopup.open()
id: darkModeCheckBox
Layout.fillWidth: true
text: "Dark mode"
StatusLightTheme { id: lightTheme }
StatusDarkTheme { id: darkTheme }
Binding {
target: Theme
property: "palette"
value: darkModeCheckBox.checked ? darkTheme : lightTheme
} }
}
HotReloaderControls { CheckBox {
id: hotReloaderControls id: darkModeCheckBox
Layout.fillWidth: true Layout.fillWidth: true
onForceReloadClicked: reloader.forceReload() text: "Dark mode"
}
Pane { StatusLightTheme { id: lightTheme }
Layout.fillWidth: true StatusDarkTheme { id: darkTheme }
Layout.fillHeight: true
Binding {
target: Theme
property: "palette"
value: darkModeCheckBox.checked ? darkTheme : lightTheme
}
}
HotReloaderControls {
id: hotReloaderControls
Layout.fillWidth: true
onForceReloadClicked: reloader.forceReload()
}
MenuSeparator {
Layout.fillWidth: true
}
FilteredPagesList { FilteredPagesList {
anchors.fill: parent Layout.fillWidth: true
Layout.fillHeight: true
currentPage: root.currentPage currentPage: root.currentPage
model: pagesModel model: pagesModel
@ -137,7 +182,7 @@ ApplicationWindow {
} }
} }
Item { Page {
SplitView.fillWidth: true SplitView.fillWidth: true
Loader { Loader {
@ -147,7 +192,7 @@ ApplicationWindow {
clip: true clip: true
source: `pages/${root.currentPage}Page.qml` source: `pages/${root.currentPage}Page.qml`
asynchronous: loadAsyncCheckBox.checked asynchronous: settingsLayout.loadAsynchronously
visible: status === Loader.Ready visible: status === Loader.Ready
// force reload when `asynchronous` changes // force reload when `asynchronous` changes
@ -167,13 +212,107 @@ ApplicationWindow {
visible: viewLoader.status === Loader.Error visible: viewLoader.status === Loader.Error
text: "Loading page failed" text: "Loading page failed"
} }
footer: PageToolBar {
id: pageToolBar
title: `pages/${root.currentPage}Page.qml`
figmaPagesCount: currentPageModelItem.object
? currentPageModelItem.object.figmaCount : 0
Instantiator {
id: currentPageModelItem
model: SingleItemProxyModel {
sourceModel: pagesModel
roleName: "title"
value: root.currentPage
}
delegate: QtObject {
readonly property string title: model.title
readonly property var figma: model.figma
readonly property int figmaCount: figma ? figma.count : 0
}
}
onFigmaPreviewClicked: {
if (!settingsLayout.figmaToken) {
noFigmaTokenDialog.open()
return
}
const window = figmaWindow.createObject(root, {
figmaModel: currentPageModelItem.object.figma,
title: currentPageModelItem.object.title + " - Figma"
})
}
}
}
}
Dialog {
id: settingsPopup
anchors.centerIn: Overlay.overlay
width: 420
modal: true
header: Pane {
background: null
Label {
text: "Settings"
}
}
SettingsLayout {
id: settingsLayout
width: parent.width
}
}
Dialog {
id: noFigmaTokenDialog
anchors.centerIn: Overlay.overlay
title: "Figma token not set"
standardButtons: Dialog.Ok
Label {
text: "Please set Figma personal token in \"Settings\""
}
}
FigmaLinksCache {
id: figmaImageLinksCache
figmaToken: settingsLayout.figmaToken
}
Component {
id: figmaWindow
FigmaPreviewWindow {
property alias figmaModel: figmaImagesProxyModel.sourceModel
model: FigmaImagesProxyModel {
id: figmaImagesProxyModel
figmaLinksCache: figmaImageLinksCache
}
onClosing: Qt.callLater(destroy)
} }
} }
Settings { Settings {
property alias currentPage: root.currentPage property alias currentPage: root.currentPage
property alias loadAsynchronously: loadAsyncCheckBox.checked property alias loadAsynchronously: settingsLayout.loadAsynchronously
property alias darkMode: darkModeCheckBox.checked property alias darkMode: darkModeCheckBox.checked
property alias hotReloading: hotReloaderControls.enabled property alias hotReloading: hotReloaderControls.enabled
property alias figmaToken: settingsLayout.figmaToken
} }
} }

View File

@ -0,0 +1,30 @@
import QtQuick 2.14
ListModel {
/* required */ property FigmaLinksCache figmaLinksCache
property alias sourceModel: d.model
readonly property Instantiator _d: Instantiator {
id: d
model: 0
delegate: QtObject {
id: delegate
Component.onCompleted: {
append({
rawLink: model.link,
imageLink: ""
})
figmaLinksCache.getImageUrl(model.link, link => {
if (delegate)
setProperty(model.index, "imageLink", link)
})
}
}
onObjectRemoved: console.warn("FigmaImagesProxyModel: removing items from the source model is not supported!")
}
}

View File

@ -0,0 +1,37 @@
import QtQml 2.14
QtObject {
id: root
property string figmaToken
readonly property QtObject _d: QtObject {
id: d
readonly property var linksMap: new Map()
function createKey(file, nodeId) {
return file + "/" + nodeId
}
}
function getImageUrl(figmaLink, cb) {
const { file, nodeId } = FigmaUtils.decomposeLink(figmaLink)
const key = d.createKey(file, nodeId);
if (d.linksMap.has(key)) {
cb(d.linksMap.get(key))
} else {
FigmaUtils.getLinks(root.figmaToken, file, [nodeId],
(err, result) => {
if (err)
return cb(null)
for (const value of Object.values(result)) {
d.linksMap.set(key, value)
return cb(value)
}
})
}
}
}

View File

@ -0,0 +1,64 @@
import QtQuick 2.14
import QtQuick.Controls 2.14
ApplicationWindow {
id: root
width: 1024
height: 768
visible: true
property var model
SwipeView {
id: topSwipeView
anchors.fill: parent
orientation: Qt.Vertical
interactive: false
ImagesGridView {
clip: true
model: root.model
onClicked: {
imagesSwipeView.setCurrentIndex(index)
topSwipeView.incrementCurrentIndex()
}
}
Item {
SwipeView {
id: imagesSwipeView
anchors.fill: parent
currentIndex: imageNavigationLayout.currentIndex
Repeater {
id: repeater
model: root.model
FlickableImage {
source: model.imageLink
}
}
}
ImagesNavigationLayout {
id: imageNavigationLayout
anchors.bottom: imagesSwipeView.bottom
anchors.horizontalCenter: parent.horizontalCenter
count: imagesSwipeView.count
currentIndex: imagesSwipeView.currentIndex
onUp: topSwipeView.decrementCurrentIndex()
onLeft: imagesSwipeView.decrementCurrentIndex()
onRight: imagesSwipeView.incrementCurrentIndex()
}
}
}
}

View File

@ -0,0 +1,42 @@
pragma Singleton
import QtQml 2.14
QtObject {
function decomposeLink(link) {
const fileRegex = /www\.figma\.com\/file\/([a-zA-Z0-9]+)/
const fileMatch = link.match(fileRegex)
const nodeIdRegex = /node-id=([0-9A-Za-z%]+)/
const nodeIdMatch = link.match(nodeIdRegex)
return {
file: fileMatch[1],
nodeId: nodeIdMatch[1]
}
}
function getLinks(token, file, nodeIds, cb) {
console.assert(nodeIds.length > 0)
const ids = nodeIds.join()
const url = `https://api.figma.com/v1/images/${file}?ids=${ids}`
const http = new XMLHttpRequest()
http.open("GET", url, true)
http.setRequestHeader("X-FIGMA-TOKEN", token)
http.onreadystatechange = () => {
if (http.readyState !== 4)
return
if (http.status === 200)
cb(null, JSON.parse(http.response).images)
else
cb(`Failed to fetch figma image links, status: ${http.status}`)
}
http.send()
}
}

View File

@ -0,0 +1,32 @@
import QtQuick 2.14
import QtQuick.Controls 2.14
Item {
property alias source: image.source
Rectangle {
anchors.fill: parent
color: "lightgray"
}
Flickable {
anchors.fill: parent
contentWidth: image.implicitWidth
contentHeight: image.implicitHeight
clip: true
ScrollIndicator.vertical: ScrollIndicator {}
ScrollIndicator.horizontal: ScrollIndicator {}
Image {
id: image
}
}
BusyIndicator {
anchors.centerIn: parent
running: image.status !== Image.Ready
}
}

View File

@ -0,0 +1,55 @@
import QtQuick 2.14
import QtQuick.Controls 2.14
GridView {
id: root
cellWidth: 400
cellHeight: 300
signal clicked(int index)
delegate: Item {
width: root.cellWidth
height: root.cellHeight
Frame {
anchors.fill: parent
anchors.margins: padding / 2
Image {
id: image
anchors.fill: parent
mipmap: true
source: model.imageLink
fillMode: Image.PreserveAspectFit
}
BusyIndicator {
anchors.centerIn: parent
running: image.status !== Image.Ready
}
MouseArea {
anchors.fill: parent
onClicked: root.clicked(model.index)
}
RoundButton {
anchors.bottom: parent.bottom
anchors.right: parent.right
text: "🔗"
onClicked: Qt.openUrlExternally(model.rawLink)
ToolTip.delay: 1500
ToolTip.visible: hovered
ToolTip.text: model.rawLink
}
}
}
}

View File

@ -0,0 +1,41 @@
import QtQuick 2.14
import QtQuick.Controls 2.14
import QtQuick.Layouts 1.14
ColumnLayout {
id: root
property alias count: indicator.count
property alias currentIndex: indicator.currentIndex
signal up
signal left
signal right
RowLayout {
Layout.alignment: Qt.AlignHCenter
RoundButton {
text: "⬅"
enabled: root.currentIndex !== 0
onClicked: root.left()
}
RoundButton {
text: "⬆"
onClicked: root.up()
}
RoundButton {
text: "➡"
enabled: root.currentIndex !== root.count - 1
onClicked: root.right()
}
}
PageIndicator {
id: indicator
Layout.alignment: Qt.AlignHCenter
interactive: true
}
}

View File

@ -0,0 +1,39 @@
import QtQuick 2.14
import QtQuick.Controls 2.14
import QtQuick.Layouts 1.14
ToolBar {
id: root
property string title
property int figmaPagesCount: 0
signal figmaPreviewClicked
RowLayout {
anchors.fill: parent
TextField {
Layout.fillWidth: true
text: root.title
horizontalAlignment: Qt.AlignHCenter
verticalAlignment: Qt.AlignVCenter
selectByMouse: true
readOnly: true
background: null
}
ToolSeparator {}
ToolButton {
id: openFigmaButton
enabled: root.figmaPagesCount
text: `Figma designs (${root.figmaPagesCount})`
onClicked: root.figmaPreviewClicked()
}
}
}

View File

@ -0,0 +1,44 @@
import QtQuick 2.14
import QtQuick.Controls 2.14
import QtQuick.Layouts 1.14
ColumnLayout {
property alias loadAsynchronously: loadAsyncCheckBox.checked
property alias figmaToken: figmaTokenTextInput.text
CheckBox {
id: loadAsyncCheckBox
Layout.fillWidth: true
text: "Load pages asynchronously"
}
GroupBox {
Layout.fillWidth: true
title: "Figma token"
ColumnLayout {
anchors.fill: parent
Label {
Layout.fillWidth: true
text: `Figma token can be obtained <a href=\"https://www.figma.com/developers/api#access-tokens\">here</a>
by clicking \"Get personal access token\". It's necessary to fetch figma data via Figma API.`
onLinkActivated: Qt.openUrlExternally(link)
wrapMode: Text.Wrap
}
TextField {
id: figmaTokenTextInput
Layout.fillWidth: true
placeholderText: "Figma personal access token"
}
}
}
}

View File

@ -0,0 +1,10 @@
import SortFilterProxyModel 0.2
SortFilterProxyModel {
property alias roleName: valueFilter.roleName
property alias value: valueFilter.value
filters: ValueFilter {
id: valueFilter
}
}

View File

@ -1,14 +1,24 @@
CompilationErrorsBox 1.0 CompilationErrorsBox.qml CompilationErrorsBox 1.0 CompilationErrorsBox.qml
FigmaImagesProxyModel 1.0 FigmaImagesProxyModel.qml
FigmaLinksCache 1.0 FigmaLinksCache.qml
FigmaPreviewWindow 1.0 FigmaPreviewWindow.qml
FilteredPagesList 1.0 FilteredPagesList.qml FilteredPagesList 1.0 FilteredPagesList.qml
FlickableImage 1.0 FlickableImage.qml
HotComponentFromSource 1.0 HotComponentFromSource.qml HotComponentFromSource 1.0 HotComponentFromSource.qml
HotLoader 1.0 HotLoader.qml HotLoader 1.0 HotLoader.qml
HotReloader 1.0 HotReloader.qml HotReloader 1.0 HotReloader.qml
HotReloaderControls 1.0 HotReloaderControls.qml HotReloaderControls 1.0 HotReloaderControls.qml
ImageSelectPopup 1.0 ImageSelectPopup.qml ImageSelectPopup 1.0 ImageSelectPopup.qml
ImagesGridView 1.0 ImagesGridView.qml
ImagesNavigationLayout 1.0 ImagesNavigationLayout.qml
Logs 1.0 Logs.qml Logs 1.0 Logs.qml
LogsAndControlsPanel 1.0 LogsAndControlsPanel.qml LogsAndControlsPanel 1.0 LogsAndControlsPanel.qml
LogsView 1.0 LogsView.qml LogsView 1.0 LogsView.qml
PageToolBar 1.0 PageToolBar.qml
PagesList 1.0 PagesList.qml PagesList 1.0 PagesList.qml
PopupBackground 1.0 PopupBackground.qml PopupBackground 1.0 PopupBackground.qml
SettingsLayout 1.0 SettingsLayout.qml
SingleItemProxyModel 1.0 SingleItemProxyModel.qml
SourceCodeBox 1.0 SourceCodeBox.qml SourceCodeBox 1.0 SourceCodeBox.qml
singleton FigmaUtils 1.0 FigmaUtils.qml
singleton StorybookUtils 1.0 StorybookUtils.qml singleton StorybookUtils 1.0 StorybookUtils.qml