Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Roblox internal Stories and Storybooks #29

Draft
wants to merge 49 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
c2a3b04
Work from the day
vocksel Nov 6, 2024
7ec177d
Fix types
vocksel Nov 6, 2024
4dfd89e
Place raw errors on the next line
vocksel Nov 6, 2024
922c3c3
Support "group" field on storybooks
vocksel Nov 7, 2024
df83c1a
Mark roblox internal
vocksel Nov 7, 2024
2aeaf78
Existing renderers work with Roblox stories
vocksel Nov 7, 2024
404781a
Add mapStory support
vocksel Nov 7, 2024
c66fb44
Choose any random substory to render as the story
vocksel Nov 7, 2024
6c574ba
Use Prospector for immediate performance improvement
vocksel Nov 8, 2024
80069ce
Merge remote-tracking branch 'origin/main' into roblox-internal
vocksel Nov 11, 2024
4d39e32
Merge remote-tracking branch 'origin/main' into roblox-internal
vocksel Nov 21, 2024
a85500e
Add a todo
vocksel Nov 21, 2024
78af727
Merge remote-tracking branch 'origin/main' into roblox-internal
vocksel Dec 5, 2024
0d179b0
Merge remote-tracking branch 'origin/main' into roblox-internal
vocksel Dec 12, 2024
bac3658
Don't bother gating behind a flag
vocksel Dec 12, 2024
5963e56
Merge remote-tracking branch 'origin/main' into roblox-internal
vocksel Dec 12, 2024
d075fc1
Handle more substory cases
vocksel Dec 16, 2024
d22446e
Adjust internal handling for React/ReactRoblox
vocksel Dec 16, 2024
d58594c
Fix mapStory for React storybooks
vocksel Dec 16, 2024
6be8b08
Fix build watching
vocksel Dec 16, 2024
e0b1716
Remove debug print
vocksel Dec 16, 2024
08a547d
Expose UnavailableStorybook type
vocksel Dec 16, 2024
a036d9a
Fix mapStory for Roact renderer
vocksel Dec 17, 2024
3611a65
Each Storybook has its own ModuleLoader for better sandboxing
vocksel Dec 18, 2024
23e599b
Storybooks keep track of their source module
vocksel Dec 18, 2024
a4afdd0
Fix unit tests and type errors
vocksel Dec 18, 2024
5026aea
Support `props` field on Stories
vocksel Dec 18, 2024
aab4408
Support mapDefinition
vocksel Dec 18, 2024
7de0209
Revise useStorybooks to better encapsulate loading
vocksel Dec 18, 2024
4a99804
Provide better interop with storyRoots
vocksel Dec 18, 2024
c054981
Each Storybook has its own loader
vocksel Dec 19, 2024
c7267cc
Typecasts
vocksel Dec 19, 2024
113dd99
Couple things to revert
vocksel Dec 19, 2024
1e42f86
Add some more tests
vocksel Dec 21, 2024
6ca12a8
Merge remote-tracking branch 'origin/roblox-internal' into roblox-int…
vocksel Dec 21, 2024
3e033c4
Merge remote-tracking branch 'origin/main' into roblox-internal
vocksel Dec 24, 2024
78ac720
Merge remote-tracking branch 'origin/roblox-internal' into roblox-int…
vocksel Dec 24, 2024
9a00d17
Merge remote-tracking branch 'origin/main' into storybook-contains-th…
vocksel Dec 24, 2024
05cafb2
Unskip a test
vocksel Dec 24, 2024
ee649a4
Get useStorybooks tests passing
vocksel Dec 29, 2024
5c757f6
Get useStory tests passing
vocksel Dec 29, 2024
1157951
Merge branch 'storybook-contains-the-loader' into roblox-internal
vocksel Dec 29, 2024
46f1fd1
Merge remote-tracking branch 'origin/main' into roblox-internal
vocksel Dec 29, 2024
93e827f
Require useStorybooks inline
vocksel Dec 29, 2024
722387f
Remove implicit storyRoots
vocksel Dec 30, 2024
e85ae17
Mark Roblox internal behavior
vocksel Dec 30, 2024
c668640
Remove test for removed behavior
vocksel Dec 30, 2024
cef8dc1
Update story format docs
vocksel Dec 30, 2024
182fe0b
Mark another Roblox internal feature
vocksel Dec 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .lune/build.luau
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ if args.watch then
watch({
filePatterns = {
"src/.*%.luau",
"example/.*%.luau",
},
onChanged = build,
})
Expand Down
117 changes: 38 additions & 79 deletions docs/docs/story-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,25 @@

Any ModuleScript with a `.storybook` extension will be picked up as a Storybook.

:::warning
Storybooks that do not have a `storyRoots` array will not be shown in the Flipbook UI.
:::

The properties that can be used in the module are as follows:

| **Property** | **Type** | **Description** |
| ------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `storyRoots` | `{ Instance }` | Locations that the Storybook manages. Each instance will have its descendants searched for Story modules. |
| `name` | `string?` | An optional name for the Storybook. Defaults to the module name with the extension removed. i.e. `Sample.storybook` becomes `Sample`. |
| `packages` | `{ [string]: any }?` | An optional dictionary used for supplying the Storybook with the packages to use when rendering its Stories. |
| **Property** | **Type** | **Description** |
| --------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `storyRoots` | `{ Instance }` | Locations that the Storybook manages. Each instance will have its descendants searched for Story modules. |
| `name` | `string?` | An optional name for the Storybook. Defaults to the module name with the extension removed. i.e. `Sample.storybook` becomes `Sample`. |
| `packages` | `{ [string]: any }?` | An optional dictionary used for supplying the Storybook with the packages to use when rendering its Stories. |
| `mapDefinition` | `((story: any) -> any)?` | |
| `mapStory` | `((story: any) -> (props: StoryProps) -> any)?` | |
Comment on lines +14 to +15
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either add support for mapStory and mapDefinition to all renderers or mark them as Roblox internal only and remove mention from the docs


This dictionary can also be supplied per-Story to change the renderer used, but it can be convenient to define your packages globally to avoid repetition. |
This dictionary can also be supplied per-Story to change the renderer used, but it can be convenient to define your packages globally to avoid repetition.

Example Storybook module:
The most basic Storybook module can be represented as:

```lua
-- Sample.storybook
```lua title="Plain.storybook.luau"
return {
storyRoots = {
script.Parent.Components
},
storyRoots = {
script.Parent,
},
}
```

Expand All @@ -35,80 +32,42 @@ Any ModuleScript with a `.story` extension will be picked up as a Story when it

The only required member of a Story definition is the `story` property.

| **Property** | **Type** | **Description** |
| ------------ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `story` | `<T>((props: StoryProps) -> T)` | |
| `name` | `string?` | The name of the Story as it appears in Flipbook. Defaults to the name of the Story module. i.e. `Sample.story` becomes `Sample` |
| `summary` | `string?` | A description of the Story that will appear above the rendered preview in Flipbook. |
| `controls` | `{ [string]: any }?` | Controls allow for on-the-fly configuration of your rendered UI. Read more about how to define and use Controls here: [Controls](https://www.notion.so/Controls-12f95b7912f8804388e1d746a6617716?pvs=21) |

The type of the `story` property is dependent on what kind of Story is being rendered. Flipbook does not prescribe one particular way of writing Stories, or even a particular UI library that must be used.
Stories can be written for React, Fusion, legacy Roact, plain Roblox Instances, and anything you can think of with [Manual Stories](https://www.notion.so/Story-format-12f95b7912f880068da6d74c472bf186?pvs=21).

Example Story module:

```lua
return {
story = function(props)

end
}
```

Under the hood these simply map to `packages.Roact`, `packages.React`, and `packages.ReactRoblox` respectively.
| **Property** | **Type** | **Description** |
| ------------ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `story` | `<T>((props: StoryProps) -> T)` | |
| `name` | `string?` | The name of the Story as it appears in Storyteller. Defaults to the name of the Story module. i.e. `Sample.story` becomes `Sample` |
| `summary` | `string?` | A description of the Story that will appear above the rendered preview in Storyteller. |
| `controls` | `{ [string]: any }?` | Controls allow for on-the-fly configuration of your rendered UI. Read more about how to define and use controls [here](/docs/creating-stories/controls). |
| `packages` | `{ [string]: any }?` | An optional dictionary used for supplying the Story with the packages to use when rendering. The Story inherits the packages defined by the Storybook, so this is mostly used in cases where Story needs to deviate from the usual renderer |
| `props` | `{ [string]: any }?` | |

## Stories for different renderers
The type of the `story` property is dependent on what kind of Story is being rendered. Storyteller does not prescribe one particular way of writing Stories, or even a particular UI library that must be used.

### Manual
## StoryProps

### React
A Story's `story` function is passed in a `StoryProps` object that contains the following.

### Roact
| **Property** | **Type** | **Description** |
| ------------ | --------------- | ------------------------------------------------------ |
| `container` | `Instance` | |
| `theme` | `string` | A string representing the current Roblox Studio theme. |
| `controls` | `StoryControls` | Defaults to an empty table. |

### Fusion
If `props` is supplied on the Story, then the key/value pairs will be merged with `StoryProps`.

## Legacy package support

:::warning
A future version of Storyteller may remove this compatibility layer. It is recommended to migrate to `packages` in the meantime.
:::

For UI libraries to be used by Flipbook it was required to pass them as properties to the Story or Storybook. For example, to use React with Flipbook you would define a Storybook like the following:

```lua
local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

return {
storyRoots = {
script.Parent.Components,
}
react = React,
reactRoblox = ReactRoblox,
}
```

Storyteller uses a different approach by grouping any third-party libraries used for rendering stories in a `packages` object.

```lua
local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

return {
storyRoots = {
script.Parent.Components,
}
packages = {
React = React,
ReactRoblox = ReactRoblox,
}
}
```
UI libraries used to be supplied by attaching them as properties to a Story or Storybook. This has been superseded by the `packages` object, which acts as a dedicated location to supply the packages used for rendering Stories.

For convenience, Storyteller provides backwards compatibility for the following Storybook properties:
For backwards compatibility, the following properties are migrated to their `packages` equivalent:

| **Property** | **Type** | **Description** |
| ------------- | -------- | ----------------------------------------------------------- |
| `roact` | `any` | Result of `require(ReplicatedStorage.Packages.Roact)` |
| `react` | `any` | Result of `require(ReplicatedStorage.Packages.React)` |
| `reactRoblox` | `any` | Result of `require(ReplicatedStorage.Packages.ReactRoblox)` |
| **Property** | **Mapping** |
| ------------- | ---------------------- |
| `fusion` | `packages.Fusion` |
| `react` | `packages.React` |
| `reactRoblox` | `packages.ReactRoblox` |
| `roact` | `packages.Roact` |
10 changes: 10 additions & 0 deletions foreman.toml
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remember to remove this before merging

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[tools]
rojo = { source = "rojo-rbx/rojo", version = "7.4.3" }
selene = { source = "Kampfkarren/selene", version = "0.27.1" }
StyLua = { source = "JohnnyMorganz/StyLua", version = "0.20.0" }
wally = { source = "UpliftGames/wally", version = "0.3.2" }
luau-lsp = { source = "JohnnyMorganz/luau-lsp", version = "1.34.0" }
wally-package-types = { source = "JohnnyMorganz/wally-package-types", version = "1.3.2" }
lune = { source = "lune-org/lune", version = "0.8.7" }
darklua = { source = "seaofvoices/darklua", version = "0.13.1" }
run-in-roblox = { source = "rojo-rbx/run-in-roblox", version = "0.3.0" }
5 changes: 1 addition & 4 deletions src/hooks/useStorybooks.luau
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ local useMemo = React.useMemo

type ModuleLoader = ModuleLoader.ModuleLoader
type LoadedStorybook = types.LoadedStorybook
type UnavailableStorybook = {
problem: string,
storybook: types.LoadedStorybook,
}
type UnavailableStorybook = types.UnavailableStorybook

--[=[
Performs all the discovery and loading of Storybook modules that would
Expand Down
1 change: 1 addition & 0 deletions src/init.luau
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type Story<T> = types.Story<T>
export type LoadedStory<T> = types.LoadedStory<T>
export type Storybook = types.Storybook
export type LoadedStorybook = types.LoadedStorybook
export type UnavailableStorybook = types.UnavailableStorybook
export type StoryControls = types.StoryControls
export type StoryProps = types.StoryProps
export type StoryRenderer<T> = types.StoryRenderer<T>
Expand Down
22 changes: 20 additions & 2 deletions src/loadStoryModule.luau
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ local function loadStoryModule<T>(storyModule: ModuleScript, storybook: LoadedSt
end)

if not success then
error(`Failed to load story {storyModule:GetFullName()}. Error: {result})`)
error(`Failed to load story {storyModule:GetFullName()}:\n{result})`)
end

local isLegacyStory = typeof(result) == "function"
Expand All @@ -48,12 +48,30 @@ local function loadStoryModule<T>(storyModule: ModuleScript, storybook: LoadedSt
}
end

do -- Roblox internal. This behavior may be substantially changed or removed
-- We need support for substories to be compatible with Developer
-- Storybook. In the meantime we're just selecting a randomly accessed
-- one, but ideally we should be able to render them all out
if result.stories and not result.story then
local randomSubstory
for _, substory in result.stories do
randomSubstory = substory
break
end
if typeof(randomSubstory) == "table" then
result.story = randomSubstory.story
else
result.story = randomSubstory
end
end
end

local isValid, message = types.IStory(result)

if not isValid then
error(
`Story is malformed. Check the source of {storyModule:GetFullName()} and make sure its properties are `
.. `correct. Error: {message}`
.. `correct.\nerr: {message}`
)
end

Expand Down
29 changes: 29 additions & 0 deletions src/loadStoryModule.spec.luau
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,32 @@ describe("legacy support", function()
})
end)
end)

describe("roblox internal", function()
-- Right now we don't support rendering more than one story. For now, we
-- pick a random one to render.
test("pick a single substory to show", function()
local storybook: types.LoadedStorybook = {
name = "Storybook",
storyRoots = {},
loader = ModuleLoader.new(),
source = Instance.new("ModuleScript"),
packages = {},
}

local storyModule = createMockStoryModule([[
return {
stories = {
One = function() end,
Two = function() end,
Three = function() end,
}
}
]])

local story = loadStoryModule(storyModule, storybook)

expect(story.story).toBeDefined()
expect((story :: any).substories).toBeUndefined()
end)
end)
17 changes: 16 additions & 1 deletion src/loadStorybookModule.luau
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,18 @@ local function loadStorybookModule(module: ModuleScript, providedLoader: ModuleL

assert(wasRequired, `failed to load storybook {module:GetFullName()}: {result}`)

do -- Roblox internal. This behavior may be removed without notice.
if not result.storyRoots and result.storyRoot then
-- Some storybooks use `storyRoot: Instance` instead of a
-- `storyRoots` array.
result.storyRoots = { result.storyRoot }
result.storyRoot = nil
end
end

local isStorybook, message = types.IStorybook(result)

assert(isStorybook, `failed to load storybook {module:GetFullName()}: {message}`)
assert(isStorybook, `failed to load storybook {module:GetFullName()}:\n{message}`)

if not result.packages then
result.packages = migrateLegacyPackages(result)
Expand All @@ -47,6 +56,12 @@ local function loadStorybookModule(module: ModuleScript, providedLoader: ModuleL
end
end

do -- Roblox internal. This behavior may be removed without notice.
if result.group then
result.name = `{result.group} / {result.name}`
end
end

result.source = module
result.loader = loader

Expand Down
14 changes: 14 additions & 0 deletions src/loadStorybookModule.spec.luau
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,17 @@ describe("legacy support", function()
})
end)
end)

describe("roblox internal", function()
test("map storyRoot (single instance) to storyRoots (array of instances)", function()
local storybookModule = createMockStorybookModule([[
return {
storyRoot = Instance.new("Folder"),
}
]])

local storybook = loadStorybookModule(storybookModule)

expect(#storybook.storyRoots).toBe(1)
end)
end)
18 changes: 7 additions & 11 deletions src/matchDescendants.luau
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
local Prospector = require("@pkg/Prospector")

local function hasPermission(instance: Instance)
local success = pcall(function()
return instance.Name
Expand All @@ -12,17 +14,11 @@ local function matchDescendants(parent: Instance, predicate: (descendant: Instan
return matches
end

for _, descendant in parent:GetDescendants() do
local success, result = pcall(function()
return predicate(descendant)
end)

if success and result == true then
table.insert(matches, descendant)
end
end

return matches
-- Before Prospector we were calling parent:GetDescendants() but because we
-- need to support the DataModel being passed in that case would allocate a
-- gigantic array. Prospector will traverse the hierarchy manually, which
-- takes longer but won't freeze up consumers
return Prospector.Collect.descendantsDFS(parent, predicate)
end

return matchDescendants
10 changes: 10 additions & 0 deletions src/migrateLegacyPackages.luau
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
local isReact = require("@root/roblox-internal/isReact")
local types = require("@root/types")

type StoryPackages = types.StoryPackages

local function migrateLegacyPackages(storyOrStorybook: { [string]: any }): StoryPackages?
if storyOrStorybook.roact or storyOrStorybook.react or storyOrStorybook.reactRoblox then
do -- Roblox internal. This behavior may be removed without notice.
if storyOrStorybook.roact and storyOrStorybook.reactRoblox and isReact(storyOrStorybook.roact) then
return {
React = storyOrStorybook.roact,
ReactRoblox = storyOrStorybook.reactRoblox,
}
end
end

return {
Roact = storyOrStorybook.roact,
React = storyOrStorybook.react,
Expand Down
34 changes: 34 additions & 0 deletions src/migrateLegacyPackages.spec.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
local JestGlobals = require("@pkg/JestGlobals")
local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")
local Roact = require("@pkg/Roact")

local migrateLegacyPackages = require("./migrateLegacyPackages")

local describe = JestGlobals.describe
local test = JestGlobals.test
local expect = JestGlobals.expect

test("map roact key", function()
local packages = migrateLegacyPackages({
roact = Roact,
})

expect(packages).toEqual({
Roact = Roact,
})
end)

describe("roblox internal", function()
test("react can be passed as the roact key", function()
local packages = migrateLegacyPackages({
roact = React,
reactRoblox = ReactRoblox,
})

expect(packages).toEqual({
React = React,
ReactRoblox = ReactRoblox,
})
end)
end)
Loading
Loading