From 200aac1ec752ee6d24a1f5c7a7905b970a516ca3 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Sat, 23 Dec 2023 15:46:40 -0800 Subject: [PATCH 01/23] Look for `.stories` files --- src/Storybook/isStoryModule.lua | 10 ++++++++-- src/Storybook/isStoryModule.spec.lua | 7 +++++-- src/constants.lua | 3 +++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/Storybook/isStoryModule.lua b/src/Storybook/isStoryModule.lua index 71c2ced3..be0e221f 100644 --- a/src/Storybook/isStoryModule.lua +++ b/src/Storybook/isStoryModule.lua @@ -3,8 +3,14 @@ local flipbook = script:FindFirstAncestor("flipbook") local constants = require(flipbook.constants) local function isStoryModule(instance: Instance) - if instance:IsA("ModuleScript") and instance.Name:match(constants.STORY_NAME_PATTERN) then - return true + if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then + if instance:IsA("ModuleScript") and instance.Name:match(constants.STORY_NAME_PATTERN_CSF) then + return true + end + else + if instance:IsA("ModuleScript") and instance.Name:match(constants.STORY_NAME_PATTERN) then + return true + end end return false end diff --git a/src/Storybook/isStoryModule.spec.lua b/src/Storybook/isStoryModule.spec.lua index e53d0ae5..298a0ad7 100644 --- a/src/Storybook/isStoryModule.spec.lua +++ b/src/Storybook/isStoryModule.spec.lua @@ -1,16 +1,19 @@ return function() + local flipbook = script:FindFirstAncestor("flipbook") + local isStoryModule = require(script.Parent.isStoryModule) + local constants = require(flipbook.constants) it("should return `true` for a ModuleScript with .story in the name", function() local module = Instance.new("ModuleScript") - module.Name = "Foo.story" + module.Name = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then "Foo.stories" else "Foo.story" expect(isStoryModule(module)).to.equal(true) end) it("should return `false` if the given instance is not a ModuleScript", function() local folder = Instance.new("Folder") - folder.Name = "Folder.story" + folder.Name = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then "Foo.stories" else "Folder.story" expect(isStoryModule(folder)).to.equal(false) end) diff --git a/src/constants.lua b/src/constants.lua index 325e7474..0d89b77d 100644 --- a/src/constants.lua +++ b/src/constants.lua @@ -1,5 +1,6 @@ return { STORY_NAME_PATTERN = "%.story$", + STORY_NAME_PATTERN_CSF = "%.stories$", STORYBOOK_NAME_PATTERN = "%.storybook$", SIDEBAR_INITIAL_WIDTH = 260, -- px @@ -15,6 +16,8 @@ return { -- plugin IS_DEV_MODE = false, + FLAG_ENABLE_COMPONENT_STORY_FORMAT = false, + SPRING_CONFIG = { clamp = true, mass = 0.6, From 2cd34658ff4304cd5f192171dcb85995155d71a9 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Wed, 27 Dec 2023 16:50:57 -0800 Subject: [PATCH 02/23] Add a temp script for installing CSF --- bin/wally-install.sh | 3 +++ 1 file changed, 3 insertions(+) create mode 100755 bin/wally-install.sh diff --git a/bin/wally-install.sh b/bin/wally-install.sh new file mode 100755 index 00000000..85358153 --- /dev/null +++ b/bin/wally-install.sh @@ -0,0 +1,3 @@ +wally install + +git clone https://github.com/flipbook-labs/csf-lua Packages/CSF From 875c0057b73899b4c1abd1d9275026ead0414f49 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Fri, 29 Dec 2023 14:28:29 -0800 Subject: [PATCH 03/23] Rename *.story.lua -> *.stories.lua --- example/{ArrayControls.story.lua => ArrayControls.stories.lua} | 0 example/{Button.story.lua => Button.stories.lua} | 0 ...uttonWithControls.story.lua => ButtonWithControls.stories.lua} | 0 .../{AutomaticSize.story.lua => AutomaticSize.stories.lua} | 0 ...edsBounds.story.lua => AutomaticSizeExceedsBounds.stories.lua} | 0 example/CanvasTests/{Offset.story.lua => Offset.stories.lua} | 0 example/CanvasTests/{Resizing.story.lua => Resizing.stories.lua} | 0 example/CanvasTests/{Scale.story.lua => Scale.stories.lua} | 0 .../{ScrollingFrame.story.lua => ScrollingFrame.stories.lua} | 0 example/{Counter.story.lua => Counter.stories.lua} | 0 example/{Functional.story.lua => Functional.stories.lua} | 0 example/{Hoarcekat.story.lua => Hoarcekat.stories.lua} | 0 example/{ReactCounter.story.lua => ReactCounter.stories.lua} | 0 src/Common/{Branding.story.lua => Branding.stories.lua} | 0 .../{ScrollingFrame.story.lua => ScrollingFrame.stories.lua} | 0 src/Common/{Sprite.story.lua => Sprite.stories.lua} | 0 src/Explorer/{Component.story.lua => Component.stories.lua} | 0 src/Forms/{Button.story.lua => Button.stories.lua} | 0 src/Forms/{Checkbox.story.lua => Checkbox.stories.lua} | 0 src/Forms/{Dropdown.story.lua => Dropdown.stories.lua} | 0 src/Forms/{InputField.story.lua => InputField.stories.lua} | 0 src/Forms/{Searchbar.story.lua => Searchbar.stories.lua} | 0 ...ectableTextLabel.story.lua => SelectableTextLabel.stories.lua} | 0 .../{ResizablePanel.story.lua => ResizablePanel.stories.lua} | 0 src/Panels/{Sidebar.story.lua => Sidebar.stories.lua} | 0 src/Plugin/{PluginApp.story.lua => PluginApp.stories.lua} | 0 .../{NoStorySelected.story.lua => NoStorySelected.stories.lua} | 0 .../{StoryControls.story.lua => StoryControls.stories.lua} | 0 src/Storybook/{StoryError.story.lua => StoryError.stories.lua} | 0 src/Storybook/{StoryMeta.story.lua => StoryMeta.stories.lua} | 0 30 files changed, 0 insertions(+), 0 deletions(-) rename example/{ArrayControls.story.lua => ArrayControls.stories.lua} (100%) rename example/{Button.story.lua => Button.stories.lua} (100%) rename example/{ButtonWithControls.story.lua => ButtonWithControls.stories.lua} (100%) rename example/CanvasTests/{AutomaticSize.story.lua => AutomaticSize.stories.lua} (100%) rename example/CanvasTests/{AutomaticSizeExceedsBounds.story.lua => AutomaticSizeExceedsBounds.stories.lua} (100%) rename example/CanvasTests/{Offset.story.lua => Offset.stories.lua} (100%) rename example/CanvasTests/{Resizing.story.lua => Resizing.stories.lua} (100%) rename example/CanvasTests/{Scale.story.lua => Scale.stories.lua} (100%) rename example/CanvasTests/{ScrollingFrame.story.lua => ScrollingFrame.stories.lua} (100%) rename example/{Counter.story.lua => Counter.stories.lua} (100%) rename example/{Functional.story.lua => Functional.stories.lua} (100%) rename example/{Hoarcekat.story.lua => Hoarcekat.stories.lua} (100%) rename example/{ReactCounter.story.lua => ReactCounter.stories.lua} (100%) rename src/Common/{Branding.story.lua => Branding.stories.lua} (100%) rename src/Common/{ScrollingFrame.story.lua => ScrollingFrame.stories.lua} (100%) rename src/Common/{Sprite.story.lua => Sprite.stories.lua} (100%) rename src/Explorer/{Component.story.lua => Component.stories.lua} (100%) rename src/Forms/{Button.story.lua => Button.stories.lua} (100%) rename src/Forms/{Checkbox.story.lua => Checkbox.stories.lua} (100%) rename src/Forms/{Dropdown.story.lua => Dropdown.stories.lua} (100%) rename src/Forms/{InputField.story.lua => InputField.stories.lua} (100%) rename src/Forms/{Searchbar.story.lua => Searchbar.stories.lua} (100%) rename src/Forms/{SelectableTextLabel.story.lua => SelectableTextLabel.stories.lua} (100%) rename src/Panels/{ResizablePanel.story.lua => ResizablePanel.stories.lua} (100%) rename src/Panels/{Sidebar.story.lua => Sidebar.stories.lua} (100%) rename src/Plugin/{PluginApp.story.lua => PluginApp.stories.lua} (100%) rename src/Storybook/{NoStorySelected.story.lua => NoStorySelected.stories.lua} (100%) rename src/Storybook/{StoryControls.story.lua => StoryControls.stories.lua} (100%) rename src/Storybook/{StoryError.story.lua => StoryError.stories.lua} (100%) rename src/Storybook/{StoryMeta.story.lua => StoryMeta.stories.lua} (100%) diff --git a/example/ArrayControls.story.lua b/example/ArrayControls.stories.lua similarity index 100% rename from example/ArrayControls.story.lua rename to example/ArrayControls.stories.lua diff --git a/example/Button.story.lua b/example/Button.stories.lua similarity index 100% rename from example/Button.story.lua rename to example/Button.stories.lua diff --git a/example/ButtonWithControls.story.lua b/example/ButtonWithControls.stories.lua similarity index 100% rename from example/ButtonWithControls.story.lua rename to example/ButtonWithControls.stories.lua diff --git a/example/CanvasTests/AutomaticSize.story.lua b/example/CanvasTests/AutomaticSize.stories.lua similarity index 100% rename from example/CanvasTests/AutomaticSize.story.lua rename to example/CanvasTests/AutomaticSize.stories.lua diff --git a/example/CanvasTests/AutomaticSizeExceedsBounds.story.lua b/example/CanvasTests/AutomaticSizeExceedsBounds.stories.lua similarity index 100% rename from example/CanvasTests/AutomaticSizeExceedsBounds.story.lua rename to example/CanvasTests/AutomaticSizeExceedsBounds.stories.lua diff --git a/example/CanvasTests/Offset.story.lua b/example/CanvasTests/Offset.stories.lua similarity index 100% rename from example/CanvasTests/Offset.story.lua rename to example/CanvasTests/Offset.stories.lua diff --git a/example/CanvasTests/Resizing.story.lua b/example/CanvasTests/Resizing.stories.lua similarity index 100% rename from example/CanvasTests/Resizing.story.lua rename to example/CanvasTests/Resizing.stories.lua diff --git a/example/CanvasTests/Scale.story.lua b/example/CanvasTests/Scale.stories.lua similarity index 100% rename from example/CanvasTests/Scale.story.lua rename to example/CanvasTests/Scale.stories.lua diff --git a/example/CanvasTests/ScrollingFrame.story.lua b/example/CanvasTests/ScrollingFrame.stories.lua similarity index 100% rename from example/CanvasTests/ScrollingFrame.story.lua rename to example/CanvasTests/ScrollingFrame.stories.lua diff --git a/example/Counter.story.lua b/example/Counter.stories.lua similarity index 100% rename from example/Counter.story.lua rename to example/Counter.stories.lua diff --git a/example/Functional.story.lua b/example/Functional.stories.lua similarity index 100% rename from example/Functional.story.lua rename to example/Functional.stories.lua diff --git a/example/Hoarcekat.story.lua b/example/Hoarcekat.stories.lua similarity index 100% rename from example/Hoarcekat.story.lua rename to example/Hoarcekat.stories.lua diff --git a/example/ReactCounter.story.lua b/example/ReactCounter.stories.lua similarity index 100% rename from example/ReactCounter.story.lua rename to example/ReactCounter.stories.lua diff --git a/src/Common/Branding.story.lua b/src/Common/Branding.stories.lua similarity index 100% rename from src/Common/Branding.story.lua rename to src/Common/Branding.stories.lua diff --git a/src/Common/ScrollingFrame.story.lua b/src/Common/ScrollingFrame.stories.lua similarity index 100% rename from src/Common/ScrollingFrame.story.lua rename to src/Common/ScrollingFrame.stories.lua diff --git a/src/Common/Sprite.story.lua b/src/Common/Sprite.stories.lua similarity index 100% rename from src/Common/Sprite.story.lua rename to src/Common/Sprite.stories.lua diff --git a/src/Explorer/Component.story.lua b/src/Explorer/Component.stories.lua similarity index 100% rename from src/Explorer/Component.story.lua rename to src/Explorer/Component.stories.lua diff --git a/src/Forms/Button.story.lua b/src/Forms/Button.stories.lua similarity index 100% rename from src/Forms/Button.story.lua rename to src/Forms/Button.stories.lua diff --git a/src/Forms/Checkbox.story.lua b/src/Forms/Checkbox.stories.lua similarity index 100% rename from src/Forms/Checkbox.story.lua rename to src/Forms/Checkbox.stories.lua diff --git a/src/Forms/Dropdown.story.lua b/src/Forms/Dropdown.stories.lua similarity index 100% rename from src/Forms/Dropdown.story.lua rename to src/Forms/Dropdown.stories.lua diff --git a/src/Forms/InputField.story.lua b/src/Forms/InputField.stories.lua similarity index 100% rename from src/Forms/InputField.story.lua rename to src/Forms/InputField.stories.lua diff --git a/src/Forms/Searchbar.story.lua b/src/Forms/Searchbar.stories.lua similarity index 100% rename from src/Forms/Searchbar.story.lua rename to src/Forms/Searchbar.stories.lua diff --git a/src/Forms/SelectableTextLabel.story.lua b/src/Forms/SelectableTextLabel.stories.lua similarity index 100% rename from src/Forms/SelectableTextLabel.story.lua rename to src/Forms/SelectableTextLabel.stories.lua diff --git a/src/Panels/ResizablePanel.story.lua b/src/Panels/ResizablePanel.stories.lua similarity index 100% rename from src/Panels/ResizablePanel.story.lua rename to src/Panels/ResizablePanel.stories.lua diff --git a/src/Panels/Sidebar.story.lua b/src/Panels/Sidebar.stories.lua similarity index 100% rename from src/Panels/Sidebar.story.lua rename to src/Panels/Sidebar.stories.lua diff --git a/src/Plugin/PluginApp.story.lua b/src/Plugin/PluginApp.stories.lua similarity index 100% rename from src/Plugin/PluginApp.story.lua rename to src/Plugin/PluginApp.stories.lua diff --git a/src/Storybook/NoStorySelected.story.lua b/src/Storybook/NoStorySelected.stories.lua similarity index 100% rename from src/Storybook/NoStorySelected.story.lua rename to src/Storybook/NoStorySelected.stories.lua diff --git a/src/Storybook/StoryControls.story.lua b/src/Storybook/StoryControls.stories.lua similarity index 100% rename from src/Storybook/StoryControls.story.lua rename to src/Storybook/StoryControls.stories.lua diff --git a/src/Storybook/StoryError.story.lua b/src/Storybook/StoryError.stories.lua similarity index 100% rename from src/Storybook/StoryError.story.lua rename to src/Storybook/StoryError.stories.lua diff --git a/src/Storybook/StoryMeta.story.lua b/src/Storybook/StoryMeta.stories.lua similarity index 100% rename from src/Storybook/StoryMeta.story.lua rename to src/Storybook/StoryMeta.stories.lua From e90e4b4eb01a44e7fe1816a6191dca1e710d44f3 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Fri, 29 Dec 2023 14:33:23 -0800 Subject: [PATCH 04/23] Support both file extensions --- src/Storybook/isStoryModule.lua | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Storybook/isStoryModule.lua b/src/Storybook/isStoryModule.lua index be0e221f..6e656ca5 100644 --- a/src/Storybook/isStoryModule.lua +++ b/src/Storybook/isStoryModule.lua @@ -3,14 +3,11 @@ local flipbook = script:FindFirstAncestor("flipbook") local constants = require(flipbook.constants) local function isStoryModule(instance: Instance) - if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then - if instance:IsA("ModuleScript") and instance.Name:match(constants.STORY_NAME_PATTERN_CSF) then - return true - end - else - if instance:IsA("ModuleScript") and instance.Name:match(constants.STORY_NAME_PATTERN) then - return true - end + if + instance:IsA("ModuleScript") + and (instance.Name:match(constants.STORY_NAME_PATTERN) or instance.Name:match(constants.STORY_NAME_PATTERN_CSF)) + then + return true end return false end From bb652fe56908c3c2de1a7cf144f77a83a8ba1697 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Fri, 29 Dec 2023 15:23:05 -0800 Subject: [PATCH 05/23] Convert example stories to CSF --- example/ArrayControls.stories.lua | 26 +++++---- example/Button.stories.lua | 18 ++++--- example/ButtonWithControls.stories.lua | 24 +++++---- example/CanvasTests/AutomaticSize.stories.lua | 26 +++++---- .../AutomaticSizeExceedsBounds.stories.lua | 26 +++++---- example/CanvasTests/Offset.stories.lua | 26 +++++---- example/CanvasTests/Resizing.stories.lua | 14 +++-- example/CanvasTests/Scale.stories.lua | 26 +++++---- .../CanvasTests/ScrollingFrame.stories.lua | 34 +++++++----- example/Counter.stories.lua | 18 ++++--- example/Functional.stories.lua | 53 +++++++++++-------- example/ReactCounter.stories.lua | 18 ++++--- src/Storybook/StoryError.stories.lua | 24 +++++---- 13 files changed, 208 insertions(+), 125 deletions(-) diff --git a/example/ArrayControls.stories.lua b/example/ArrayControls.stories.lua index 14e27cd7..bba199e3 100644 --- a/example/ArrayControls.stories.lua +++ b/example/ArrayControls.stories.lua @@ -3,6 +3,7 @@ local Example = script:FindFirstAncestor("Example") local React = require(Example.Parent.Packages.React) local ReactRoblox = require(Example.Parent.Packages.ReactRoblox) local Sift = require(Example.Parent.Packages.Sift) +local constants = require(Example.Parent.constants) local fonts = Sift.Array.sort(Enum.Font:GetEnumItems(), function(a: Enum.Font, z: Enum.Font) return a.Name < z.Name @@ -19,19 +20,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.stories.lua b/example/Button.stories.lua index 4f23b032..059066bc 100644 --- a/example/Button.stories.lua +++ b/example/Button.stories.lua @@ -1,15 +1,21 @@ 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.stories.lua b/example/ButtonWithControls.stories.lua index 3d0aad70..2c7148f9 100644 --- a/example/ButtonWithControls.stories.lua +++ b/example/ButtonWithControls.stories.lua @@ -1,6 +1,7 @@ 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 = { @@ -11,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.stories.lua b/example/CanvasTests/AutomaticSize.stories.lua index 6cdac44f..c7ff7794 100644 --- a/example/CanvasTests/AutomaticSize.stories.lua +++ b/example/CanvasTests/AutomaticSize.stories.lua @@ -2,19 +2,25 @@ 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.stories.lua b/example/CanvasTests/AutomaticSizeExceedsBounds.stories.lua index c89a59ff..afa721ef 100644 --- a/example/CanvasTests/AutomaticSizeExceedsBounds.stories.lua +++ b/example/CanvasTests/AutomaticSizeExceedsBounds.stories.lua @@ -2,19 +2,25 @@ 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.stories.lua b/example/CanvasTests/Offset.stories.lua index 444fb7a3..c884104e 100644 --- a/example/CanvasTests/Offset.stories.lua +++ b/example/CanvasTests/Offset.stories.lua @@ -2,19 +2,25 @@ 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.stories.lua b/example/CanvasTests/Resizing.stories.lua index d802518d..3fa09d7d 100644 --- a/example/CanvasTests/Resizing.stories.lua +++ b/example/CanvasTests/Resizing.stories.lua @@ -4,6 +4,7 @@ local RunService = game:GetService("RunService") local React = require(Example.Parent.Packages.React) local ReactRoblox = require(Example.Parent.Packages.ReactRoblox) +local constants = require(Example.Parent.constants) local RESIZE_DURATIOn = 3 -- seconds local MAX_SIZE = 2000 -- px @@ -34,11 +35,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.stories.lua b/example/CanvasTests/Scale.stories.lua index 39c8026e..a3cead56 100644 --- a/example/CanvasTests/Scale.stories.lua +++ b/example/CanvasTests/Scale.stories.lua @@ -2,19 +2,25 @@ 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.stories.lua b/example/CanvasTests/ScrollingFrame.stories.lua index 360ba701..0701f9ef 100644 --- a/example/CanvasTests/ScrollingFrame.stories.lua +++ b/example/CanvasTests/ScrollingFrame.stories.lua @@ -2,23 +2,29 @@ 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.stories.lua b/example/Counter.stories.lua index 4c2b5f41..264ed65c 100644 --- a/example/Counter.stories.lua +++ b/example/Counter.stories.lua @@ -1,6 +1,7 @@ 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 = { @@ -12,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.stories.lua b/example/Functional.stories.lua index c8871515..920b1d20 100644 --- a/example/Functional.stories.lua +++ b/example/Functional.stories.lua @@ -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.stories.lua b/example/ReactCounter.stories.lua index d43b6237..6183232a 100644 --- a/example/ReactCounter.stories.lua +++ b/example/ReactCounter.stories.lua @@ -2,6 +2,7 @@ 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 ReactCounter = require(script.Parent.ReactCounter) local controls = { @@ -13,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/Storybook/StoryError.stories.lua b/src/Storybook/StoryError.stories.lua index 04bffa07..17a44764 100644 --- a/src/Storybook/StoryError.stories.lua +++ b/src/Storybook/StoryError.stories.lua @@ -2,16 +2,22 @@ local flipbook = script:FindFirstAncestor("flipbook") local React = require(flipbook.Packages.React) local StoryError = require(flipbook.Storybook.StoryError) +local constants = require(flipbook.constants) + +local stories = {} + +stories.Primary = function() + local _, result = xpcall(function() + error("Oops!") + end, debug.traceback) + + return 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(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, } From ccf4383f22677adccb1eb63d1c573bd0c51b3623 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Fri, 29 Dec 2023 17:03:02 -0800 Subject: [PATCH 06/23] Add some tests for loadStoryModule --- src/Storybook/loadStoryModule.spec.lua | 143 +++++++++++++++++++++++++ src/Storybook/types.lua | 2 + 2 files changed, 145 insertions(+) create mode 100644 src/Storybook/loadStoryModule.spec.lua diff --git a/src/Storybook/loadStoryModule.spec.lua b/src/Storybook/loadStoryModule.spec.lua new file mode 100644 index 00000000..98ec3fcf --- /dev/null +++ b/src/Storybook/loadStoryModule.spec.lua @@ -0,0 +1,143 @@ +return function() + local flipbook = script:FindFirstAncestor("flipbook") + + local ModuleLoader = require(flipbook.Packages.ModuleLoader) + local types = require(flipbook.Storybook.types) + local constants = require(flipbook.constants) + local loadStoryModule = require(script.Parent.loadStoryModule) + + 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 = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then "Foo.stories" else "Foo.story" + storyModule.Source = source + + return storyModule + end + + it("should 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).to.equal("Sample") + end) + + it("should 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).to.be.ok() + end) + + it("should 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 = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT + then "SampleName.stories" + else "SampleName.story" + + local story = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) + + expect(story.name).to.equal(storyModule.Name) + end) + + it("should 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).to.be.ok() + expect(err).never.to.be.ok() + expect(story.react).to.be.ok() + expect(story.reactRoblox).to.be.ok() + + story, err = loadStoryModule(loader, storyModule, MOCK_ROACT_STORYBOOK) + + expect(story).to.be.ok() + expect(err).never.to.be.ok() + expect(story.roact).to.be.ok() + end) + + it("should handle generic failures for stories", function() + local loader = ModuleLoader.new() + local storyModule = createMockStoryModule([[ + return { + } + ]]) + + local story, err = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) + + expect(story).never.to.be.ok() + expect(err).to.be.ok() + end) + + it("should handle 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).never.to.be.ok() + expect(err).to.be.ok() + end) +end diff --git a/src/Storybook/types.lua b/src/Storybook/types.lua index dd478e0c..81ffb6e1 100644 --- a/src/Storybook/types.lua +++ b/src/Storybook/types.lua @@ -60,6 +60,7 @@ types.Storybook = t.interface({ export type StoryMeta = { name: string, + story: unknown, summary: string?, controls: Controls?, roact: Roact?, @@ -67,6 +68,7 @@ export type StoryMeta = { reactRoblox: ReactRoblox?, } types.StoryMeta = t.interface({ + story = t.any, name = t.optional(t.string), summary = t.optional(t.string), controls = t.optional(types.Controls), From c0bb6ca392e5ad6c2d3bc11b8337abdee9951c2b Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Thu, 4 Jan 2024 13:00:12 -0800 Subject: [PATCH 07/23] Add example of how renderers could work --- src/Renderers/createFusionRenderer.lua | 40 +++++++++++++++++++ src/Renderers/createReactRenderer.lua | 39 +++++++++++++++++++ src/Renderers/createRoactRenderer.lua | 33 ++++++++++++++++ src/Renderers/createRobloxRenderer.lua | 30 ++++++++++++++ src/Renderers/example.lua | 54 ++++++++++++++++++++++++++ src/Renderers/types.lua | 8 ++++ 6 files changed, 204 insertions(+) create mode 100644 src/Renderers/createFusionRenderer.lua create mode 100644 src/Renderers/createReactRenderer.lua create mode 100644 src/Renderers/createRoactRenderer.lua create mode 100644 src/Renderers/createRobloxRenderer.lua create mode 100644 src/Renderers/example.lua create mode 100644 src/Renderers/types.lua diff --git a/src/Renderers/createFusionRenderer.lua b/src/Renderers/createFusionRenderer.lua new file mode 100644 index 00000000..e42f7aa5 --- /dev/null +++ b/src/Renderers/createFusionRenderer.lua @@ -0,0 +1,40 @@ +local flipbook = script:FindFirstAncestor("flipbook") + +local types = require(flipbook.Renderers.types) +local createRobloxRenderer = require(flipbook.Renderers.createRobloxRenderer) + +type Renderer = types.Renderer + +type Packages = { + Fusion: any, +} + +local function createFusionRenderer(packages: Packages): Renderer + local Fusion = packages.Fusion + local robloxRenderer = createRobloxRenderer() + + local function transformArgs(args) + local newArgs = {} + for k, v in args do + newArgs[k] = Fusion.Value(v) + end + return newArgs + end + + local function shouldUpdate(context, prevContext) + if context.args ~= prevContext.args then + return false + else + return nil + end + end + + return { + transformArgs = transformArgs, + shouldUpdate = shouldUpdate, + mount = robloxRenderer.mount, + unmount = robloxRenderer.unmount, + } +end + +return createFusionRenderer diff --git a/src/Renderers/createReactRenderer.lua b/src/Renderers/createReactRenderer.lua new file mode 100644 index 00000000..57f9deef --- /dev/null +++ b/src/Renderers/createReactRenderer.lua @@ -0,0 +1,39 @@ +local flipbook = script:FindFirstAncestor("flipbook") + +local types = require(flipbook.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 container = Instance.new("Folder") + local root = ReactRoblox.createRoot(container) + + local function mount(element) + if typeof(element) == "function" then + element = React.createElement(element, props) + end + + root:render(element) + + return container + end + + local function unmount() + root:unmount() + end + + return { + mount = mount, + unmount = unmount, + } +end + +return createReactRenderer diff --git a/src/Renderers/createRoactRenderer.lua b/src/Renderers/createRoactRenderer.lua new file mode 100644 index 00000000..10551c91 --- /dev/null +++ b/src/Renderers/createRoactRenderer.lua @@ -0,0 +1,33 @@ +local flipbook = script:FindFirstAncestor("flipbook") + +local types = require(flipbook.Renderers.types) + +type Renderer = types.Renderer + +type Packages = { + Roact: any, +} + +local function createRoactRenderer(packages: Packages): Renderer + local Roact = packages.Roact + local container + local handle + + local function mount(element) + container = Instance.new("Folder") + handle = Roact.mount(element, container, "RoactRenderer") + return container + end + + local function unmount() + Roact.unmount(handle) + container:Destroy() + end + + return { + mount = mount, + unmount = unmount, + } +end + +return createRoactRenderer diff --git a/src/Renderers/createRobloxRenderer.lua b/src/Renderers/createRobloxRenderer.lua new file mode 100644 index 00000000..130d9b80 --- /dev/null +++ b/src/Renderers/createRobloxRenderer.lua @@ -0,0 +1,30 @@ +local flipbook = script:FindFirstAncestor("flipbook") + +local types = require(flipbook.Renderers.types) + +type Renderer = types.Renderer + +local function createRobloxRenderer(): Renderer + local handle + + local function mount(target, element) + if typeof(element) == "Instance" and element:IsA("GuiObject") then + element.Parent = target + handle = element + end + return element + end + + local function unmount() + if handle then + handle:Destroy() + end + end + + return { + mount = mount, + unmount = unmount, + } +end + +return createRobloxRenderer diff --git a/src/Renderers/example.lua b/src/Renderers/example.lua new file mode 100644 index 00000000..04e54a8a --- /dev/null +++ b/src/Renderers/example.lua @@ -0,0 +1,54 @@ +render = function(target, props) + local label = Instance.new("TextLabel") + label.Text = if props.args.isEnabled then "Enabled" else "Disabled" + + return function() + label:Destroy() + end +end + +-- Plain GuiObjects +exports.PrimaryRoblox = { + 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 +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 +exports.PrimaryFusion = { + args = { + isEnabled = true, + }, + renderer = FusionRenderer, + render = function(args) + return new("TextLabel")({ + Text = if args.isEnabled then "Enabled" else "Disabled", + }) + end, +} diff --git a/src/Renderers/types.lua b/src/Renderers/types.lua new file mode 100644 index 00000000..7fb2f27a --- /dev/null +++ b/src/Renderers/types.lua @@ -0,0 +1,8 @@ +export type Renderer = { + transformArgs: ((args: { [string]: any }) -> { [string]: any })?, + shouldUpdate: (() -> boolean)?, + mount: (target: Instance, element: any) -> GuiObject | Folder, + unmount: (() -> ())?, +} + +return nil From c7c2e159b6a75be03b94309f4273bccaff67a105 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Thu, 4 Jan 2024 15:35:00 -0800 Subject: [PATCH 08/23] Turn the example into markdown --- src/Renderers/example.lua | 54 ----------------------------- src/Renderers/example.md | 72 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 54 deletions(-) delete mode 100644 src/Renderers/example.lua create mode 100644 src/Renderers/example.md diff --git a/src/Renderers/example.lua b/src/Renderers/example.lua deleted file mode 100644 index 04e54a8a..00000000 --- a/src/Renderers/example.lua +++ /dev/null @@ -1,54 +0,0 @@ -render = function(target, props) - local label = Instance.new("TextLabel") - label.Text = if props.args.isEnabled then "Enabled" else "Disabled" - - return function() - label:Destroy() - end -end - --- Plain GuiObjects -exports.PrimaryRoblox = { - 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 -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 -exports.PrimaryFusion = { - args = { - isEnabled = true, - }, - renderer = FusionRenderer, - render = function(args) - return new("TextLabel")({ - Text = if args.isEnabled then "Enabled" else "Disabled", - }) - end, -} diff --git a/src/Renderers/example.md b/src/Renderers/example.md new file mode 100644 index 00000000..12b034d3 --- /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 +} +``` From 8e343ec8416f969843cc34f6e2031f03893bf6c7 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Sun, 3 Mar 2024 08:48:51 -0800 Subject: [PATCH 09/23] Add WIP changes --- .vscode/settings.json | 5 +- src/Renderers/createFusionRenderer.spec.lua | 68 +++++++++++++++++++++ src/Renderers/createRobloxRenderer.lua | 16 ++++- src/Renderers/render.lua | 37 +++++++++++ src/Renderers/types.lua | 17 ++++-- story-example.lua | 14 +++++ wally.toml | 1 + 7 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 src/Renderers/createFusionRenderer.spec.lua create mode 100644 src/Renderers/render.lua create mode 100644 story-example.lua diff --git a/.vscode/settings.json b/.vscode/settings.json index 2b242420..be160690 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,6 @@ { - "luau-lsp.sourcemap.rojoProjectFile": "tests.project.json" + "luau-lsp.sourcemap.rojoProjectFile": "tests.project.json", + "luau-lsp.types.definitionFiles": [ + "testez.d.lua" + ] } \ No newline at end of file diff --git a/src/Renderers/createFusionRenderer.spec.lua b/src/Renderers/createFusionRenderer.spec.lua new file mode 100644 index 00000000..7bd3d374 --- /dev/null +++ b/src/Renderers/createFusionRenderer.spec.lua @@ -0,0 +1,68 @@ +return function() + local flipbook = script:FindFirstAncestor("flipbook") + + local Fusion = require(flipbook.Packages.Fusion) + local createFusionRenderer = require(script.Parent.createFusionRenderer) + + local New = Fusion.New + local Value = Fusion.Value + type StateObject = Fusion.StateObject + + type ButtonProps = { + isDisabled: StateObject, + } + local function Button(props) + return New("TextButton")({ + Text = if props.isDisabled:get() then "Disabled" else "Enabled", + }) + end + + it("should render a Fusion component", function() + local renderer = createFusionRenderer({ Fusion = Fusion }) + local args = { + isDisabled = Value(false), + } + + local target = Instance.new("Folder") + local gui = renderer.mount(target, Button, args) + + expect(gui).to.be.ok() + expect(gui.Text).to.equal("Enabled") + end) + + it("should unmount a Fusion component", function() + local renderer = createFusionRenderer({ Fusion = Fusion }) + local args = { + isDisabled = Value(false), + } + + local target = Instance.new("Folder") + local gui = renderer.mount(target, Button, args) + + expect(gui).to.be.ok() + expect(gui:IsDescendantOf(game)).to.equal(true) + + renderer.unmount() + + expect(gui:IsDescendantOf(game)).to.equal(false) + end) + + it("should update the component on arg changes", function() + local renderer = createFusionRenderer({ Fusion = Fusion }) + local args = { + isDisabled = Value(false), + } + + local target = Instance.new("Folder") + local gui = renderer.mount(target, Button, args) + + expect(gui).to.be.ok() + expect(gui:IsDescendantOf(game)).to.equal(true) + + renderer.unmount() + + expect(gui:IsDescendantOf(game)).to.equal(false) + end) + + it("should never re-mount on arg changes", function() end) +end diff --git a/src/Renderers/createRobloxRenderer.lua b/src/Renderers/createRobloxRenderer.lua index 130d9b80..a73cdd47 100644 --- a/src/Renderers/createRobloxRenderer.lua +++ b/src/Renderers/createRobloxRenderer.lua @@ -7,7 +7,15 @@ type Renderer = types.Renderer local function createRobloxRenderer(): Renderer local handle - local function mount(target, element) + local function shouldUpdate() + return true + end + + local function mount(target, element, args) + if typeof(element) == "function" then + element = element(args) + end + if typeof(element) == "Instance" and element:IsA("GuiObject") then element.Parent = target handle = element @@ -21,7 +29,13 @@ local function createRobloxRenderer(): Renderer end end + local function render() + unmount() + mount() + end + return { + shouldUpdate = shouldUpdate, mount = mount, unmount = unmount, } diff --git a/src/Renderers/render.lua b/src/Renderers/render.lua new file mode 100644 index 00000000..283bf094 --- /dev/null +++ b/src/Renderers/render.lua @@ -0,0 +1,37 @@ +local flipbook = script:FindFirstAncestor("flipbook") + +local types = require(flipbook.Renderers.types) + +type Args = types.Args +type Context = types.Context +type Renderer = types.Renderer + +local function render(renderer: Renderer, target: Instance, element: any, args: Args) + local handle: Instance + local context: Context = { + target = target, + element = element, + args = args, + } + local prevContext: Context? = nil + + local function renderOnce() + if not renderer.shouldUpdate or renderer.shouldUpdate(context, prevContext) then + if renderer.unmount then + renderer.unmount(context) + else + handle:Destroy() + end + + handle = renderer.mount(target, element, context) + end + end + + renderOnce() + + return function() + renderOnce() + end +end + +return render diff --git a/src/Renderers/types.lua b/src/Renderers/types.lua index 7fb2f27a..1436f87b 100644 --- a/src/Renderers/types.lua +++ b/src/Renderers/types.lua @@ -1,8 +1,17 @@ +export type Args = { + [string]: any, +} + +export type Context = { + args: Args, + target: Instance, +} + export type Renderer = { - transformArgs: ((args: { [string]: any }) -> { [string]: any })?, - shouldUpdate: (() -> boolean)?, - mount: (target: Instance, element: any) -> GuiObject | Folder, - unmount: (() -> ())?, + transformArgs: ((args: Args, context: Context) -> Args)?, + shouldUpdate: ((context: Context, prevContext: Context?) -> boolean)?, + mount: (target: Instance, element: any, context: Context) -> GuiObject | Folder, + unmount: ((context: Context) -> ())?, } return nil diff --git a/story-example.lua b/story-example.lua new file mode 100644 index 00000000..1e1f43bb --- /dev/null +++ b/story-example.lua @@ -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 0fbe2165..55068274 100644 --- a/wally.toml +++ b/wally.toml @@ -17,3 +17,4 @@ t = "osyrisrblx/t@3.0.0" # dev dependencies Roact = "roblox/roact@1.4.4" TestEZ = "roblox/testez@0.4.1" +Fusion = "elttob/fusion@0.2.0" From 44c6e1e4c4380642062d4cb3221ffb573dc3b244 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Sun, 7 Apr 2024 20:58:47 -0700 Subject: [PATCH 10/23] Install CSF from Wally --- .vscode/settings.json | 5 +---- bin/wally-install.sh | 2 -- wally.toml | 1 + 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index be160690..2b242420 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,3 @@ { - "luau-lsp.sourcemap.rojoProjectFile": "tests.project.json", - "luau-lsp.types.definitionFiles": [ - "testez.d.lua" - ] + "luau-lsp.sourcemap.rojoProjectFile": "tests.project.json" } \ No newline at end of file diff --git a/bin/wally-install.sh b/bin/wally-install.sh index f69d8af5..3e6b7ec5 100755 --- a/bin/wally-install.sh +++ b/bin/wally-install.sh @@ -4,8 +4,6 @@ set -e wally install -git clone https://github.com/flipbook-labs/csf-lua Packages/CSF - rojo sourcemap tests.project.json --output sourcemap.json wally-package-types --sourcemap sourcemap.json Packages/ diff --git a/wally.toml b/wally.toml index 55068274..80099550 100644 --- a/wally.toml +++ b/wally.toml @@ -7,6 +7,7 @@ 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" From bb7f9fbe2d181e0de1334c4ac8828443571da4e1 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Sun, 7 Apr 2024 21:03:37 -0700 Subject: [PATCH 11/23] Port more stories --- src/Common/Branding.stories.lua | 7 ++- src/Common/ScrollingFrame.stories.lua | 52 ++++++++++++---------- src/Common/Sprite.stories.lua | 54 ++++++++++++----------- src/Forms/Button.stories.lua | 20 +++++---- src/Forms/Checkbox.stories.lua | 16 ++++--- src/Forms/Dropdown.stories.lua | 34 +++++++------- src/Forms/InputField.stories.lua | 30 +++++++------ src/Forms/Searchbar.stories.lua | 6 ++- src/Forms/SelectableTextLabel.stories.lua | 14 +++--- src/Panels/ResizablePanel.stories.lua | 28 +++++++----- src/Panels/Sidebar.stories.lua | 26 ++++++----- src/Plugin/PluginApp.stories.lua | 12 +++-- src/Storybook/NoStorySelected.stories.lua | 6 ++- src/Storybook/StoryControls.stories.lua | 28 +++++++----- src/Storybook/StoryMeta.stories.lua | 16 ++++--- 15 files changed, 204 insertions(+), 145 deletions(-) diff --git a/src/Common/Branding.stories.lua b/src/Common/Branding.stories.lua index cdf11ec6..cb913105 100644 --- a/src/Common/Branding.stories.lua +++ b/src/Common/Branding.stories.lua @@ -3,8 +3,11 @@ local flipbook = script:FindFirstAncestor("flipbook") local React = require(flipbook.Packages.React) local Branding = require(script.Parent.Branding) +local stories = {} + +stories.Primary = React.createElement(Branding) + return { summary = "Icon and Typography for flipbook", - controls = {}, - story = React.createElement(Branding), + stories = stories, } diff --git a/src/Common/ScrollingFrame.stories.lua b/src/Common/ScrollingFrame.stories.lua index 4daf015f..f18958b0 100644 --- a/src/Common/ScrollingFrame.stories.lua +++ b/src/Common/ScrollingFrame.stories.lua @@ -12,31 +12,35 @@ type Props = { controls: typeof(controls), } -return { - controls = controls, - story = function(props: Props) - local children = {} +local stories = {} - children.Layout = React.createElement("UIListLayout", { - SortOrder = Enum.SortOrder.LayoutOrder, - Padding = UDim.new(0, 16), - }) +stories.Primary = function(props: Props) + local children = {} + + 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 - - return React.createElement("Frame", { - Size = UDim2.new(1, 0, 0, 200), - BackgroundTransparency = 1, - }, { - ScrollingFrame = React.createElement(ScrollingFrame, {}, children), + 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, + end + + return React.createElement("Frame", { + Size = UDim2.new(1, 0, 0, 200), + BackgroundTransparency = 1, + }, { + ScrollingFrame = React.createElement(ScrollingFrame, {}, children), + }) +end + +return { + controls = controls, + stories = stories, } diff --git a/src/Common/Sprite.stories.lua b/src/Common/Sprite.stories.lua index 2bea94b8..6c783465 100644 --- a/src/Common/Sprite.stories.lua +++ b/src/Common/Sprite.stories.lua @@ -4,30 +4,34 @@ local React = require(flipbook.Packages.React) local assets = require(flipbook.assets) local Sprite = require(script.Parent.Sprite) -return { - story = React.createElement("Folder", {}, { - Layout = React.createElement("UIListLayout", { - SortOrder = Enum.SortOrder.LayoutOrder, - }), - - flipbook = React.createElement(Sprite, { - layoutOrder = 1, - image = assets.flipbook, - }), - - Storybook = React.createElement(Sprite, { - layoutOrder = 2, - image = assets.Storybook, - }), - - Folder = React.createElement(Sprite, { - layoutOrder = 3, - image = assets.Folder, - }), - - Component = React.createElement(Sprite, { - layoutOrder = 4, - image = assets.Component, - }), +local stories = {} + +stories.Primary = React.createElement("Folder", {}, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + }), + + flipbook = React.createElement(Sprite, { + layoutOrder = 1, + image = assets.flipbook, + }), + + Storybook = React.createElement(Sprite, { + layoutOrder = 2, + image = assets.Storybook, }), + + Folder = React.createElement(Sprite, { + layoutOrder = 3, + image = assets.Folder, + }), + + Component = React.createElement(Sprite, { + layoutOrder = 4, + image = assets.Component, + }), +}) + +return { + stories = stories, } diff --git a/src/Forms/Button.stories.lua b/src/Forms/Button.stories.lua index 9bd6acb3..7e79bc8a 100644 --- a/src/Forms/Button.stories.lua +++ b/src/Forms/Button.stories.lua @@ -11,15 +11,19 @@ type Props = { controls: typeof(controls), } +local stories = {} + +stories.Primary = function(props: Props) + return 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(Button, { - text = props.controls.text, - onClick = function() - print("click") - end, - }) - end, + stories = stories, } diff --git a/src/Forms/Checkbox.stories.lua b/src/Forms/Checkbox.stories.lua index a8ef8dba..b2b04f77 100644 --- a/src/Forms/Checkbox.stories.lua +++ b/src/Forms/Checkbox.stories.lua @@ -3,12 +3,16 @@ local flipbook = script:FindFirstAncestor("flipbook") local React = require(flipbook.Packages.React) local Checkbox = require(script.Parent.Checkbox) +local stories = {} + +stories.Primary = 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(Checkbox, { - initialState = true, - onStateChange = function(newState) - print("Checkbox state changed to", newState) - end, - }), + stories = stories, } diff --git a/src/Forms/Dropdown.stories.lua b/src/Forms/Dropdown.stories.lua index 7c87fa85..71590cb9 100644 --- a/src/Forms/Dropdown.stories.lua +++ b/src/Forms/Dropdown.stories.lua @@ -12,21 +12,25 @@ 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(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(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.stories.lua b/src/Forms/InputField.stories.lua index c100e0bb..631e7092 100644 --- a/src/Forms/InputField.stories.lua +++ b/src/Forms/InputField.stories.lua @@ -3,18 +3,22 @@ local flipbook = script:FindFirstAncestor("flipbook") local React = require(flipbook.Packages.React) local InputField = require(script.Parent.InputField) +local stories = {} + +stories.Primary = 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 { - story = 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, - }), + stories = stories, } diff --git a/src/Forms/Searchbar.stories.lua b/src/Forms/Searchbar.stories.lua index 60421e9c..770e41e1 100644 --- a/src/Forms/Searchbar.stories.lua +++ b/src/Forms/Searchbar.stories.lua @@ -3,8 +3,12 @@ local flipbook = script:FindFirstAncestor("flipbook") local React = require(flipbook.Packages.React) local Searchbar = require(script.Parent.Searchbar) +local stories = {} + +stories.Primary = React.createElement(Searchbar) + return { summary = "Searchbar used to search for components", controls = {}, - story = React.createElement(Searchbar), + stories = stories, } diff --git a/src/Forms/SelectableTextLabel.stories.lua b/src/Forms/SelectableTextLabel.stories.lua index 4de7c718..6e1dfbfe 100644 --- a/src/Forms/SelectableTextLabel.stories.lua +++ b/src/Forms/SelectableTextLabel.stories.lua @@ -11,12 +11,16 @@ type Props = { controls: typeof(controls), } +local stories = {} + +stories.Primary = function(props: Props) + return 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(SelectableTextLabel, { - Text = props.controls.text, - }) - end, + stories = stories, } diff --git a/src/Panels/ResizablePanel.stories.lua b/src/Panels/ResizablePanel.stories.lua index 3f429477..cf9ee7f8 100644 --- a/src/Panels/ResizablePanel.stories.lua +++ b/src/Panels/ResizablePanel.stories.lua @@ -14,18 +14,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.stories.lua b/src/Panels/Sidebar.stories.lua index b332bb49..5e3fd819 100644 --- a/src/Panels/Sidebar.stories.lua +++ b/src/Panels/Sidebar.stories.lua @@ -4,18 +4,22 @@ local React = require(flipbook.Packages.React) local internalStorybook = require(flipbook["init.storybook"]) local Sidebar = require(script.Parent.Sidebar) +local stories = {} + +stories.Primary = 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(Sidebar, { - storybooks = { - internalStorybook, - }, - selectStory = function(storyModule) - print(storyModule) - end, - selectStorybook = function(storybook) - print(storybook) - end, - }), + stories = stories, } diff --git a/src/Plugin/PluginApp.stories.lua b/src/Plugin/PluginApp.stories.lua index 32aef06e..a44c52cf 100644 --- a/src/Plugin/PluginApp.stories.lua +++ b/src/Plugin/PluginApp.stories.lua @@ -4,11 +4,15 @@ local React = require(flipbook.Packages.React) local ModuleLoader = require(flipbook.Packages.ModuleLoader) local PluginApp = require(script.Parent.PluginApp) +local stories = {} + +stories.Primary = React.createElement(PluginApp, { + loader = ModuleLoader.new(), + plugin = plugin, +}) + return { summary = "The main component that handles the entire plugin", controls = {}, - story = React.createElement(PluginApp, { - loader = ModuleLoader.new(), - plugin = plugin, - }), + stories = stories, } diff --git a/src/Storybook/NoStorySelected.stories.lua b/src/Storybook/NoStorySelected.stories.lua index f47cdbe8..c035f45b 100644 --- a/src/Storybook/NoStorySelected.stories.lua +++ b/src/Storybook/NoStorySelected.stories.lua @@ -3,6 +3,10 @@ local flipbook = script:FindFirstAncestor("flipbook") local React = require(flipbook.Packages.React) local NoStorySelected = require(script.Parent.NoStorySelected) +local stories = {} + +stories.Primary = React.createElement(NoStorySelected) + return { - story = React.createElement(NoStorySelected), + stories = stories, } diff --git a/src/Storybook/StoryControls.stories.lua b/src/Storybook/StoryControls.stories.lua index 92e808f4..d36d1630 100644 --- a/src/Storybook/StoryControls.stories.lua +++ b/src/Storybook/StoryControls.stories.lua @@ -3,18 +3,22 @@ local flipbook = script:FindFirstAncestor("flipbook") local React = require(flipbook.Packages.React) local StoryControls = require(flipbook.Storybook.StoryControls) +local stories = {} + +stories.Primary = 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 = React.createElement(StoryControls, { - controls = { - foo = "bar", - checkbox = false, - dropdown = { - "Option 1", - "Option 2", - "Option 3", - }, - }, - setControl = function() end, - }), + stories = stories, } diff --git a/src/Storybook/StoryMeta.stories.lua b/src/Storybook/StoryMeta.stories.lua index 149989b9..42e2bf90 100644 --- a/src/Storybook/StoryMeta.stories.lua +++ b/src/Storybook/StoryMeta.stories.lua @@ -3,11 +3,15 @@ local flipbook = script:FindFirstAncestor("flipbook") local React = require(flipbook.Packages.React) local StoryMeta = require(flipbook.Storybook.StoryMeta) +local stories = {} + +stories.Primary = React.createElement(StoryMeta, { + story = { + name = "Story", + summary = "Story summary", + }, +}) + return { - story = React.createElement(StoryMeta, { - story = { - name = "Story", - summary = "Story summary", - }, - }), + stories = stories, } From 8cad5f2e53a2ccb46c5c1fdb0e340d9db9330f0c Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Thu, 11 Apr 2024 18:16:27 -0700 Subject: [PATCH 12/23] Add some render test cases --- src/Renderers/createRobloxRenderer.lua | 5 - src/Renderers/render.lua | 4 +- src/Renderers/render.spec.lua | 130 +++++++++++++++++++++++++ src/Renderers/types.lua | 5 +- 4 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 src/Renderers/render.spec.lua diff --git a/src/Renderers/createRobloxRenderer.lua b/src/Renderers/createRobloxRenderer.lua index a73cdd47..01feee02 100644 --- a/src/Renderers/createRobloxRenderer.lua +++ b/src/Renderers/createRobloxRenderer.lua @@ -29,11 +29,6 @@ local function createRobloxRenderer(): Renderer end end - local function render() - unmount() - mount() - end - return { shouldUpdate = shouldUpdate, mount = mount, diff --git a/src/Renderers/render.lua b/src/Renderers/render.lua index 283bf094..2736fcc9 100644 --- a/src/Renderers/render.lua +++ b/src/Renderers/render.lua @@ -6,7 +6,9 @@ type Args = types.Args type Context = types.Context type Renderer = types.Renderer -local function render(renderer: Renderer, target: Instance, element: any, args: Args) +type UpdateFn = () -> () + +local function render(renderer: Renderer, target: Instance, element: any, args: Args): UpdateFn local handle: Instance local context: Context = { target = target, diff --git a/src/Renderers/render.spec.lua b/src/Renderers/render.spec.lua new file mode 100644 index 00000000..f5b0a50b --- /dev/null +++ b/src/Renderers/render.spec.lua @@ -0,0 +1,130 @@ +local flipbook = script:FindFirstAncestor("flipbook") + +local JestGlobals = require(flipbook.Packages.JestGlobals) +local types = require(flipbook.Renderers.types) +local render = require(script.Parent.render) + +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 target: Instance +local element = jest.fn() + +beforeEach(function() + target = Instance.new("Folder") +end) + +afterEach(function() + target:Destroy() + jest.resetAllMocks() +end) + +test("call `mount` immediately", function() + local mockMount = jest.fn() + + local mockRenderer: Renderer = { + mount = mockMount, + } + + render(mockRenderer, target, 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 update = render(mockRenderer) + + expect(mockMount).toHaveBeenCalledTimes(1) + + update() + + expect(mockMount).toHaveBeenCalledTimes(2) +end) + +test("current context and prev context are passed to shouldUpdate", function() + local context, prevContext + + local mockRenderer: Renderer = { + shouldUpdate = function(_context, _prevContext) + context = _context + prevContext = _prevContext + return true + end, + } + + local update = render(mockRenderer, target, element) +end) + +test("context is passed to shouldUpdate", function() + local context + + local args = { + foo = true, + } + + local mockRenderer: Renderer = { + shouldUpdate = function(_context) + context = _context + end, + } + + render(mockRenderer, target, element, args) + + expect(context).toEqual({ + target = target, + element = element, + args = args, + }) +end) + +test("only render if shouldUpdate returns true", function() + local mockMount = jest.fn() + + local mockRenderer: Renderer = { + shouldUpdate = function() + return false + end, + mount = mockMount, + } + + local update = render(mockRenderer) + + expect(mockMount).never.toHaveBeenCalled() + + update() + + expect(mockMount).never.toHaveBeenCalled() +end) + +test("if unmount is not specified, implicitly destroy the handle", function() + local mockDestroy = jest.fn() + + local mockRenderer: Renderer = { + mount = function() + local mockInstance = { + Destroy = mockDestroy, + } + return mockInstance + end, + } + + local update = render(mockRenderer) + + expect(mockDestroy).never.toHaveBeenCalled() + + update() + + expect(mockDestroy).toHaveBeenCalled() +end) diff --git a/src/Renderers/types.lua b/src/Renderers/types.lua index 1436f87b..a480d9f4 100644 --- a/src/Renderers/types.lua +++ b/src/Renderers/types.lua @@ -3,14 +3,15 @@ export type Args = { } export type Context = { - args: Args, target: Instance, + element: unknown, + args: Args?, } export type Renderer = { transformArgs: ((args: Args, context: Context) -> Args)?, shouldUpdate: ((context: Context, prevContext: Context?) -> boolean)?, - mount: (target: Instance, element: any, context: Context) -> GuiObject | Folder, + mount: (target: Instance, element: unknown, context: Context) -> GuiObject | Folder, unmount: ((context: Context) -> ())?, } From adf9a503c25ba3ef0796eae3bfb691640fd84136 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Sun, 21 Apr 2024 22:08:03 -0700 Subject: [PATCH 13/23] Fix some merge issues --- example/ReactCounter.stories.luau | 6 +- src/Common/ScrollingFrame.stories.luau | 2 +- src/Common/Sprite.stories.luau | 8 +- src/Forms/Dropdown.stories.luau | 6 +- src/Forms/InputField.stories.luau | 2 +- src/Panels/ResizablePanel.stories.luau | 2 +- src/Renderers/createFusionRenderer.luau | 6 +- src/Renderers/createFusionRenderer.spec.luau | 2 +- src/Renderers/createReactRenderer.luau | 4 +- src/Renderers/createRoactRenderer.luau | 4 +- src/Renderers/createRobloxRenderer.luau | 4 +- src/Renderers/render.luau | 4 +- src/Renderers/render.spec.luau | 8 +- src/Storybook/StoryError.stories.luau | 2 +- src/Storybook/loadStoryModule.spec.luau | 186 +++++++++---------- 15 files changed, 112 insertions(+), 134 deletions(-) diff --git a/example/ReactCounter.stories.luau b/example/ReactCounter.stories.luau index 8629a25d..5958f81f 100644 --- a/example/ReactCounter.stories.luau +++ b/example/ReactCounter.stories.luau @@ -1,13 +1,9 @@ local Example = script:FindFirstAncestor("Example") local React = require(Example.Parent.Packages.React) -<<<<<<<< HEAD:example/ReactCounter.stories.lua -local ReactRoblox = require(Example.Parent.Packages.ReactRoblox) -local constants = require(Example.Parent.constants) -======== ->>>>>>>> origin/main:example/ReactCounter.story.luau local ReactCounter = require(script.Parent.ReactCounter) local ReactRoblox = require(Example.Parent.Packages.ReactRoblox) +local constants = require(Example.Parent.constants) local controls = { increment = 1, diff --git a/src/Common/ScrollingFrame.stories.luau b/src/Common/ScrollingFrame.stories.luau index f18958b0..4e93b8ce 100644 --- a/src/Common/ScrollingFrame.stories.luau +++ b/src/Common/ScrollingFrame.stories.luau @@ -1,6 +1,6 @@ local flipbook = script:FindFirstAncestor("flipbook") -local React = require(flipbook.Packages.React) +local React = require("@pkg/React") local ScrollingFrame = require(script.Parent.ScrollingFrame) local controls = { diff --git a/src/Common/Sprite.stories.luau b/src/Common/Sprite.stories.luau index 6c783465..cc308491 100644 --- a/src/Common/Sprite.stories.luau +++ b/src/Common/Sprite.stories.luau @@ -1,8 +1,6 @@ -local flipbook = script:FindFirstAncestor("flipbook") - -local React = require(flipbook.Packages.React) -local assets = require(flipbook.assets) -local Sprite = require(script.Parent.Sprite) +local React = require("@pkg/React") +local Sprite = require("./Sprite") +local assets = require("@root/assets") local stories = {} diff --git a/src/Forms/Dropdown.stories.luau b/src/Forms/Dropdown.stories.luau index 71590cb9..c08eabde 100644 --- a/src/Forms/Dropdown.stories.luau +++ b/src/Forms/Dropdown.stories.luau @@ -1,7 +1,5 @@ -local flipbook = script:FindFirstAncestor("flipbook") - -local React = require(flipbook.Packages.React) -local Dropdown = require(flipbook.Forms.Dropdown) +local Dropdown = require("@root/Forms/Dropdown") +local React = require("@pkg/React") local controls = { useDefault = true, diff --git a/src/Forms/InputField.stories.luau b/src/Forms/InputField.stories.luau index 631e7092..16e2238e 100644 --- a/src/Forms/InputField.stories.luau +++ b/src/Forms/InputField.stories.luau @@ -1,7 +1,7 @@ local flipbook = script:FindFirstAncestor("flipbook") -local React = require(flipbook.Packages.React) local InputField = require(script.Parent.InputField) +local React = require("@pkg/React") local stories = {} diff --git a/src/Panels/ResizablePanel.stories.luau b/src/Panels/ResizablePanel.stories.luau index cf9ee7f8..6c30bc8c 100644 --- a/src/Panels/ResizablePanel.stories.luau +++ b/src/Panels/ResizablePanel.stories.luau @@ -1,6 +1,6 @@ local flipbook = script:FindFirstAncestor("flipbook") -local React = require(flipbook.Packages.React) +local React = require("@pkg/React") local ResizablePanel = require(script.Parent.ResizablePanel) local controls = { diff --git a/src/Renderers/createFusionRenderer.luau b/src/Renderers/createFusionRenderer.luau index e42f7aa5..73a12f5f 100644 --- a/src/Renderers/createFusionRenderer.luau +++ b/src/Renderers/createFusionRenderer.luau @@ -1,7 +1,5 @@ -local flipbook = script:FindFirstAncestor("flipbook") - -local types = require(flipbook.Renderers.types) -local createRobloxRenderer = require(flipbook.Renderers.createRobloxRenderer) +local createRobloxRenderer = require("@root/Renderers/createRobloxRenderer") +local types = require("@root/Renderers/types") type Renderer = types.Renderer diff --git a/src/Renderers/createFusionRenderer.spec.luau b/src/Renderers/createFusionRenderer.spec.luau index 7bd3d374..328bbb17 100644 --- a/src/Renderers/createFusionRenderer.spec.luau +++ b/src/Renderers/createFusionRenderer.spec.luau @@ -1,7 +1,7 @@ return function() local flipbook = script:FindFirstAncestor("flipbook") - local Fusion = require(flipbook.Packages.Fusion) + local Fusion = require("@pkg/Fusion") local createFusionRenderer = require(script.Parent.createFusionRenderer) local New = Fusion.New diff --git a/src/Renderers/createReactRenderer.luau b/src/Renderers/createReactRenderer.luau index 57f9deef..2290c042 100644 --- a/src/Renderers/createReactRenderer.luau +++ b/src/Renderers/createReactRenderer.luau @@ -1,6 +1,4 @@ -local flipbook = script:FindFirstAncestor("flipbook") - -local types = require(flipbook.Renderers.types) +local types = require("@root/Renderers/types") type Renderer = types.Renderer diff --git a/src/Renderers/createRoactRenderer.luau b/src/Renderers/createRoactRenderer.luau index 10551c91..c9252f17 100644 --- a/src/Renderers/createRoactRenderer.luau +++ b/src/Renderers/createRoactRenderer.luau @@ -1,6 +1,4 @@ -local flipbook = script:FindFirstAncestor("flipbook") - -local types = require(flipbook.Renderers.types) +local types = require("@root/Renderers/types") type Renderer = types.Renderer diff --git a/src/Renderers/createRobloxRenderer.luau b/src/Renderers/createRobloxRenderer.luau index 01feee02..3a71f949 100644 --- a/src/Renderers/createRobloxRenderer.luau +++ b/src/Renderers/createRobloxRenderer.luau @@ -1,6 +1,4 @@ -local flipbook = script:FindFirstAncestor("flipbook") - -local types = require(flipbook.Renderers.types) +local types = require("@root/Renderers/types") type Renderer = types.Renderer diff --git a/src/Renderers/render.luau b/src/Renderers/render.luau index 2736fcc9..8febed7c 100644 --- a/src/Renderers/render.luau +++ b/src/Renderers/render.luau @@ -1,6 +1,4 @@ -local flipbook = script:FindFirstAncestor("flipbook") - -local types = require(flipbook.Renderers.types) +local types = require("@root/Renderers/types") type Args = types.Args type Context = types.Context diff --git a/src/Renderers/render.spec.luau b/src/Renderers/render.spec.luau index f5b0a50b..11ce4fb0 100644 --- a/src/Renderers/render.spec.luau +++ b/src/Renderers/render.spec.luau @@ -1,8 +1,6 @@ -local flipbook = script:FindFirstAncestor("flipbook") - -local JestGlobals = require(flipbook.Packages.JestGlobals) -local types = require(flipbook.Renderers.types) -local render = require(script.Parent.render) +local JestGlobals = require("@pkg/JestGlobals") +local render = require("./render") +local types = require("@root/Renderers/types") local afterEach = JestGlobals.afterEach local beforeEach = JestGlobals.beforeEach diff --git a/src/Storybook/StoryError.stories.luau b/src/Storybook/StoryError.stories.luau index 17a44764..88e918ad 100644 --- a/src/Storybook/StoryError.stories.luau +++ b/src/Storybook/StoryError.stories.luau @@ -1,6 +1,6 @@ local flipbook = script:FindFirstAncestor("flipbook") -local React = require(flipbook.Packages.React) +local React = require("@pkg/React") local StoryError = require(flipbook.Storybook.StoryError) local constants = require(flipbook.constants) diff --git a/src/Storybook/loadStoryModule.spec.luau b/src/Storybook/loadStoryModule.spec.luau index 98ec3fcf..f1588f1f 100644 --- a/src/Storybook/loadStoryModule.spec.luau +++ b/src/Storybook/loadStoryModule.spec.luau @@ -1,65 +1,66 @@ -return function() - local flipbook = script:FindFirstAncestor("flipbook") - - local ModuleLoader = require(flipbook.Packages.ModuleLoader) - local types = require(flipbook.Storybook.types) - local constants = require(flipbook.constants) - local loadStoryModule = require(script.Parent.loadStoryModule) - - 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 = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then "Foo.stories" else "Foo.story" - storyModule.Source = source - - return storyModule - end - - it("should load a story module as a table", function() - local loader = ModuleLoader.new() - local storyModule = createMockStoryModule([[ +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 = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then "Foo.stories" else "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) + local story = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) - expect(story.name).to.equal("Sample") - end) + expect(story.name).toEqual("Sample") +end) - it("should handle Hoarcekat stories", function() - local loader = ModuleLoader.new() - local storyModule = createMockStoryModule([[ +test("handle Hoarcekat stories", function() + local loader = ModuleLoader.new() + local storyModule = createMockStoryModule([[ return function(target) local gui = Instance.new("TextLabel") gui.Parent = target @@ -70,74 +71,71 @@ return function() end ]]) - local story = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) + local story = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) - expect(story).to.be.ok() - end) + expect(story).toBeDefined() +end) - it("should use the name of the story module for the story name", function() - local loader = ModuleLoader.new() - local storyModule = createMockStoryModule([[ +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 = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT - then "SampleName.stories" - else "SampleName.story" + storyModule.Name = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then "SampleName.stories" else "SampleName.story" - local story = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) + local story = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) - expect(story.name).to.equal(storyModule.Name) - end) + expect(story.name).toEqual(storyModule.Name) +end) - it("should pass the storybook's renderer to the story", function() - local loader = ModuleLoader.new() - local storyModule = createMockStoryModule([[ +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) + local story, err = loadStoryModule(loader, storyModule, MOCK_REACT_STORYBOOK) - expect(story).to.be.ok() - expect(err).never.to.be.ok() - expect(story.react).to.be.ok() - expect(story.reactRoblox).to.be.ok() + expect(story).toBeDefined() + expect(err).toBeNil() + expect(story.react).toBeDefined() + expect(story.reactRoblox).toBeDefined() - story, err = loadStoryModule(loader, storyModule, MOCK_ROACT_STORYBOOK) + story, err = loadStoryModule(loader, storyModule, MOCK_ROACT_STORYBOOK) - expect(story).to.be.ok() - expect(err).never.to.be.ok() - expect(story.roact).to.be.ok() - end) + expect(story).toBeDefined() + expect(err).toBeNil() + expect(story.roact).toBeDefined() +end) - it("should handle generic failures for stories", function() - local loader = ModuleLoader.new() - local storyModule = createMockStoryModule([[ +test("generic failures for stories", function() + local loader = ModuleLoader.new() + local storyModule = createMockStoryModule([[ return { } ]]) - local story, err = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) + local story, err = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) - expect(story).never.to.be.ok() - expect(err).to.be.ok() - end) + expect(story).toBeNil() + expect(err).toBeDefined() +end) - it("should handle malformed stories", function() - local loader = ModuleLoader.new() - local storyModule = createMockStoryModule([[ +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) + local story, err = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) - expect(story).never.to.be.ok() - expect(err).to.be.ok() - end) -end + expect(story).toBeNil() + expect(err).toBeDefined() +end) From 1938f60bd71d7bbb892b2189238cb59f1788e8fc Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Mon, 22 Apr 2024 21:17:45 -0700 Subject: [PATCH 14/23] Continue using .story extension for now --- .../{ArrayControls.stories.luau => ArrayControls.story.luau} | 0 example/{Button.stories.luau => Button.story.luau} | 0 ...thControls.stories.luau => ButtonWithControls.story.luau} | 0 .../{AutomaticSize.stories.luau => AutomaticSize.story.luau} | 0 ...ds.stories.luau => AutomaticSizeExceedsBounds.story.luau} | 0 .../CanvasTests/{Offset.stories.luau => Offset.story.luau} | 0 .../{Resizing.stories.luau => Resizing.story.luau} | 0 example/CanvasTests/{Scale.stories.luau => Scale.story.luau} | 0 ...ScrollingFrame.stories.luau => ScrollingFrame.story.luau} | 0 example/{Counter.stories.luau => Counter.story.luau} | 0 example/{Functional.stories.luau => Functional.story.luau} | 0 example/{Hoarcekat.stories.luau => Hoarcekat.story.luau} | 0 .../{ReactCounter.stories.luau => ReactCounter.story.luau} | 0 src/Common/{Branding.stories.luau => Branding.story.luau} | 0 ...ScrollingFrame.stories.luau => ScrollingFrame.story.luau} | 0 src/Common/{Sprite.stories.luau => Sprite.story.luau} | 0 .../{Component.stories.luau => Component.story.luau} | 0 src/Forms/{Button.stories.luau => Button.story.luau} | 0 src/Forms/{Checkbox.stories.luau => Checkbox.story.luau} | 0 src/Forms/{Dropdown.stories.luau => Dropdown.story.luau} | 0 src/Forms/{InputField.stories.luau => InputField.story.luau} | 0 src/Forms/{Searchbar.stories.luau => Searchbar.story.luau} | 0 ...TextLabel.stories.luau => SelectableTextLabel.story.luau} | 0 ...ResizablePanel.stories.luau => ResizablePanel.story.luau} | 0 src/Panels/{Sidebar.stories.luau => Sidebar.story.luau} | 0 src/Plugin/{PluginApp.stories.luau => PluginApp.story.luau} | 0 ...StorySelected.stories.luau => NoStorySelected.story.luau} | 0 .../{StoryControls.stories.luau => StoryControls.story.luau} | 0 .../{StoryError.stories.luau => StoryError.story.luau} | 0 .../{StoryMeta.stories.luau => StoryMeta.story.luau} | 0 src/Storybook/isStoryModule.spec.luau | 5 ++--- src/Storybook/loadStoryModule.spec.luau | 4 ++-- src/constants.luau | 1 - 33 files changed, 4 insertions(+), 6 deletions(-) rename example/{ArrayControls.stories.luau => ArrayControls.story.luau} (100%) rename example/{Button.stories.luau => Button.story.luau} (100%) rename example/{ButtonWithControls.stories.luau => ButtonWithControls.story.luau} (100%) rename example/CanvasTests/{AutomaticSize.stories.luau => AutomaticSize.story.luau} (100%) rename example/CanvasTests/{AutomaticSizeExceedsBounds.stories.luau => AutomaticSizeExceedsBounds.story.luau} (100%) rename example/CanvasTests/{Offset.stories.luau => Offset.story.luau} (100%) rename example/CanvasTests/{Resizing.stories.luau => Resizing.story.luau} (100%) rename example/CanvasTests/{Scale.stories.luau => Scale.story.luau} (100%) rename example/CanvasTests/{ScrollingFrame.stories.luau => ScrollingFrame.story.luau} (100%) rename example/{Counter.stories.luau => Counter.story.luau} (100%) rename example/{Functional.stories.luau => Functional.story.luau} (100%) rename example/{Hoarcekat.stories.luau => Hoarcekat.story.luau} (100%) rename example/{ReactCounter.stories.luau => ReactCounter.story.luau} (100%) rename src/Common/{Branding.stories.luau => Branding.story.luau} (100%) rename src/Common/{ScrollingFrame.stories.luau => ScrollingFrame.story.luau} (100%) rename src/Common/{Sprite.stories.luau => Sprite.story.luau} (100%) rename src/Explorer/{Component.stories.luau => Component.story.luau} (100%) rename src/Forms/{Button.stories.luau => Button.story.luau} (100%) rename src/Forms/{Checkbox.stories.luau => Checkbox.story.luau} (100%) rename src/Forms/{Dropdown.stories.luau => Dropdown.story.luau} (100%) rename src/Forms/{InputField.stories.luau => InputField.story.luau} (100%) rename src/Forms/{Searchbar.stories.luau => Searchbar.story.luau} (100%) rename src/Forms/{SelectableTextLabel.stories.luau => SelectableTextLabel.story.luau} (100%) rename src/Panels/{ResizablePanel.stories.luau => ResizablePanel.story.luau} (100%) rename src/Panels/{Sidebar.stories.luau => Sidebar.story.luau} (100%) rename src/Plugin/{PluginApp.stories.luau => PluginApp.story.luau} (100%) rename src/Storybook/{NoStorySelected.stories.luau => NoStorySelected.story.luau} (100%) rename src/Storybook/{StoryControls.stories.luau => StoryControls.story.luau} (100%) rename src/Storybook/{StoryError.stories.luau => StoryError.story.luau} (100%) rename src/Storybook/{StoryMeta.stories.luau => StoryMeta.story.luau} (100%) diff --git a/example/ArrayControls.stories.luau b/example/ArrayControls.story.luau similarity index 100% rename from example/ArrayControls.stories.luau rename to example/ArrayControls.story.luau diff --git a/example/Button.stories.luau b/example/Button.story.luau similarity index 100% rename from example/Button.stories.luau rename to example/Button.story.luau diff --git a/example/ButtonWithControls.stories.luau b/example/ButtonWithControls.story.luau similarity index 100% rename from example/ButtonWithControls.stories.luau rename to example/ButtonWithControls.story.luau diff --git a/example/CanvasTests/AutomaticSize.stories.luau b/example/CanvasTests/AutomaticSize.story.luau similarity index 100% rename from example/CanvasTests/AutomaticSize.stories.luau rename to example/CanvasTests/AutomaticSize.story.luau diff --git a/example/CanvasTests/AutomaticSizeExceedsBounds.stories.luau b/example/CanvasTests/AutomaticSizeExceedsBounds.story.luau similarity index 100% rename from example/CanvasTests/AutomaticSizeExceedsBounds.stories.luau rename to example/CanvasTests/AutomaticSizeExceedsBounds.story.luau diff --git a/example/CanvasTests/Offset.stories.luau b/example/CanvasTests/Offset.story.luau similarity index 100% rename from example/CanvasTests/Offset.stories.luau rename to example/CanvasTests/Offset.story.luau diff --git a/example/CanvasTests/Resizing.stories.luau b/example/CanvasTests/Resizing.story.luau similarity index 100% rename from example/CanvasTests/Resizing.stories.luau rename to example/CanvasTests/Resizing.story.luau diff --git a/example/CanvasTests/Scale.stories.luau b/example/CanvasTests/Scale.story.luau similarity index 100% rename from example/CanvasTests/Scale.stories.luau rename to example/CanvasTests/Scale.story.luau diff --git a/example/CanvasTests/ScrollingFrame.stories.luau b/example/CanvasTests/ScrollingFrame.story.luau similarity index 100% rename from example/CanvasTests/ScrollingFrame.stories.luau rename to example/CanvasTests/ScrollingFrame.story.luau diff --git a/example/Counter.stories.luau b/example/Counter.story.luau similarity index 100% rename from example/Counter.stories.luau rename to example/Counter.story.luau diff --git a/example/Functional.stories.luau b/example/Functional.story.luau similarity index 100% rename from example/Functional.stories.luau rename to example/Functional.story.luau diff --git a/example/Hoarcekat.stories.luau b/example/Hoarcekat.story.luau similarity index 100% rename from example/Hoarcekat.stories.luau rename to example/Hoarcekat.story.luau diff --git a/example/ReactCounter.stories.luau b/example/ReactCounter.story.luau similarity index 100% rename from example/ReactCounter.stories.luau rename to example/ReactCounter.story.luau diff --git a/src/Common/Branding.stories.luau b/src/Common/Branding.story.luau similarity index 100% rename from src/Common/Branding.stories.luau rename to src/Common/Branding.story.luau diff --git a/src/Common/ScrollingFrame.stories.luau b/src/Common/ScrollingFrame.story.luau similarity index 100% rename from src/Common/ScrollingFrame.stories.luau rename to src/Common/ScrollingFrame.story.luau diff --git a/src/Common/Sprite.stories.luau b/src/Common/Sprite.story.luau similarity index 100% rename from src/Common/Sprite.stories.luau rename to src/Common/Sprite.story.luau diff --git a/src/Explorer/Component.stories.luau b/src/Explorer/Component.story.luau similarity index 100% rename from src/Explorer/Component.stories.luau rename to src/Explorer/Component.story.luau diff --git a/src/Forms/Button.stories.luau b/src/Forms/Button.story.luau similarity index 100% rename from src/Forms/Button.stories.luau rename to src/Forms/Button.story.luau diff --git a/src/Forms/Checkbox.stories.luau b/src/Forms/Checkbox.story.luau similarity index 100% rename from src/Forms/Checkbox.stories.luau rename to src/Forms/Checkbox.story.luau diff --git a/src/Forms/Dropdown.stories.luau b/src/Forms/Dropdown.story.luau similarity index 100% rename from src/Forms/Dropdown.stories.luau rename to src/Forms/Dropdown.story.luau diff --git a/src/Forms/InputField.stories.luau b/src/Forms/InputField.story.luau similarity index 100% rename from src/Forms/InputField.stories.luau rename to src/Forms/InputField.story.luau diff --git a/src/Forms/Searchbar.stories.luau b/src/Forms/Searchbar.story.luau similarity index 100% rename from src/Forms/Searchbar.stories.luau rename to src/Forms/Searchbar.story.luau diff --git a/src/Forms/SelectableTextLabel.stories.luau b/src/Forms/SelectableTextLabel.story.luau similarity index 100% rename from src/Forms/SelectableTextLabel.stories.luau rename to src/Forms/SelectableTextLabel.story.luau diff --git a/src/Panels/ResizablePanel.stories.luau b/src/Panels/ResizablePanel.story.luau similarity index 100% rename from src/Panels/ResizablePanel.stories.luau rename to src/Panels/ResizablePanel.story.luau diff --git a/src/Panels/Sidebar.stories.luau b/src/Panels/Sidebar.story.luau similarity index 100% rename from src/Panels/Sidebar.stories.luau rename to src/Panels/Sidebar.story.luau diff --git a/src/Plugin/PluginApp.stories.luau b/src/Plugin/PluginApp.story.luau similarity index 100% rename from src/Plugin/PluginApp.stories.luau rename to src/Plugin/PluginApp.story.luau diff --git a/src/Storybook/NoStorySelected.stories.luau b/src/Storybook/NoStorySelected.story.luau similarity index 100% rename from src/Storybook/NoStorySelected.stories.luau rename to src/Storybook/NoStorySelected.story.luau diff --git a/src/Storybook/StoryControls.stories.luau b/src/Storybook/StoryControls.story.luau similarity index 100% rename from src/Storybook/StoryControls.stories.luau rename to src/Storybook/StoryControls.story.luau diff --git a/src/Storybook/StoryError.stories.luau b/src/Storybook/StoryError.story.luau similarity index 100% rename from src/Storybook/StoryError.stories.luau rename to src/Storybook/StoryError.story.luau diff --git a/src/Storybook/StoryMeta.stories.luau b/src/Storybook/StoryMeta.story.luau similarity index 100% rename from src/Storybook/StoryMeta.stories.luau rename to src/Storybook/StoryMeta.story.luau diff --git a/src/Storybook/isStoryModule.spec.luau b/src/Storybook/isStoryModule.spec.luau index cd4673bd..8ebc1b74 100644 --- a/src/Storybook/isStoryModule.spec.luau +++ b/src/Storybook/isStoryModule.spec.luau @@ -1,5 +1,4 @@ local JestGlobals = require("@pkg/JestGlobals") -local constants = require("@root/constants") local isStoryModule = require("./isStoryModule") local expect = JestGlobals.expect @@ -7,14 +6,14 @@ local test = JestGlobals.test test("return `true` for a ModuleScript with .story in the name", function() local module = Instance.new("ModuleScript") - module.Name = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then "Foo.stories" else "Foo.story" + 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 = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then "Folder.stories" else "Folder.story" + folder.Name = "Folder.story" expect(isStoryModule(folder)).toBe(false) end) diff --git a/src/Storybook/loadStoryModule.spec.luau b/src/Storybook/loadStoryModule.spec.luau index f1588f1f..29c5e844 100644 --- a/src/Storybook/loadStoryModule.spec.luau +++ b/src/Storybook/loadStoryModule.spec.luau @@ -38,7 +38,7 @@ local MOCK_REACT_STORYBOOK: types.Storybook = { local function createMockStoryModule(source: string): ModuleScript local storyModule = Instance.new("ModuleScript") - storyModule.Name = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then "Foo.stories" else "Foo.story" + storyModule.Name = "Foo.story" storyModule.Source = source return storyModule @@ -83,7 +83,7 @@ test("use the name of the story module for the story name", function() story = function() end } ]]) - storyModule.Name = if constants.FLAG_ENABLE_COMPONENT_STORY_FORMAT then "SampleName.stories" else "SampleName.story" + storyModule.Name = "SampleName.story" local story = loadStoryModule(loader, storyModule, MOCK_PLAIN_STORYBOOK) diff --git a/src/constants.luau b/src/constants.luau index 0d89b77d..1bdb4ddb 100644 --- a/src/constants.luau +++ b/src/constants.luau @@ -1,6 +1,5 @@ return { STORY_NAME_PATTERN = "%.story$", - STORY_NAME_PATTERN_CSF = "%.stories$", STORYBOOK_NAME_PATTERN = "%.storybook$", SIDEBAR_INITIAL_WIDTH = 260, -- px From 9b1c28da9f7ce45599da1b4a2c46fcfc03d58eb6 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Mon, 22 Apr 2024 21:21:22 -0700 Subject: [PATCH 15/23] Fix up some non-string paths --- src/Common/ScrollingFrame.story.luau | 4 +--- src/Forms/InputField.story.luau | 4 +--- src/Panels/ResizablePanel.story.luau | 4 +--- src/Storybook/StoryError.story.luau | 6 ++---- 4 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/Common/ScrollingFrame.story.luau b/src/Common/ScrollingFrame.story.luau index 4e93b8ce..530ebcab 100644 --- a/src/Common/ScrollingFrame.story.luau +++ b/src/Common/ScrollingFrame.story.luau @@ -1,7 +1,5 @@ -local flipbook = script:FindFirstAncestor("flipbook") - local React = require("@pkg/React") -local ScrollingFrame = require(script.Parent.ScrollingFrame) +local ScrollingFrame = require("./ScrollingFrame") local controls = { numItems = 10, diff --git a/src/Forms/InputField.story.luau b/src/Forms/InputField.story.luau index 16e2238e..fa791522 100644 --- a/src/Forms/InputField.story.luau +++ b/src/Forms/InputField.story.luau @@ -1,6 +1,4 @@ -local flipbook = script:FindFirstAncestor("flipbook") - -local InputField = require(script.Parent.InputField) +local InputField = require("./InputField") local React = require("@pkg/React") local stories = {} diff --git a/src/Panels/ResizablePanel.story.luau b/src/Panels/ResizablePanel.story.luau index 6c30bc8c..a25a656a 100644 --- a/src/Panels/ResizablePanel.story.luau +++ b/src/Panels/ResizablePanel.story.luau @@ -1,7 +1,5 @@ -local flipbook = script:FindFirstAncestor("flipbook") - local React = require("@pkg/React") -local ResizablePanel = require(script.Parent.ResizablePanel) +local ResizablePanel = require("./ResizablePanel") local controls = { minWidth = 200, diff --git a/src/Storybook/StoryError.story.luau b/src/Storybook/StoryError.story.luau index 88e918ad..386b49f9 100644 --- a/src/Storybook/StoryError.story.luau +++ b/src/Storybook/StoryError.story.luau @@ -1,8 +1,6 @@ -local flipbook = script:FindFirstAncestor("flipbook") - local React = require("@pkg/React") -local StoryError = require(flipbook.Storybook.StoryError) -local constants = require(flipbook.constants) +local StoryError = require("@root/Storybook/StoryError") +local constants = require("@root/constants") local stories = {} From f6560fa15d3f2f7e294d037cfb9ebd58ae2be5d7 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Mon, 22 Apr 2024 21:21:32 -0700 Subject: [PATCH 16/23] Update Fusion renderer tests to use Jest --- src/Renderers/createFusionRenderer.spec.luau | 106 +++++++++---------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/src/Renderers/createFusionRenderer.spec.luau b/src/Renderers/createFusionRenderer.spec.luau index 328bbb17..3a3ae2de 100644 --- a/src/Renderers/createFusionRenderer.spec.luau +++ b/src/Renderers/createFusionRenderer.spec.luau @@ -1,68 +1,68 @@ -return function() - local flipbook = script:FindFirstAncestor("flipbook") - - local Fusion = require("@pkg/Fusion") - local createFusionRenderer = require(script.Parent.createFusionRenderer) - - local New = Fusion.New - local Value = Fusion.Value - type StateObject = Fusion.StateObject +local Fusion = require("@pkg/Fusion") +local JestGlobals = require("@pkg/JestGlobals") +local createFusionRenderer = require("./createFusionRenderer") + +local expect = JestGlobals.expect +local test = JestGlobals.test + +local New = Fusion.New +local Value = Fusion.Value +type StateObject = Fusion.StateObject + +type ButtonProps = { + isDisabled: StateObject, +} +local function Button(props) + return New("TextButton")({ + Text = if props.isDisabled:get() then "Disabled" else "Enabled", + }) +end - type ButtonProps = { - isDisabled: StateObject, +test("render a Fusion component", function() + local renderer = createFusionRenderer({ Fusion = Fusion }) + local args = { + isDisabled = Value(false), } - local function Button(props) - return New("TextButton")({ - Text = if props.isDisabled:get() then "Disabled" else "Enabled", - }) - end - - it("should render a Fusion component", function() - local renderer = createFusionRenderer({ Fusion = Fusion }) - local args = { - isDisabled = Value(false), - } - local target = Instance.new("Folder") - local gui = renderer.mount(target, Button, args) + local target = Instance.new("Folder") + local gui = renderer.mount(target, Button, args) - expect(gui).to.be.ok() - expect(gui.Text).to.equal("Enabled") - end) + expect(gui).to.be.ok() + expect(gui.Text).to.equal("Enabled") +end) - it("should unmount a Fusion component", function() - local renderer = createFusionRenderer({ Fusion = Fusion }) - local args = { - isDisabled = Value(false), - } +test("unmount a Fusion component", function() + local renderer = createFusionRenderer({ Fusion = Fusion }) + local args = { + isDisabled = Value(false), + } - local target = Instance.new("Folder") - local gui = renderer.mount(target, Button, args) + local target = Instance.new("Folder") + local gui = renderer.mount(target, Button, args) - expect(gui).to.be.ok() - expect(gui:IsDescendantOf(game)).to.equal(true) + expect(gui).to.be.ok() + expect(gui:IsDescendantOf(game)).to.equal(true) - renderer.unmount() + renderer.unmount() - expect(gui:IsDescendantOf(game)).to.equal(false) - end) + expect(gui:IsDescendantOf(game)).to.equal(false) +end) - it("should update the component on arg changes", function() - local renderer = createFusionRenderer({ Fusion = Fusion }) - local args = { - isDisabled = Value(false), - } +test("update the component on arg changes", function() + local renderer = createFusionRenderer({ Fusion = Fusion }) + local args = { + isDisabled = Value(false), + } - local target = Instance.new("Folder") - local gui = renderer.mount(target, Button, args) + local target = Instance.new("Folder") + local gui = renderer.mount(target, Button, args) - expect(gui).to.be.ok() - expect(gui:IsDescendantOf(game)).to.equal(true) + expect(gui).to.be.ok() + expect(gui:IsDescendantOf(game)).to.equal(true) - renderer.unmount() + renderer.unmount() - expect(gui:IsDescendantOf(game)).to.equal(false) - end) + expect(gui:IsDescendantOf(game)).to.equal(false) +end) - it("should never re-mount on arg changes", function() end) -end +test("never re-mount on arg changes", function() end) From bd7a5b0ca96ce3f09a10de86b732454fda457d59 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Mon, 22 Apr 2024 21:26:19 -0700 Subject: [PATCH 17/23] Fix Fusion expectation matchers --- src/Renderers/createFusionRenderer.spec.luau | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Renderers/createFusionRenderer.spec.luau b/src/Renderers/createFusionRenderer.spec.luau index 3a3ae2de..79df6b9a 100644 --- a/src/Renderers/createFusionRenderer.spec.luau +++ b/src/Renderers/createFusionRenderer.spec.luau @@ -27,8 +27,8 @@ test("render a Fusion component", function() local target = Instance.new("Folder") local gui = renderer.mount(target, Button, args) - expect(gui).to.be.ok() - expect(gui.Text).to.equal("Enabled") + expect(gui).toBedefined() + expect(gui.Text).toBe("Enabled") end) test("unmount a Fusion component", function() @@ -40,12 +40,12 @@ test("unmount a Fusion component", function() local target = Instance.new("Folder") local gui = renderer.mount(target, Button, args) - expect(gui).to.be.ok() - expect(gui:IsDescendantOf(game)).to.equal(true) + expect(gui).toBedefined() + expect(gui:IsDescendantOf(game)).toBe(true) renderer.unmount() - expect(gui:IsDescendantOf(game)).to.equal(false) + expect(gui:IsDescendantOf(game)).toBe(false) end) test("update the component on arg changes", function() @@ -57,12 +57,12 @@ test("update the component on arg changes", function() local target = Instance.new("Folder") local gui = renderer.mount(target, Button, args) - expect(gui).to.be.ok() - expect(gui:IsDescendantOf(game)).to.equal(true) + expect(gui).toBedefined() + expect(gui:IsDescendantOf(game)).toBe(true) renderer.unmount() - expect(gui:IsDescendantOf(game)).to.equal(false) + expect(gui:IsDescendantOf(game)).toBe(false) end) test("never re-mount on arg changes", function() end) From 18fb9f1b39d31d350dae745228dc8551675384a2 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Mon, 22 Apr 2024 21:26:27 -0700 Subject: [PATCH 18/23] Revert isStoryModule --- src/Storybook/isStoryModule.luau | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Storybook/isStoryModule.luau b/src/Storybook/isStoryModule.luau index bdab387c..963821dc 100644 --- a/src/Storybook/isStoryModule.luau +++ b/src/Storybook/isStoryModule.luau @@ -1,10 +1,7 @@ local constants = require("@root/constants") local function isStoryModule(instance: Instance) - if - instance:IsA("ModuleScript") - and (instance.Name:match(constants.STORY_NAME_PATTERN) or instance.Name:match(constants.STORY_NAME_PATTERN_CSF)) - then + if instance:IsA("ModuleScript") and instance.Name:match(constants.STORY_NAME_PATTERN) then return true end return false From a852016fc38a62d607739432dfc3a514b3056fb4 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Thu, 25 Apr 2024 20:00:43 -0700 Subject: [PATCH 19/23] Commit WIP changes --- src/Renderers/createFusionRenderer.spec.luau | 8 +-- src/Renderers/createReactRenderer.luau | 11 ++- src/Renderers/createReactRenderer.spec.luau | 74 ++++++++++++++++++++ src/Renderers/createRobloxRenderer.luau | 4 +- src/Renderers/render.luau | 30 ++++---- src/Renderers/render.spec.luau | 29 +++++++- src/Renderers/renderers.spec.luau | 21 ++++++ src/Renderers/types.luau | 6 +- 8 files changed, 151 insertions(+), 32 deletions(-) create mode 100644 src/Renderers/createReactRenderer.spec.luau create mode 100644 src/Renderers/renderers.spec.luau diff --git a/src/Renderers/createFusionRenderer.spec.luau b/src/Renderers/createFusionRenderer.spec.luau index 79df6b9a..197bb8fa 100644 --- a/src/Renderers/createFusionRenderer.spec.luau +++ b/src/Renderers/createFusionRenderer.spec.luau @@ -41,11 +41,11 @@ test("unmount a Fusion component", function() local gui = renderer.mount(target, Button, args) expect(gui).toBedefined() - expect(gui:IsDescendantOf(game)).toBe(true) + expect(gui:IsDescendantOf(target)).toBe(true) renderer.unmount() - expect(gui:IsDescendantOf(game)).toBe(false) + expect(gui:IsDescendantOf(target)).toBe(false) end) test("update the component on arg changes", function() @@ -58,11 +58,11 @@ test("update the component on arg changes", function() local gui = renderer.mount(target, Button, args) expect(gui).toBedefined() - expect(gui:IsDescendantOf(game)).toBe(true) + expect(gui:IsDescendantOf(target)).toBe(true) renderer.unmount() - expect(gui:IsDescendantOf(game)).toBe(false) + expect(gui:IsDescendantOf(target)).toBe(false) end) test("never re-mount on arg changes", function() end) diff --git a/src/Renderers/createReactRenderer.luau b/src/Renderers/createReactRenderer.luau index 2290c042..d4b179bb 100644 --- a/src/Renderers/createReactRenderer.luau +++ b/src/Renderers/createReactRenderer.luau @@ -11,17 +11,16 @@ local function createReactRenderer(packages: Packages): Renderer local React = packages.React local ReactRoblox = packages.ReactRoblox - local container = Instance.new("Folder") - local root = ReactRoblox.createRoot(container) + local root + + local function mount(container, element, context) + root = ReactRoblox.createRoot(container) - local function mount(element) if typeof(element) == "function" then - element = React.createElement(element, props) + element = React.createElement(element, context.args) end root:render(element) - - return container end local function unmount() diff --git a/src/Renderers/createReactRenderer.spec.luau b/src/Renderers/createReactRenderer.spec.luau new file mode 100644 index 00000000..d113b97b --- /dev/null +++ b/src/Renderers/createReactRenderer.spec.luau @@ -0,0 +1,74 @@ +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 target +local renderer + +local function Button(props: { isDisabled: boolean? }) + return React.createElement("TextButton", { + Text = if props.isDisabled then "Disabled" else "Enabled", + }) +end + +beforeEach(function() + target = Instance.new("Folder") + + renderer = createReactRenderer({ + React = React, + ReactRoblox = ReactRoblox, + }) +end) + +test("render a React componnet", function() + local element = renderer.mount(target, Button) + + expect(element).toBeDefined() + expect(typeof(element)).toBe("Instance") + assert(element:IsA("TextButton"), "not a TextButton") + expect(element.Text).toBe("Enabled") +end) + +test("unmount a React component", function() end) + +test("pass args as props", function() + local element = renderer.mount(target, Button) + + render(renderer, target, element, { + isDisabled = true, + }) + + local button = target:FindFirstChildWhichIsA("TextButton") + + expect(button).toBeDefined() + expect(button.Text).toBe("Disabled") +end) + +test("update the component on arg changes", function() + local element = renderer.mount(target, Button) + + local update = render(renderer, target, element, { + isDisabled = true, + }) + + local button = target:FindFirstChildWhichIsA("TextButton") + + expect(button).toBeDefined() + expect(button.Text).toBe("Disabled") + + update({ + isDisabled = false, + }) + + expect(button.Text).toBe("Enabled") +end) + +test("never re-mount on arg changes", function() end) + +test("portals", function() end) diff --git a/src/Renderers/createRobloxRenderer.luau b/src/Renderers/createRobloxRenderer.luau index 3a71f949..bd243537 100644 --- a/src/Renderers/createRobloxRenderer.luau +++ b/src/Renderers/createRobloxRenderer.luau @@ -9,13 +9,13 @@ local function createRobloxRenderer(): Renderer return true end - local function mount(target, element, args) + local function mount(container, element, args) if typeof(element) == "function" then element = element(args) end if typeof(element) == "Instance" and element:IsA("GuiObject") then - element.Parent = target + element.Parent = container handle = element end return element diff --git a/src/Renderers/render.luau b/src/Renderers/render.luau index 8febed7c..e3dd0b83 100644 --- a/src/Renderers/render.luau +++ b/src/Renderers/render.luau @@ -6,32 +6,30 @@ type Renderer = types.Renderer type UpdateFn = () -> () -local function render(renderer: Renderer, target: Instance, element: any, args: Args): UpdateFn +local function render(renderer: Renderer, container: Instance, element: T, args: Args?): UpdateFn local handle: Instance local context: Context = { - target = target, - element = element, + container = container, args = args, } local prevContext: Context? = nil - local function renderOnce() - if not renderer.shouldUpdate or renderer.shouldUpdate(context, prevContext) then - if renderer.unmount then - renderer.unmount(context) - else - handle:Destroy() - end - - handle = renderer.mount(target, element, context) + if not renderer.shouldUpdate or renderer.shouldUpdate(context, prevContext) then + if renderer.unmount then + renderer.unmount(context) + else + handle:Destroy() end - end - renderOnce() + handle = renderer.mount(container, element, context) + prevContext = context + end - return function() - renderOnce() + local function rerender(newArgs: Args?) + render(renderer, container, element, newArgs) end + + return rerender end return render diff --git a/src/Renderers/render.spec.luau b/src/Renderers/render.spec.luau index 11ce4fb0..d5dc9b2d 100644 --- a/src/Renderers/render.spec.luau +++ b/src/Renderers/render.spec.luau @@ -42,7 +42,7 @@ test("returns a function to trigger a re-render", function() mount = mockMount, } - local update = render(mockRenderer) + local update = render(mockRenderer, target, element) expect(mockMount).toHaveBeenCalledTimes(1) @@ -126,3 +126,30 @@ test("if unmount is not specified, implicitly destroy the handle", function() expect(mockDestroy).toHaveBeenCalled() 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 update = render(mockRenderer, target, element) + + local context = mockShouldUpdate.mock.lastCall[1] + expect(context).toBeDefined() + expect(mockShouldUpdate.mock.lastCall[2]).toBeNil() + + update() + + expect(mockShouldUpdate.mock.lastCall[1]).toBeDefined() + expect(mockShouldUpdate.mock.lastCall[2]).toEqual(mockShouldUpdate.mock.lastCall[1]) +end) diff --git a/src/Renderers/renderers.spec.luau b/src/Renderers/renderers.spec.luau new file mode 100644 index 00000000..5f03d3bb --- /dev/null +++ b/src/Renderers/renderers.spec.luau @@ -0,0 +1,21 @@ +local JestGlobals = require("@pkg/JestGlobals") +local types = require("@root/Renderers/types") + +local describe = JestGlobals.describe +local test = JestGlobals.test +local expect = JestGlobals.expect + +type Renderer = types.Renderer + +describe.each({ + { "Fusion", require("./createFusionRenderer") }, + { "React", require("./createReactRenderer") }, + { "Roact", require("./createRoactRenderer") }, + { "Roblox", require("./createRobloxRenderer") }, +})("%s renderer", function(_name, createRenderer: Renderer) + test("mount", function() end) + + test("unmount", function() end) + + test("update on arg changes", function() end) +end) diff --git a/src/Renderers/types.luau b/src/Renderers/types.luau index a480d9f4..ee26a7e0 100644 --- a/src/Renderers/types.luau +++ b/src/Renderers/types.luau @@ -3,15 +3,15 @@ export type Args = { } export type Context = { - target: Instance, - element: unknown, + container: Instance, args: Args?, } export type Renderer = { + mount: (container: Instance, element: unknown, context: Context) -> GuiObject | Folder, + transformArgs: ((args: Args, context: Context) -> Args)?, shouldUpdate: ((context: Context, prevContext: Context?) -> boolean)?, - mount: (target: Instance, element: unknown, context: Context) -> GuiObject | Folder, unmount: ((context: Context) -> ())?, } From 5fe1ce626d9e6adee86a1b04f8fd8f6eb04f40c5 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Fri, 3 May 2024 16:45:03 -0700 Subject: [PATCH 20/23] WIP changes for StorytellerContext --- src/Common/ContextStack.luau | 20 ++++++ src/Renderers/renderers.spec.luau | 21 ------- src/Renderers/types.luau | 2 +- src/Renderers/useStoryRenderer.luau | 41 ++++++++++++ src/Storybook/StorytellerContext.luau | 91 +++++++++++++++++++++++++++ src/Storybook/loadStoryModule.luau | 4 ++ src/Storybook/types.luau | 5 ++ src/Testing/renderHook.luau | 37 +++++++++++ src/Testing/renderHook.spec.luau | 51 +++++++++++++++ wally.toml | 2 +- 10 files changed, 251 insertions(+), 23 deletions(-) create mode 100644 src/Common/ContextStack.luau delete mode 100644 src/Renderers/renderers.spec.luau create mode 100644 src/Renderers/useStoryRenderer.luau create mode 100644 src/Storybook/StorytellerContext.luau create mode 100644 src/Testing/renderHook.luau create mode 100644 src/Testing/renderHook.spec.luau diff --git a/src/Common/ContextStack.luau b/src/Common/ContextStack.luau new file mode 100644 index 00000000..b8bfd661 --- /dev/null +++ b/src/Common/ContextStack.luau @@ -0,0 +1,20 @@ +--!strict +local React = require("@pkg/React") + +export type Props = { + providers: { React.ReactElement }, + children: React.ReactNode, +} + +local function ContextStack(props: Props) + local mostRecent = props.children + + for providerIndex = #props.providers, 1, -1 do + local providerElement = props.providers[providerIndex] + mostRecent = React.cloneElement(providerElement, nil, mostRecent) + end + + return mostRecent +end + +return ContextStack diff --git a/src/Renderers/renderers.spec.luau b/src/Renderers/renderers.spec.luau deleted file mode 100644 index 5f03d3bb..00000000 --- a/src/Renderers/renderers.spec.luau +++ /dev/null @@ -1,21 +0,0 @@ -local JestGlobals = require("@pkg/JestGlobals") -local types = require("@root/Renderers/types") - -local describe = JestGlobals.describe -local test = JestGlobals.test -local expect = JestGlobals.expect - -type Renderer = types.Renderer - -describe.each({ - { "Fusion", require("./createFusionRenderer") }, - { "React", require("./createReactRenderer") }, - { "Roact", require("./createRoactRenderer") }, - { "Roblox", require("./createRobloxRenderer") }, -})("%s renderer", function(_name, createRenderer: Renderer) - test("mount", function() end) - - test("unmount", function() end) - - test("update on arg changes", function() end) -end) diff --git a/src/Renderers/types.luau b/src/Renderers/types.luau index ee26a7e0..755f52f8 100644 --- a/src/Renderers/types.luau +++ b/src/Renderers/types.luau @@ -9,10 +9,10 @@ export type Context = { export type Renderer = { mount: (container: Instance, element: unknown, context: Context) -> GuiObject | Folder, + unmount: (context: Context?) -> (), transformArgs: ((args: Args, context: Context) -> Args)?, shouldUpdate: ((context: Context, prevContext: Context?) -> boolean)?, - unmount: ((context: Context) -> ())?, } 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/StorytellerContext.luau b/src/Storybook/StorytellerContext.luau new file mode 100644 index 00000000..96fa5426 --- /dev/null +++ b/src/Storybook/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/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/types.luau b/src/Storybook/types.luau index cfd55213..c90415d3 100644 --- a/src/Storybook/types.luau +++ b/src/Storybook/types.luau @@ -79,8 +79,13 @@ export type Storybook = RoactStorybook | ReactStorybook | StorybookMeta export type StoryMeta = { name: string, story: unknown, + source: ModuleScript, + storybook: Storybook, + summary: string?, controls: Controls?, + + -- Renderer-specific roact: Roact?, react: React?, reactRoblox: ReactRoblox?, diff --git a/src/Testing/renderHook.luau b/src/Testing/renderHook.luau new file mode 100644 index 00000000..d844ff98 --- /dev/null +++ b/src/Testing/renderHook.luau @@ -0,0 +1,37 @@ +local React = require("@pkg/React") +local ReactRoblox = require("@pkg/ReactRoblox") + +local act = ReactRoblox.act + +type Options = { + wrapper: React.ComponentType<{ + children: React.ReactNode, + }>, +} + +local function renderTestHook(hook: () -> T..., options: Options?): () -> T... + local result = React.createRef() + + local function TestComponent() + result.current = table.pack(hook()) + end + + local container = Instance.new("Folder") + local root = ReactRoblox.createRoot(container) + + local element = React.createElement(TestComponent) + + if options and options.wrapper then + element = React.createElement(options.wrapper, nil, element) + end + + act(function() + root:render(element) + end) + + return function() + return table.unpack(result.current) + end +end + +return renderTestHook diff --git a/src/Testing/renderHook.spec.luau b/src/Testing/renderHook.spec.luau new file mode 100644 index 00000000..d9a80b3f --- /dev/null +++ b/src/Testing/renderHook.spec.luau @@ -0,0 +1,51 @@ +local JestGlobals = require("@pkg/JestGlobals") +local React = require("@pkg/React") +local ReactRoblox = require("@pkg/ReactRoblox") +local renderHook = require("./renderHook") + +local test = JestGlobals.test +local expect = JestGlobals.expect + +local act = ReactRoblox.act + +test("works with useState", function() + local getHookResult = renderHook(function() + return React.useState(1) + end) + + local state, setState = getHookResult() + + expect(state).toBe(1) + + act(function() + setState(2) + end) + + state, setState = getHookResult() + + expect(state).toBe(2) +end) + +test("able to supply a wrapper element", function() + local context = React.createContext({}) + + local function Provider(props): React.Node + return React.createElement(context.Provider, { + value = { + isWrapped = true, + }, + }, props.children) + end + + local getHookResult = renderHook(function() + return React.useContext(context) + end, { + wrapper = Provider, + }) + + local result = getHookResult() + + expect(result).toEqual({ + isWrapped = true, + }) +end) diff --git a/wally.toml b/wally.toml index 4593d5a2..a2229a96 100644 --- a/wally.toml +++ b/wally.toml @@ -12,7 +12,7 @@ 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 From b09922b347ae7199647021586f36ee0bc92e378e Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Sat, 4 May 2024 09:17:33 -0700 Subject: [PATCH 21/23] Move to Context folder --- src/{Storybook => Context}/StorytellerContext.luau | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/{Storybook => Context}/StorytellerContext.luau (100%) diff --git a/src/Storybook/StorytellerContext.luau b/src/Context/StorytellerContext.luau similarity index 100% rename from src/Storybook/StorytellerContext.luau rename to src/Context/StorytellerContext.luau From afbdf590e259c582a900e1efab3442a398179674 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Sat, 17 Aug 2024 16:53:49 -0700 Subject: [PATCH 22/23] Get React/Roact renderers working --- src/Renderers/createReactRenderer.luau | 20 ++- src/Renderers/createReactRenderer.spec.luau | 128 ++++++++++++++++---- src/Renderers/createRoactRenderer.luau | 27 +++-- src/Renderers/createRoactRenderer.spec.luau | 120 ++++++++++++++++++ src/Renderers/createRobloxRenderer.luau | 12 +- src/Renderers/render.luau | 44 ++++--- src/Renderers/render.spec.luau | 84 ++++++++----- src/Renderers/types.luau | 11 +- 8 files changed, 348 insertions(+), 98 deletions(-) create mode 100644 src/Renderers/createRoactRenderer.spec.luau diff --git a/src/Renderers/createReactRenderer.luau b/src/Renderers/createReactRenderer.luau index d4b179bb..5c9ec643 100644 --- a/src/Renderers/createReactRenderer.luau +++ b/src/Renderers/createReactRenderer.luau @@ -12,15 +12,26 @@ local function createReactRenderer(packages: Packages): Renderer 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 - if typeof(element) == "function" then - element = React.createElement(element, context.args) + local function update(newContext) + if currentElement then + reactRender(currentElement, newContext) end - - root:render(element) end local function unmount() @@ -29,6 +40,7 @@ local function createReactRenderer(packages: Packages): Renderer return { mount = mount, + update = update, unmount = unmount, } end diff --git a/src/Renderers/createReactRenderer.spec.luau b/src/Renderers/createReactRenderer.spec.luau index d113b97b..47655893 100644 --- a/src/Renderers/createReactRenderer.spec.luau +++ b/src/Renderers/createReactRenderer.spec.luau @@ -2,14 +2,12 @@ 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 target -local renderer +local act = ReactRoblox.act local function Button(props: { isDisabled: boolean? }) return React.createElement("TextButton", { @@ -17,8 +15,19 @@ local function Button(props: { isDisabled: boolean? }) }) 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() - target = Instance.new("Folder") + container = Instance.new("Folder") renderer = createReactRenderer({ React = React, @@ -26,49 +35,122 @@ beforeEach(function() }) end) -test("render a React componnet", function() - local element = renderer.mount(target, Button) +test("render a functional componnet", function() + renderer.mount(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() + renderer.mount(container, ButtonClassComponent) + + act(function() + task.wait() + end) + + local element = container:GetChildren()[1] - expect(element).toBeDefined() + assert(element, "no element found") expect(typeof(element)).toBe("Instance") assert(element:IsA("TextButton"), "not a TextButton") expect(element.Text).toBe("Enabled") end) -test("unmount a React component", function() end) +test("lifecycle", function() + expect(#container:GetChildren()).toBe(0) -test("pass args as props", function() - local element = renderer.mount(target, Button) + renderer.mount(container, Button, { + args = { + isDisabled = false, + }, + }) - render(renderer, target, element, { - isDisabled = true, + 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") + + renderer.update({ + args = { + 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") + + renderer.unmount() + + act(function() + task.wait() + end) + + expect(#container:GetChildren()).toBe(0) +end) + +test("pass args as props", function() + renderer.mount(container, Button, { + args = { + isDisabled = true, + }, }) - local button = target:FindFirstChildWhichIsA("TextButton") + 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 element = renderer.mount(target, Button) - - local update = render(renderer, target, element, { - isDisabled = true, + renderer.mount(container, Button, { + args = { + isDisabled = true, + }, }) - local button = target:FindFirstChildWhichIsA("TextButton") + act(function() + task.wait() + end) + + local button = container:FindFirstChildWhichIsA("TextButton") expect(button).toBeDefined() expect(button.Text).toBe("Disabled") - update({ + renderer.update({ isDisabled = false, }) + act(function() + task.wait() + end) + expect(button.Text).toBe("Enabled") end) - -test("never re-mount on arg changes", function() end) - -test("portals", function() end) diff --git a/src/Renderers/createRoactRenderer.luau b/src/Renderers/createRoactRenderer.luau index c9252f17..1b556465 100644 --- a/src/Renderers/createRoactRenderer.luau +++ b/src/Renderers/createRoactRenderer.luau @@ -8,22 +8,33 @@ type Packages = { local function createRoactRenderer(packages: Packages): Renderer local Roact = packages.Roact - local container - local handle + local tree + local currentElement - local function mount(element) - container = Instance.new("Folder") - handle = Roact.mount(element, container, "RoactRenderer") - return container + 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() - Roact.unmount(handle) - container:Destroy() + if tree then + Roact.unmount(tree) + end end return { mount = mount, + update = update, unmount = unmount, } end diff --git a/src/Renderers/createRoactRenderer.spec.luau b/src/Renderers/createRoactRenderer.spec.luau new file mode 100644 index 00000000..4d95e7e6 --- /dev/null +++ b/src/Renderers/createRoactRenderer.spec.luau @@ -0,0 +1,120 @@ +local JestGlobals = require("@pkg/JestGlobals") +local Roact = require("@pkg/Roact") +local createRoactRenderer = require("./createRoactRenderer") + +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() + renderer.mount(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() + renderer.mount(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("lifecycle", function() + expect(#container:GetChildren()).toBe(0) + + renderer.mount(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") + + renderer.update({ + args = { + 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") + + renderer.unmount() + + expect(#container:GetChildren()).toBe(0) +end) + +test("pass args as props", function() + renderer.mount(container, Button, { + args = { + isDisabled = true, + }, + }) + + local button = container:FindFirstChildWhichIsA("TextButton") + + expect(button).toBeDefined() + expect(button.Text).toBe("Disabled") +end) + +test("update the component on arg changes", function() + renderer.mount(container, Button, { + args = { + isDisabled = true, + }, + }) + + local button = container:FindFirstChildWhichIsA("TextButton") + + expect(button).toBeDefined() + expect(button.Text).toBe("Disabled") + + renderer.update({ + isDisabled = false, + }) + + expect(button.Text).toBe("Enabled") +end) diff --git a/src/Renderers/createRobloxRenderer.luau b/src/Renderers/createRobloxRenderer.luau index bd243537..d3e321f6 100644 --- a/src/Renderers/createRobloxRenderer.luau +++ b/src/Renderers/createRobloxRenderer.luau @@ -5,20 +5,15 @@ type Renderer = types.Renderer local function createRobloxRenderer(): Renderer local handle - local function shouldUpdate() - return true - end - - local function mount(container, element, args) + local function mount(container, element, context) if typeof(element) == "function" then - element = element(args) + element = element(context.args) end if typeof(element) == "Instance" and element:IsA("GuiObject") then - element.Parent = container handle = element + element.Parent = container end - return element end local function unmount() @@ -28,7 +23,6 @@ local function createRobloxRenderer(): Renderer end return { - shouldUpdate = shouldUpdate, mount = mount, unmount = unmount, } diff --git a/src/Renderers/render.luau b/src/Renderers/render.luau index e3dd0b83..e254f034 100644 --- a/src/Renderers/render.luau +++ b/src/Renderers/render.luau @@ -4,32 +4,38 @@ type Args = types.Args type Context = types.Context type Renderer = types.Renderer -type UpdateFn = () -> () +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 not renderer.shouldUpdate or renderer.shouldUpdate(context, prevContext) then + renderer.mount(container, element, context) + prevContext = context + end + end -local function render(renderer: Renderer, container: Instance, element: T, args: Args?): UpdateFn - local handle: Instance - local context: Context = { - container = container, - args = args, - } - local prevContext: Context? = nil + local function update(newArgs: Args?) + renderOnce(newArgs) + end - if not renderer.shouldUpdate or renderer.shouldUpdate(context, prevContext) then + local function unmount() if renderer.unmount then - renderer.unmount(context) - else - handle:Destroy() + renderer.unmount(prevContext) end - - handle = renderer.mount(container, element, context) - prevContext = context + container:ClearAllChildren() end - local function rerender(newArgs: Args?) - render(renderer, container, element, newArgs) - end + renderOnce(initialArgs) - return rerender + return { + update = update, + unmount = unmount, + } end return render diff --git a/src/Renderers/render.spec.luau b/src/Renderers/render.spec.luau index d5dc9b2d..580f94d8 100644 --- a/src/Renderers/render.spec.luau +++ b/src/Renderers/render.spec.luau @@ -10,15 +10,15 @@ local test = JestGlobals.test type Renderer = types.Renderer -local target: Instance +local container: Instance local element = jest.fn() beforeEach(function() - target = Instance.new("Folder") + container = Instance.new("Folder") end) afterEach(function() - target:Destroy() + container:Destroy() jest.resetAllMocks() end) @@ -29,7 +29,7 @@ test("call `mount` immediately", function() mount = mockMount, } - render(mockRenderer, target, element) + render(mockRenderer, container, element) expect(mockMount).toHaveBeenCalledTimes(1) end) @@ -42,11 +42,11 @@ test("returns a function to trigger a re-render", function() mount = mockMount, } - local update = render(mockRenderer, target, element) + local lifecycle = render(mockRenderer, container, element) expect(mockMount).toHaveBeenCalledTimes(1) - update() + lifecycle.update() expect(mockMount).toHaveBeenCalledTimes(2) end) @@ -55,6 +55,7 @@ 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 @@ -62,7 +63,15 @@ test("current context and prev context are passed to shouldUpdate", function() end, } - local update = render(mockRenderer, target, element) + 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() @@ -76,55 +85,58 @@ test("context is passed to shouldUpdate", function() shouldUpdate = function(_context) context = _context end, + mount = function() end, } - render(mockRenderer, target, element, args) + render(mockRenderer, container, element, args) expect(context).toEqual({ - target = target, - element = element, + container = container, args = args, }) end) -test("only render if shouldUpdate returns true", function() +test("only rerender if shouldUpdate returns true", function() local mockMount = jest.fn() + local mockShouldUpdate = jest.fn().mockReturnValue(true) local mockRenderer: Renderer = { - shouldUpdate = function() - return false - end, + shouldUpdate = mockShouldUpdate, mount = mockMount, } - local update = render(mockRenderer) + local lifecycle = render(mockRenderer, container, {}) - expect(mockMount).never.toHaveBeenCalled() + expect(mockMount).toHaveBeenCalledTimes(1) + + lifecycle.update() + + expect(mockMount).toHaveBeenCalledTimes(2) - update() + mockShouldUpdate.mockReturnValue(false) + lifecycle.update() - expect(mockMount).never.toHaveBeenCalled() + expect(mockMount).toHaveBeenCalledTimes(2) end) -test("if unmount is not specified, implicitly destroy the handle", function() - local mockDestroy = jest.fn() +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() - local mockInstance = { - Destroy = mockDestroy, - } - return mockInstance + mount = function(container, element) + element.Parent = container end, + shouldUpdate = mockShouldUpdate, } - local update = render(mockRenderer) + local element = Instance.new("Folder") + local lifecycle = render(mockRenderer, container, element) - expect(mockDestroy).never.toHaveBeenCalled() + expect(#container:GetChildren()).toBe(1) - update() + lifecycle.unmount() - expect(mockDestroy).toHaveBeenCalled() + expect(#container:GetChildren()).toBe(0) end) test("prevContext is nil on the first render", function() @@ -142,14 +154,20 @@ test("prevContext is nil on the first render", function() end, } - local update = render(mockRenderer, target, element) + local lifecycle = render(mockRenderer, container, element) local context = mockShouldUpdate.mock.lastCall[1] expect(context).toBeDefined() - expect(mockShouldUpdate.mock.lastCall[2]).toBeNil() + --[[ + 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)") - update() + lifecycle.update() expect(mockShouldUpdate.mock.lastCall[1]).toBeDefined() - expect(mockShouldUpdate.mock.lastCall[2]).toEqual(mockShouldUpdate.mock.lastCall[1]) + expect(mockShouldUpdate.mock.lastCall[2]).toEqual(context) end) diff --git a/src/Renderers/types.luau b/src/Renderers/types.luau index 755f52f8..0ba0f0d4 100644 --- a/src/Renderers/types.luau +++ b/src/Renderers/types.luau @@ -1,16 +1,23 @@ +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) -> GuiObject | Folder, - unmount: (context: Context?) -> (), + mount: (container: Instance, element: unknown, context: Context?) -> (), + unmount: ((context: Context?) -> ())?, + update: ((context: Context?) -> ())?, transformArgs: ((args: Args, context: Context) -> Args)?, shouldUpdate: ((context: Context, prevContext: Context?) -> boolean)?, } From 473f2ad154e50c92d21ba96c8717bad40bdec820 Mon Sep 17 00:00:00 2001 From: Marin Minnerly Date: Sat, 17 Aug 2024 18:48:59 -0700 Subject: [PATCH 23/23] Almost stable! Everything but Fusion --- src/Renderers/createFusionRenderer.luau | 35 +++++--- src/Renderers/createFusionRenderer.spec.luau | 81 +++++++++-------- src/Renderers/createReactRenderer.spec.luau | 86 +++++++++--------- src/Renderers/createRoactRenderer.spec.luau | 79 ++++++++-------- src/Renderers/createRobloxRenderer.luau | 17 +++- src/Renderers/createRobloxRenderer.spec.luau | 94 ++++++++++++++++++++ src/Renderers/example.md | 2 +- src/Renderers/render.luau | 18 +++- src/Renderers/types.luau | 2 +- 9 files changed, 273 insertions(+), 141 deletions(-) create mode 100644 src/Renderers/createRobloxRenderer.spec.luau diff --git a/src/Renderers/createFusionRenderer.luau b/src/Renderers/createFusionRenderer.luau index 73a12f5f..03715088 100644 --- a/src/Renderers/createFusionRenderer.luau +++ b/src/Renderers/createFusionRenderer.luau @@ -1,3 +1,5 @@ +local Sift = require("@pkg/Sift") + local createRobloxRenderer = require("@root/Renderers/createRobloxRenderer") local types = require("@root/Renderers/types") @@ -11,27 +13,40 @@ local function createFusionRenderer(packages: Packages): Renderer local Fusion = packages.Fusion local robloxRenderer = createRobloxRenderer() - local function transformArgs(args) - local newArgs = {} - for k, v in args do - newArgs[k] = Fusion.Value(v) + 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 newArgs + return context end local function shouldUpdate(context, prevContext) - if context.args ~= prevContext.args then + -- 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 - else - return nil end + return true end return { - transformArgs = transformArgs, - shouldUpdate = shouldUpdate, mount = robloxRenderer.mount, unmount = robloxRenderer.unmount, + shouldUpdate = shouldUpdate, + transformContext = transformContext, } end diff --git a/src/Renderers/createFusionRenderer.spec.luau b/src/Renderers/createFusionRenderer.spec.luau index 197bb8fa..768dd732 100644 --- a/src/Renderers/createFusionRenderer.spec.luau +++ b/src/Renderers/createFusionRenderer.spec.luau @@ -1,68 +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 -local Value = Fusion.Value type StateObject = Fusion.StateObject -type ButtonProps = { +local function Button(props: { isDisabled: StateObject, -} -local function Button(props) +}) return New("TextButton")({ - Text = if props.isDisabled:get() then "Disabled" else "Enabled", + Text = if props.isDisabled and props.isDisabled:get() then "Disabled" else "Enabled", }) end -test("render a Fusion component", function() - local renderer = createFusionRenderer({ Fusion = Fusion }) - local args = { - isDisabled = Value(false), - } +local container +local renderer - local target = Instance.new("Folder") - local gui = renderer.mount(target, Button, args) +beforeEach(function() + container = Instance.new("Folder") - expect(gui).toBedefined() - expect(gui.Text).toBe("Enabled") + 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 renderer = createFusionRenderer({ Fusion = Fusion }) - local args = { - isDisabled = Value(false), - } + local lifecycle = render(renderer, container, Button) - local target = Instance.new("Folder") - local gui = renderer.mount(target, Button, args) + expect(#container:GetChildren()).toBe(1) - expect(gui).toBedefined() - expect(gui:IsDescendantOf(target)).toBe(true) + lifecycle.unmount() - renderer.unmount() + expect(#container:GetChildren()).toBe(0) +end) - expect(gui:IsDescendantOf(target)).toBe(false) +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() - local renderer = createFusionRenderer({ Fusion = Fusion }) - local args = { - isDisabled = Value(false), - } + expect(#container:GetChildren()).toBe(0) + + local lifecycle = render(renderer, container, Button, { + isDisabled = true, + }) - local target = Instance.new("Folder") - local gui = renderer.mount(target, Button, args) + expect(#container:GetChildren()).toBe(1) - expect(gui).toBedefined() - expect(gui:IsDescendantOf(target)).toBe(true) + local element = container:FindFirstChildWhichIsA("TextButton") + assert(element, "no element found") + expect(element.Text).toBe("Disabled") - renderer.unmount() + lifecycle.update({ + isDisabled = false, + }) - expect(gui:IsDescendantOf(target)).toBe(false) + expect(#container:GetChildren()).toBe(1) + expect(element.Text).toBe("Enabled") end) - -test("never re-mount on arg changes", function() end) diff --git a/src/Renderers/createReactRenderer.spec.luau b/src/Renderers/createReactRenderer.spec.luau index 47655893..3a87a5be 100644 --- a/src/Renderers/createReactRenderer.spec.luau +++ b/src/Renderers/createReactRenderer.spec.luau @@ -1,7 +1,9 @@ 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 @@ -36,7 +38,7 @@ beforeEach(function() end) test("render a functional componnet", function() - renderer.mount(container, Button) + render(renderer, container, Button) act(function() task.wait() @@ -51,7 +53,7 @@ test("render a functional componnet", function() end) test("render a class component", function() - renderer.mount(container, ButtonClassComponent) + render(renderer, container, ButtonClassComponent) act(function() task.wait() @@ -65,92 +67,84 @@ test("render a class component", function() expect(element.Text).toBe("Enabled") end) -test("lifecycle", function() - expect(#container:GetChildren()).toBe(0) - - renderer.mount(container, Button, { - args = { - isDisabled = false, - }, +test("pass args as props", function() + render(renderer, container, Button, { + isDisabled = true, }) act(function() task.wait() end) - expect(#container:GetChildren()).toBe(1) + local button = container:FindFirstChildWhichIsA("TextButton") - local element = container:FindFirstChildWhichIsA("TextButton") - assert(element, "no TextButton found") - expect(element.Text).toBe("Enabled") + expect(button).toBeDefined() + expect(button.Text).toBe("Disabled") +end) - renderer.update({ - args = { - isDisabled = true, - }, +test("update the component on arg changes", function() + local lifecycle = render(renderer, container, Button, { + isDisabled = true, }) act(function() task.wait() end) - expect(#container:GetChildren()).toBe(1) + local button = container:FindFirstChildWhichIsA("TextButton") - local prevElement = element - element = container:FindFirstChildWhichIsA("TextButton") - assert(element, "no TextButton found") - expect(element).toBe(prevElement) - expect(element.Text).toBe("Disabled") + expect(button).toBeDefined() + expect(button.Text).toBe("Disabled") - renderer.unmount() + lifecycle.update({ + isDisabled = false, + }) act(function() task.wait() end) - expect(#container:GetChildren()).toBe(0) + expect(button.Text).toBe("Enabled") end) -test("pass args as props", function() - renderer.mount(container, Button, { - args = { - isDisabled = true, - }, +test("lifecycle", function() + expect(#container:GetChildren()).toBe(0) + + local lifecycle = render(renderer, container, Button, { + isDisabled = false, }) act(function() task.wait() end) - local button = container:FindFirstChildWhichIsA("TextButton") + expect(#container:GetChildren()).toBe(1) - expect(button).toBeDefined() - expect(button.Text).toBe("Disabled") -end) + local element = container:FindFirstChildWhichIsA("TextButton") + assert(element, "no TextButton found") + expect(element.Text).toBe("Enabled") -test("update the component on arg changes", function() - renderer.mount(container, Button, { - args = { - isDisabled = true, - }, + lifecycle.update({ + isDisabled = true, }) act(function() task.wait() end) - local button = container:FindFirstChildWhichIsA("TextButton") + expect(#container:GetChildren()).toBe(1) - expect(button).toBeDefined() - expect(button.Text).toBe("Disabled") + local prevElement = element + element = container:FindFirstChildWhichIsA("TextButton") + assert(element, "no TextButton found") + expect(element).toBe(prevElement) + expect(element.Text).toBe("Disabled") - renderer.update({ - isDisabled = false, - }) + lifecycle.unmount() act(function() task.wait() end) - expect(button.Text).toBe("Enabled") + expect(#container:GetChildren()).toBe(0) end) diff --git a/src/Renderers/createRoactRenderer.spec.luau b/src/Renderers/createRoactRenderer.spec.luau index 4d95e7e6..1cf949cf 100644 --- a/src/Renderers/createRoactRenderer.spec.luau +++ b/src/Renderers/createRoactRenderer.spec.luau @@ -1,6 +1,7 @@ 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 @@ -32,7 +33,7 @@ beforeEach(function() end) test("render a functional componnet", function() - renderer.mount(container, Button) + render(renderer, container, Button) local element = container:GetChildren()[1] @@ -43,7 +44,7 @@ test("render a functional componnet", function() end) test("render a class component", function() - renderer.mount(container, ButtonClassComponent) + render(renderer, container, ButtonClassComponent) local element = container:GetChildren()[1] @@ -53,13 +54,39 @@ test("render a class component", function() 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) - renderer.mount(container, Button, { - args = { - isDisabled = false, - }, + local lifecycle = render(renderer, container, Button, { + isDisabled = false, }) expect(#container:GetChildren()).toBe(1) @@ -68,10 +95,8 @@ test("lifecycle", function() assert(element, "no TextButton found") expect(element.Text).toBe("Enabled") - renderer.update({ - args = { - isDisabled = true, - }, + lifecycle.update({ + isDisabled = true, }) expect(#container:GetChildren()).toBe(1) @@ -82,39 +107,7 @@ test("lifecycle", function() expect(element).toBe(prevElement) expect(element.Text).toBe("Disabled") - renderer.unmount() + lifecycle.unmount() expect(#container:GetChildren()).toBe(0) end) - -test("pass args as props", function() - renderer.mount(container, Button, { - args = { - isDisabled = true, - }, - }) - - local button = container:FindFirstChildWhichIsA("TextButton") - - expect(button).toBeDefined() - expect(button.Text).toBe("Disabled") -end) - -test("update the component on arg changes", function() - renderer.mount(container, Button, { - args = { - isDisabled = true, - }, - }) - - local button = container:FindFirstChildWhichIsA("TextButton") - - expect(button).toBeDefined() - expect(button.Text).toBe("Disabled") - - renderer.update({ - isDisabled = false, - }) - - expect(button.Text).toBe("Enabled") -end) diff --git a/src/Renderers/createRobloxRenderer.luau b/src/Renderers/createRobloxRenderer.luau index d3e321f6..770f138f 100644 --- a/src/Renderers/createRobloxRenderer.luau +++ b/src/Renderers/createRobloxRenderer.luau @@ -3,11 +3,15 @@ local types = require("@root/Renderers/types") type Renderer = types.Renderer local function createRobloxRenderer(): Renderer - local handle + local handle: GuiObject? + local currentElement local function mount(container, element, context) + currentElement = element + if typeof(element) == "function" then - element = element(context.args) + local args = if context and context.args then context.args else {} + element = element(args) end if typeof(element) == "Instance" and element:IsA("GuiObject") then @@ -22,8 +26,17 @@ local function createRobloxRenderer(): Renderer 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 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 index 12b034d3..1beebdc7 100644 --- a/src/Renderers/example.md +++ b/src/Renderers/example.md @@ -12,8 +12,8 @@ exports.Primary = { return label end -``` } +``` Fusion diff --git a/src/Renderers/render.luau b/src/Renderers/render.luau index e254f034..96794003 100644 --- a/src/Renderers/render.luau +++ b/src/Renderers/render.luau @@ -1,3 +1,5 @@ +local Sift = require("@pkg/Sift") + local types = require("@root/Renderers/types") type Args = types.Args @@ -13,14 +15,26 @@ local function render(renderer: Renderer, container: Instance, element: T, in 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) - prevContext = context end + + prevContext = context end local function update(newArgs: Args?) - renderOnce(newArgs) + if renderer.update then + local context = Sift.Dictionary.join(prevContext, { + args = newArgs, + }) + renderer.update(context) + else + renderOnce(newArgs) + end end local function unmount() diff --git a/src/Renderers/types.luau b/src/Renderers/types.luau index 0ba0f0d4..02e7fbe6 100644 --- a/src/Renderers/types.luau +++ b/src/Renderers/types.luau @@ -18,7 +18,7 @@ export type Renderer = { unmount: ((context: Context?) -> ())?, update: ((context: Context?) -> ())?, - transformArgs: ((args: Args, context: Context) -> Args)?, + transformContext: ((context: Context, prevContext: Context?) -> Context)?, shouldUpdate: ((context: Context, prevContext: Context?) -> boolean)?, }