From 211b4cfab94ab6704586a60f72442e677f7852eb Mon Sep 17 00:00:00 2001 From: vocksel Date: Mon, 9 Dec 2024 07:32:51 -0800 Subject: [PATCH 1/4] Fix bug where NoStorySelected never renders (#303) # Problem When no story is selected we used to have a message displayed # Solution Simply changed where NoStorySelected is rendered. Works now! Resolves #302 # Checklist - [x] Ran `lune run test` locally before merging --- src/Navigation/Screen.luau | 3 +++ src/Storybook/StoryCanvas.luau | 3 --- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Navigation/Screen.luau b/src/Navigation/Screen.luau index 427d5e7c..ac3909dd 100644 --- a/src/Navigation/Screen.luau +++ b/src/Navigation/Screen.luau @@ -4,6 +4,7 @@ local Storyteller = require("@pkg/Storyteller") local AboutView = require("@root/About/AboutView") local NavigationContext = require("@root/Navigation/NavigationContext") +local NoStorySelected = require("@root/Storybook/NoStorySelected") local SettingsView = require("@root/UserSettings/SettingsView") local StoryCanvas = require("@root/Storybook/StoryCanvas") @@ -30,6 +31,8 @@ local function Screen(props: Props) story = props.story, storybook = props.storybook, }) + else + return React.createElement(NoStorySelected) end elseif currentScreen == "Settings" then return React.createElement(SettingsView) diff --git a/src/Storybook/StoryCanvas.luau b/src/Storybook/StoryCanvas.luau index f796bd5d..a1927253 100644 --- a/src/Storybook/StoryCanvas.luau +++ b/src/Storybook/StoryCanvas.luau @@ -2,7 +2,6 @@ local ModuleLoader = require("@pkg/ModuleLoader") local React = require("@pkg/React") local Storyteller = require("@pkg/Storyteller") -local NoStorySelected = require("@root/Storybook/NoStorySelected") local StoryView = require("@root/Storybook/StoryView") local useTheme = require("@root/Common/useTheme") @@ -48,8 +47,6 @@ local function Canvas(props: Props) story = props.story, storybook = props.storybook, }), - - NoStorySelected = not props.story and e(NoStorySelected), }), }) end From 56e3e950f44d7c54df07ee105bd2e36049798bbd Mon Sep 17 00:00:00 2001 From: vocksel Date: Mon, 9 Dec 2024 07:33:06 -0800 Subject: [PATCH 2/4] Return home when navigating to same view (#304) # Problem When navigating to About or Settings it feels like toggling the same button should do something. Right now it does not # Solution When toggling the same screen, return home. This feels a bit better on the UX side Resolves #281 # Checklist - [x] Ran `lune run test` locally before merging --- src/Panels/Topbar.luau | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Panels/Topbar.luau b/src/Panels/Topbar.luau index 1261f726..dbd1d547 100644 --- a/src/Panels/Topbar.luau +++ b/src/Panels/Topbar.luau @@ -17,12 +17,20 @@ local function Topbar(props: Props) local navigation = NavigationContext.use() local navigateToSettings = useCallback(function() - navigation.navigateTo("Settings") - end, { navigation.navigateTo }) + if navigation.currentScreen ~= "Settings" then + navigation.navigateTo("Settings") + else + navigation.navigateTo("Home") + end + end, { navigation.navigateTo, navigation.currentScreen } :: { unknown }) local navigateToAbout = useCallback(function() - navigation.navigateTo("About") - end, { navigation.navigateTo }) + if navigation.currentScreen ~= "About" then + navigation.navigateTo("About") + else + navigation.navigateTo("Home") + end + end, { navigation.navigateTo, navigation.currentScreen } :: { unknown }) return React.createElement("Frame", { BackgroundColor3 = theme.sidebar, From 42e091cb00c7ad4b9cba9fb6f6cc97246e01681f Mon Sep 17 00:00:00 2001 From: vocksel Date: Wed, 11 Dec 2024 20:44:22 -0800 Subject: [PATCH 3/4] Update Storyteller to v0.6.0 (#307) # Problem Storyteller has some updates for nameless storybooks and a QOL change to useStorybooks that we'd like to consume # Solution Bumped Storyteller to v0.6.0 and updated usage of useStorybooks to be compatible # Checklist - [x] Ran `lune run test` locally before merging --- src/Plugin/PluginApp.luau | 2 +- wally.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Plugin/PluginApp.luau b/src/Plugin/PluginApp.luau index 7081df03..85b6cae5 100644 --- a/src/Plugin/PluginApp.luau +++ b/src/Plugin/PluginApp.luau @@ -64,7 +64,7 @@ local function App(props: Props) Sidebar = React.createElement(Sidebar, { selectStory = selectStory, selectStorybook = selectStorybook, - storybooks = storybooks, + storybooks = storybooks.available, }), }), diff --git a/wally.toml b/wally.toml index e95907af..f368d55e 100644 --- a/wally.toml +++ b/wally.toml @@ -8,7 +8,7 @@ exclude = ["*"] [dependencies] ModuleLoader = "flipbook-labs/module-loader@0.6.2" -Storyteller = "flipbook-labs/storyteller@0.5.0" +Storyteller = "flipbook-labs/storyteller@0.6.0" React = "jsdotlua/react@17.0.2" ReactRoblox = "jsdotlua/react-roblox@17.0.2" ReactSpring = "chriscerie/react-spring@2.0.0" From 20bdafe3f59f339f7e97c83252078bb4ac8c99b2 Mon Sep 17 00:00:00 2001 From: vocksel Date: Wed, 11 Dec 2024 20:57:35 -0800 Subject: [PATCH 4/4] New tree view (#295) # Problem Our tree view is one of the oldest parts of the plugin and needs some upgrades before we can solve issues like #275, #282, and #291 # Solution I've created a new tree view from scratch that should support all the issues mentioned above. The tree view itself is fairly generic with the intent of supporting future cases that require manipulating the structure beyond just mimicking the DataModel. And due to it being general it should make a good candidate to be packaged up later. The StorybookTreeView component sits on top of the tree view to build out the tree of storybooks and stories. I've also introduced a new user setting to remember the last opened story. Now when reopening Flipbook the last story is restored Resolves #275 # Checklist - [ ] Resolve issues with search - [ ] Ran `lune run test` locally before merging --- src/Common/ContextProviders.luau | 2 + src/Common/getInstanceFromFullName.luau | 68 ++++++ src/Common/getInstanceFromFullName.spec.luau | 70 +++++++ src/Explorer/Component.story.luau | 55 ----- src/Explorer/Component/Directory.luau | 103 --------- src/Explorer/Component/Story.luau | 88 -------- src/Explorer/Component/init.luau | 101 --------- src/Explorer/filterComponentTreeNode.luau | 24 --- .../filterComponentTreeNode.spec.luau | 76 ------- src/Explorer/getTreeDescendants.luau | 21 -- src/Explorer/getTreeDescendants.spec.luau | 37 ---- src/Explorer/init.luau | 40 ---- src/Explorer/types.luau | 13 -- src/Forms/InputField.luau | 2 +- src/Forms/Searchbar.luau | 2 +- src/Panels/Sidebar.luau | 35 +--- src/Panels/Sidebar.story.luau | 8 +- src/Plugin/PluginApp.luau | 21 +- src/Storybook/StorybookTreeView.luau | 93 +++++++++ src/Storybook/StorybookTreeView.story.luau | 38 ++++ src/Storybook/createStoryNodes.luau | 61 ------ src/Storybook/createStoryNodes.spec.luau | 63 ------ .../createTreeNodesForStorybook.luau | 65 ++++++ src/Storybook/useLastOpenedStory.luau | 42 ++++ src/TreeView/TreeNode.luau | 190 +++++++++++++++++ src/TreeView/TreeView.luau | 35 ++++ src/TreeView/TreeView.story.luau | 196 ++++++++++++++++++ src/TreeView/TreeViewContext.luau | 173 ++++++++++++++++ src/TreeView/createTreeNodesFromPartials.luau | 77 +++++++ .../createTreeNodesFromPartials.spec.luau | 105 ++++++++++ src/TreeView/getAncestry.luau | 15 ++ src/TreeView/init.luau | 20 ++ src/TreeView/reduceTree.luau | 34 +++ src/TreeView/types.luau | 37 ++++ src/TreeView/useTreeNodeIcon.luau | 27 +++ src/UserSettings/defaultSettings.luau | 16 +- 36 files changed, 1318 insertions(+), 735 deletions(-) create mode 100644 src/Common/getInstanceFromFullName.luau create mode 100644 src/Common/getInstanceFromFullName.spec.luau delete mode 100644 src/Explorer/Component.story.luau delete mode 100644 src/Explorer/Component/Directory.luau delete mode 100644 src/Explorer/Component/Story.luau delete mode 100644 src/Explorer/Component/init.luau delete mode 100644 src/Explorer/filterComponentTreeNode.luau delete mode 100644 src/Explorer/filterComponentTreeNode.spec.luau delete mode 100644 src/Explorer/getTreeDescendants.luau delete mode 100644 src/Explorer/getTreeDescendants.spec.luau delete mode 100644 src/Explorer/init.luau delete mode 100644 src/Explorer/types.luau create mode 100644 src/Storybook/StorybookTreeView.luau create mode 100644 src/Storybook/StorybookTreeView.story.luau delete mode 100644 src/Storybook/createStoryNodes.luau delete mode 100644 src/Storybook/createStoryNodes.spec.luau create mode 100644 src/Storybook/createTreeNodesForStorybook.luau create mode 100644 src/Storybook/useLastOpenedStory.luau create mode 100644 src/TreeView/TreeNode.luau create mode 100644 src/TreeView/TreeView.luau create mode 100644 src/TreeView/TreeView.story.luau create mode 100644 src/TreeView/TreeViewContext.luau create mode 100644 src/TreeView/createTreeNodesFromPartials.luau create mode 100644 src/TreeView/createTreeNodesFromPartials.spec.luau create mode 100644 src/TreeView/getAncestry.luau create mode 100644 src/TreeView/init.luau create mode 100644 src/TreeView/reduceTree.luau create mode 100644 src/TreeView/types.luau create mode 100644 src/TreeView/useTreeNodeIcon.luau diff --git a/src/Common/ContextProviders.luau b/src/Common/ContextProviders.luau index 6b13652d..430571ed 100644 --- a/src/Common/ContextProviders.luau +++ b/src/Common/ContextProviders.luau @@ -1,4 +1,5 @@ local React = require("@pkg/React") +local TreeView = require("@root/TreeView") local ContextStack = require("@root/Common/ContextStack") local NavigationContext = require("@root/Navigation/NavigationContext") @@ -20,6 +21,7 @@ local function ContextProviders(props: Props) defaultScreen = "Home", }), React.createElement(SettingsContext.Provider), + React.createElement(TreeView.TreeViewProvider), }, }, props.children) end diff --git a/src/Common/getInstanceFromFullName.luau b/src/Common/getInstanceFromFullName.luau new file mode 100644 index 00000000..e66aa8e6 --- /dev/null +++ b/src/Common/getInstanceFromFullName.luau @@ -0,0 +1,68 @@ +--[[ + Gets an instance based off the result of GetFullName(). + + This is used in conjunction with debug.info() to locate the calling script. + + Returns nil if the instance is outside the DataModel. +]] + +local Sift = require("@pkg/Sift") + +local PATH_SEPERATOR = "." + +local function maybeGetService(serviceName: string): Instance? + local success, current: any = pcall(function() + return game:GetService(serviceName) + end) + + if success and current and current:IsA("Instance") then + return current + else + return nil + end +end + +local function getInstanceFromFullName(fullName: string): Instance? + local parts = fullName:split(PATH_SEPERATOR) + local serviceName = table.remove(parts, 1) + + if serviceName then + -- This function only works for instances in the DataModel. As such, the + -- first part of the path will always be a service, so if we can't find + -- one we exit out and return nil + local current = maybeGetService(serviceName) + + if current then + while #parts > 0 do + -- Keep around a copy of the `parts` array. We are going to concat this + -- into new paths, and incrementally remove from the right to narrow + -- down the file path. + local tempParts = Sift.Array.copy(parts) + + -- The result of GetFullName() uses dots to separate paths, but we also + -- use dots in our file names (e.g. with spec and story files). As such, + -- this block will look ahead to see if multiple parts are actually a + -- single filename. + for _ = 1, #tempParts do + local name = table.concat(tempParts, PATH_SEPERATOR) + local found = current:FindFirstChild(name) + + if found then + current = found + parts = Sift.List.shift(parts, #name:split(PATH_SEPERATOR)) + break + else + -- Reduce from the right until we find the next instance + tempParts = Sift.List.pop(tempParts) + end + end + end + + return current + end + end + + return nil +end + +return getInstanceFromFullName diff --git a/src/Common/getInstanceFromFullName.spec.luau b/src/Common/getInstanceFromFullName.spec.luau new file mode 100644 index 00000000..d8f9d153 --- /dev/null +++ b/src/Common/getInstanceFromFullName.spec.luau @@ -0,0 +1,70 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local JestGlobals = require("@pkg/JestGlobals") +local newFolder = require("@root/Testing/newFolder") + +local getInstanceFromFullName = require("./getInstanceFromFullName") + +local expect = JestGlobals.expect +local test = JestGlobals.test +local afterEach = JestGlobals.afterEach + +local folder: Folder + +afterEach(function() + if folder then + folder:Destroy() + end +end) + +test("gets services", function() + local path = getInstanceFromFullName("ReplicatedStorage") + expect(path).toBe(ReplicatedStorage) +end) + +test("works on nested instances", function() + local module = Instance.new("ModuleScript") + + folder = newFolder({ + foo = newFolder({ + bar = module, + }), + }) + folder.Parent = ReplicatedStorage + + local path = getInstanceFromFullName(module:GetFullName()) + expect(path).toBe(module) +end) + +test("works with spec files", function() + local module = Instance.new("ModuleScript") + + folder = newFolder({ + foo = newFolder({ + ["bar.spec"] = module, + }), + }) + folder.Parent = ReplicatedStorage + + local path = getInstanceFromFullName(module:GetFullName()) + expect(path).toBe(module) +end) + +test("finds spec files BEFORE the module it is associated with", function() + local module = Instance.new("ModuleScript") + + folder = newFolder({ + foo = newFolder({ + bar = Instance.new("ModuleScript"), + ["bar.spec"] = module, + }), + }) + folder.Parent = ReplicatedStorage + + local path = getInstanceFromFullName(module:GetFullName()) + expect(path).toBe(module) +end) + +test("returns nil if the first part of the path is not a service", function() + expect(getInstanceFromFullName("Part")).toBeUndefined() +end) diff --git a/src/Explorer/Component.story.luau b/src/Explorer/Component.story.luau deleted file mode 100644 index f8229d78..00000000 --- a/src/Explorer/Component.story.luau +++ /dev/null @@ -1,55 +0,0 @@ -local React = require("@pkg/React") - -local Component = require("./Component") -local ContextProviders = require("@root/Common/ContextProviders") -local MockPlugin = require("@root/Testing/MockPlugin") - -local childNode1 = { - name = "Button", - icon = "story" :: "story", - children = {}, -} - -local childNode2 = { - name = "Toggle", - icon = "story" :: "story", - children = {}, -} - -local childNode3 = { - name = "Radio", - icon = "story" :: "story", - children = {}, -} - -local directoryNode1 = { - name = "Files", - icon = "folder" :: "folder", - children = { - childNode1, - childNode2, - childNode3, - }, -} - -local storybookNode = { - name = "Storybook", - icon = "storybook" :: "storybook", - children = { - directoryNode1, - }, -} - -return { - summary = "Component as storybook with children", - controls = {}, - story = React.createElement(ContextProviders, { - plugin = MockPlugin.new() :: any, - }, { - Component = React.createElement(Component, { - activeNode = nil, - node = storybookNode, - onClick = function() end, - }), - }), -} diff --git a/src/Explorer/Component/Directory.luau b/src/Explorer/Component/Directory.luau deleted file mode 100644 index 7ea1695a..00000000 --- a/src/Explorer/Component/Directory.luau +++ /dev/null @@ -1,103 +0,0 @@ -local React = require("@pkg/React") -local ReactSpring = require("@pkg/ReactSpring") -local Sprite = require("@root/Common/Sprite") -local assets = require("@root/assets") -local constants = require("@root/constants") -local types = require("@root/Explorer/types") -local useTheme = require("@root/Common/useTheme") - -local e = React.createElement - -type Props = { - expanded: boolean, - hasChildren: boolean, - indent: number, - node: types.ComponentTreeNode, - onClick: (types.ComponentTreeNode) -> (), -} - -local function Directory(props: Props) - local theme = useTheme() - local hover, setHover = React.useState(false) - local styles = (ReactSpring.useSpring :: any)({ - alpha = if hover then 0 else 1, - rotation = if props.expanded then 90 else 0, - config = constants.SPRING_CONFIG, - }) - - return e("TextButton", { - AutoButtonColor = false, - BackgroundColor3 = theme.divider, - BackgroundTransparency = styles.alpha, - LayoutOrder = 0, - Size = UDim2.new(1, 0, 0, 36), - Text = "", - [React.Event.MouseEnter] = function() - setHover(true) - end, - [React.Event.MouseLeave] = function() - setHover(false) - end, - [React.Event.Activated] = function() - props.onClick(props.node) - end, - }, { - UICorner = e("UICorner", { - CornerRadius = theme.corner, - }), - - UIPadding = e("UIPadding", { - PaddingBottom = theme.padding, - PaddingLeft = theme.paddingSmall + UDim.new(0, theme.padding.Offset * props.indent), - PaddingRight = theme.paddingSmall, - PaddingTop = theme.padding, - }), - - Detail = e("Frame", { - BackgroundTransparency = 1, - Size = UDim2.new(1, -16, 1, 0), - }, { - UIListLayout = e("UIListLayout", { - FillDirection = Enum.FillDirection.Horizontal, - Padding = theme.paddingSmall, - SortOrder = Enum.SortOrder.LayoutOrder, - VerticalAlignment = Enum.VerticalAlignment.Center, - }), - - Icon = e(Sprite, { - layoutOrder = 0, - image = if props.node.icon == "folder" then assets.Folder else assets.Storybook, - color = if props.node.icon == "folder" then theme.directory else theme.textFaded, - size = UDim2.fromOffset(16, 16), - }), - - Typography = e("TextLabel", { - AutomaticSize = Enum.AutomaticSize.XY, - BackgroundTransparency = 1, - Font = theme.font, - LayoutOrder = 1, - Size = UDim2.fromOffset(0, 0), - -- TODO: Do string parsing to get rid of `.storybook` - Text = props.node.name, - TextColor3 = theme.textFaded, - TextSize = theme.textSize, - }), - }), - - ChevronWrapper = e("Frame", { - AnchorPoint = Vector2.new(1, 0.5), - BackgroundTransparency = 1, - Position = UDim2.fromScale(1, 0.5), - Rotation = styles.rotation, - Size = UDim2.fromOffset(16, 16), - }, { - Chevron = e(Sprite, { - image = assets.ChevronRight, - color = theme.text, - size = UDim2.fromScale(1, 1), - }), - }), - }) -end - -return Directory diff --git a/src/Explorer/Component/Story.luau b/src/Explorer/Component/Story.luau deleted file mode 100644 index c4675895..00000000 --- a/src/Explorer/Component/Story.luau +++ /dev/null @@ -1,88 +0,0 @@ -local React = require("@pkg/React") -local ReactSpring = require("@pkg/ReactSpring") -local Sprite = require("@root/Common/Sprite") -local assets = require("@root/assets") -local constants = require("@root/constants") -local types = require("@root/Explorer/types") -local useTheme = require("@root/Common/useTheme") - -local e = React.createElement - -type Props = { - active: boolean, - indent: number, - node: types.ComponentTreeNode, - onClick: (types.ComponentTreeNode) -> (), -} - -local function Story(props: Props) - local theme = useTheme() - local hover, setHover = React.useState(false) - local styles = (ReactSpring.useSpring :: any)({ - alpha = if not props.active then if hover then 0 else 1 else 0, - color = if not props.active then theme.divider else theme.selection, - textColor = if not props.active then theme.textFaded else theme.background, - config = constants.SPRING_CONFIG, - }) - - return e("TextButton", { - AutoButtonColor = false, - BackgroundColor3 = styles.color, - BackgroundTransparency = styles.alpha, - LayoutOrder = 0, - Size = UDim2.new(1, 0, 0, 36), - Text = "", - [React.Event.MouseEnter] = function() - setHover(true) - end, - [React.Event.MouseLeave] = function() - setHover(false) - end, - [React.Event.Activated] = function() - props.onClick(props.node) - end, - }, { - UICorner = e("UICorner", { - CornerRadius = theme.corner, - }), - - UIPadding = e("UIPadding", { - PaddingBottom = theme.padding, - PaddingLeft = theme.paddingSmall + UDim.new(0, theme.padding.Offset * props.indent), - PaddingRight = theme.paddingSmall, - PaddingTop = theme.padding, - }), - - Detail = e("Frame", { - BackgroundTransparency = 1, - Size = UDim2.new(1, -16, 1, 0), - }, { - UIListLayout = e("UIListLayout", { - FillDirection = Enum.FillDirection.Horizontal, - Padding = theme.paddingSmall, - SortOrder = Enum.SortOrder.LayoutOrder, - VerticalAlignment = Enum.VerticalAlignment.Center, - }), - - Icon = e(Sprite, { - image = assets.Component, - color = theme.story, - layoutOrder = 0, - size = UDim2.fromOffset(16, 16), - }), - - Typography = e("TextLabel", { - AutomaticSize = Enum.AutomaticSize.XY, - BackgroundTransparency = 1, - Font = theme.font, - LayoutOrder = 1, - Size = UDim2.fromOffset(0, 0), - Text = props.node.name:sub(1, #props.node.name - 6), - TextColor3 = theme.text, - TextSize = theme.textSize, - }), - }), - }) -end - -return Story diff --git a/src/Explorer/Component/init.luau b/src/Explorer/Component/init.luau deleted file mode 100644 index 9e5b04b5..00000000 --- a/src/Explorer/Component/init.luau +++ /dev/null @@ -1,101 +0,0 @@ -local Directory = require("./Directory") -local React = require("@pkg/React") -local Sift = require("@pkg/Sift") -local Story = require("./Story") -local filterComponentTreeNode = require("@root/Explorer/filterComponentTreeNode") -local types = require("@root/Explorer/types") - -local e = React.createElement - -local defaultProps = { - indent = 0, -} - -type Props = { - node: types.ComponentTreeNode, - filter: string?, - activeNode: types.ComponentTreeNode?, - onClick: ((types.ComponentTreeNode) -> ())?, -} - -type InternalProps = Props & typeof(defaultProps) - -local function Component(providedProps: Props) - local props: InternalProps = Sift.Dictionary.merge(defaultProps, providedProps) - - local hasChildren = props.node.children and #props.node.children > 0 - - local expanded, setExpanded = React.useState(false) - local onClick = React.useCallback(function() - if props.onClick then - props.onClick(props.node) - end - - if hasChildren then - setExpanded(function(prev) - return not prev - end) - end - end, { setExpanded }) - - local children: { [string]: React.Node } = { - UIListLayout = if hasChildren - then e("UIListLayout", { - SortOrder = Enum.SortOrder.Name, - }) - else nil, - } - - if hasChildren and props.node.children then - for idx, child in ipairs(props.node.children) do - children[child.name .. idx] = React.createElement(Component, { - node = child, - indent = props.indent + 1, - filter = props.filter, - activeNode = props.activeNode, - onClick = props.onClick, - }) - end - end - - if props.filter and filterComponentTreeNode(props.node, props.filter) then - return - end - - return e("Frame", { - AutomaticSize = Enum.AutomaticSize.Y, - BackgroundTransparency = 1, - ClipsDescendants = true, - Size = UDim2.fromScale(1, 0), - }, { - UIListLayout = e("UIListLayout", { - SortOrder = Enum.SortOrder.LayoutOrder, - }), - - Node = if props.node.icon ~= "story" - then e(Directory, { - expanded = expanded, - hasChildren = hasChildren, - indent = props.indent, - node = props.node, - onClick = onClick, - }) - else e(Story, { - active = props.activeNode == props.node, - indent = props.indent, - node = props.node, - onClick = onClick, - }), - - Children = if expanded and hasChildren - then e("Frame", { - AutomaticSize = if expanded then Enum.AutomaticSize.Y else Enum.AutomaticSize.None, - BackgroundTransparency = 1, - LayoutOrder = 2, - Size = UDim2.fromScale(1, 0), - }, children) - else nil, - }) -end - -return Component diff --git a/src/Explorer/filterComponentTreeNode.luau b/src/Explorer/filterComponentTreeNode.luau deleted file mode 100644 index a1732445..00000000 --- a/src/Explorer/filterComponentTreeNode.luau +++ /dev/null @@ -1,24 +0,0 @@ -local getTreeDescendants = require("./getTreeDescendants") -local types = require("./types") - -local function filterComponentTreeNode(node: types.ComponentTreeNode, filter: string): boolean - if node.icon == "story" then - if not node.name:lower():match(filter:lower()) then - return true - end - - return false - end - - local isEmpty = true - for _, descendant in getTreeDescendants(node) do - if descendant.name:lower():match(filter:lower()) then - isEmpty = false - break - end - end - - return isEmpty -end - -return filterComponentTreeNode diff --git a/src/Explorer/filterComponentTreeNode.spec.luau b/src/Explorer/filterComponentTreeNode.spec.luau deleted file mode 100644 index 1797d69b..00000000 --- a/src/Explorer/filterComponentTreeNode.spec.luau +++ /dev/null @@ -1,76 +0,0 @@ -local JestGlobals = require("@pkg/JestGlobals") -local filterComponentTreeNode = require("./filterComponentTreeNode") -local types = require("./types") - -local expect = JestGlobals.expect -local test = JestGlobals.test - -test("return true when the query does not match the story name", function() - local target: types.ComponentTreeNode = { - children = {}, - name = "test", - icon = "story", - } - local query = "other" - - local result = filterComponentTreeNode(target, query) - expect(result).toBe(true) -end) - -test("return false the query matches the story name", function() - local target: types.ComponentTreeNode = { - children = {}, - name = "test", - icon = "story", - } - local query = "tes" - - local result = filterComponentTreeNode(target, query) - expect(result).toBe(false) -end) - -test("return true when the filter does not match any of node in tree", function() - local target: types.ComponentTreeNode = { - children = { - { - children = {}, - name = "test", - icon = "story", - }, - { - children = {}, - name = "folder", - icon = "folder", - }, - }, - name = "storybook", - icon = "storybook", - } - local query = "other" - - local result = filterComponentTreeNode(target, query) - expect(result).toBe(true) -end) - -test("return false when a filter match at least one of nodes in tree", function() - local target: types.ComponentTreeNode = { - children = { - { - children = {}, - name = "test", - icon = "story", - }, - { - children = {}, - name = "folder", - icon = "folder", - }, - }, - name = "storybook", - icon = "storybook", - } - local query = "tes" - - local result = filterComponentTreeNode(target, query) - expect(result).toBe(false) -end) diff --git a/src/Explorer/getTreeDescendants.luau b/src/Explorer/getTreeDescendants.luau deleted file mode 100644 index 7fce0f24..00000000 --- a/src/Explorer/getTreeDescendants.luau +++ /dev/null @@ -1,21 +0,0 @@ -local types = require("@root/Explorer/types") - -local function getTreeDescendants(root: types.ComponentTreeNode): { types.ComponentTreeNode } - local descendants: { types.ComponentTreeNode } = {} - - local function traverse(node: types.ComponentTreeNode, isRoot: boolean) - if not isRoot then - table.insert(descendants, node) - end - - for _, child in node.children do - traverse(child, false) - end - end - - traverse(root, true) - - return descendants -end - -return getTreeDescendants diff --git a/src/Explorer/getTreeDescendants.spec.luau b/src/Explorer/getTreeDescendants.spec.luau deleted file mode 100644 index b70be4da..00000000 --- a/src/Explorer/getTreeDescendants.spec.luau +++ /dev/null @@ -1,37 +0,0 @@ -local JestGlobals = require("@pkg/JestGlobals") -local getTreeDescendants = require("./getTreeDescendants") - -local expect = JestGlobals.expect -local test = JestGlobals.test - -test("return an empty table when the root has no children", function() - local root = { name = "root", children = {} } - - local result = getTreeDescendants(root) - expect(result).toEqual({}) - expect(#result).toBe(0) -end) - -test("return a table with all descendants when the root has children", function() - local child1 = { name = "child1", children = {} } - local child2 = { name = "child2", children = {} } - local root = { name = "root", children = { child1, child2 } } - - local result = getTreeDescendants(root) - expect(result).toEqual({ - child1, - child2, - }) -end) - -test("return a table with all descendants when the tree has multiple levels", function() - local grandchild = { name = "grandchild", children = {} } - local child = { name = "child", children = { grandchild } } - local root = { name = "root", children = { child } } - - local result = getTreeDescendants(root) - expect(result).toEqual({ - child, - grandchild, - }) -end) diff --git a/src/Explorer/init.luau b/src/Explorer/init.luau deleted file mode 100644 index b9fe1caa..00000000 --- a/src/Explorer/init.luau +++ /dev/null @@ -1,40 +0,0 @@ -local Component = require("./Component") -local React = require("@pkg/React") -local types = require("./types") - -local e = React.createElement - -export type Node = types.ComponentTreeNode -export type Props = { - nodes: { types.ComponentTreeNode }, - activeNode: types.ComponentTreeNode?, - layoutOrder: number?, - filter: string?, - onClick: ((types.ComponentTreeNode) -> ())?, -} - -local function ComponentTree(props: Props) - local children: { [string]: React.Node } = {} - - children.UIListLayoutLayout = e("UIListLayout", { - SortOrder = Enum.SortOrder.Name, - }) - - for index, node in ipairs(props.nodes) do - children[node.name .. index] = e(Component, { - node = node, - activeNode = props.activeNode, - filter = props.filter, - onClick = props.onClick, - }) - end - - return e("Frame", { - AutomaticSize = Enum.AutomaticSize.Y, - BackgroundTransparency = 1, - LayoutOrder = props.layoutOrder, - Size = UDim2.fromScale(1, 0), - }, children) -end - -return ComponentTree diff --git a/src/Explorer/types.luau b/src/Explorer/types.luau deleted file mode 100644 index df60bf5c..00000000 --- a/src/Explorer/types.luau +++ /dev/null @@ -1,13 +0,0 @@ -local Storyteller = require("@pkg/Storyteller") - -type LoadedStorybook = Storyteller.LoadedStorybook - -export type ComponentTreeNode = { - name: string, - children: { ComponentTreeNode }, - icon: ("folder" | "story" | "storybook")?, - instance: Instance?, - storybook: LoadedStorybook?, -} - -return nil diff --git a/src/Forms/InputField.luau b/src/Forms/InputField.luau index fc93de21..2432f86a 100644 --- a/src/Forms/InputField.luau +++ b/src/Forms/InputField.luau @@ -53,7 +53,7 @@ local function InputField(providedProps: Props) newText = newText:gsub("$%s+", ""):gsub("%s+^", "") - if newText == text or newText == "" then + if newText == text then return end diff --git a/src/Forms/Searchbar.luau b/src/Forms/Searchbar.luau index 8e436727..2b8f63f0 100644 --- a/src/Forms/Searchbar.luau +++ b/src/Forms/Searchbar.luau @@ -96,7 +96,7 @@ local function Searchbar(providedProps: Props) BackgroundTransparency = 1, }, { InputField = isExpanded and e(InputField, { - placeholder = "Enter component name...", + placeholder = "Enter story name...", autoFocus = true, onFocus = onFocus, onFocusLost = onFocusLost, diff --git a/src/Panels/Sidebar.luau b/src/Panels/Sidebar.luau index 2a5db8a2..f58b76b3 100644 --- a/src/Panels/Sidebar.luau +++ b/src/Panels/Sidebar.luau @@ -2,46 +2,24 @@ local React = require("@pkg/React") local Storyteller = require("@pkg/Storyteller") local Branding = require("@root/Common/Branding") -local ComponentTree = require("@root/Explorer") local ScrollingFrame = require("@root/Common/ScrollingFrame") local Searchbar = require("@root/Forms/Searchbar") -local constants = require("@root/constants") -local createStoryNodes = require("@root/Storybook/createStoryNodes") -local explorerTypes = require("@root/Explorer/types") +local StorybookTreeView = require("@root/Storybook/StorybookTreeView") local useTheme = require("@root/Common/useTheme") type LoadedStorybook = Storyteller.LoadedStorybook -type ComponentTreeNode = explorerTypes.ComponentTreeNode local e = React.createElement type Props = { layoutOrder: number?, - selectStory: (ModuleScript) -> (), - selectStorybook: (LoadedStorybook) -> (), + onStoryChanged: (storyModule: ModuleScript?, storybook: LoadedStorybook?) -> (), storybooks: { LoadedStorybook }, } local function Sidebar(props: Props) local theme = useTheme() - local activeNode, setActiveNode = React.useState(nil :: ComponentTreeNode?) - local onClick = React.useCallback(function(node: ComponentTreeNode) - if node.instance and node.instance:IsA("ModuleScript") and node.name:match(constants.STORY_NAME_PATTERN) then - if node.storybook then - props.selectStorybook(node.storybook) - end - props.selectStory(node.instance) - setActiveNode(function(prevNode) - return if prevNode ~= node then node else nil - end) - end - end, {}) - - local storybookNodes = React.useMemo(function() - return createStoryNodes(props.storybooks) - end, { props.storybooks }) - local headerHeight, setHeaderHeight = React.useState(0) local onHeaderSizeChanged = React.useCallback(function(rbx: Frame) setHeaderHeight(rbx.AbsoluteSize.Y) @@ -100,11 +78,10 @@ local function Sidebar(props: Props) LayoutOrder = 1, Size = UDim2.fromScale(1, 1) - UDim2.fromOffset(0, headerHeight), }, { - ComponentTree = e(ComponentTree, { - filter = search, - activeNode = activeNode, - nodes = storybookNodes, - onClick = onClick, + StorybookTreeView = e(StorybookTreeView, { + searchTerm = search, + storybooks = props.storybooks, + onStoryChanged = props.onStoryChanged, }), }), }) diff --git a/src/Panels/Sidebar.story.luau b/src/Panels/Sidebar.story.luau index 97670fb5..5c02ff74 100644 --- a/src/Panels/Sidebar.story.luau +++ b/src/Panels/Sidebar.story.luau @@ -15,11 +15,9 @@ return { storybooks = { internalStorybook, }, - selectStory = function(storyModule) - print(storyModule) - end, - selectStorybook = function(storybook) - print(storybook) + onStoryChanged = function(storyModule, storybook) + print("storyModule", storyModule) + print("storybook", storybook) end, }), }), diff --git a/src/Plugin/PluginApp.luau b/src/Plugin/PluginApp.luau index 85b6cae5..53877344 100644 --- a/src/Plugin/PluginApp.luau +++ b/src/Plugin/PluginApp.luau @@ -14,6 +14,8 @@ local useTheme = require("@root/Common/useTheme") local TOPBAR_HEIGHT_PX = 32 +type LoadedStorybook = Storyteller.LoadedStorybook + export type Props = { loader: ModuleLoader.ModuleLoader, } @@ -22,19 +24,21 @@ local function App(props: Props) local theme = useTheme() local settingsContext = SettingsContext.use() local storybooks = Storyteller.useStorybooks(game, props.loader) - local story: ModuleScript?, setStory = React.useState(nil :: ModuleScript?) - local storybook, selectStorybook = React.useState(nil :: ModuleScript?) + local storyModule: ModuleScript?, setStoryModule = React.useState(nil :: ModuleScript?) + local storybook, setStorybook = React.useState(nil :: LoadedStorybook?) local initialSidebarWidth = settingsContext.getSetting("sidebarWidth") local sidebarWidth, setSidebarWidth = React.useState(initialSidebarWidth) local navigation = NavigationContext.use() - local selectStory = React.useCallback(function(newStory: ModuleScript) + local onStoryChanged = React.useCallback(function(newStoryModule: ModuleScript?, newStorybook: LoadedStorybook?) navigation.navigateTo("Home") - setStory(function(prevStory) - return if prevStory ~= newStory then newStory else nil + setStoryModule(function(prev: ModuleScript?) + return if prev ~= newStoryModule then newStoryModule else nil end) - end, { setStory, navigation.navigateTo } :: { unknown }) + + setStorybook(newStorybook) + end, { navigation.navigateTo } :: { unknown }) local onSidebarResized = React.useCallback(function(newSize: Vector2) setSidebarWidth(newSize.X) @@ -62,8 +66,7 @@ local function App(props: Props) onResize = onSidebarResized, }, { Sidebar = React.createElement(Sidebar, { - selectStory = selectStory, - selectStorybook = selectStorybook, + onStoryChanged = onStoryChanged, storybooks = storybooks.available, }), }), @@ -89,7 +92,7 @@ local function App(props: Props) }, { Screen = React.createElement(Screen, { loader = props.loader, - story = story, + story = storyModule, storybook = storybook, }), }), diff --git a/src/Storybook/StorybookTreeView.luau b/src/Storybook/StorybookTreeView.luau new file mode 100644 index 00000000..13a3c4e5 --- /dev/null +++ b/src/Storybook/StorybookTreeView.luau @@ -0,0 +1,93 @@ +local React = require("@pkg/React") +local Storyteller = require("@pkg/Storyteller") +local TreeView = require("@root/TreeView") + +local createTreeNodesForStorybook = require("@root/Storybook/createTreeNodesForStorybook") +local useLastOpenedStory = require("@root/Storybook/useLastOpenedStory") +local usePrevious = require("@root/Common/usePrevious") + +type TreeNode = TreeView.TreeNode +type LoadedStorybook = Storyteller.LoadedStorybook + +local useEffect = React.useEffect +local useRef = React.useRef + +export type Props = { + searchTerm: string?, + storybooks: { LoadedStorybook }, + onStoryChanged: ((storyModule: ModuleScript?, storybook: LoadedStorybook?) -> ())?, + layoutOrder: number?, +} + +local function StorybookTreeView(props: Props) + local treeViewContext = TreeView.useTreeViewContext() + + local selectedNode = treeViewContext.getSelectedNode() + local prevSelectedNode = usePrevious(selectedNode) + local storybookByNodeId = useRef({} :: { [string]: LoadedStorybook }) + local lastOpenedStory, setLastOpenedStory = useLastOpenedStory() + + useEffect(function() + storybookByNodeId.current = {} + local roots: { TreeNode } = {} + for _, storybook in props.storybooks do + local root = createTreeNodesForStorybook(storybook) + table.insert(roots, root) + storybookByNodeId.current[root.id] = storybook + end + treeViewContext.setRoots(roots) + + return function() + treeViewContext.setRoots({}) + end + end, { props.storybooks, treeViewContext.setRoots } :: { unknown }) + + useEffect(function() + treeViewContext.search(props.searchTerm) + end, { props.searchTerm, treeViewContext.search } :: { unknown }) + + local wasLastStoryOpened = useRef(false) + useEffect(function() + if wasLastStoryOpened.current then + return + end + + if lastOpenedStory then + local node = treeViewContext.getNodeByInstance(lastOpenedStory) + + if node then + wasLastStoryOpened.current = true + treeViewContext.activateNode(node) + end + end + end, { lastOpenedStory, treeViewContext.getNodeByInstance, treeViewContext.activateNode } :: { unknown }) + + useEffect(function() + if props.onStoryChanged and selectedNode ~= prevSelectedNode then + if selectedNode then + if + selectedNode.icon == TreeView.TreeNodeIcon.Story + and selectedNode.instance + and selectedNode.instance:IsA("ModuleScript") + then + local ancestry = TreeView.getAncestry(selectedNode) + local root = ancestry[#ancestry] + local storybook = storybookByNodeId.current[root.id] + + if storybook then + props.onStoryChanged(selectedNode.instance, storybook) + setLastOpenedStory(selectedNode.instance) + end + end + else + props.onStoryChanged(nil, nil) + end + end + end, { props.onStoryChanged, selectedNode, prevSelectedNode, setLastOpenedStory } :: { unknown }) + + return React.createElement(TreeView.TreeView, { + layoutOrder = props.layoutOrder, + }) +end + +return StorybookTreeView diff --git a/src/Storybook/StorybookTreeView.story.luau b/src/Storybook/StorybookTreeView.story.luau new file mode 100644 index 00000000..fc31a4d3 --- /dev/null +++ b/src/Storybook/StorybookTreeView.story.luau @@ -0,0 +1,38 @@ +local ModuleLoader = require("@pkg/ModuleLoader") +local React = require("@pkg/React") +local Storyteller = require("@pkg/Storyteller") + +local ContextProviders = require("@root/Common/ContextProviders") +local MockPlugin = require("@root/Testing/MockPlugin") +local StorybookTreeView = require("./StorybookTreeView") + +local loader = ModuleLoader.new() + +local function Story() + local storybooks = Storyteller.useStorybooks(game, loader) + + return React.createElement("Frame", { + Size = UDim2.fromOffset(300, 0), + AutomaticSize = Enum.AutomaticSize.Y, + BackgroundTransparency = 1, + }, { + StorybookTreeView = React.createElement(StorybookTreeView, { + storybooks = storybooks, + onStoryChanged = function(storyModule) + if storyModule then + print("selected", storyModule:GetFullName()) + end + end, + }), + }) +end + +return { + story = function() + return React.createElement(ContextProviders, { + plugin = MockPlugin.new(), + }, { + Story = React.createElement(Story), + }) + end, +} diff --git a/src/Storybook/createStoryNodes.luau b/src/Storybook/createStoryNodes.luau deleted file mode 100644 index 042d6d41..00000000 --- a/src/Storybook/createStoryNodes.luau +++ /dev/null @@ -1,61 +0,0 @@ -local Storyteller = require("@pkg/Storyteller") - -local explorerTypes = require("@root/Explorer/types") - -type LoadedStorybook = Storyteller.LoadedStorybook -type ComponentTreeNode = explorerTypes.ComponentTreeNode - -local function hasStories(instance: Instance): boolean - for _, descendant in ipairs(instance:GetDescendants()) do - if Storyteller.isStoryModule(descendant) then - return true - end - end - return false -end - -local function createChildNodes(parent: ComponentTreeNode, instance: Instance, storybook: LoadedStorybook) - for _, child in ipairs(instance:GetChildren()) do - local isStory = Storyteller.isStoryModule(child) - local isContainer = hasStories(child) - - if isStory or isContainer then - local node: ComponentTreeNode = { - name = child.Name, - instance = child, - children = {}, - - icon = if isStory then "story" else "folder", - storybook = if isStory then storybook else nil, - } - - table.insert(parent.children, node) - - if not isStory and isContainer then - createChildNodes(node, child, storybook) - end - end - end -end - -local function createStoryNodes(storybooks: { LoadedStorybook }): { ComponentTreeNode } - local nodes: { ComponentTreeNode } = {} - - for _, storybook in ipairs(storybooks) do - local node: ComponentTreeNode = { - name = if storybook.name then storybook.name else "Unnamed Storybook", - icon = "storybook" :: "storybook", - children = {}, - } - - table.insert(nodes, node) - - for _, root in ipairs(storybook.storyRoots) do - createChildNodes(node, root, storybook) - end - end - - return nodes -end - -return createStoryNodes diff --git a/src/Storybook/createStoryNodes.spec.luau b/src/Storybook/createStoryNodes.spec.luau deleted file mode 100644 index 66c3ca90..00000000 --- a/src/Storybook/createStoryNodes.spec.luau +++ /dev/null @@ -1,63 +0,0 @@ -local JestGlobals = require("@pkg/JestGlobals") -local Storyteller = require("@pkg/Storyteller") - -local createStoryNodes = require("./createStoryNodes") -local newFolder = require("@root/Testing/newFolder") - -local expect = JestGlobals.expect -local test = JestGlobals.test - -local mockStoryModule = Instance.new("ModuleScript") - -local mockStoryRoot = newFolder({ - Components = newFolder({ - ["Component"] = Instance.new("ModuleScript"), - ["Component.story"] = mockStoryModule, - }), -}) - -local mockStorybook: Storyteller.LoadedStorybook = { - name = "MockStorybook", - storyRoots = { mockStoryRoot }, -} - -test("use an icon for storybooks", function() - local nodes = createStoryNodes({ mockStorybook }) - - local storybook = nodes[1] - expect(storybook).toBeDefined() - expect(storybook.icon).toBe("storybook") -end) - -test("use an icon for container instances", function() - local nodes = createStoryNodes({ mockStorybook }) - - local storybook = nodes[1] - local components = storybook.children[1] - - expect(components).toBeDefined() - expect(components.icon).toBe("folder") -end) - -test("use an icon for stories", function() - local nodes = createStoryNodes({ mockStorybook }) - - local storybook = nodes[1] - local components = storybook.children[1] - local story = components.children[1] - - expect(story).toBeDefined() - expect(story.icon).toBe("story") -end) - -test("ignore other ModuleScripts", function() - local nodes = createStoryNodes({ mockStorybook }) - - local storybook = nodes[1] - local components = storybook.children[1] - - -- In mockStoryRoot, there is a Component module and an accompanying - -- story. We only want stories in the node tree, so we only expect to - -- get one child - expect(#components.children).toBe(1) -end) diff --git a/src/Storybook/createTreeNodesForStorybook.luau b/src/Storybook/createTreeNodesForStorybook.luau new file mode 100644 index 00000000..ed061634 --- /dev/null +++ b/src/Storybook/createTreeNodesForStorybook.luau @@ -0,0 +1,65 @@ +local HttpService = game:GetService("HttpService") + +local Storyteller = require("@pkg/Storyteller") +local TreeView = require("@root/TreeView") + +type PartialTreeNode = TreeView.PartialTreeNode +type TreeNode = TreeView.TreeNode +type LoadedStorybook = Storyteller.LoadedStorybook +type LoadedStory = Storyteller.LoadedStory + +local function createTreeNodesForStorybook(storybook: LoadedStorybook): TreeNode + local nodesByInstance: { [Instance]: TreeNode } = {} + + local root: TreeNode = { + id = HttpService:GenerateGUID(), + label = if storybook.name then storybook.name else "Unnamed Storybook", + icon = "storybook", + isExpanded = false, + children = {}, + } + + for _, storyModule in Storyteller.findStoryModulesForStorybook(storybook) do + local currentNode: TreeNode = { + id = HttpService:GenerateGUID(), + label = storyModule.Name:gsub("%.story", ""), + icon = "story", + isExpanded = false, + instance = storyModule, + children = {}, + } + + local parentInstance = storyModule.Parent + + while parentInstance do + if table.find(storybook.storyRoots, parentInstance) then + table.insert(root.children, currentNode) + break + end + + local existingParentNode = nodesByInstance[parentInstance] + + if existingParentNode then + table.insert(existingParentNode.children, currentNode) + break + else + local parentNode: TreeNode = { + id = HttpService:GenerateGUID(), + label = parentInstance.Name, + icon = "folder", + isExpanded = false, + children = { currentNode }, + } + + nodesByInstance[parentInstance] = parentNode + currentNode = parentNode + end + + parentInstance = parentInstance.Parent + end + end + + return root +end + +return createTreeNodesForStorybook diff --git a/src/Storybook/useLastOpenedStory.luau b/src/Storybook/useLastOpenedStory.luau new file mode 100644 index 00000000..bd9e1caa --- /dev/null +++ b/src/Storybook/useLastOpenedStory.luau @@ -0,0 +1,42 @@ +local React = require("@pkg/React") + +local PluginContext = require("@root/Plugin/PluginContext") +local SettingsContext = require("@root/UserSettings/SettingsContext") +local getInstanceFromFullName = require("@root/Common/getInstanceFromFullName") + +local useContext = React.useContext +local useCallback = React.useCallback +local useMemo = React.useMemo + +local function useLastOpenedStory(): (ModuleScript?, (storyModule: ModuleScript?) -> ()) + local plugin = useContext(PluginContext.Context) + + local settingsContext = SettingsContext.use() + local rememberLastOpenedStory = settingsContext.getSetting("rememberLastOpenedStory") + + local setLastOpenedStory = useCallback(function(storyModule: ModuleScript?) + plugin:SetSetting("lastOpenedStoryPath", if storyModule then storyModule:GetFullName() else nil) + end, { plugin }) + + local lastOpenedStory = useMemo(function(): ModuleScript? + if not rememberLastOpenedStory then + return nil + end + + local lastOpenedStoryPath = plugin:GetSetting("lastOpenedStoryPath") + + if lastOpenedStoryPath then + local instance = getInstanceFromFullName(lastOpenedStoryPath) + + if instance and instance:IsA("ModuleScript") then + return instance + end + end + + return nil + end, { rememberLastOpenedStory, plugin }) + + return lastOpenedStory, setLastOpenedStory +end + +return useLastOpenedStory diff --git a/src/TreeView/TreeNode.luau b/src/TreeView/TreeNode.luau new file mode 100644 index 00000000..0842f63f --- /dev/null +++ b/src/TreeView/TreeNode.luau @@ -0,0 +1,190 @@ +local React = require("@pkg/React") +local ReactSpring = require("@pkg/ReactSpring") + +local Sprite = require("@root/Common/Sprite") +local TreeViewContext = require("@root/TreeView/TreeViewContext") +local assets = require("@root/assets") +local constants = require("@root/constants") +local types = require("@root/TreeView/types") +local useTheme = require("@root/Common/useTheme") +local useTreeNodeIcon = require("@root/TreeView/useTreeNodeIcon") + +local useSpring = ReactSpring.useSpring :: any +local useCallback = React.useCallback +local useMemo = React.useMemo +local useState = React.useState + +type TreeNode = types.TreeNode + +local function countParents(node: TreeNode): number + local current = node + local count = 0 + while current.parent do + current = current.parent + count += 1 + end + return count +end + +export type Props = { + node: TreeNode, + onActivated: (() -> ())?, + layoutOrder: number?, +} + +local function TreeNode(props: Props) + local theme = useTheme() + local icon, iconColor = useTreeNodeIcon(props.node.icon) + local isHovered, setIsHovered = useState(false) + local treeViewContext = TreeViewContext.use() + local isExpanded = treeViewContext.isExpanded(props.node) + local isSelected = treeViewContext.isSelected(props.node) + + local styles = useSpring({ + hover = if isHovered or isSelected then 0 else 1, + expand = if isExpanded then 1 else 0, + config = constants.SPRING_CONFIG, + }) + + local numParents = useMemo(function() + return countParents(props.node) + end, { props.node }) + + local children = useMemo(function() + local elements: { [string]: React.Node } = {} + if props.node.children then + for index, child in props.node.children do + elements[child.label] = React.createElement(TreeNode, { + layoutOrder = index, + node = child, + }) + end + end + return elements + end, { props.node.children }) + + local onMouseEnter = useCallback(function() + setIsHovered(true) + end, {}) + + local onMouseLeave = useCallback(function() + setIsHovered(false) + end, {}) + + local onActivated = useCallback(function() + if props.onActivated then + props.onActivated() + end + + treeViewContext.activateNode(props.node) + end, { props.onActivated, treeViewContext, props.node } :: { unknown }) + + local backgroundColor = useMemo(function(): Color3? + if isSelected then + return theme.selection + else + return theme.divider + end + end, { isSelected }) + + return React.createElement("Frame", { + LayoutOrder = props.layoutOrder, + AutomaticSize = Enum.AutomaticSize.XY, + ClipsDescendants = true, + BackgroundTransparency = 1, + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + }), + + Node = React.createElement("ImageButton", { + LayoutOrder = 1, + AutoButtonColor = false, + AutomaticSize = Enum.AutomaticSize.XY, + BorderSizePixel = 0, + BackgroundColor3 = backgroundColor, + BackgroundTransparency = styles.hover, + + [React.Event.MouseEnter] = onMouseEnter, + [React.Event.MouseLeave] = onMouseLeave, + [React.Event.Activated] = onActivated, + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = Enum.FillDirection.Horizontal, + Padding = theme.paddingSmall, + HorizontalFlex = Enum.UIFlexAlignment.Fill, + }), + + Corner = React.createElement("UICorner", { + CornerRadius = theme.paddingSmall, + }), + + Padding = React.createElement("UIPadding", { + PaddingTop = theme.paddingSmall, + PaddingBottom = theme.paddingSmall, + PaddingRight = theme.paddingSmall, + PaddingLeft = theme.paddingSmall + UDim.new(0, theme.padding.Offset * numParents), + }), + + Icon = React.createElement(Sprite, { + image = icon, + color = iconColor, + layoutOrder = 1, + size = UDim2.fromOffset(16, 16), + }), + + Text = React.createElement("TextLabel", { + LayoutOrder = 2, + Text = props.node.label, + AutomaticSize = Enum.AutomaticSize.Y, + BackgroundTransparency = 1, + Font = theme.font, + Size = UDim2.fromScale(1, 0), + TextXAlignment = Enum.TextXAlignment.Left, + TextColor3 = theme.text, + TextSize = theme.textSize, + }, { + Flex = React.createElement("UIFlexItem", { + FlexMode = Enum.UIFlexMode.Shrink, + }), + }), + + Toggle = if #props.node.children > 0 + then React.createElement("Frame", { + LayoutOrder = 3, + BackgroundTransparency = 1, + AutomaticSize = Enum.AutomaticSize.XY, + }, { + RotationWrapper = React.createElement("Frame", { + BackgroundTransparency = 1, + AutomaticSize = Enum.AutomaticSize.XY, + Rotation = styles.expand:map(function(value) + return 90 * value + end), + }, { + Icon = React.createElement(Sprite, { + image = assets.ChevronRight, + color = theme.text, + size = UDim2.fromOffset(16, 16), + }), + }), + }) + else nil, + }), + + Children = if isExpanded + then React.createElement("Frame", { + LayoutOrder = 2, + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundTransparency = 1, + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + }), + }, children) + else nil, + }) +end + +return TreeNode diff --git a/src/TreeView/TreeView.luau b/src/TreeView/TreeView.luau new file mode 100644 index 00000000..e1a5715a --- /dev/null +++ b/src/TreeView/TreeView.luau @@ -0,0 +1,35 @@ +local React = require("@pkg/React") + +local TreeNode = require("@root/TreeView/TreeNode") +local TreeViewContext = require("@root/TreeView/TreeViewContext") +local types = require("@root/TreeView/types") + +type PartialTreeNode = types.PartialTreeNode +type TreeNode = types.TreeNode +type Tree = types.Tree + +local function TreeView(props: { + layoutOrder: number?, +}) + local treeViewContext = TreeViewContext.use() + + local children: { [string]: React.Node } = {} + for index, node in treeViewContext.getRoots() do + children[node.label] = React.createElement(TreeNode, { + layoutOrder = index, + node = node, + }) + end + + return React.createElement("Frame", { + BackgroundTransparency = 1, + AutomaticSize = Enum.AutomaticSize.XY, + LayoutOrder = props.layoutOrder, + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + }), + }, children) +end + +return TreeView diff --git a/src/TreeView/TreeView.story.luau b/src/TreeView/TreeView.story.luau new file mode 100644 index 00000000..2a73354f --- /dev/null +++ b/src/TreeView/TreeView.story.luau @@ -0,0 +1,196 @@ +local React = require("@pkg/React") + +local ContextProviders = require("@root/Common/ContextProviders") +local InputField = require("@root/Forms/InputField") +local MockPlugin = require("@root/Testing/MockPlugin") +local TreeView = require("./TreeView") +local TreeViewContext = require("@root/TreeView/TreeViewContext") +local types = require("./types") + +local useCallback = React.useCallback +local useEffect = React.useEffect +local useState = React.useState + +type PartialTreeNode = types.PartialTreeNode +type TreeNode = types.TreeNode + +local roots: { PartialTreeNode } = { + { + label = "Pinned Storybooks", + icon = types.TreeNodeIcon.None, + isExpanded = true, + children = { + { + label = "Storybook 1", + icon = types.TreeNodeIcon.Storybook, + children = { + -- ... + }, + }, + }, + }, + { + label = "Storybook 1", + icon = types.TreeNodeIcon.Storybook, + children = { + { + label = "Folder 1", + icon = types.TreeNodeIcon.Folder, + children = { + { + label = "Folder 2", + icon = types.TreeNodeIcon.Folder, + children = { + { + label = "Folder 3", + icon = types.TreeNodeIcon.Folder, + children = { + { + id = "custom-id", + label = "Deeply Nested Story", + icon = types.TreeNodeIcon.Story, + } :: PartialTreeNode, + }, + } :: PartialTreeNode, + }, + } :: PartialTreeNode, + }, + } :: PartialTreeNode, + }, + }, + + { + label = "Storybook 2", + icon = types.TreeNodeIcon.Storybook, + children = { + { + label = "Folder", + icon = types.TreeNodeIcon.Folder, + isExpanded = true, + children = { + { + label = "Story 1", + icon = types.TreeNodeIcon.Story, + }, + { + label = "Story 2", + icon = types.TreeNodeIcon.Story, + }, + }, + } :: PartialTreeNode, + { + label = "Story 1", + icon = types.TreeNodeIcon.Story, + }, + { + label = "Story 2", + icon = types.TreeNodeIcon.Story, + }, + { + label = "Story 3", + icon = types.TreeNodeIcon.Story, + }, + }, + }, + { + label = "Storybook 3", + icon = types.TreeNodeIcon.Storybook, + children = { + -- ... + }, + }, + { + label = "Unnamed Storybook", + icon = types.TreeNodeIcon.Storybook, + children = { + -- ... + }, + }, + { + label = "Unknown Stories", + icon = types.TreeNodeIcon.Storybook, + children = { + { + label = "Story 1", + icon = types.TreeNodeIcon.Story, + }, + { + label = "Story 2", + icon = types.TreeNodeIcon.Story, + }, + }, + }, +} + +local function Story() + local treeViewContext = TreeViewContext.use() + + useEffect(function() + treeViewContext.setRoots(roots) + end, {}) + + local searchTerm: string?, setSearchTerm = useState(nil :: string?) + + local onExpand = useCallback(function() + local node = treeViewContext.getNodeById("custom-id") + if node then + treeViewContext.activateNode(node) + end + end, { treeViewContext }) + + useEffect(function() + treeViewContext.search(searchTerm) + end, { treeViewContext, searchTerm } :: { unknown }) + + return React.createElement("Frame", { + Size = UDim2.fromOffset(300, 0), + AutomaticSize = Enum.AutomaticSize.Y, + BackgroundTransparency = 1, + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 16), + }), + + Topbar = React.createElement("Frame", { + LayoutOrder = 1, + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 24), + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = Enum.FillDirection.Horizontal, + Padding = UDim.new(0, 16), + }), + + Search = React.createElement(InputField, { + layoutOrder = 1, + size = UDim2.fromScale(0.8, 1), + placeholder = "Search...", + onSubmit = setSearchTerm, + onTextChange = setSearchTerm, + }), + + ExpandToNode = React.createElement("TextButton", { + LayoutOrder = 2, + Size = UDim2.fromScale(0.2, 1), + Text = "Expand", + [React.Event.Activated] = onExpand, + }), + }), + + TreeView = React.createElement(TreeView, { + layoutOrder = 2, + }), + }) +end + +return { + story = function() + return React.createElement(ContextProviders, { + plugin = MockPlugin.new(), + }, { + Story = React.createElement(Story), + }) + end, +} diff --git a/src/TreeView/TreeViewContext.luau b/src/TreeView/TreeViewContext.luau new file mode 100644 index 00000000..e89b824b --- /dev/null +++ b/src/TreeView/TreeViewContext.luau @@ -0,0 +1,173 @@ +local React = require("@pkg/React") +local Sift = require("@pkg/Sift") + +local createTreeNodesFromPartials = require("@root/TreeView/createTreeNodesFromPartials") +local getAncestry = require("@root/TreeView/getAncestry") +local reduceTree = require("@root/TreeView/reduceTree") +local types = require("@root/TreeView/types") + +type PartialTreeNode = types.PartialTreeNode +type TreeNode = types.TreeNode + +local useCallback = React.useCallback +local useContext = React.useContext +local useMemo = React.useMemo +local useState = React.useState + +type TreeViewContext = { + setRoots: (nodes: { PartialTreeNode | TreeNode }) -> (), + getRoots: () -> { TreeNode }, + getNodeById: (nodeId: string) -> TreeNode?, + getNodeByInstance: (instance: Instance) -> TreeNode?, + getSelectedNode: () -> TreeNode?, + activateNode: (node: TreeNode) -> (), + isExpanded: (node: TreeNode) -> boolean, + isSelected: (node: TreeNode) -> boolean, + search: (searchTerm: string?) -> (), +} + +local TreeViewContext = React.createContext(nil) + +local function TreeNodeProvider(props: { + children: React.Node, +}) + local nodes, setNodes = useState({ + roots = {} :: { TreeNode }, + leaves = {} :: { TreeNode }, + byId = {} :: { [string]: TreeNode }, + byInstance = {} :: { [Instance]: TreeNode }, + }) + + local expandedNodes, setExpandedNodes = useState({} :: { TreeNode }) + local selectedNode, setSelectedNode = useState(nil :: TreeNode?) + local searchTerm: string?, setSearchTerm = useState(nil :: string?) + + local expand = useCallback(function(node: TreeNode) + setExpandedNodes(function(prev) + -- If the parent node is not expanded then make sure to expand out + -- the ancestors too + local ancestry + if node.parent and not table.find(prev, node.parent) then + ancestry = getAncestry(node) + end + + return Sift.List.join(prev, { node }, ancestry) + end) + end, {}) + + local collapse = useCallback(function(node: TreeNode) + setExpandedNodes(function(prev) + local index = table.find(prev, node) + if index then + local new = table.clone(prev) + table.remove(new, index) + return new + end + return prev + end) + end, {}) + + local filteredRoots = useMemo(function() + if searchTerm then + return reduceTree(nodes.roots, function(node) + return node.label:lower():match(searchTerm:lower()) ~= nil + end) + else + return nodes.roots + end + end, { nodes.roots, searchTerm } :: { unknown }) + + local search = useCallback(function(newSearchTerm: string?) + if newSearchTerm ~= "" then + setSearchTerm(newSearchTerm) + else + setSearchTerm(nil) + end + end, {}) + + local setRoots = useCallback(function(partials: { TreeNode | PartialTreeNode }) + local newNodes = createTreeNodesFromPartials(partials) + + for _, node in newNodes.expandedByDefault do + expand(node) + end + + setNodes({ + roots = newNodes.roots, + leaves = newNodes.leaves, + byId = newNodes.byId, + byInstance = newNodes.byInstance, + }) + end, {}) + + local getRoots = useCallback(function() + return filteredRoots + end, { filteredRoots }) + + local getNodeById = useCallback(function(nodeId: string) + return nodes.byId[nodeId] + end, { nodes.byId }) + + local getNodeByInstance = useCallback(function(instance: Instance) + return nodes.byInstance[instance] + end, { nodes.byId }) + + local getSelectedNode = useCallback(function() + return selectedNode + end, { selectedNode }) + + local isExpanded = useCallback(function(node: TreeNode): boolean + if searchTerm then + return true + else + return table.find(expandedNodes, node) ~= nil + end + end, { expandedNodes, searchTerm } :: { unknown }) + + local isSelected = useCallback(function(node: TreeNode): boolean + return selectedNode == node + end, { selectedNode }) + + local activateNode = useCallback(function(node: TreeNode) + if isExpanded(node) then + collapse(node) + else + expand(node) + end + + if node ~= selectedNode then + if table.find(nodes.leaves, node) then + setSelectedNode(node) + end + else + setSelectedNode(nil) + end + end, { nodes, selectedNode, isExpanded, expand, collapse } :: { unknown }) + + local context: TreeViewContext = { + setRoots = setRoots, + getRoots = getRoots, + getNodeById = getNodeById, + getNodeByInstance = getNodeByInstance, + getSelectedNode = getSelectedNode, + activateNode = activateNode, + isExpanded = isExpanded, + isSelected = isSelected, + search = search, + } + + return React.createElement(TreeViewContext.Provider, { + value = context, + }, props.children) +end + +local function use(): TreeViewContext + local context = useContext(TreeViewContext) + assert(context, "failed to use TreeViewContext, is `TreeViewContext.Provider` defined in the React hierarchy?`") + return context +end + +return { + Provider = TreeNodeProvider, + use = use, +} diff --git a/src/TreeView/createTreeNodesFromPartials.luau b/src/TreeView/createTreeNodesFromPartials.luau new file mode 100644 index 00000000..382b2ad2 --- /dev/null +++ b/src/TreeView/createTreeNodesFromPartials.luau @@ -0,0 +1,77 @@ +local HttpService = game:GetService("HttpService") + +local Sift = require("@pkg/Sift") + +local types = require("./types") + +type PartialTreeNode = types.PartialTreeNode +type TreeNode = types.TreeNode + +local function createTreeNodesFromPartials(partialRoots: { PartialTreeNode | TreeNode }): { + roots: { TreeNode }, + leaves: { TreeNode }, + byId: { [string]: TreeNode }, + byInstance: { [Instance]: TreeNode }, + expandedByDefault: { TreeNode }, +} + local leaves: { TreeNode } = {} + local expandedByDefault: { TreeNode } = {} + local byId: { [string]: TreeNode } = {} + local byInstance: { [Instance]: TreeNode } = {} + + local function process(partials: { PartialTreeNode | TreeNode }, parent: TreeNode?): { TreeNode } + local siblings: { TreeNode } = {} + + for _, partial in partials do + local base: TreeNode = { + id = HttpService:GenerateGUID(), + label = "Unknown", + icon = "none", + children = {}, + parent = parent, + isExpanded = false, + } + + local node = Sift.Dictionary.join(base, partial) + + if node.isExpanded then + table.insert(expandedByDefault, node) + end + + if node.instance then + byInstance[node.instance] = node + end + + if partial.children and #partial.children > 0 then + node.children = process(partial.children, node) + else + table.insert(leaves, node) + end + + byId[node.id] = node + table.insert(siblings, node) + end + + table.sort(siblings, function(a, b) + if a.icon ~= b.icon then + -- Sort by type + return a.icon < b.icon + else + -- Sort alphabetically + return a.label:lower() < b.label:lower() + end + end) + + return siblings + end + + return { + roots = process(partialRoots), + byId = byId, + byInstance = byInstance, + leaves = leaves, + expandedByDefault = expandedByDefault, + } +end + +return createTreeNodesFromPartials diff --git a/src/TreeView/createTreeNodesFromPartials.spec.luau b/src/TreeView/createTreeNodesFromPartials.spec.luau new file mode 100644 index 00000000..613e334b --- /dev/null +++ b/src/TreeView/createTreeNodesFromPartials.spec.luau @@ -0,0 +1,105 @@ +local JestGlobals = require("@pkg/JestGlobals") +local createTreeNodesFromPartials = require("./createTreeNodesFromPartials") +local types = require("./types") + +local expect = JestGlobals.expect +local test = JestGlobals.test + +type PartialTreeNode = types.PartialTreeNode + +test("top-level nodes with no children", function() + local partials: { PartialTreeNode } = { + { + label = "Node 1", + }, + { + label = "Node 2", + }, + { + label = "Node 3", + }, + } + + expect(createTreeNodesFromPartials(partials)).toEqual({ + { + label = "Node 1", + icon = "none", + isExpanded = false, + children = {}, + }, + { + label = "Node 2", + icon = "none", + isExpanded = false, + children = {}, + }, + { + label = "Node 3", + icon = "none", + isExpanded = false, + children = {}, + }, + }) +end) + +test("nodes with children", function() + local partials: { PartialTreeNode } = { + { + label = "Node 1", + children = { + { + label = "Child A 1", + children = { + { + label = "Child B 1", + }, + }, + } :: PartialTreeNode, + { + label = "Child A 2", + } :: PartialTreeNode, + }, + }, + { + label = "Node 2", + }, + } + + expect(createTreeNodesFromPartials(partials)).toEqual({ + { + label = "Node 1", + icon = "none", + isExpanded = false, + children = { + { + label = "Child A 1", + parent = expect.anything(), + icon = "none", + isExpanded = false, + children = { + { + label = "Child B 1", + parent = expect.anything(), + icon = "none", + isExpanded = false, + children = {}, + }, + }, + }, + { + label = "Child A 2", + parent = expect.anything(), + icon = "none", + isExpanded = false, + children = {}, + }, + }, + }, + { + label = "Node 2", + icon = "none", + isExpanded = false, + children = {}, + }, + }) +end) diff --git a/src/TreeView/getAncestry.luau b/src/TreeView/getAncestry.luau new file mode 100644 index 00000000..ebfd4656 --- /dev/null +++ b/src/TreeView/getAncestry.luau @@ -0,0 +1,15 @@ +local types = require("./types") + +type TreeNode = types.TreeNode + +local function getAncestry(node: TreeNode): { TreeNode } + local ancestry = {} + local parent = node.parent + while parent do + table.insert(ancestry, parent) + parent = parent.parent + end + return ancestry +end + +return getAncestry diff --git a/src/TreeView/init.luau b/src/TreeView/init.luau new file mode 100644 index 00000000..3ad2c29b --- /dev/null +++ b/src/TreeView/init.luau @@ -0,0 +1,20 @@ +local TreeViewContext = require("./TreeViewContext") +local types = require("./types") + +export type TreeNode = types.TreeNode +export type PartialTreeNode = types.PartialTreeNode + +return { + -- Enums + TreeNodeIcon = types.TreeNodeIcon, + + -- Components + TreeViewProvider = TreeViewContext.Provider, + TreeView = require("./TreeView"), + + -- Hooks + useTreeViewContext = TreeViewContext.use, + + -- Functions + getAncestry = require("./getAncestry"), +} diff --git a/src/TreeView/reduceTree.luau b/src/TreeView/reduceTree.luau new file mode 100644 index 00000000..de70f2ea --- /dev/null +++ b/src/TreeView/reduceTree.luau @@ -0,0 +1,34 @@ +local Sift = require("@pkg/Sift") + +local types = require("@root/TreeView/types") + +type TreeNode = types.TreeNode +type SearchMatch = (node: TreeNode) -> boolean + +local function reduceTree(nodes: { TreeNode }, searchMatch: SearchMatch): { TreeNode } + local function reduceNodes(accumulator: { TreeNode }, node: TreeNode) + if searchMatch(node) then + table.insert(accumulator, node) + return accumulator + end + + if #node.children > 0 then + local children = Sift.List.reduce(node.children, reduceNodes, {}) + + if #children > 0 then + table.insert( + accumulator, + Sift.Dictionary.join(node, { + children = children, + }) + ) + end + end + + return accumulator + end + + return Sift.List.reduce(nodes, reduceNodes, {}) +end + +return reduceTree diff --git a/src/TreeView/types.luau b/src/TreeView/types.luau new file mode 100644 index 00000000..f84293fc --- /dev/null +++ b/src/TreeView/types.luau @@ -0,0 +1,37 @@ +local types = {} + +export type TreeNodeIcon = "none" | "story" | "storybook" | "folder" + +types.TreeNodeIcon = { + None = "none" :: "none", + Story = "story" :: "story", + Storybook = "storybook" :: "storybook", + Folder = "folder" :: "folder", +} + +export type PartialTreeNode = { + id: string?, + label: string, + icon: TreeNodeIcon?, + isExpanded: boolean?, + children: { PartialTreeNode }?, + instance: Instance?, +} + +export type TreeNode = { + id: string, + label: string, + icon: TreeNodeIcon, + isExpanded: boolean, + children: { TreeNode }, + parent: TreeNode?, + instance: Instance?, +} + +export type Tree = { + root: { PartialTreeNode }, + leafNodes: { TreeNode }, + nodesById: { [string]: TreeNode }, +} + +return types diff --git a/src/TreeView/useTreeNodeIcon.luau b/src/TreeView/useTreeNodeIcon.luau new file mode 100644 index 00000000..92525320 --- /dev/null +++ b/src/TreeView/useTreeNodeIcon.luau @@ -0,0 +1,27 @@ +local assets = require("@root/assets") +local useTheme = require("@root/Common/useTheme") +local types = require("./types") + +type TreeNodeIcon = types.TreeNodeIcon + +type Sprite = { + Image: string, + ImageRectOffset: Vector2, + ImageRectSize: Vector2, +} + +local function useTreeNodeIcon(icon: TreeNodeIcon): (Sprite, Color3) + local theme = useTheme() + + if icon == types.TreeNodeIcon.Story then + return assets.Component, theme.story + elseif icon == types.TreeNodeIcon.Storybook then + return assets.Storybook, theme.textFaded + elseif icon == types.TreeNodeIcon.Folder then + return assets.Folder, theme.directory + else + return assets.Folder, theme.textFaded + end +end + +return useTreeNodeIcon diff --git a/src/UserSettings/defaultSettings.luau b/src/UserSettings/defaultSettings.luau index 46bd7678..2915d86a 100644 --- a/src/UserSettings/defaultSettings.luau +++ b/src/UserSettings/defaultSettings.luau @@ -59,13 +59,13 @@ export type Setting = CheckboxSetting | DropdownSetting | NumberSetting -- }, -- } --- local rememberLastOpenedStory: Setting = { --- name = "rememberLastOpenedStory", --- displayName = "Remember last opened story", --- description = "Open the last viewed story when starting", --- settingType = SettingType.Checkbox, --- value = true, --- } +local rememberLastOpenedStory: Setting = { + name = "rememberLastOpenedStory", + displayName = "Remember last opened story", + description = "Open the last viewed story when starting", + settingType = SettingType.Checkbox, + value = true, +} local theme: DropdownSetting = { name = "theme", @@ -108,7 +108,7 @@ local controlsHeight: NumberSetting = { local settings = { -- expandNodesOnStart = expandNodesOnStart, - -- rememberLastOpenedStory = rememberLastOpenedStory, + rememberLastOpenedStory = rememberLastOpenedStory, theme = theme, sidebarWidth = sidebarWidth, controlsHeight = controlsHeight,