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/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/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/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, diff --git a/src/Plugin/PluginApp.luau b/src/Plugin/PluginApp.luau index 7081df03..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,9 +66,8 @@ local function App(props: Props) onResize = onSidebarResized, }, { Sidebar = React.createElement(Sidebar, { - selectStory = selectStory, - selectStorybook = selectStorybook, - storybooks = storybooks, + 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/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 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 fb3175f6..2eb5a701 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, 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"