From e85d110d88f646b176be93087b65eec66aaef6aa Mon Sep 17 00:00:00 2001 From: vocksel Date: Sun, 10 Nov 2024 06:51:18 -0800 Subject: [PATCH] Use Storyteller for handling all of our Story/Storybook needs (#267) # Problem Our org has a new package for handling all Story and Storybook logic we previously had baked into flipbook in the form of Storyteller. https://github.com/flipbook-labs/storyteller # Solution This PR adds Storyteller to our dependencies and starts consuming it. All Story and Storybook related logic has been stripped out and migrated over As a bonus, Storyteller gives us Fusion support out of the box. Closes #225 # Checklist - [ ] Ran `lune run test` locally before merging --- .vscode/settings.json | 3 - src/Common/useDescendants.luau | 77 -------------- src/Common/useDescendants.spec.luau | 108 -------------------- src/Explorer/types.luau | 4 +- src/Navigation/Screen.luau | 8 +- src/Panels/Sidebar.luau | 7 +- src/Plugin/PluginApp.luau | 5 +- src/Storybook/StoryCanvas.luau | 9 +- src/Storybook/StoryControls.luau | 9 +- src/Storybook/StoryMeta.luau | 9 +- src/Storybook/StoryMeta.story.luau | 4 + src/Storybook/StoryPreview.luau | 54 +++++++--- src/Storybook/StoryView.luau | 52 ++++++---- src/Storybook/createStoryNodes.luau | 10 +- src/Storybook/createStoryNodes.spec.luau | 5 +- src/Storybook/isStoryModule.luau | 10 -- src/Storybook/isStoryModule.spec.luau | 25 ----- src/Storybook/isStorybookModule.luau | 11 -- src/Storybook/isStorybookModule.spec.luau | 45 --------- src/Storybook/loadStoryModule.luau | 67 ------------- src/Storybook/mountStory.luau | 66 ------------ src/Storybook/types.luau | 117 ---------------------- src/Storybook/useStory.luau | 43 -------- src/Storybook/useStorybooks.luau | 65 ------------ src/init.storybook.luau | 6 +- src/stories.spec.luau | 87 ++++++++++------ wally.toml | 3 +- 27 files changed, 175 insertions(+), 734 deletions(-) delete mode 100644 src/Common/useDescendants.luau delete mode 100644 src/Common/useDescendants.spec.luau delete mode 100644 src/Storybook/isStoryModule.luau delete mode 100644 src/Storybook/isStoryModule.spec.luau delete mode 100644 src/Storybook/isStorybookModule.luau delete mode 100644 src/Storybook/isStorybookModule.spec.luau delete mode 100644 src/Storybook/loadStoryModule.luau delete mode 100644 src/Storybook/mountStory.luau delete mode 100644 src/Storybook/types.luau delete mode 100644 src/Storybook/useStory.luau delete mode 100644 src/Storybook/useStorybooks.luau diff --git a/.vscode/settings.json b/.vscode/settings.json index c82dc676..3d62bfd0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,8 +9,5 @@ "@pkg": "./Packages", "@root": "./src", "@lune/": "~/.lune/.typedefs/0.8.3/" - }, - "files.associations": { - "*.luau": "lua" } } \ No newline at end of file diff --git a/src/Common/useDescendants.luau b/src/Common/useDescendants.luau deleted file mode 100644 index 60d5458d..00000000 --- a/src/Common/useDescendants.luau +++ /dev/null @@ -1,77 +0,0 @@ -local React = require("@pkg/React") -local Sift = require("@pkg/Sift") - -local function hasPermission(instance: Instance) - local success = pcall(function() - return instance.Name - end) - return success -end - -local function useDescendants(parent: Instance, predicate: (descendant: Instance) -> boolean): { Instance } - local descendants: { Instance }, setDescendants = React.useState({}) - - local onDescendantChanged = React.useCallback(function(descendant: Instance) - setDescendants(function(prev) - local exists = table.find(prev, descendant) - - if not hasPermission(descendant) then - return prev - end - - if predicate(descendant) then - if exists then - -- Force a re-render. Nothing about the state changed, but the - -- module uses a new name now - return table.clone(prev) - else - return Sift.Array.push(prev, descendant) - end - else - if exists then - return Sift.Array.filter(prev, function(other: Instance) - return descendant ~= other - end) - end - end - - return prev - end) - end, { predicate, descendants } :: { unknown }) - - -- Setup the initial list of descendants for the current parent - React.useEffect(function() - setDescendants(Sift.Array.filter(parent:GetDescendants(), predicate)) - end, { parent }) - - React.useEffect(function() - local connections = { - parent.DescendantAdded:Connect(onDescendantChanged), - parent.DescendantRemoving:Connect(onDescendantChanged), - } - - -- Listen for name changes and update the list of descendants - for _, descendant in parent:GetDescendants() do - if not hasPermission(descendant) then - continue - end - - table.insert( - connections, - descendant:GetPropertyChangedSignal("Name"):Connect(function() - onDescendantChanged(descendant) - end) - ) - end - - return function() - for _, conn in connections do - conn:Disconnect() - end - end - end, { parent, onDescendantChanged } :: { unknown }) - - return descendants -end - -return useDescendants diff --git a/src/Common/useDescendants.spec.luau b/src/Common/useDescendants.spec.luau deleted file mode 100644 index 336a8aa2..00000000 --- a/src/Common/useDescendants.spec.luau +++ /dev/null @@ -1,108 +0,0 @@ -local JestGlobals = require("@pkg/JestGlobals") -local React = require("@pkg/React") -local ReactRoblox = require("@pkg/ReactRoblox") -local newFolder = require("@root/Testing/newFolder") -local useDescendants = require("./useDescendants") - -local afterEach = JestGlobals.afterEach -local expect = JestGlobals.expect -local test = JestGlobals.test - -local container = Instance.new("ScreenGui") -local root = ReactRoblox.createRoot(container) - -afterEach(function() - ReactRoblox.act(function() - root:unmount() - end) -end) - -test("return an initial list of descendants that match the predicate", function() - local tree = newFolder({ - Match = Instance.new("Part"), - Foo = Instance.new("Part"), - }) - - local descendants - local function HookTester() - descendants = useDescendants(tree, function(descendant) - return descendant.Name == "Match" - end) - - return nil - end - - ReactRoblox.act(function() - root:render(React.createElement(HookTester)) - end) - - expect(descendants).toBeDefined() - expect(#descendants).toBe(1) - expect(descendants[1]).toBe(tree:FindFirstChild("Match")) -end) - -test("respond to changes in descendants that match the predicate", function() - local tree = newFolder({ - Match = Instance.new("Part"), - Foo = Instance.new("Part"), - }) - - local descendants - local function HookTester() - descendants = useDescendants(tree, function(descendant) - return descendant.Name == "Match" - end) - - return nil - end - - ReactRoblox.act(function() - root:render(React.createElement(HookTester)) - end) - - expect(descendants).toBeDefined() - expect(#descendants).toBe(1) - - local folder = newFolder({ - Match = Instance.new("Part"), - }) - - ReactRoblox.act(function() - folder.Parent = tree - end) - - expect(#descendants).toBe(2) -end) - -test("force an update when a matching descendant's name changes", function() - local descendants - - local tree = newFolder({ - Match = Instance.new("Part"), - }) - - local function HookTester() - descendants = useDescendants(tree, function(descendant) - return descendant:IsA("Part") - end) - - return nil - end - - ReactRoblox.act(function() - root:render(React.createElement(HookTester)) - end) - - expect(descendants).toBeDefined() - expect(#descendants).toBe(1) - - local prev = descendants - local match = tree:FindFirstChild("Match") :: Instance - - ReactRoblox.act(function() - match.Name = "Changed" - end) - - expect(descendants).never.toBe(prev) - expect(descendants[1]).toBe(match) -end) diff --git a/src/Explorer/types.luau b/src/Explorer/types.luau index 68b9535d..9b49b05b 100644 --- a/src/Explorer/types.luau +++ b/src/Explorer/types.luau @@ -1,6 +1,6 @@ -local storybookTypes = require("@root/Storybook/types") +local Storyteller = require("@pkg/Storyteller") -type Storybook = storybookTypes.Storybook +type Storybook = Storyteller.Storybook export type ComponentTreeNode = { name: string, diff --git a/src/Navigation/Screen.luau b/src/Navigation/Screen.luau index 90cef980..7dd39506 100644 --- a/src/Navigation/Screen.luau +++ b/src/Navigation/Screen.luau @@ -1,19 +1,19 @@ local ModuleLoader = require("@pkg/ModuleLoader") local React = require("@pkg/React") +local Storyteller = require("@pkg/Storyteller") local AboutView = require("@root/About/AboutView") local NavigationContext = require("@root/Navigation/NavigationContext") local SettingsView = require("@root/UserSettings/SettingsView") local StoryCanvas = require("@root/Storybook/StoryCanvas") -local storybookTypes = require("@root/Storybook/types") local useMemo = React.useMemo -type Story = storybookTypes.Story -type Storybook = storybookTypes.Storybook +type ModuleLoader = ModuleLoader.ModuleLoader +type Storybook = Storyteller.Storybook export type Props = { - loader: ModuleLoader.ModuleLoader, + loader: ModuleLoader, story: ModuleScript?, storybook: Storybook?, } diff --git a/src/Panels/Sidebar.luau b/src/Panels/Sidebar.luau index f805b6be..6e8930d9 100644 --- a/src/Panels/Sidebar.luau +++ b/src/Panels/Sidebar.luau @@ -1,15 +1,16 @@ +local React = require("@pkg/React") +local Storyteller = require("@pkg/Storyteller") + local Branding = require("@root/Common/Branding") local ComponentTree = require("@root/Explorer") -local React = require("@pkg/React") 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 storybookTypes = require("@root/Storybook/types") local useTheme = require("@root/Common/useTheme") -type Storybook = storybookTypes.Storybook +type Storybook = Storyteller.Storybook type ComponentTreeNode = explorerTypes.ComponentTreeNode local e = React.createElement diff --git a/src/Plugin/PluginApp.luau b/src/Plugin/PluginApp.luau index aa345cba..7081df03 100644 --- a/src/Plugin/PluginApp.luau +++ b/src/Plugin/PluginApp.luau @@ -1,5 +1,6 @@ local ModuleLoader = require("@pkg/ModuleLoader") local React = require("@pkg/React") +local Storyteller = require("@pkg/Storyteller") local NavigationContext = require("@root/Navigation/NavigationContext") local ResizablePanel = require("@root/Panels/ResizablePanel") @@ -9,7 +10,6 @@ local Sidebar = require("@root/Panels/Sidebar") local Topbar = require("@root/Panels/Topbar") local constants = require("@root/constants") local nextLayoutOrder = require("@root/Common/nextLayoutOrder") -local useStorybooks = require("@root/Storybook/useStorybooks") local useTheme = require("@root/Common/useTheme") local TOPBAR_HEIGHT_PX = 32 @@ -21,7 +21,7 @@ export type Props = { local function App(props: Props) local theme = useTheme() local settingsContext = SettingsContext.use() - local storybooks = useStorybooks(game, props.loader) + local storybooks = Storyteller.useStorybooks(game, props.loader) local story: ModuleScript?, setStory = React.useState(nil :: ModuleScript?) local storybook, selectStorybook = React.useState(nil :: ModuleScript?) local initialSidebarWidth = settingsContext.getSetting("sidebarWidth") @@ -71,6 +71,7 @@ local function App(props: Props) MainWrapper = React.createElement("Frame", { LayoutOrder = nextLayoutOrder(), Size = UDim2.fromScale(1, 1) - UDim2.fromOffset(sidebarWidth, 0), + BackgroundTransparency = 1, }, { Layout = React.createElement("UIListLayout", { SortOrder = Enum.SortOrder.LayoutOrder, diff --git a/src/Storybook/StoryCanvas.luau b/src/Storybook/StoryCanvas.luau index 06c5cf03..389ccdbe 100644 --- a/src/Storybook/StoryCanvas.luau +++ b/src/Storybook/StoryCanvas.luau @@ -1,17 +1,20 @@ 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 types = require("@root/Storybook/types") local useTheme = require("@root/Common/useTheme") local e = React.createElement +type ModuleLoader = ModuleLoader.ModuleLoader +type Storybook = Storyteller.Storybook + type Props = { story: ModuleScript, - loader: ModuleLoader.ModuleLoader, - storybook: types.Storybook, + loader: ModuleLoader, + storybook: Storybook, layoutOrder: number?, } diff --git a/src/Storybook/StoryControls.luau b/src/Storybook/StoryControls.luau index e689f821..213c0912 100644 --- a/src/Storybook/StoryControls.luau +++ b/src/Storybook/StoryControls.luau @@ -111,7 +111,7 @@ local function StoryControls(props: Props) TextTruncate = Enum.TextTruncate.AtEnd, }), - Option = e("Frame", { + OptionWrapper = e("Frame", { LayoutOrder = 2, BackgroundTransparency = 1, Size = UDim2.fromScale(1, 0), @@ -120,7 +120,12 @@ local function StoryControls(props: Props) Flex = e("UIFlexItem", { FlexMode = Enum.UIFlexMode.Shrink, }), - }, option), + }, { + -- Keying by the identity of sortedControls fixes a bug where + -- the options visually do not update when two stories have the + -- exact same controls + [`Option_{sortedControls}`] = option, + }), }) end diff --git a/src/Storybook/StoryMeta.luau b/src/Storybook/StoryMeta.luau index 42207a0e..ca611ce0 100644 --- a/src/Storybook/StoryMeta.luau +++ b/src/Storybook/StoryMeta.luau @@ -1,13 +1,16 @@ local React = require("@pkg/React") -local types = require("@root/Storybook/types") +local Storyteller = require("@pkg/Storyteller") + local useTheme = require("@root/Common/useTheme") local MAX_SUMMARY_SIZE = 600 local e = React.createElement +type Story = Storyteller.Story + export type Props = { - story: types.Story, + story: Story, layoutOrder: number?, } @@ -31,7 +34,7 @@ local function StoryMeta(props: Props) BackgroundTransparency = 1, Font = theme.headerFont, Size = UDim2.fromScale(0, 0), - Text = props.story.name:sub(1, #props.story.name - 6), + Text = props.story.name, TextColor3 = theme.text, TextSize = theme.headerTextSize, }), diff --git a/src/Storybook/StoryMeta.story.luau b/src/Storybook/StoryMeta.story.luau index cbd5566c..54d28fa3 100644 --- a/src/Storybook/StoryMeta.story.luau +++ b/src/Storybook/StoryMeta.story.luau @@ -12,6 +12,10 @@ return { story = { name = "Story", summary = "Story summary", + source = Instance.new("ModuleScript"), + storybook = { + storyRoots = {}, + }, }, }), }), diff --git a/src/Storybook/StoryPreview.luau b/src/Storybook/StoryPreview.luau index 4f7ffdb6..56f7e282 100644 --- a/src/Storybook/StoryPreview.luau +++ b/src/Storybook/StoryPreview.luau @@ -2,11 +2,12 @@ local CoreGui = game:GetService("CoreGui") local React = require("@pkg/React") local ReactRoblox = require("@pkg/ReactRoblox") -local ScrollingFrame = require("@root/Common/ScrollingFrame") local Sift = require("@pkg/Sift") +local Storyteller = require("@pkg/Storyteller") + +local ScrollingFrame = require("@root/Common/ScrollingFrame") local StoryError = require("@root/Storybook/StoryError") -local mountStory = require("@root/Storybook/mountStory") -local types = require("@root/Storybook/types") +local usePrevious = require("@root/Common/usePrevious") local e = React.createElement @@ -15,42 +16,65 @@ local defaultProps = { zoom = 0, } +type Story = Storyteller.Story + export type Props = { - story: types.Story, + story: Story, controls: { [string]: any }, - storyModule: ModuleScript, - ref: any, + isMountedInViewport: boolean?, layoutOrder: number?, + zoom: number?, + ref: any, } type InternalProps = Props & typeof(defaultProps) local StoryPreview = React.forwardRef(function(providedProps: Props, ref: any) local props: InternalProps = Sift.Dictionary.merge(defaultProps, providedProps) - - local err, setErr = React.useState(nil) + local lifecycle = React.useRef(nil :: Storyteller.RenderLifecycle?) + local err, setErr = React.useState(nil :: string?) + local prevControls = usePrevious(props.controls) + local prevStory = usePrevious(props.story) React.useEffect(function() setErr(nil) end, { props.story, ref }) React.useEffect(function() + if props.story == prevStory and props.controls ~= prevControls then + local areControlsDifferent = prevControls and not Sift.Dictionary.equals(props.controls, prevControls) + + if lifecycle.current and areControlsDifferent then + local success, result = xpcall(function() + lifecycle.current.update(props.controls) + end, debug.traceback) + + if not success then + setErr(result) + end + end + end + end, { props.controls, prevControls, props.story, prevStory } :: { unknown }) + + React.useEffect(function(): (() -> ())? if props.story and ref.current then local success, result = xpcall(function() - return mountStory(props.story, props.controls, ref.current) + lifecycle.current = Storyteller.render(ref.current, props.story) end, debug.traceback) - if success then - return result - else + if not success then setErr(result) - return nil end end - return nil - end, { props.story, props.controls, props.isMountedInViewport, ref.current } :: { unknown }) + return function() + if lifecycle.current then + lifecycle.current.unmount() + lifecycle.current = nil + end + end + end, { props.story, props.isMountedInViewport } :: { unknown }) if err then return e(StoryError, { diff --git a/src/Storybook/StoryView.luau b/src/Storybook/StoryView.luau index 3359a9c8..e24ea64d 100644 --- a/src/Storybook/StoryView.luau +++ b/src/Storybook/StoryView.luau @@ -3,6 +3,7 @@ local Selection = game:GetService("Selection") local ModuleLoader = require("@pkg/ModuleLoader") local React = require("@pkg/React") local Sift = require("@pkg/Sift") +local Storyteller = require("@pkg/Storyteller") local PluginContext = require("@root/Plugin/PluginContext") local ResizablePanel = require("@root/Panels/ResizablePanel") @@ -14,49 +15,56 @@ local StoryMeta = require("@root/Storybook/StoryMeta") local StoryPreview = require("@root/Storybook/StoryPreview") local StoryViewNavbar = require("@root/Storybook/StoryViewNavbar") local constants = require("@root/constants") -local types = require("@root/Storybook/types") -local useStory = require("@root/Storybook/useStory") local useTheme = require("@root/Common/useTheme") local useZoom = require("@root/Common/useZoom") local e = React.createElement +type ModuleLoader = ModuleLoader.ModuleLoader +type Storybook = Storyteller.Storybook + type Props = { - loader: ModuleLoader.ModuleLoader, + loader: ModuleLoader, story: ModuleScript, - storybook: types.Storybook, + storybook: Storybook, } local function StoryView(props: Props) local theme = useTheme() local settingsContext = SettingsContext.use() - local story, storyErr = useStory(props.story, props.storybook, props.loader) + local story, storyErr = Storyteller.useStory(props.story, props.storybook, props.loader) local zoom = useZoom(props.story) local plugin = React.useContext(PluginContext.Context) - local extraControls, setExtraControls = React.useState({}) + local changedControls, setChangedControls = React.useState({}) local initialControlsHeight = settingsContext.getSetting("controlsHeight") local controlsHeight, setControlsHeight = React.useState(initialControlsHeight) local topbarHeight, setTopbarHeight = React.useState(0) local storyParentRef = React.useRef(nil :: GuiObject?) - local controls - - if story and story.controls then - controls = {} - for key, value in story.controls do - local override = extraControls[key] - - if override ~= nil and typeof(value) ~= "table" then - controls[key] = override - else - controls[key] = value + React.useEffect(function() + setChangedControls({}) + end, { story }) + + local controlsWithUserOverrides = React.useMemo(function() + local controls = {} + if story and story.controls then + for key, value in story.controls do + local override = changedControls[key] + + if override ~= nil and typeof(value) ~= "table" then + controls[key] = override + else + controls[key] = value + end end end - end + return controls + end, { story, changedControls } :: { unknown }) + + local showControls = controlsWithUserOverrides and not Sift.isEmpty(controlsWithUserOverrides) - local showControls = controls and not Sift.isEmpty(controls) local setControl = React.useCallback(function(control: string, newValue: any) - setExtraControls(function(prev) + setChangedControls(function(prev) return Sift.Dictionary.merge(prev, { [control] = newValue, }) @@ -164,7 +172,7 @@ local function StoryView(props: Props) StoryPreview = e(StoryPreview, { zoom = zoom.value, story = story, - controls = Sift.Dictionary.merge(controls, extraControls), + controls = Sift.Dictionary.merge(controlsWithUserOverrides, changedControls), storyModule = props.story, isMountedInViewport = isMountedInViewport, ref = storyParentRef, @@ -187,7 +195,7 @@ local function StoryView(props: Props) BackgroundColor3 = theme.sidebar, }, { StoryControls = e(StoryControls, { - controls = controls, + controls = controlsWithUserOverrides, setControl = setControl, }), }), diff --git a/src/Storybook/createStoryNodes.luau b/src/Storybook/createStoryNodes.luau index bf7b9ce8..a4741ccb 100644 --- a/src/Storybook/createStoryNodes.luau +++ b/src/Storybook/createStoryNodes.luau @@ -1,13 +1,13 @@ +local Storyteller = require("@pkg/Storyteller") + local explorerTypes = require("@root/Explorer/types") -local isStoryModule = require("@root/Storybook/isStoryModule") -local storybookTypes = require("@root/Storybook/types") -type Storybook = storybookTypes.Storybook +type Storybook = Storyteller.Storybook type ComponentTreeNode = explorerTypes.ComponentTreeNode local function hasStories(instance: Instance): boolean for _, descendant in ipairs(instance:GetDescendants()) do - if isStoryModule(descendant) then + if Storyteller.isStoryModule(descendant) then return true end end @@ -16,7 +16,7 @@ end local function createChildNodes(parent: ComponentTreeNode, instance: Instance, storybook: Storybook) for _, child in ipairs(instance:GetChildren()) do - local isStory = isStoryModule(child) + local isStory = Storyteller.isStoryModule(child) local isContainer = hasStories(child) if isStory or isContainer then diff --git a/src/Storybook/createStoryNodes.spec.luau b/src/Storybook/createStoryNodes.spec.luau index ec7f365d..fd6f6092 100644 --- a/src/Storybook/createStoryNodes.spec.luau +++ b/src/Storybook/createStoryNodes.spec.luau @@ -1,7 +1,8 @@ local JestGlobals = require("@pkg/JestGlobals") +local Storyteller = require("@pkg/Storyteller") + local createStoryNodes = require("./createStoryNodes") local newFolder = require("@root/Testing/newFolder") -local types = require("@root/Storybook/types") local expect = JestGlobals.expect local test = JestGlobals.test @@ -15,7 +16,7 @@ local mockStoryRoot = newFolder({ }), }) -local mockStorybook: types.Storybook = { +local mockStorybook: Storyteller.Storybook = { name = "MockStorybook", storyRoots = { mockStoryRoot }, } diff --git a/src/Storybook/isStoryModule.luau b/src/Storybook/isStoryModule.luau deleted file mode 100644 index 963821dc..00000000 --- a/src/Storybook/isStoryModule.luau +++ /dev/null @@ -1,10 +0,0 @@ -local constants = require("@root/constants") - -local function isStoryModule(instance: Instance) - if instance:IsA("ModuleScript") and instance.Name:match(constants.STORY_NAME_PATTERN) then - return true - end - return false -end - -return isStoryModule diff --git a/src/Storybook/isStoryModule.spec.luau b/src/Storybook/isStoryModule.spec.luau deleted file mode 100644 index 8ebc1b74..00000000 --- a/src/Storybook/isStoryModule.spec.luau +++ /dev/null @@ -1,25 +0,0 @@ -local JestGlobals = require("@pkg/JestGlobals") -local isStoryModule = require("./isStoryModule") - -local expect = JestGlobals.expect -local test = JestGlobals.test - -test("return `true` for a ModuleScript with .story in the name", function() - local module = Instance.new("ModuleScript") - module.Name = "Foo.story" - - expect(isStoryModule(module)).toBe(true) -end) - -test("return `false` if the given instance is not a ModuleScript", function() - local folder = Instance.new("Folder") - folder.Name = "Folder.story" - - expect(isStoryModule(folder)).toBe(false) -end) - -test("return `false` if a ModuleScript does not have .story in the name", function() - local module = Instance.new("ModuleScript") - - expect(isStoryModule(module)).toBe(false) -end) diff --git a/src/Storybook/isStorybookModule.luau b/src/Storybook/isStorybookModule.luau deleted file mode 100644 index 974c2954..00000000 --- a/src/Storybook/isStorybookModule.luau +++ /dev/null @@ -1,11 +0,0 @@ -local CoreGui = game:GetService("CoreGui") - -local constants = require("@root/constants") - -local function isStorybookModule(instance: Instance): boolean - return instance:IsA("ModuleScript") - and instance.Name:match(constants.STORYBOOK_NAME_PATTERN) ~= nil - and not instance:IsDescendantOf(CoreGui) -end - -return isStorybookModule diff --git a/src/Storybook/isStorybookModule.spec.luau b/src/Storybook/isStorybookModule.spec.luau deleted file mode 100644 index 3a893cdf..00000000 --- a/src/Storybook/isStorybookModule.spec.luau +++ /dev/null @@ -1,45 +0,0 @@ -local CoreGui = game:GetService("CoreGui") - -local JestGlobals = require("@pkg/JestGlobals") -local isStorybookModule = require("./isStorybookModule") - -local expect = JestGlobals.expect -local test = JestGlobals.test - -test("return true for ModuleScripts with the .storybook extension", function() - local storybook = Instance.new("ModuleScript") - storybook.Name = "Foo.storybook" - - expect(isStorybookModule(storybook)).toBe(true) -end) - -test("return false for non-ModuleScript instances", function() - local storybook = Instance.new("Folder") - storybook.Name = "Foo.storybook" - - expect(isStorybookModule(storybook)).toBe(false) -end) - -test("return false if .storybook is not part of the name", function() - local storybook = Instance.new("ModuleScript") - storybook.Name = "Foo" - - expect(isStorybookModule(storybook)).toBe(false) -end) - -test("return false if .storybook is in the wrong place", function() - local storybook = Instance.new("ModuleScript") - storybook.Name = "Foo.storybook.extra" - - expect(isStorybookModule(storybook)).toBe(false) -end) - -test("return false for storybooks in CoreGui", function() - local storybook = Instance.new("ModuleScript") - storybook.Name = "Foo.storybook" - storybook.Parent = CoreGui - - expect(isStorybookModule(storybook)).toBe(false) - - storybook:Destroy() -end) diff --git a/src/Storybook/loadStoryModule.luau b/src/Storybook/loadStoryModule.luau deleted file mode 100644 index a7c3368a..00000000 --- a/src/Storybook/loadStoryModule.luau +++ /dev/null @@ -1,67 +0,0 @@ -local ModuleLoader = require("@pkg/ModuleLoader") -local Sift = require("@pkg/Sift") - -local types = require("@root/Storybook/types") - -local Errors = { - MalformedStory = "Story is malformed. Check the source of %q and make sure its properties are correct", - Generic = "Failed to load story %q. Error: %s", -} - -local function loadStoryModule( - loader: ModuleLoader.ModuleLoader, - module: ModuleScript, - storybook: types.Storybook -): (types.Story?, string?) - if not module then - return nil, "Did not receive a module to load" - end - - local success, result = pcall(function() - return loader:require(module) - end) - - if not success then - return nil, Errors.Generic:format(module:GetFullName(), tostring(result)) - end - - local story: types.Story - if typeof(result) == "function" then - story = { - name = module.Name, - story = result, - } - else - local isValid, message = types.StoryMeta(result) - - if isValid then - local extraProps = {} - if types.ReactStorybook(storybook) then - local reactStorybook = storybook :: types.ReactStorybook - extraProps = { - react = reactStorybook.react, - reactRoblox = reactStorybook.reactRoblox, - } - elseif types.RoactStorybook(storybook) then - local roactStorybook = storybook :: types.RoactStorybook - extraProps = { - roact = roactStorybook.roact, - } - end - - story = Sift.Dictionary.merge({ - name = module.Name, - }, extraProps, result) - else - return nil, Errors.Generic:format(module:GetFullName(), message) - end - end - - if story then - return story, nil - else - return nil, Errors.MalformedStory:format(module:GetFullName()) - end -end - -return loadStoryModule diff --git a/src/Storybook/mountStory.luau b/src/Storybook/mountStory.luau deleted file mode 100644 index 74935ca1..00000000 --- a/src/Storybook/mountStory.luau +++ /dev/null @@ -1,66 +0,0 @@ -local types = require("@root/Storybook/types") - -local function mountFunctionalStory(story: types.FunctionalStory, props: types.StoryProps, parent: GuiObject) - local cleanup = story.story(parent, props) - - return function() - if typeof(cleanup) == "function" then - cleanup() - end - end -end - -local function mountRoactStory(story: types.RoactStory, props: types.StoryProps, parent: GuiObject) - local Roact = story.roact - - local element - if typeof(story.story) == "function" then - element = Roact.createElement(story.story, props) - else - element = story.story - end - - local handle = Roact.mount(element, parent, story.name) - - return function() - Roact.unmount(handle) - end -end - -local function mountReactStory(story: types.ReactStory, props: types.StoryProps, parent: GuiObject) - local React = story.react - local ReactRoblox = story.reactRoblox - - local root = ReactRoblox.createRoot(parent) - - local element - if typeof(story.story) == "function" then - element = React.createElement(story.story, props) - else - element = story.story - end - - root:render(element) - - return function() - root:unmount() - end -end - -local function mountStory(story: types.Story, controls: types.Controls, parent: GuiObject): (() -> ())? - local props: types.StoryProps = { - controls = controls, - } - - if story.roact then - return mountRoactStory(story :: types.RoactStory, props, parent) - elseif story.react and story.reactRoblox then - return mountReactStory(story :: types.ReactStory, props, parent) - elseif typeof(story.story) == "function" then - return mountFunctionalStory(story :: types.FunctionalStory, props, parent) - else - return nil - end -end - -return mountStory diff --git a/src/Storybook/types.luau b/src/Storybook/types.luau deleted file mode 100644 index 201d7c43..00000000 --- a/src/Storybook/types.luau +++ /dev/null @@ -1,117 +0,0 @@ -local t = require("@pkg/t") - -local types = {} - -export type RoactElement = { [string]: any } -export type Roact = { - createElement: (...any) -> any, - mount: (...any) -> any, - unmount: (...any) -> (), -} -types.Roact = t.interface({ - createElement = t.callback, - mount = t.callback, - unmount = t.callback, -}) - -type ReactElement = { [string]: any } - -type React = { - createElement: (...any) -> any, -} -types.React = t.interface({ - createElement = t.callback, -}) - -type ReactRoblox = { - createRoot: (Instance) -> { - render: (any, any) -> (), - unmount: (any) -> (), - }, -} -types.ReactRoblox = t.interface({ - createRoot = t.callback, -}) - -export type Controls = { - [string]: string | number | boolean, -} -types.Controls = t.map(t.string, t.union(t.string, t.number, t.boolean, t.map(t.number, t.any))) - -export type StoryProps = { - controls: Controls, -} - -export type StorybookMeta = { - storyRoots: { Instance }, - name: string?, -} -types.Storybook = t.interface({ - storyRoots = t.array(t.Instance), - - name = t.optional(t.string), - roact = t.optional(types.Roact), - react = t.optional(types.React), - reactRoblox = t.optional(types.ReactRoblox), -}) - -export type RoactStorybook = StorybookMeta & { - roact: Roact, -} -types.RoactStorybook = t.union( - types.Storybook, - t.interface({ - roact = t.optional(types.Roact), - }) -) - -export type ReactStorybook = StorybookMeta & { - react: React, - reactRoblox: ReactRoblox, -} -types.ReactStorybook = t.union( - types.Storybook, - t.interface({ - react = t.optional(types.React), - reactRoblox = t.optional(types.ReactRoblox), - }) -) - -export type Storybook = RoactStorybook | ReactStorybook | StorybookMeta - -export type StoryMeta = { - name: string, - story: any, - summary: string?, - controls: Controls?, - roact: Roact?, - react: React?, - reactRoblox: ReactRoblox?, -} -types.StoryMeta = t.interface({ - name = t.optional(t.string), - summary = t.optional(t.string), - controls = t.optional(types.Controls), - roact = t.optional(types.Roact), - react = t.optional(types.React), - reactRoblox = t.optional(types.ReactRoblox), -}) - -export type RoactStory = StoryMeta & { - story: RoactElement | (props: StoryProps) -> RoactElement, - roact: Roact, -} - -export type ReactStory = StoryMeta & { - story: ReactElement | (props: StoryProps) -> ReactElement, - react: React, - reactRoblox: ReactRoblox, -} - -export type FunctionalStory = StoryMeta & { - story: (target: GuiObject, props: StoryProps) -> (() -> ())?, -} - -export type Story = FunctionalStory | RoactStory | ReactStory | StoryMeta - -return types diff --git a/src/Storybook/useStory.luau b/src/Storybook/useStory.luau deleted file mode 100644 index 577931b4..00000000 --- a/src/Storybook/useStory.luau +++ /dev/null @@ -1,43 +0,0 @@ -local ModuleLoader = require("@pkg/ModuleLoader") -local React = require("@pkg/React") - -local loadStoryModule = require("@root/Storybook/loadStoryModule") -local types = require("@root/Storybook/types") - -local function useStory( - module: ModuleScript, - storybook: types.Storybook, - loader: ModuleLoader.ModuleLoader -): (types.Story?, string?) - local state, setState = React.useState({} :: { - story: types.Story?, - err: string?, - }) - - local loadStory = React.useCallback(function() - local story, err = loadStoryModule(loader, module, storybook) - - setState({ - story = story, - err = err, - }) - end, { loader, module, storybook } :: { unknown }) - - React.useEffect(function() - local conn = loader.loadedModuleChanged:Connect(function(other) - if other == module then - loadStory() - end - end) - - loadStory() - - return function() - conn:Disconnect() - end - end, { module, loadStory, loader } :: { unknown }) - - return state.story, state.err -end - -return useStory diff --git a/src/Storybook/useStorybooks.luau b/src/Storybook/useStorybooks.luau deleted file mode 100644 index 98d7833f..00000000 --- a/src/Storybook/useStorybooks.luau +++ /dev/null @@ -1,65 +0,0 @@ -local ModuleLoader = require("@pkg/ModuleLoader") -local React = require("@pkg/React") - -local constants = require("@root/constants") -local isStorybookModule = require("@root/Storybook/isStorybookModule") -local types = require("@root/Storybook/types") -local useDescendants = require("@root/Common/useDescendants") - -local function hasPermission(instance: Instance) - local success = pcall(function() - return instance.Name - end) - return success -end - -local function useStorybooks(parent: Instance, loader: ModuleLoader.ModuleLoader) - local storybooks, set = React.useState({}) - local modules = useDescendants(game, function(descendant) - return hasPermission(descendant) and isStorybookModule(descendant) - end) - - local loadStorybooks = React.useCallback(function() - local newStorybooks = {} - - for _, module in modules do - local wasRequired, result = pcall(function() - return loader:require(module :: ModuleScript) - end) - - if wasRequired then - local success, message = types.Storybook(result) - - if success then - result.name = if result.name - then result.name - else module.Name:gsub(constants.STORYBOOK_NAME_PATTERN, "") - - table.insert(newStorybooks, result) - else - warn(("Failed to load storybook %s. Error: %s"):format(module:GetFullName(), message)) - end - end - end - - set(newStorybooks) - end, { set, parent, loader, modules } :: { unknown }) - - React.useEffect(function() - local conn = loader.loadedModuleChanged:Connect(function(other) - if types.Storybook(other) then - loadStorybooks() - end - end) - - loadStorybooks() - - return function() - conn:Disconnect() - end - end, { loadStorybooks, loader } :: { unknown }) - - return storybooks -end - -return useStorybooks diff --git a/src/init.storybook.luau b/src/init.storybook.luau index af5486e7..5210a7e9 100644 --- a/src/init.storybook.luau +++ b/src/init.storybook.luau @@ -6,6 +6,8 @@ return { storyRoots = { script.Parent, }, - react = React, - reactRoblox = ReactRoblox, + packages = { + React = React, + ReactRoblox = ReactRoblox, + }, } diff --git a/src/stories.spec.luau b/src/stories.spec.luau index 4e7fe90d..0d26cc17 100644 --- a/src/stories.spec.luau +++ b/src/stories.spec.luau @@ -1,41 +1,66 @@ local CoreGui = game:GetService("CoreGui") local JestGlobals = require("@pkg/JestGlobals") +local ModuleLoader = require("@pkg/ModuleLoader") local React = require("@pkg/React") local ReactRoblox = require("@pkg/ReactRoblox") -local isStoryModule = require("@root/Storybook/isStoryModule") -local mountStory = require("@root/Storybook/mountStory") +local Sift = require("@pkg/Sift") +local Storyteller = require("@pkg/Storyteller") +local afterEach = JestGlobals.afterEach +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local describeEach = describe.each :: any local expect = JestGlobals.expect local test = JestGlobals.test -local storyModules: { ModuleScript } = {} -for _, descendant in ipairs(script.Parent:GetDescendants()) do - if isStoryModule(descendant) then - table.insert(storyModules, descendant) - end -end - -for _, storyModule in storyModules do - test(`mount/unmount {storyModule:GetFullName()}`, function() - local story = (require :: any)(storyModule) - if typeof(story) == "function" then - story = { - name = storyModule.Name, - story = story, - } - end - - story.react = React - story.reactRoblox = ReactRoblox - - local cleanup - expect(function() - cleanup = mountStory(story, story.controls, CoreGui) - end).never.toThrow() - - if cleanup then - expect(cleanup).never.toThrow() - end +local container + +beforeEach(function() + container = Instance.new("Folder") + container.Parent = CoreGui +end) + +afterEach(function() + container:Destroy() +end) + +describeEach({ + Storyteller.findStorybookModules(script.Parent), +})("%s", function(storybookModule) + -- FIXME: This is needed to get around a bug with React renders. I'm hoping + -- to keep this for now, but in the future this should really be a + -- ModuleLoader instance + local mockModuleLoader = ( + { + require = function(_self, path) + return (require :: any)(path) + end, + } :: any + ) :: ModuleLoader.ModuleLoader + + local storybook = Storyteller.loadStorybookModule(mockModuleLoader, storybookModule) + + describeEach({ + Storyteller.findStoryModulesForStorybook(storybook), + })("%s", function(storyModule) + test("basic mount/unmount lifecycle", function() + local story = Storyteller.loadStoryModule(mockModuleLoader, storyModule, storybook) + + if story.packages then + story.packages = Sift.Dictionary.join(story.packages, { + React = React, + ReactRoblox = ReactRoblox, + }) + end + + local lifecycle = Storyteller.render(container, story) + + expect(#container:GetChildren()).toBe(1) + + lifecycle.unmount() + + expect(#container:GetChildren()).toBe(0) + end) end) -end +end) diff --git a/wally.toml b/wally.toml index ed032e02..6e08dd51 100644 --- a/wally.toml +++ b/wally.toml @@ -8,10 +8,11 @@ exclude = ["*"] [dependencies] ModuleLoader = "flipbook-labs/module-loader@0.6.2" +Storyteller = "flipbook-labs/storyteller@0.4.2" React = "jsdotlua/react@17.0.2" ReactRoblox = "jsdotlua/react-roblox@17.0.2" ReactSpring = "chriscerie/react-spring@2.0.0" -Sift = "csqrl/sift@0.0.4" +Sift = "csqrl/sift@0.0.8" t = "osyrisrblx/t@3.0.0" # dev dependencies