diff --git a/example/ArrayControls.story.luau b/example/ArrayControls.story.luau index 9593541b..e45225b7 100644 --- a/example/ArrayControls.story.luau +++ b/example/ArrayControls.story.luau @@ -1,6 +1,7 @@ local React = require("@pkg/React") local ReactRoblox = require("@pkg/ReactRoblox") local Sift = require("@pkg/Sift") +local constants = require("@root/constants") local fonts = Sift.Array.sort(Enum.Font:GetEnumItems(), function(a: Enum.Font, z: Enum.Font) return a.Name < z.Name @@ -17,19 +18,24 @@ type Props = { }, } +local stories = {} + +stories.Primary = function(props: Props) + return React.createElement("TextLabel", { + Text = props.controls.font.Name, + Font = props.controls.font, + TextScaled = true, + TextColor3 = Color3.fromRGB(0, 0, 0), + BackgroundColor3 = Color3.fromRGB(255, 255, 255), + Size = UDim2.fromOffset(300, 100), + }) +end + return { summary = "Example of using array controls to set the font for a TextLabel", controls = controls, react = React, reactRoblox = ReactRoblox, - story = function(props: Props) - return React.createElement("TextLabel", { - Text = props.controls.font.Name, - Font = props.controls.font, - TextScaled = true, - TextColor3 = Color3.fromRGB(0, 0, 0), - BackgroundColor3 = Color3.fromRGB(255, 255, 255), - Size = UDim2.fromOffset(300, 100), - }) - end, + story = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then nil else stories.Primary, + stories = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then stories else nil, } diff --git a/example/Button.story.luau b/example/Button.story.luau index 53fa013f..059066bc 100644 --- a/example/Button.story.luau +++ b/example/Button.story.luau @@ -1,13 +1,21 @@ -local Button = require("./Button") -local Roact = require("@pkg/Roact") +local Example = script:FindFirstAncestor("Example") + +local Roact = require(Example.Parent.Packages.Roact) +local constants = require(Example.Parent.constants) +local Button = require(script.Parent.Button) + +local stories = {} + +stories.Primary = Roact.createElement(Button, { + text = "Click me", + onActivated = function() + print("click") + end, +}) return { summary = "A generic button component that can be used anywhere", roact = Roact, - story = Roact.createElement(Button, { - text = "Click me", - onActivated = function() - print("click") - end, - }), + story = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then nil else stories.Primary, + stories = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then stories else nil, } diff --git a/example/ButtonWithControls.story.luau b/example/ButtonWithControls.story.luau index 60f7c9eb..2c7148f9 100644 --- a/example/ButtonWithControls.story.luau +++ b/example/ButtonWithControls.story.luau @@ -1,5 +1,8 @@ -local ButtonWithControls = require("./ButtonWithControls") -local Roact = require("@pkg/Roact") +local Example = script:FindFirstAncestor("Example") + +local Roact = require(Example.Parent.Packages.Roact) +local constants = require(Example.Parent.constants) +local ButtonWithControls = require(script.Parent.ButtonWithControls) local controls = { isDisabled = false, @@ -9,17 +12,22 @@ type Props = { controls: typeof(controls), } +local stories = {} + +stories.Primary = function(props: Props) + return Roact.createElement(ButtonWithControls, { + text = "Click me", + isDisabled = props.controls.isDisabled, + onActivated = function() + print("click") + end, + }) +end + return { summary = "A generic button component that can be used anywhere", controls = controls, roact = Roact, - story = function(props: Props) - return Roact.createElement(ButtonWithControls, { - text = "Click me", - isDisabled = props.controls.isDisabled, - onActivated = function() - print("click") - end, - }) - end, + story = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then nil else stories.Primary, + stories = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then stories else nil, } diff --git a/example/CanvasTests/AutomaticSize.story.luau b/example/CanvasTests/AutomaticSize.story.luau index 20ec7708..c7ff7794 100644 --- a/example/CanvasTests/AutomaticSize.story.luau +++ b/example/CanvasTests/AutomaticSize.story.luau @@ -1,18 +1,26 @@ -local React = require("@pkg/React") -local ReactRoblox = require("@pkg/ReactRoblox") +local Example = script:FindFirstAncestor("Example") + +local React = require(Example.Parent.Packages.React) +local ReactRoblox = require(Example.Parent.Packages.ReactRoblox) +local constants = require(Example.Parent.constants) + +local stories = {} + +stories.Primary = function() + return React.createElement("TextLabel", { + Size = UDim2.new(0, 0, 0, 0), + AutomaticSize = Enum.AutomaticSize.XY, + + TextSize = 24, + Text = script.Name, + Font = Enum.Font.GothamBold, + }) +end return { summary = "AutoamticSize test for the story preview", react = React, reactRoblox = ReactRoblox, - story = function() - return React.createElement("TextLabel", { - Size = UDim2.new(0, 0, 0, 0), - AutomaticSize = Enum.AutomaticSize.XY, - - TextSize = 24, - Text = script.Name, - Font = Enum.Font.GothamBold, - }) - end, + story = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then nil else stories.Primary, + stories = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then stories else nil, } diff --git a/example/CanvasTests/AutomaticSizeExceedsBounds.story.luau b/example/CanvasTests/AutomaticSizeExceedsBounds.story.luau index 30c9b107..afa721ef 100644 --- a/example/CanvasTests/AutomaticSizeExceedsBounds.story.luau +++ b/example/CanvasTests/AutomaticSizeExceedsBounds.story.luau @@ -1,18 +1,26 @@ -local React = require("@pkg/React") -local ReactRoblox = require("@pkg/ReactRoblox") +local Example = script:FindFirstAncestor("Example") + +local React = require(Example.Parent.Packages.React) +local ReactRoblox = require(Example.Parent.Packages.ReactRoblox) +local constants = require(Example.Parent.constants) + +local stories = {} + +stories.Primary = function() + return React.createElement("TextLabel", { + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + + TextSize = 24, + Text = script.Name .. string.rep("\nLine", 100), + Font = Enum.Font.GothamBold, + }) +end return { summary = "AutoamticSize test using a height that exceeds the story preview", react = React, reactRoblox = ReactRoblox, - story = function() - return React.createElement("TextLabel", { - Size = UDim2.fromScale(1, 0), - AutomaticSize = Enum.AutomaticSize.Y, - - TextSize = 24, - Text = script.Name .. string.rep("\nLine", 100), - Font = Enum.Font.GothamBold, - }) - end, + story = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then nil else stories.Primary, + stories = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then stories else nil, } diff --git a/example/CanvasTests/Offset.story.luau b/example/CanvasTests/Offset.story.luau index 40571ba1..c884104e 100644 --- a/example/CanvasTests/Offset.story.luau +++ b/example/CanvasTests/Offset.story.luau @@ -1,18 +1,26 @@ -local React = require("@pkg/React") -local ReactRoblox = require("@pkg/ReactRoblox") +local Example = script:FindFirstAncestor("Example") + +local React = require(Example.Parent.Packages.React) +local ReactRoblox = require(Example.Parent.Packages.ReactRoblox) +local constants = require(Example.Parent.constants) + +local stories = {} + +stories.Primary = function() + return React.createElement("TextLabel", { + Size = UDim2.fromOffset(2000, 2000), + AutomaticSize = Enum.AutomaticSize.None, + + TextSize = 24, + Text = script.Name, + Font = Enum.Font.GothamBold, + }) +end return { summary = "Offset test for the story preview", react = React, reactRoblox = ReactRoblox, - story = function() - return React.createElement("TextLabel", { - Size = UDim2.fromOffset(2000, 2000), - AutomaticSize = Enum.AutomaticSize.None, - - TextSize = 24, - Text = script.Name, - Font = Enum.Font.GothamBold, - }) - end, + story = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then nil else stories.Primary, + stories = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then stories else nil, } diff --git a/example/CanvasTests/Resizing.story.luau b/example/CanvasTests/Resizing.story.luau index 7ae1e1b7..817b5780 100644 --- a/example/CanvasTests/Resizing.story.luau +++ b/example/CanvasTests/Resizing.story.luau @@ -2,6 +2,7 @@ local RunService = game:GetService("RunService") local React = require("@pkg/React") local ReactRoblox = require("@pkg/ReactRoblox") +local constants = require("@root/constants") local RESIZE_DURATIOn = 3 -- seconds local MAX_SIZE = 2000 -- px @@ -32,11 +33,18 @@ local function Story() }) end +local stories = {} + +stories.Primary = React.createElement(Story) + return { summary = "Resizing test for the story preview", react = React, reactRoblox = ReactRoblox, - story = function() - return React.createElement(Story) - end, + story = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT + then nil + else function() + return React.createElement(Story) + end, + stories = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then stories else nil, } diff --git a/example/CanvasTests/Scale.story.luau b/example/CanvasTests/Scale.story.luau index 6c315d57..a3cead56 100644 --- a/example/CanvasTests/Scale.story.luau +++ b/example/CanvasTests/Scale.story.luau @@ -1,18 +1,26 @@ -local React = require("@pkg/React") -local ReactRoblox = require("@pkg/ReactRoblox") +local Example = script:FindFirstAncestor("Example") + +local React = require(Example.Parent.Packages.React) +local ReactRoblox = require(Example.Parent.Packages.ReactRoblox) +local constants = require(Example.Parent.constants) + +local stories = {} + +stories.Primary = function() + return React.createElement("TextLabel", { + Size = UDim2.fromScale(1, 1), + AutomaticSize = Enum.AutomaticSize.None, + + TextSize = 24, + Text = script.Name, + Font = Enum.Font.GothamBold, + }) +end return { summary = "Scale test for the story preview", react = React, reactRoblox = ReactRoblox, - story = function() - return React.createElement("TextLabel", { - Size = UDim2.fromScale(1, 1), - AutomaticSize = Enum.AutomaticSize.None, - - TextSize = 24, - Text = script.Name, - Font = Enum.Font.GothamBold, - }) - end, + story = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then nil else stories.Primary, + stories = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then stories else nil, } diff --git a/example/CanvasTests/ScrollingFrame.story.luau b/example/CanvasTests/ScrollingFrame.story.luau index 4a7184c5..0701f9ef 100644 --- a/example/CanvasTests/ScrollingFrame.story.luau +++ b/example/CanvasTests/ScrollingFrame.story.luau @@ -1,22 +1,30 @@ -local React = require("@pkg/React") -local ReactRoblox = require("@pkg/ReactRoblox") +local Example = script:FindFirstAncestor("Example") + +local React = require(Example.Parent.Packages.React) +local ReactRoblox = require(Example.Parent.Packages.ReactRoblox) +local constants = require(Example.Parent.constants) + +local stories = {} + +stories.Primary = function() + return React.createElement("ScrollingFrame", { + Size = UDim2.fromScale(1, 1), + }, { + Label = React.createElement("TextLabel", { + Size = UDim2.fromScale(1, 1), + AutomaticSize = Enum.AutomaticSize.None, + + TextSize = 24, + Text = script.Name, + Font = Enum.Font.GothamBold, + }), + }) +end return { summary = "ScrollingFrame test for the story preview", react = React, reactRoblox = ReactRoblox, - story = function() - return React.createElement("ScrollingFrame", { - Size = UDim2.fromScale(1, 1), - }, { - Label = React.createElement("TextLabel", { - Size = UDim2.fromScale(1, 1), - AutomaticSize = Enum.AutomaticSize.None, - - TextSize = 24, - Text = script.Name, - Font = Enum.Font.GothamBold, - }), - }) - end, + story = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then nil else stories.Primary, + stories = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then stories else nil, } diff --git a/example/Counter.story.luau b/example/Counter.story.luau index b5d7726b..264ed65c 100644 --- a/example/Counter.story.luau +++ b/example/Counter.story.luau @@ -1,5 +1,8 @@ -local Counter = require("./Counter") -local Roact = require("@pkg/Roact") +local Example = script:FindFirstAncestor("Example") + +local Roact = require(Example.Parent.Packages.Roact) +local constants = require(Example.Parent.constants) +local Counter = require(script.Parent.Counter) local controls = { increment = 1, @@ -10,14 +13,19 @@ type Props = { controls: typeof(controls), } +local stories = {} + +stories.Primary = function(props: Props) + return Roact.createElement(Counter, { + increment = props.controls.increment, + waitTime = props.controls.waitTime, + }) +end + return { summary = "A simple counter that increments every second", controls = controls, roact = Roact, - story = function(props: Props) - return Roact.createElement(Counter, { - increment = props.controls.increment, - waitTime = props.controls.waitTime, - }) - end, + story = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then nil else stories.Primary, + stories = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then stories else nil, } diff --git a/example/Functional.story.luau b/example/Functional.story.luau index c8871515..920b1d20 100644 --- a/example/Functional.story.luau +++ b/example/Functional.story.luau @@ -1,3 +1,7 @@ +local Example = script:FindFirstAncestor("Example") + +local constants = require(Example.Parent.constants) + local controls = { text = "Functional Story", } @@ -6,29 +10,34 @@ type Props = { controls: typeof(controls), } +local stories = {} + +stories.Primary = function(parent: GuiObject, props: Props) + local label = Instance.new("TextLabel") + label.Text = props.controls.text + label.Font = Enum.Font.Gotham + label.TextColor3 = Color3.fromRGB(0, 0, 0) + label.BackgroundColor3 = Color3.fromRGB(255, 255, 255) + label.TextSize = 16 + label.AutomaticSize = Enum.AutomaticSize.XY + + local padding = Instance.new("UIPadding") + padding.PaddingTop = UDim.new(0, 8) + padding.PaddingRight = padding.PaddingTop + padding.PaddingBottom = padding.PaddingTop + padding.PaddingLeft = padding.PaddingTop + padding.Parent = label + + label.Parent = parent + + return function() + label:Destroy() + end +end + return { summary = "This story uses a function with a cleanup callback to create and mount the gui elements. This works similarly to Hoarcekat stories but also supports controls and other metadata. Check out the source to learn more", controls = controls, - story = function(parent: GuiObject, props: Props) - local label = Instance.new("TextLabel") - label.Text = props.controls.text - label.Font = Enum.Font.Gotham - label.TextColor3 = Color3.fromRGB(0, 0, 0) - label.BackgroundColor3 = Color3.fromRGB(255, 255, 255) - label.TextSize = 16 - label.AutomaticSize = Enum.AutomaticSize.XY - - local padding = Instance.new("UIPadding") - padding.PaddingTop = UDim.new(0, 8) - padding.PaddingRight = padding.PaddingTop - padding.PaddingBottom = padding.PaddingTop - padding.PaddingLeft = padding.PaddingTop - padding.Parent = label - - label.Parent = parent - - return function() - label:Destroy() - end - end, + story = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then nil else stories.Primary, + stories = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then stories else nil, } diff --git a/example/ReactCounter.story.luau b/example/ReactCounter.story.luau index 7fbe9dd7..5958f81f 100644 --- a/example/ReactCounter.story.luau +++ b/example/ReactCounter.story.luau @@ -1,6 +1,9 @@ -local React = require("@pkg/React") -local ReactCounter = require("./ReactCounter") -local ReactRoblox = require("@pkg/ReactRoblox") +local Example = script:FindFirstAncestor("Example") + +local React = require(Example.Parent.Packages.React) +local ReactCounter = require(script.Parent.ReactCounter) +local ReactRoblox = require(Example.Parent.Packages.ReactRoblox) +local constants = require(Example.Parent.constants) local controls = { increment = 1, @@ -11,15 +14,20 @@ type Props = { controls: typeof(controls), } +local stories = {} + +stories.Primary = function(props: Props) + return React.createElement(ReactCounter, { + increment = props.controls.increment, + waitTime = props.controls.waitTime, + }) +end + return { summary = "A simple counter that increments every second. This is a copy of the Counter component, but written with React", controls = controls, react = React, reactRoblox = ReactRoblox, - story = function(props: Props) - return React.createElement(ReactCounter, { - increment = props.controls.increment, - waitTime = props.controls.waitTime, - }) - end, + story = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then nil else stories.Primary, + stories = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then stories else nil, } diff --git a/src/Common/Branding.story.luau b/src/Common/Branding.story.luau index 042bbc57..da95df87 100644 --- a/src/Common/Branding.story.luau +++ b/src/Common/Branding.story.luau @@ -4,12 +4,15 @@ local Branding = require("./Branding") local ContextProviders = require("@root/Common/ContextProviders") local MockPlugin = require("@root/Testing/MockPlugin") +local stories = {} + +stories.Primary = React.createElement(ContextProviders, { + plugin = MockPlugin.new(), +}, { + Branding = React.createElement(Branding), +}) + return { summary = "Icon and Typography for flipbook", - controls = {}, - story = React.createElement(ContextProviders, { - plugin = MockPlugin.new(), - }, { - Branding = React.createElement(Branding), - }), + stories = stories, } diff --git a/src/Common/ScrollingFrame.story.luau b/src/Common/ScrollingFrame.story.luau index d969c5f9..05296360 100644 --- a/src/Common/ScrollingFrame.story.luau +++ b/src/Common/ScrollingFrame.story.luau @@ -13,35 +13,39 @@ type Props = { controls: typeof(controls), } -return { - controls = controls, - story = function(props: Props) - local children = {} +local stories = {} + +stories.Primary = function(props: Props) + local children = {} + + children.Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 16), + }) - children.Layout = React.createElement("UIListLayout", { - SortOrder = Enum.SortOrder.LayoutOrder, - Padding = UDim.new(0, 16), + for i = 1, props.controls.numItems do + children["Box" .. i] = React.createElement("Frame", { + LayoutOrder = i, + Size = UDim2.fromOffset(100, 100), + BackgroundColor3 = if props.controls.useGradient + then Color3.fromRGB(0, 255 / i, 0) + else Color3.fromRGB(0, 255, 0), }) + end - for i = 1, props.controls.numItems do - children["Box" .. i] = React.createElement("Frame", { - LayoutOrder = i, - Size = UDim2.fromOffset(100, 100), - BackgroundColor3 = if props.controls.useGradient - then Color3.fromRGB(0, 255 / i, 0) - else Color3.fromRGB(0, 255, 0), - }) - end - - return React.createElement(ContextProviders, { - plugin = MockPlugin.new(), + return React.createElement(ContextProviders, { + plugin = MockPlugin.new(), + }, { + Wrapper = React.createElement("Frame", { + Size = UDim2.new(1, 0, 0, 200), + BackgroundTransparency = 1, }, { - Wrapper = React.createElement("Frame", { - Size = UDim2.new(1, 0, 0, 200), - BackgroundTransparency = 1, - }, { - ScrollingFrame = React.createElement(ScrollingFrame, {}, children), - }), - }) - end, + ScrollingFrame = React.createElement(ScrollingFrame, {}, children), + }), + }) +end + +return { + controls = controls, + stories = stories, } diff --git a/src/Common/Sprite.story.luau b/src/Common/Sprite.story.luau index a709907b..cc308491 100644 --- a/src/Common/Sprite.story.luau +++ b/src/Common/Sprite.story.luau @@ -2,30 +2,34 @@ local React = require("@pkg/React") local Sprite = require("./Sprite") local assets = require("@root/assets") -return { - story = React.createElement("Folder", {}, { - Layout = React.createElement("UIListLayout", { - SortOrder = Enum.SortOrder.LayoutOrder, - }), +local stories = {} + +stories.Primary = React.createElement("Folder", {}, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + }), - flipbook = React.createElement(Sprite, { - layoutOrder = 1, - image = assets.flipbook, - }), + flipbook = React.createElement(Sprite, { + layoutOrder = 1, + image = assets.flipbook, + }), - Storybook = React.createElement(Sprite, { - layoutOrder = 2, - image = assets.Storybook, - }), + Storybook = React.createElement(Sprite, { + layoutOrder = 2, + image = assets.Storybook, + }), - Folder = React.createElement(Sprite, { - layoutOrder = 3, - image = assets.Folder, - }), + Folder = React.createElement(Sprite, { + layoutOrder = 3, + image = assets.Folder, + }), - Component = React.createElement(Sprite, { - layoutOrder = 4, - image = assets.Component, - }), + Component = React.createElement(Sprite, { + layoutOrder = 4, + image = assets.Component, }), +}) + +return { + stories = stories, } diff --git a/src/Context/StorytellerContext.luau b/src/Context/StorytellerContext.luau new file mode 100644 index 00000000..96fa5426 --- /dev/null +++ b/src/Context/StorytellerContext.luau @@ -0,0 +1,91 @@ +local ModuleLoader = require("@pkg/ModuleLoader") +local React = require("@pkg/React") +local Sift = require("@pkg/Sift") + +local loadStoryModule = require("@root/Storybook/loadStoryModule") +local storybookTypes = require("@root/Storybook/types") +local useStorybooks = require("@root/Storybook/useStorybooks") + +local createContext = React.createContext +local useCallback = React.useCallback +local useContext = React.useContext +local useState = React.useState + +type Story = storybookTypes.Story +type Storybook = storybookTypes.Storybook + +export type StorytellerContext = { + storybooks: { Storybook }, + activeStory: Story?, + activeStorybook: Storybook?, + + setContainer: (container: Instance) -> (), + + openStory: (storyModule: ModuleScript, storybook: Storybook) -> (), +} + +local StorytellerContext = createContext() + +export type Props = { + -- The location to search for descendant storybooks + storybookRoot: Instance, + + -- An instance of ModuleLoader for loading storybooks and stories + loader: any, +} + +local defaultProps = { + loader = ModuleLoader.new(), +} + +type InternalProps = Props & typeof(defaultProps) + +local function StorytellerProvider(providedProps: Props) + local props: InternalProps = Sift.Dictionary.join(defaultProps, providedProps) + + -- All storybooks are gathered and loaded right away, but stories do not + -- receive the same treatment. Reason being, we need the storybooks to + -- discover where all the stories are so we can render them in the UI. But + -- stories themselves are only useful to be rendered when the user selects + -- one. This is why we often deal with ModuleScripts for stories directly + local storybooks = useStorybooks(props.storybookRoot, props.loader) + + local container, setContainer = useState(nil :: Instance?) + + local currentStoryModule, setCurrentStoryModule = useState(nil :: ModuleScript?) + + local activePair, setActivePair = useState({ + story = nil :: Story?, + storybook = nil :: Storybook?, + }) + + local openStory = useCallback(function(storyModule: ModuleScript, storybook: Storybook) + local story, err = loadStoryModule(props.loader, storyModule, storybook) + + setActivePair({ + story = story, + storybook = storybook, + }) + end, { props.loader }) + + return React.createElement(StorytellerContext.Provider, { + value = { + storybooks = storybooks, + activeStory = activePair.story, + activeStorybook = activePair.storybook, + + setContainer = setContainer, + openStory = openStory, + }, + }) +end + +local function useStorytellerContext(): StorytellerContext + return useContext(StorytellerContext) +end + +return { + StorytellerContext = StorytellerContext, + StorytellerProvider = StorytellerProvider, + useStorytellerContext = useStorytellerContext, +} diff --git a/src/Forms/Button.story.luau b/src/Forms/Button.story.luau index 4e47d4e6..0ae00671 100644 --- a/src/Forms/Button.story.luau +++ b/src/Forms/Button.story.luau @@ -12,17 +12,21 @@ type Props = { controls: typeof(controls), } +local stories = {} + +stories.Primary = function(props: Props) + return React.createElement(ContextProviders, { plugin = MockPlugin.new() }, { + Button = React.createElement(Button, { + text = props.controls.text, + onClick = function() + print("click") + end, + }), + }) +end + return { summary = "A generic button component that can be used anywhere", controls = controls, - story = function(props: Props) - return React.createElement(ContextProviders, { plugin = MockPlugin.new() }, { - Button = React.createElement(Button, { - text = props.controls.text, - onClick = function() - print("click") - end, - }), - }) - end, + stories = stories, } diff --git a/src/Forms/Checkbox.story.luau b/src/Forms/Checkbox.story.luau index 30a989a0..f75d73e5 100644 --- a/src/Forms/Checkbox.story.luau +++ b/src/Forms/Checkbox.story.luau @@ -4,16 +4,20 @@ local Checkbox = require("./Checkbox") local ContextProviders = require("@root/Common/ContextProviders") local MockPlugin = require("@root/Testing/MockPlugin") +local stories = {} + +stories.Primary = React.createElement(ContextProviders, { + plugin = MockPlugin.new(), +}, { + Checkbox = React.createElement(Checkbox, { + initialState = true, + onStateChange = function(newState) + print("Checkbox state changed to", newState) + end, + }), +}) + return { summary = "Generic checkbox used for story controls", - story = React.createElement(ContextProviders, { - plugin = MockPlugin.new(), - }, { - Checkbox = React.createElement(Checkbox, { - initialState = true, - onStateChange = function(newState) - print("Checkbox state changed to", newState) - end, - }), - }), + stories = stories, } diff --git a/src/Forms/Dropdown.story.luau b/src/Forms/Dropdown.story.luau index c6b9081c..019e28b4 100644 --- a/src/Forms/Dropdown.story.luau +++ b/src/Forms/Dropdown.story.luau @@ -13,25 +13,29 @@ type Props = { controls: typeof(controls), } +local stories = {} + +stories.Primary = function(props: Props) + local options = {} + for i = 1, props.controls.numOptions do + table.insert(options, "Option " .. i) + end + + return React.createElement(ContextProviders, { + plugin = MockPlugin.new(), + }, { + Dropdown = React.createElement(Dropdown, { + placeholder = "Select an option", + default = if props.controls.useDefault then options[1] else nil, + options = options, + onOptionChange = function(option) + print("Selected", option) + end, + }), + }) +end + return { controls = controls, - story = function(props: Props) - local options = {} - for i = 1, props.controls.numOptions do - table.insert(options, "Option " .. i) - end - - return React.createElement(ContextProviders, { - plugin = MockPlugin.new(), - }, { - Dropdown = React.createElement(Dropdown, { - placeholder = "Select an option", - default = if props.controls.useDefault then options[1] else nil, - options = options, - onOptionChange = function(option) - print("Selected", option) - end, - }), - }) - end, + stories = stories, } diff --git a/src/Forms/InputField.story.luau b/src/Forms/InputField.story.luau index c5a22ba1..6d02990c 100644 --- a/src/Forms/InputField.story.luau +++ b/src/Forms/InputField.story.luau @@ -4,22 +4,26 @@ local ContextProviders = require("@root/Common/ContextProviders") local InputField = require("./InputField") local MockPlugin = require("@root/Testing/MockPlugin") -return { - story = React.createElement(ContextProviders, { - plugin = MockPlugin.new(), - }, { - InputField = React.createElement(InputField, { - placeholder = "Enter information...", - autoFocus = true, - onSubmit = function(text) - print(text) - end, - validate = function(text: string) - return #text <= 4 - end, - transform = function(text: string) - return text:upper() - end, - }), +local stories = {} + +stories.Primary = React.createElement(ContextProviders, { + plugin = MockPlugin.new(), +}, { + InputField = React.createElement(InputField, { + placeholder = "Enter information...", + autoFocus = true, + onSubmit = function(text) + print(text) + end, + validate = function(text: string) + return #text <= 4 + end, + transform = function(text: string) + return text:upper() + end, }), +}) + +return { + stories = stories, } diff --git a/src/Forms/Searchbar.story.luau b/src/Forms/Searchbar.story.luau index 394520ca..8247a2a0 100644 --- a/src/Forms/Searchbar.story.luau +++ b/src/Forms/Searchbar.story.luau @@ -4,12 +4,16 @@ local ContextProviders = require("@root/Common/ContextProviders") local MockPlugin = require("@root/Testing/MockPlugin") local Searchbar = require("./Searchbar") +local stories = {} + +stories.Primary = React.createElement(ContextProviders, { + plugin = MockPlugin.new(), +}, { + Searchbar = React.createElement(Searchbar), +}) + return { summary = "Searchbar used to search for components", controls = {}, - story = React.createElement(ContextProviders, { - plugin = MockPlugin.new(), - }, { - Searchbar = React.createElement(Searchbar), - }), + stories = stories, } diff --git a/src/Forms/SelectableTextLabel.story.luau b/src/Forms/SelectableTextLabel.story.luau index ea92b8d2..9d6891e9 100644 --- a/src/Forms/SelectableTextLabel.story.luau +++ b/src/Forms/SelectableTextLabel.story.luau @@ -12,16 +12,20 @@ type Props = { controls: typeof(controls), } +local stories = {} + +stories.Primary = function(props: Props) + return React.createElement(ContextProviders, { + plugin = MockPlugin.new(), + }, { + SelectableTextLabel = React.createElement(SelectableTextLabel, { + Text = props.controls.text, + }), + }) +end + return { summary = "A styled TextLabel with selectable text. Click and drag with the mouse to select content", controls = controls, - story = function(props: Props) - return React.createElement(ContextProviders, { - plugin = MockPlugin.new(), - }, { - SelectableTextLabel = React.createElement(SelectableTextLabel, { - Text = props.controls.text, - }), - }) - end, + stories = stories, } diff --git a/src/Panels/ResizablePanel.story.luau b/src/Panels/ResizablePanel.story.luau index 4e9e2572..a25a656a 100644 --- a/src/Panels/ResizablePanel.story.luau +++ b/src/Panels/ResizablePanel.story.luau @@ -12,18 +12,22 @@ type Props = { controls: typeof(controls), } +local stories = {} + +stories.Primary = function(props) + return React.createElement(ResizablePanel, { + initialSize = UDim2.fromOffset(props.controls.maxWidth - props.controls.minWidth, 300), + maxSize = Vector2.new(props.controls.maxWidth, props.controls.maxHeight), + minSize = Vector2.new(props.controls.minWidth, props.controls.minHeight), + dragHandles = { "Right", "Bottom" }, + }, { + Content = React.createElement("Frame", { + Size = UDim2.fromScale(1, 1), + }), + }) +end + return { controls = controls, - story = function(props) - return React.createElement(ResizablePanel, { - initialSize = UDim2.fromOffset(props.controls.maxWidth - props.controls.minWidth, 300), - maxSize = Vector2.new(props.controls.maxWidth, props.controls.maxHeight), - minSize = Vector2.new(props.controls.minWidth, props.controls.minHeight), - dragHandles = { "Right", "Bottom" }, - }, { - Content = React.createElement("Frame", { - Size = UDim2.fromScale(1, 1), - }), - }) - end, + stories = stories, } diff --git a/src/Panels/Sidebar.story.luau b/src/Panels/Sidebar.story.luau index 8ea6b3de..a312f494 100644 --- a/src/Panels/Sidebar.story.luau +++ b/src/Panels/Sidebar.story.luau @@ -5,22 +5,26 @@ local MockPlugin = require("@root/Testing/MockPlugin") local Sidebar = require("./Sidebar") local internalStorybook = require("@root/init.storybook.luau") +local stories = {} + +stories.Primary = React.createElement(ContextProviders, { + plugin = MockPlugin.new(), +}, { + Sidebar = React.createElement(Sidebar, { + storybooks = { + internalStorybook, + }, + selectStory = function(storyModule) + print(storyModule) + end, + selectStorybook = function(storybook) + print(storybook) + end, + }), +}) + return { summary = "Sidebar containing brand, searchbar, and component tree", controls = {}, - story = React.createElement(ContextProviders, { - plugin = MockPlugin.new(), - }, { - Sidebar = React.createElement(Sidebar, { - storybooks = { - internalStorybook, - }, - selectStory = function(storyModule) - print(storyModule) - end, - selectStorybook = function(storybook) - print(storybook) - end, - }), - }), + stories = stories, } diff --git a/src/Plugin/PluginApp.story.luau b/src/Plugin/PluginApp.story.luau index 5e738cfc..943c1cf2 100644 --- a/src/Plugin/PluginApp.story.luau +++ b/src/Plugin/PluginApp.story.luau @@ -5,15 +5,19 @@ local ContextProviders = require("@root/Common/ContextProviders") local MockPlugin = require("@root/Testing/MockPlugin") local PluginApp = require("./PluginApp") +local stories = {} + +stories.Primary = React.createElement(ContextProviders, { + plugin = MockPlugin.new(), +}, { + PluginApp = React.createElement(PluginApp, { + loader = ModuleLoader.new(), + plugin = plugin, + }), +}) + return { summary = "The main component that handles the entire plugin", controls = {}, - story = React.createElement(ContextProviders, { - plugin = MockPlugin.new(), - }, { - PluginApp = React.createElement(PluginApp, { - loader = ModuleLoader.new(), - plugin = plugin, - }), - }), + stories = stories, } diff --git a/src/Renderers/createFusionRenderer.luau b/src/Renderers/createFusionRenderer.luau new file mode 100644 index 00000000..03715088 --- /dev/null +++ b/src/Renderers/createFusionRenderer.luau @@ -0,0 +1,53 @@ +local Sift = require("@pkg/Sift") + +local createRobloxRenderer = require("@root/Renderers/createRobloxRenderer") +local types = require("@root/Renderers/types") + +type Renderer = types.Renderer + +type Packages = { + Fusion: any, +} + +local function createFusionRenderer(packages: Packages): Renderer + local Fusion = packages.Fusion + local robloxRenderer = createRobloxRenderer() + + local function transformContext(context, prevContext) + if context.args and prevContext and prevContext.args then + local transformed = table.clone(context) + + transformed.args = Sift.Dictionary.map(transformed.args, function(arg, key) + -- Retain Fusion.Value identities and update the internal value with + -- the new arg's value + local prevArg = if prevContext.args then prevContext.args[key] else nil + if prevArg then + prevArg:set(arg) + return prevArg + end + return Fusion.Value(arg) + end) + + return transformed + end + return context + end + + local function shouldUpdate(context, prevContext) + -- Arg changes should never trigger a remount. We retain the Value + -- identities to Fusion can handle its update logic + if prevContext and not Sift.Dictionary.equals(context.args, prevContext.args) then + return false + end + return true + end + + return { + mount = robloxRenderer.mount, + unmount = robloxRenderer.unmount, + shouldUpdate = shouldUpdate, + transformContext = transformContext, + } +end + +return createFusionRenderer diff --git a/src/Renderers/createFusionRenderer.spec.luau b/src/Renderers/createFusionRenderer.spec.luau new file mode 100644 index 00000000..768dd732 --- /dev/null +++ b/src/Renderers/createFusionRenderer.spec.luau @@ -0,0 +1,77 @@ +local Fusion = require("@pkg/Fusion") +local JestGlobals = require("@pkg/JestGlobals") + +local createFusionRenderer = require("./createFusionRenderer") +local render = require("./render") + +local beforeEach = JestGlobals.beforeEach +local expect = JestGlobals.expect +local test = JestGlobals.test + +local New = Fusion.New +type StateObject = Fusion.StateObject + +local function Button(props: { + isDisabled: StateObject, +}) + return New("TextButton")({ + Text = if props.isDisabled and props.isDisabled:get() then "Disabled" else "Enabled", + }) +end + +local container +local renderer + +beforeEach(function() + container = Instance.new("Folder") + + renderer = createFusionRenderer({ + Fusion = Fusion, + }) +end) + +test("render a Fusion component", function() + render(renderer, container, Button) + expect(container:FindFirstChildWhichIsA("TextButton")).toBeDefined() +end) + +test("unmount a Fusion component", function() + local lifecycle = render(renderer, container, Button) + + expect(#container:GetChildren()).toBe(1) + + lifecycle.unmount() + + expect(#container:GetChildren()).toBe(0) +end) + +test("args are transformed into Values", function() + render(renderer, container, Button, { + isDisabled = true, + }) + + local element = container:FindFirstChildWhichIsA("TextButton") + assert(element, "no element found") + expect(element.Text).toBe("Disabled") +end) + +test("update the component on arg changes", function() + expect(#container:GetChildren()).toBe(0) + + local lifecycle = render(renderer, container, Button, { + isDisabled = true, + }) + + expect(#container:GetChildren()).toBe(1) + + local element = container:FindFirstChildWhichIsA("TextButton") + assert(element, "no element found") + expect(element.Text).toBe("Disabled") + + lifecycle.update({ + isDisabled = false, + }) + + expect(#container:GetChildren()).toBe(1) + expect(element.Text).toBe("Enabled") +end) diff --git a/src/Renderers/createReactRenderer.luau b/src/Renderers/createReactRenderer.luau new file mode 100644 index 00000000..5c9ec643 --- /dev/null +++ b/src/Renderers/createReactRenderer.luau @@ -0,0 +1,48 @@ +local types = require("@root/Renderers/types") + +type Renderer = types.Renderer + +type Packages = { + React: any, + ReactRoblox: any, +} + +local function createReactRenderer(packages: Packages): Renderer + local React = packages.React + local ReactRoblox = packages.ReactRoblox + + local root + local currentElement + + local function reactRender(element, context) + local props = if context then context.args else nil + + currentElement = element + + element = React.createElement(element, props) + root:render(element) + end + + local function mount(container, element, context) + root = ReactRoblox.createRoot(container) + reactRender(element, context) + end + + local function update(newContext) + if currentElement then + reactRender(currentElement, newContext) + end + end + + local function unmount() + root:unmount() + end + + return { + mount = mount, + update = update, + unmount = unmount, + } +end + +return createReactRenderer diff --git a/src/Renderers/createReactRenderer.spec.luau b/src/Renderers/createReactRenderer.spec.luau new file mode 100644 index 00000000..3a87a5be --- /dev/null +++ b/src/Renderers/createReactRenderer.spec.luau @@ -0,0 +1,150 @@ +local JestGlobals = require("@pkg/JestGlobals") +local React = require("@pkg/React") +local ReactRoblox = require("@pkg/ReactRoblox") + +local createReactRenderer = require("./createReactRenderer") +local render = require("./render") + +local beforeEach = JestGlobals.beforeEach +local expect = JestGlobals.expect +local test = JestGlobals.test + +local act = ReactRoblox.act + +local function Button(props: { isDisabled: boolean? }) + return React.createElement("TextButton", { + Text = if props.isDisabled then "Disabled" else "Enabled", + }) +end + +local ButtonClassComponent = React.Component:extend("ButtonClassComponent") + +function ButtonClassComponent:render() + return React.createElement("TextButton", { + Text = if self.props.isDisabled then "Disabled" else "Enabled", + }) +end + +local container +local renderer + +beforeEach(function() + container = Instance.new("Folder") + + renderer = createReactRenderer({ + React = React, + ReactRoblox = ReactRoblox, + }) +end) + +test("render a functional componnet", function() + render(renderer, container, Button) + + act(function() + task.wait() + end) + + local element = container:GetChildren()[1] + + assert(element, "no element found") + expect(typeof(element)).toBe("Instance") + assert(element:IsA("TextButton"), "not a TextButton") + expect(element.Text).toBe("Enabled") +end) + +test("render a class component", function() + render(renderer, container, ButtonClassComponent) + + act(function() + task.wait() + end) + + local element = container:GetChildren()[1] + + assert(element, "no element found") + expect(typeof(element)).toBe("Instance") + assert(element:IsA("TextButton"), "not a TextButton") + expect(element.Text).toBe("Enabled") +end) + +test("pass args as props", function() + render(renderer, container, Button, { + isDisabled = true, + }) + + act(function() + task.wait() + end) + + local button = container:FindFirstChildWhichIsA("TextButton") + + expect(button).toBeDefined() + expect(button.Text).toBe("Disabled") +end) + +test("update the component on arg changes", function() + local lifecycle = render(renderer, container, Button, { + isDisabled = true, + }) + + act(function() + task.wait() + end) + + local button = container:FindFirstChildWhichIsA("TextButton") + + expect(button).toBeDefined() + expect(button.Text).toBe("Disabled") + + lifecycle.update({ + isDisabled = false, + }) + + act(function() + task.wait() + end) + + expect(button.Text).toBe("Enabled") +end) + +test("lifecycle", function() + expect(#container:GetChildren()).toBe(0) + + local lifecycle = render(renderer, container, Button, { + isDisabled = false, + }) + + act(function() + task.wait() + end) + + expect(#container:GetChildren()).toBe(1) + + local element = container:FindFirstChildWhichIsA("TextButton") + assert(element, "no TextButton found") + expect(element.Text).toBe("Enabled") + + lifecycle.update({ + isDisabled = true, + }) + + act(function() + task.wait() + end) + + expect(#container:GetChildren()).toBe(1) + + local prevElement = element + element = container:FindFirstChildWhichIsA("TextButton") + assert(element, "no TextButton found") + expect(element).toBe(prevElement) + expect(element.Text).toBe("Disabled") + + lifecycle.unmount() + + act(function() + task.wait() + end) + + expect(#container:GetChildren()).toBe(0) +end) diff --git a/src/Renderers/createRoactRenderer.luau b/src/Renderers/createRoactRenderer.luau new file mode 100644 index 00000000..1b556465 --- /dev/null +++ b/src/Renderers/createRoactRenderer.luau @@ -0,0 +1,42 @@ +local types = require("@root/Renderers/types") + +type Renderer = types.Renderer + +type Packages = { + Roact: any, +} + +local function createRoactRenderer(packages: Packages): Renderer + local Roact = packages.Roact + local tree + local currentElement + + local function mount(container, element, context) + local props = if context then context.args else nil + currentElement = element + local renderedElement = Roact.createElement(element, props) + tree = Roact.mount(renderedElement, container, "RoactRenderer") + end + + local function update(context) + if tree and currentElement then + local props = if context then context.args else nil + local element = Roact.createElement(currentElement, props) + Roact.update(tree, element) + end + end + + local function unmount() + if tree then + Roact.unmount(tree) + end + end + + return { + mount = mount, + update = update, + unmount = unmount, + } +end + +return createRoactRenderer diff --git a/src/Renderers/createRoactRenderer.spec.luau b/src/Renderers/createRoactRenderer.spec.luau new file mode 100644 index 00000000..1cf949cf --- /dev/null +++ b/src/Renderers/createRoactRenderer.spec.luau @@ -0,0 +1,113 @@ +local JestGlobals = require("@pkg/JestGlobals") +local Roact = require("@pkg/Roact") +local createRoactRenderer = require("./createRoactRenderer") +local render = require("./render") + +local beforeEach = JestGlobals.beforeEach +local expect = JestGlobals.expect +local test = JestGlobals.test + +local function Button(props: { isDisabled: boolean? }) + return Roact.createElement("TextButton", { + Text = if props.isDisabled then "Disabled" else "Enabled", + }) +end + +local ButtonClassComponent = Roact.Component:extend("ButtonClassComponent") + +function ButtonClassComponent:render() + return Roact.createElement("TextButton", { + Text = if self.props.isDisabled then "Disabled" else "Enabled", + }) +end + +local container +local renderer + +beforeEach(function() + container = Instance.new("Folder") + + renderer = createRoactRenderer({ + Roact = Roact, + }) +end) + +test("render a functional componnet", function() + render(renderer, container, Button) + + local element = container:GetChildren()[1] + + assert(element, "no element found") + expect(typeof(element)).toBe("Instance") + assert(element:IsA("TextButton"), "not a TextButton") + expect(element.Text).toBe("Enabled") +end) + +test("render a class component", function() + render(renderer, container, ButtonClassComponent) + + local element = container:GetChildren()[1] + + assert(element, "no element found") + expect(typeof(element)).toBe("Instance") + assert(element:IsA("TextButton"), "not a TextButton") + expect(element.Text).toBe("Enabled") +end) + +test("pass args as props", function() + render(renderer, container, Button, { + isDisabled = true, + }) + + local button = container:FindFirstChildWhichIsA("TextButton") + + expect(button).toBeDefined() + expect(button.Text).toBe("Disabled") +end) + +test("update the component on arg changes", function() + local lifecycle = render(renderer, container, Button, { + isDisabled = true, + }) + + local button = container:FindFirstChildWhichIsA("TextButton") + + expect(button).toBeDefined() + expect(button.Text).toBe("Disabled") + + lifecycle.update({ + isDisabled = false, + }) + + expect(button.Text).toBe("Enabled") +end) + +test("lifecycle", function() + expect(#container:GetChildren()).toBe(0) + + local lifecycle = render(renderer, container, Button, { + isDisabled = false, + }) + + expect(#container:GetChildren()).toBe(1) + + local element = container:FindFirstChildWhichIsA("TextButton") + assert(element, "no TextButton found") + expect(element.Text).toBe("Enabled") + + lifecycle.update({ + isDisabled = true, + }) + + expect(#container:GetChildren()).toBe(1) + + local prevElement = element + element = container:FindFirstChildWhichIsA("TextButton") + assert(element, "no TextButton found") + expect(element).toBe(prevElement) + expect(element.Text).toBe("Disabled") + + lifecycle.unmount() + + expect(#container:GetChildren()).toBe(0) +end) diff --git a/src/Renderers/createRobloxRenderer.luau b/src/Renderers/createRobloxRenderer.luau new file mode 100644 index 00000000..770f138f --- /dev/null +++ b/src/Renderers/createRobloxRenderer.luau @@ -0,0 +1,44 @@ +local types = require("@root/Renderers/types") + +type Renderer = types.Renderer + +local function createRobloxRenderer(): Renderer + local handle: GuiObject? + local currentElement + + local function mount(container, element, context) + currentElement = element + + if typeof(element) == "function" then + local args = if context and context.args then context.args else {} + element = element(args) + end + + if typeof(element) == "Instance" and element:IsA("GuiObject") then + handle = element + element.Parent = container + end + end + + local function unmount() + if handle then + handle:Destroy() + end + end + + local function update(context) + if handle then + local container = handle.Parent + unmount() + mount(container, currentElement, context) + end + end + + return { + mount = mount, + update = update, + unmount = unmount, + } +end + +return createRobloxRenderer diff --git a/src/Renderers/createRobloxRenderer.spec.luau b/src/Renderers/createRobloxRenderer.spec.luau new file mode 100644 index 00000000..609e9454 --- /dev/null +++ b/src/Renderers/createRobloxRenderer.spec.luau @@ -0,0 +1,94 @@ +local JestGlobals = require("@pkg/JestGlobals") + +local createRobloxRenderer = require("./createRobloxRenderer") +local render = require("./render") + +local beforeEach = JestGlobals.beforeEach +local expect = JestGlobals.expect +local test = JestGlobals.test + +local function Button(args: { isDisabled: boolean? }) + local button = Instance.new("TextButton") + button.Text = if args.isDisabled then "Disabled" else "Enabled" + + return button +end + +local container +local renderer + +beforeEach(function() + container = Instance.new("Folder") + renderer = createRobloxRenderer() +end) + +test("render a GuiObject", function() + render(renderer, container, Instance.new("TextLabel")) + + local element = container:GetChildren()[1] + + assert(element, "no element found") + expect(typeof(element)).toBe("Instance") + assert(element:IsA("TextLabel"), "not a TextButton") +end) + +test("render a GuiObject with args", function() + render(renderer, container, Button, { + isDisabled = true, + }) + + local element = container:FindFirstChildWhichIsA("TextButton") + assert(element, "no element found") + expect(element.Text).toBe("Disabled") +end) + +test("update the GuiObject on arg changes", function() + local lifecycle = render(renderer, container, Button, { + isDisabled = true, + }) + + local button = container:FindFirstChildWhichIsA("TextButton") + + expect(button).toBeDefined() + expect(button.Text).toBe("Disabled") + + lifecycle.update({ + isDisabled = false, + }) + + button = container:FindFirstChildWhichIsA("TextButton") + + expect(button.Text).toBe("Enabled") +end) + +test("lifecycle", function() + expect(#container:GetChildren()).toBe(0) + + local lifecycle = render(renderer, container, Button, { + args = { + isDisabled = false, + }, + }) + + expect(#container:GetChildren()).toBe(1) + + local element = container:FindFirstChildWhichIsA("TextButton") + assert(element, "no TextButton found") + expect(element.Text).toBe("Enabled") + + lifecycle.update({ + isDisabled = true, + }) + + expect(#container:GetChildren()).toBe(1) + + local prevElement = element + element = container:FindFirstChildWhichIsA("TextButton") + assert(element, "no TextButton found") + expect(element).never.toBe(prevElement) + expect(element.Text).toBe("Disabled") + + lifecycle.unmount() + + expect(#container:GetChildren()).toBe(0) +end) diff --git a/src/Renderers/example.md b/src/Renderers/example.md new file mode 100644 index 00000000..1beebdc7 --- /dev/null +++ b/src/Renderers/example.md @@ -0,0 +1,72 @@ +Plain GuiObjects + +```lua +exports.Primary = { + args = { + isEnabled = true, + }, + renderer = RobloxRenderer, + render = function(args) + local label = Instance.new("TextLabel") + label.Text = if args.isEnabled then "Enabled" else "Disabled" + + return label + end +} +``` + +Fusion + +```lua +local function Button(props) + local isHovering = Value(false) + + return New "TextButton" { + BackgroundColor3 = Computed(function() + return if isHovering:get() then HOVER_COLOUR else REST_COLOUR + end), + + [OnEvent "MouseEnter"] = function() + isHovering:set(true) + end, + + [OnEvent "MouseLeave"] = function() + isHovering:set(false) + end, + + -- ... some properties ... + } +end + +local exports = {} +exports.Primary = { + args = { + isEnabled = true, + }, + renderer = createFusionRenderer(Fusion), + render = function(args) + return New "TextLabel" { + Text = Computed(function() + return if args.isEnabled:get() then "Enabled" else "Disabled" + end) + } + end +} +``` + + +React + +```lua +exports.Primary = { + args = { + isEnabled = true, + } + renderer = createReactRenderer(React, ReactRoblox), + render = function(args) + return React.createElement("TextLabel", { + Text = if args.isEnabled then "Enabled" else "Disabled" + }) + end +} +``` diff --git a/src/Renderers/render.luau b/src/Renderers/render.luau new file mode 100644 index 00000000..96794003 --- /dev/null +++ b/src/Renderers/render.luau @@ -0,0 +1,55 @@ +local Sift = require("@pkg/Sift") + +local types = require("@root/Renderers/types") + +type Args = types.Args +type Context = types.Context +type Renderer = types.Renderer + +local function render(renderer: Renderer, container: Instance, element: T, initialArgs: Args?) + local prevContext: Context? + + local function renderOnce(args: Args?) + local context: Context = { + container = container, + args = args, + } + + if renderer.transformContext then + context = renderer.transformContext(context, prevContext) + end + + if not renderer.shouldUpdate or renderer.shouldUpdate(context, prevContext) then + renderer.mount(container, element, context) + end + + prevContext = context + end + + local function update(newArgs: Args?) + if renderer.update then + local context = Sift.Dictionary.join(prevContext, { + args = newArgs, + }) + renderer.update(context) + else + renderOnce(newArgs) + end + end + + local function unmount() + if renderer.unmount then + renderer.unmount(prevContext) + end + container:ClearAllChildren() + end + + renderOnce(initialArgs) + + return { + update = update, + unmount = unmount, + } +end + +return render diff --git a/src/Renderers/render.spec.luau b/src/Renderers/render.spec.luau new file mode 100644 index 00000000..580f94d8 --- /dev/null +++ b/src/Renderers/render.spec.luau @@ -0,0 +1,173 @@ +local JestGlobals = require("@pkg/JestGlobals") +local render = require("./render") +local types = require("@root/Renderers/types") + +local afterEach = JestGlobals.afterEach +local beforeEach = JestGlobals.beforeEach +local expect = JestGlobals.expect +local jest = JestGlobals.jest +local test = JestGlobals.test + +type Renderer = types.Renderer + +local container: Instance +local element = jest.fn() + +beforeEach(function() + container = Instance.new("Folder") +end) + +afterEach(function() + container:Destroy() + jest.resetAllMocks() +end) + +test("call `mount` immediately", function() + local mockMount = jest.fn() + + local mockRenderer: Renderer = { + mount = mockMount, + } + + render(mockRenderer, container, element) + + expect(mockMount).toHaveBeenCalledTimes(1) +end) + +test("returns a function to trigger a re-render", function() + local mockMount = jest.fn() + mockMount.mockReturnValue = Instance.new("ScreenGui") + + local mockRenderer: Renderer = { + mount = mockMount, + } + + local lifecycle = render(mockRenderer, container, element) + + expect(mockMount).toHaveBeenCalledTimes(1) + + lifecycle.update() + + expect(mockMount).toHaveBeenCalledTimes(2) +end) + +test("current context and prev context are passed to shouldUpdate", function() + local context, prevContext + + local mockRenderer: Renderer = { + mount = function() end, + shouldUpdate = function(_context, _prevContext) + context = _context + prevContext = _prevContext + return true + end, + } + + local lifecycle = render(mockRenderer, container, element) + + expect(context).toBeDefined() + expect(prevContext).toBeNil() + + lifecycle.update() + + expect(context).toBeDefined() + expect(prevContext).toBeDefined() +end) + +test("context is passed to shouldUpdate", function() + local context + + local args = { + foo = true, + } + + local mockRenderer: Renderer = { + shouldUpdate = function(_context) + context = _context + end, + mount = function() end, + } + + render(mockRenderer, container, element, args) + + expect(context).toEqual({ + container = container, + args = args, + }) +end) + +test("only rerender if shouldUpdate returns true", function() + local mockMount = jest.fn() + local mockShouldUpdate = jest.fn().mockReturnValue(true) + + local mockRenderer: Renderer = { + shouldUpdate = mockShouldUpdate, + mount = mockMount, + } + + local lifecycle = render(mockRenderer, container, {}) + + expect(mockMount).toHaveBeenCalledTimes(1) + + lifecycle.update() + + expect(mockMount).toHaveBeenCalledTimes(2) + + mockShouldUpdate.mockReturnValue(false) + lifecycle.update() + + expect(mockMount).toHaveBeenCalledTimes(2) +end) + +test("destroy all children of the container when rerendering if shouldUpdate is true", function() + local mockShouldUpdate = jest.fn().mockReturnValue(true) + + local mockRenderer: Renderer = { + mount = function(container, element) + element.Parent = container + end, + shouldUpdate = mockShouldUpdate, + } + + local element = Instance.new("Folder") + local lifecycle = render(mockRenderer, container, element) + + expect(#container:GetChildren()).toBe(1) + + lifecycle.unmount() + + expect(#container:GetChildren()).toBe(0) +end) + +test("prevContext is nil on the first render", function() + local renders = 0 + + local mockShouldUpdate = jest.fn().mockImplementation(function() + return true + end) + + local mockRenderer: Renderer = { + shouldUpdate = mockShouldUpdate, + mount = function() + renders += 1 + return Instance.new("Folder") + end, + } + + local lifecycle = render(mockRenderer, container, element) + + local context = mockShouldUpdate.mock.lastCall[1] + expect(context).toBeDefined() + --[[ + Jest is sending back the second arg as a Symbol representing nil, which + doesn't match with toBeNil. There needs to be an upstream change before + we can use the commented expect() call. + ]] + -- expect(mockShouldUpdate.mock.lastCall[2]).toBeNil() + expect(tostring(mockShouldUpdate.mock.lastCall[2])).toEqual("Symbol($$nil)") + + lifecycle.update() + + expect(mockShouldUpdate.mock.lastCall[1]).toBeDefined() + expect(mockShouldUpdate.mock.lastCall[2]).toEqual(context) +end) diff --git a/src/Renderers/types.luau b/src/Renderers/types.luau new file mode 100644 index 00000000..02e7fbe6 --- /dev/null +++ b/src/Renderers/types.luau @@ -0,0 +1,25 @@ +local storyTypes = require("@root/Storybook/types") + +export type Theme = "System" | "Light" | "Dark" +export type Args = { + [string]: any, +} + +export type Context = { + -- theme: Theme, + -- story: storyTypes.Story, + -- storybook: storyTypes.Storybook, + container: Instance, + args: Args?, +} + +export type Renderer = { + mount: (container: Instance, element: unknown, context: Context?) -> (), + + unmount: ((context: Context?) -> ())?, + update: ((context: Context?) -> ())?, + transformContext: ((context: Context, prevContext: Context?) -> Context)?, + shouldUpdate: ((context: Context, prevContext: Context?) -> boolean)?, +} + +return nil diff --git a/src/Renderers/useStoryRenderer.luau b/src/Renderers/useStoryRenderer.luau new file mode 100644 index 00000000..69016463 --- /dev/null +++ b/src/Renderers/useStoryRenderer.luau @@ -0,0 +1,41 @@ +local React = require("@pkg/React") +local rendererTypes = require("@root/Renderers/types") +local storybookTypes = require("@root/Storybook/types") +local usePrevious = require("@root/Common/usePrevious") + +local useEffect = React.useEffect +local useMemo = React.useMemo +local useState = React.useState + +type Renderer = rendererTypes.Renderer +type Story = storybookTypes.Story +type Storybook = storybookTypes.Storybook + +local function useStoryRenderer(renderer: Renderer, story: Story) + local container, setContainer = useState(nil :: Instance?) + + local renderContext = useMemo(function() + return { + container = container, + args = story.controls, + } + end, { container, story }) + + local prevRenderContext = usePrevious(renderContext) + + useEffect(function() + if not renderer.shouldUpdate or renderer.shouldUpdate(renderContext, prevRenderContext) then + renderer.mount(container, story.story, renderContext) + end + + return function() + renderer.unmount() + end + end, { renderer, story, renderContext, prevRenderContext }) + + return { + setContainer = setContainer, + } +end + +return useStoryRenderer diff --git a/src/Storybook/NoStorySelected.story.luau b/src/Storybook/NoStorySelected.story.luau index bd2415db..f8c6ce46 100644 --- a/src/Storybook/NoStorySelected.story.luau +++ b/src/Storybook/NoStorySelected.story.luau @@ -4,10 +4,14 @@ local ContextProviders = require("@root/Common/ContextProviders") local MockPlugin = require("@root/Testing/MockPlugin") local NoStorySelected = require("./NoStorySelected") +local stories = {} + +stories.Primary = React.createElement(ContextProviders, { + plugin = MockPlugin.new(), +}, { + NoStorySelected = React.createElement(NoStorySelected), +}) + return { - story = React.createElement(ContextProviders, { - plugin = MockPlugin.new(), - }, { - NoStorySelected = React.createElement(NoStorySelected), - }), + stories = stories, } diff --git a/src/Storybook/StoryControls.story.luau b/src/Storybook/StoryControls.story.luau index a4dce867..cf850a10 100644 --- a/src/Storybook/StoryControls.story.luau +++ b/src/Storybook/StoryControls.story.luau @@ -4,24 +4,26 @@ local ContextProviders = require("@root/Common/ContextProviders") local MockPlugin = require("@root/Testing/MockPlugin") local StoryControls = require("@root/Storybook/StoryControls") +local stories = {} + +stories.Primary = React.createElement(ContextProviders, { + plugin = MockPlugin.new(), +}, { + StoryControls = React.createElement(StoryControls, { + controls = { + foo = "bar", + checkbox = false, + dropdown = { + "Option 1", + "Option 2", + "Option 3", + }, + }, + setControl = function() end, + }), +}) + return { summary = "Panel for configuring the controls of a story", - story = function() - return React.createElement(ContextProviders, { - plugin = MockPlugin.new(), - }, { - StoryControls = React.createElement(StoryControls, { - controls = { - foo = "bar", - checkbox = false, - dropdown = { - "Option 1", - "Option 2", - "Option 3", - }, - }, - setControl = function() end, - }), - }) - end, + stories = stories, } diff --git a/src/Storybook/StoryError.story.luau b/src/Storybook/StoryError.story.luau index b56b0b3e..6e1c7978 100644 --- a/src/Storybook/StoryError.story.luau +++ b/src/Storybook/StoryError.story.luau @@ -3,20 +3,26 @@ local React = require("@pkg/React") local ContextProviders = require("@root/Common/ContextProviders") local MockPlugin = require("@root/Testing/MockPlugin") local StoryError = require("@root/Storybook/StoryError") +local constants = require("@root/constants") + +local stories = {} + +stories.Primary = function() + local _, result = xpcall(function() + error("Oops!") + end, debug.traceback) + + return React.createElement(ContextProviders, { + plugin = MockPlugin.new(), + }, { + StoryError = React.createElement(StoryError, { + err = result, + }), + }) +end return { summary = "Component for displaying error messages to the user", - story = function() - local _, result = xpcall(function() - error("Oops!") - end, debug.traceback) - - return React.createElement(ContextProviders, { - plugin = MockPlugin.new(), - }, { - StoryError = React.createElement(StoryError, { - err = result, - }), - }) - end, + story = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then nil else stories.Primary, + stories = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then stories else nil, } diff --git a/src/Storybook/StoryMeta.story.luau b/src/Storybook/StoryMeta.story.luau index 870edd0a..220c7a07 100644 --- a/src/Storybook/StoryMeta.story.luau +++ b/src/Storybook/StoryMeta.story.luau @@ -4,15 +4,19 @@ local ContextProviders = require("@root/Common/ContextProviders") local MockPlugin = require("@root/Testing/MockPlugin") local StoryMeta = require("@root/Storybook/StoryMeta") -return { - story = React.createElement(ContextProviders, { - plugin = MockPlugin.new(), - }, { - StoryMeta = React.createElement(StoryMeta, { - story = { - name = "Story", - summary = "Story summary", - }, - }), +local stories = {} + +stories.Primary = React.createElement(ContextProviders, { + plugin = MockPlugin.new(), +}, { + StoryMeta = React.createElement(StoryMeta, { + story = { + name = "Story", + summary = "Story summary", + }, }), +}) + +return { + stories = stories, } diff --git a/src/Storybook/loadStoryModule.luau b/src/Storybook/loadStoryModule.luau index 7f462464..83e000bf 100644 --- a/src/Storybook/loadStoryModule.luau +++ b/src/Storybook/loadStoryModule.luau @@ -24,6 +24,8 @@ local function loadStoryModule(loader: any, module: ModuleScript, storybook: typ story = { name = module.Name, story = result, + storybook = storybook, + source = module, } else local isValid, message = types.StoryMeta(result) @@ -45,6 +47,8 @@ local function loadStoryModule(loader: any, module: ModuleScript, storybook: typ story = Sift.Dictionary.merge({ name = module.Name, + storybook = storybook, + source = module, }, extraProps, result) else return nil, Errors.Generic:format(module:GetFullName(), message) diff --git a/src/Storybook/loadStoryModule.spec.luau b/src/Storybook/loadStoryModule.spec.luau new file mode 100644 index 00000000..29c5e844 --- /dev/null +++ b/src/Storybook/loadStoryModule.spec.luau @@ -0,0 +1,141 @@ +local JestGlobals = require("@pkg/JestGlobals") +local ModuleLoader = require("@pkg/ModuleLoader") +local constants = require("@root/constants") +local loadStoryModule = require("./loadStoryModule") +local types = require("@root/Storybook/types") + +local test = JestGlobals.test +local expect = JestGlobals.expect + +local MOCK_ROACT = { + createElement = function() end, + mount = function() end, + unmount = function() end, +} + +local MOCK_REACT = { + createElement = function() end, +} + +local MOCK_REACT_ROBLOX = { + createRoot = function() end, +} + +local MOCK_PLAIN_STORYBOOK: types.Storybook = { + storyRoots = {}, +} + +local MOCK_ROACT_STORYBOOK: types.Storybook = { + storyRoots = {}, + roact = MOCK_ROACT, +} + +local MOCK_REACT_STORYBOOK: types.Storybook = { + storyRoots = {}, + react = MOCK_REACT, + reactRoblox = MOCK_REACT_ROBLOX, +} + +local function createMockStoryModule(source: string): ModuleScript + local storyModule = Instance.new("ModuleScript") + storyModule.Name = "Foo.story" + storyModule.Source = source + + return storyModule +end + +test("load a story module as a table", function() + local loader = ModuleLoader.new() + local storyModule = createMockStoryModule([[ + return { + name = "Sample", + story = function() end + } + ]]) + + local story = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) + + expect(story.name).toEqual("Sample") +end) + +test("handle Hoarcekat stories", function() + local loader = ModuleLoader.new() + local storyModule = createMockStoryModule([[ + return function(target) + local gui = Instance.new("TextLabel") + gui.Parent = target + + return function() + gui:Destroy() + end + end + ]]) + + local story = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) + + expect(story).toBeDefined() +end) + +test("use the name of the story module for the story name", function() + local loader = ModuleLoader.new() + local storyModule = createMockStoryModule([[ + return { + story = function() end + } + ]]) + storyModule.Name = "SampleName.story" + + local story = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) + + expect(story.name).toEqual(storyModule.Name) +end) + +test("pass the storybook's renderer to the story", function() + local loader = ModuleLoader.new() + local storyModule = createMockStoryModule([[ + return { + story = function() end + } + ]]) + + local story, err = loadStoryModule(loader, storyModule, MOCK_REACT_STORYBOOK) + + expect(story).toBeDefined() + expect(err).toBeNil() + expect(story.react).toBeDefined() + expect(story.reactRoblox).toBeDefined() + + story, err = loadStoryModule(loader, storyModule, MOCK_ROACT_STORYBOOK) + + expect(story).toBeDefined() + expect(err).toBeNil() + expect(story.roact).toBeDefined() +end) + +test("generic failures for stories", function() + local loader = ModuleLoader.new() + local storyModule = createMockStoryModule([[ + return { + } + ]]) + + local story, err = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) + + expect(story).toBeNil() + expect(err).toBeDefined() +end) + +test("malformed stories", function() + local loader = ModuleLoader.new() + local storyModule = createMockStoryModule([[ + return { + name = false, + story = "should be a function" + } + ]]) + + local story, err = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) + + expect(story).toBeNil() + expect(err).toBeDefined() +end) diff --git a/src/Storybook/types.luau b/src/Storybook/types.luau index e1263428..c90415d3 100644 --- a/src/Storybook/types.luau +++ b/src/Storybook/types.luau @@ -78,14 +78,20 @@ export type Storybook = RoactStorybook | ReactStorybook | StorybookMeta export type StoryMeta = { name: string, - story: any, + story: unknown, + source: ModuleScript, + storybook: Storybook, + summary: string?, controls: Controls?, + + -- Renderer-specific roact: Roact?, react: React?, reactRoblox: ReactRoblox?, } types.StoryMeta = t.interface({ + story = t.any, name = t.optional(t.string), summary = t.optional(t.string), controls = t.optional(types.Controls), diff --git a/src/constants.luau b/src/constants.luau index 325e7474..1bdb4ddb 100644 --- a/src/constants.luau +++ b/src/constants.luau @@ -15,6 +15,8 @@ return { -- plugin IS_DEV_MODE = false, + FLAG_ENABLE_COMPONENT_STORY_FORMAT = false, + SPRING_CONFIG = { clamp = true, mass = 0.6, diff --git a/story-example.luau b/story-example.luau new file mode 100644 index 00000000..1e1f43bb --- /dev/null +++ b/story-example.luau @@ -0,0 +1,14 @@ +local React = require("@pkg/React") +local Button = require("./Button") +local FlipbookReact = require("@pkg/flipbook-react") +type StoryObj = FlipbookReact.StoryObj + +local stories: { [string]: StoryObj } = {} + +stories.Primary = React.createElement(Button, {}) + +return { + name = "Button", + component = Button, + stories = stories, +} diff --git a/wally.toml b/wally.toml index ec3fc616..a2229a96 100644 --- a/wally.toml +++ b/wally.toml @@ -7,14 +7,16 @@ realm = "shared" exclude = ["*"] [dependencies] +CSF = "flipbook-labs/csf@0.1.0" ModuleLoader = "flipbook-labs/module-loader@0.6.1" 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 Roact = "roblox/roact@1.4.4" +Fusion = "elttob/fusion@0.2.0" Jest = "jsdotlua/jest@3.6.1-rc.2" JestGlobals = "jsdotlua/jest-globals@3.6.1-rc.2"