chore(components): Refactor Page to be built from composed parts#2867
chore(components): Refactor Page to be built from composed parts#2867
Conversation
Deploying atlantis with
|
| Latest commit: |
5b18efa
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://fd96dd56.atlantis.pages.dev |
| Branch Preview URL: | https://cleanup-page-refactor-compou.atlantis.pages.dev |
| <Page.TitleMeta | ||
| title={title} | ||
| titleMetaData={titleMetaData} | ||
| subtitle={subtitle} |
There was a problem hiding this comment.
is there a reason we'd want to stop at this level rather than continuing to a point where we have: Page.Subtitle, Page.Title, and Page.TitleMetaData?
all 3 of those feel like pieces that people would reasonably want to customize since 2/3 are already ReactNodes.
so we'd have something like
<Page.Wrapper>
</Page.Wrapper>
actually, do we even need a Page.Wrapper? or would it be possible to just have Page as the top level container?
either way, going back to what I was about to say!
<Page.Wrapper>
<Page.Header>
<Page.TitleBar>
<Page.Title>
// this is the default, you can give it different content but it'll be the predefined component
<Page.TitleLabel><Trans>Hello friend</Trans></Page.TitleLabel>
// this is a fully custom value that may get out of sync
<Heading level=3><Trans>Hola Amigo</Trans></Heading>
</Page.Title>
<Page.TitleMetaData>
// doesn't seem like we have a default? so it's just a slot really
<SomeComponent/>
</Page.TitleMetaData>
<Page.Subtitle>
// this is the default
<Page.SubtitleLabel><Trans>This is my subtitle that gets the default appearance</Page.Subtitle>
// this is for fully custom
<Text><Trans>I want my subtitle to look entirely different for some reason</Trans></Text>
</Page.Subtitle>
</Page.TitleBar>
// other parts
</Page.Header>
</Page.Wrapper>
I'm not 100% on the "label" naming but that's what I did in Menu so just went with the same idea.
basically we are providing the slot, that way you can customize heavily, but we also provide the default if all you want is a declarative style but are otherwise happy with what we provide
There was a problem hiding this comment.
Implemented in e5c7132.
Page.TitleMeta has been replaced with separate Page.Title and Page.Subtitle components. Page.Wrapper is gone - Page itself is the top-level container and handles width internally.
Page.Titleaccepts children (the heading text) and an optionalmetadataprop for badges/status labels.Page.Subtitleaccepts either astring(gets the default Text/Emphasis/Markdown "treatment") or aReactNodefor full customization.- I didn't go as deep as "Page.TitleLabel" / "Page.SubtitleLabel" since the string VS ReactNode pattern on children covers the same use case with less nesting.
| ref={primaryAction?.ref} | ||
| visible={!!primaryAction} | ||
| > | ||
| <Button {...getActionProps(primaryAction)} fullWidth /> |
There was a problem hiding this comment.
this getActionProps feels a bit clunky. I wonder if we couldn't find a way to abstract that or avoid people having to use this on their instances.
There was a problem hiding this comment.
getActionProps is only used internally by the legacy renderer now.
In the composable API, Page.PrimaryAction and Page.SecondaryAction now handle their own defaults, so consumers never touch this. But if/when Button gets native ref support (as you suggested below), the need for this will likely go away.
| <Page.ActionGroup visible={!!showActionGroup}> | ||
| <Page.PrimaryAction | ||
| ref={primaryAction?.ref} | ||
| visible={!!primaryAction} |
There was a problem hiding this comment.
I feel like there's gotta be an alternate approach here.
having a visible prop with a declarative style feels redundant.
ie. if I don't want it to be visible, then wouldn't I simply not render it?
{isAdmin && <Page.PrimaryAction ...>}
There was a problem hiding this comment.
I agree! The composable API is fully declarative now. I removed this prop.
| > | ||
| <Button {...getActionProps(primaryAction)} fullWidth /> | ||
| </Page.PrimaryAction> | ||
| <Page.ActionButton |
There was a problem hiding this comment.
curious how this would work now. previously we would limit you to a primary action and a single secondary.
looking at how it works now, I assume I would be able to add as many ActionButtons as I want. maybe that's fine, but if we have opinions on how many buttons a Page should have, that would be worth documenting.
There was a problem hiding this comment.
I replaced Page.ActionButton with moredistinct Page.PrimaryAction, Page.SecondaryAction, and Page.Menu.
Hopefully, this way we "document" our opinion on the page structure (at most these three action slots) while making each one self-documenting. I also added sensible Button defaults for both PrimaryAction and SecondaryAction, while accepting children (in case the consumers would want to provide a custom content).
| type="secondary" | ||
| /> | ||
| </Page.ActionButton> | ||
| <Page.ActionButton visible={!!showMenu}> |
There was a problem hiding this comment.
same idea for the ActionButtons, a visible prop feels odd to me. I wonder if we can avoid that and leverage the declarative style.
also kinda coming back to the topic of having N ActionButtons, it seems to me these are quite different.
one is a regular Button, and one is a Menu. while yes it will look the same at a glance, it has very different behavior.
how would someone know the default configuration? and how to build it?
I'm wondering about maybe a Page.PrimaryAction Page.SecondaryAction and Page.TertiaryAction or Page.ActionMenu
that way our opinions on how many elements should be there, in what order, and what they are is easy to understand and implement.
like with some other pieces, we'd still need to do a bit more work to create good simple defaults for people to use, and then also offer a "slot" where heavy customization can be applied - but they don't have to sort out the order/position/layout of these 3 actions.
There was a problem hiding this comment.
Responded in the comment above.
| ...rest | ||
| }: { | ||
| readonly title: ReactNode; | ||
| readonly titleMetaData: ReactNode; |
There was a problem hiding this comment.
currently titleMetaData is optional. let's stick to that unless there's a compelling reason to make it mandatory.
https://github.com/GetJobber/atlantis/blob/master/packages/components/src/Page/Page.tsx#L37
There was a problem hiding this comment.
I left it as optional on Page.Title.
|
after trying to build with these pieces, I would say there are a handful of things we should strongly consider changing and a few others that would also be worthwhile improvements. that said, this is a great start! one final piece of FUD I have is what we are asking of consumers through all of this. if someone has an existing Page instance, and all they want is to apply data attributes to their actions - there's no way for them to only replace that module. they are going to have to fully refactor their entire that's a pretty big chunk of work for a relatively small addition. something to keep in mind. |
| import { type SectionProps } from "../Menu"; | ||
|
|
||
| export type ButtonActionProps = ButtonProps & { | ||
| ref?: React.RefObject<HTMLDivElement | null>; |
There was a problem hiding this comment.
ugh, if we get that ref support on Button done, this can go away. I was wondering why we had these extra refs that we are stripping from the props with that extra helper function.
|
whoops, I forgot to try the entire point of all this 😅 I feel like giving the data attributes to a bit to sort out with the defaults cases, but for the fully custom where it's acting as a slot you'd just do another argument for more atomic pieces for defaults that we receive the props for and can abstract with a very thin layer. oh also |
- Uses the same TS overload pattern as Menu - Replaces ts-xor XOR with native discriminated union - Props-based usage is fully preserved; composable mode activates when title is not passed as a prop - Page.Menu uses the composable Menu internally, enabling consumers to provide Menu.Item children compatible with client-side routers - PrimaryAction/SecondaryAction provide sensible Button defaults but accept children for custom elements - All compound components support data-attributes via filterDataAttributes
- Creates PageNotes.mdx covering all sub-components, the slot pattern for custom actions, and an explanation of TSR integration via createLink(Menu.Item).
|
|
||
| **_Page.Body (Required)_** | ||
|
|
||
| Main content area of the page. Wraps children in a `Content` component. |
There was a problem hiding this comment.
I see PageBody wraps children in Content, but why do all of the stories/examples do that as well? Is that necessary? 🤔
<Page.Body>
<Content>
<Text>Page content here</Text>
</Content>
</Page.Body>There was a problem hiding this comment.
You're right. That was not necessary. Not sure how I missed that, but I removed the duplication in da2a414.
There was a problem hiding this comment.
Mentioned in another comment, looks like PageLegacy (and the original Page implementation) wraps their children in Content as well + consumers additionally wrap with Content too.
I guess I want to triple check here, does multiple nested Contents cause multiple levels of padding? Or are they harmless though redundant?
| <Container | ||
| name="page-titlebar" | ||
| autoWidth | ||
| dataAttributes={dataAttrs as CommonAtlantisProps["dataAttributes"]} |
There was a problem hiding this comment.
Off topic, not a blocker: the casting is interesting. Makes me think that filterDataAttributes and CommonAtlantisProps["dataAttributes"] should actually share the same record type.
Maybe can we log a ticket for that if it makes sense?
There was a problem hiding this comment.
I looked into this. We have 2 approaches to handle data attributes in Atlantis:
- explicit prop (
CommonAtlantisProps): some components likeContaineracceptdataAttributes?: { [key: data-${string}]: string }as a named prop. Values arestringthere because the consumer explicitly constructs the object. - rest spread (
filterDataAttributes): some components accept...restand filter outdata-*keys. The return type isPartial<Record<data-${string}, unknown>>because rest is can have values of any type and TS doesn't know what's in there.
Perhaps, @ZakaryH can provide more input, but I think the fix could be to either:
- change
filterDataAttributes'sDataAttributestype to usestringinstead ofunknown, with aString()coercion inside the function (to satisfy the assignment), or - change
CommonAtlantisProps["dataAttributes"]to accept unknown values.
There was a problem hiding this comment.
Will let Zak respond, but according to MDN, all data attribute values are strings:
Each property is a string and can be read and written
https://developer.mozilla.org/en-US/docs/Web/HTML/How_to/Use_data_attributes#javascript_access
There was a problem hiding this comment.
That's correct. The problem is that the filterDataAttributes accepts the ...rest prop, which can have props, where values are any type. So that's why we have unknown in DataAttributes, which is used as a return type of that function:
type DataAttributes = Partial<Record<`data-${string}`, unknown>>;
function filterDataAttributes<T extends object>(props: T): DataAttributesSo const dataAttrs = filterDataAttributes(rest); returns dataAttrs of type DataAttributes, which includes unknown. But dataAttributes?: { [key: data-${string}]: string }; expects a string. That's why casting is used there.
There was a problem hiding this comment.
the CommonAtlantisProps was an early iteration; however it's not the direction we want to go anymore. we should avoid updating it if possible and instead deprecate and replace it with what we'd prefer to do.
iirc the unknown was largely to appease the types with minimal casting. I don't have any strong objections to it.
the only thing that I might want to consider at all is that it's acceptable to give non string values to data attributes in React. data-yes={true} and data-count={9} are fine. the final html will be a string, but those are not invalid as far as I'm aware.
| return ( | ||
| <div {...dataAttrs}> | ||
| <Heading level={1}>{children}</Heading> | ||
| </div> |
There was a problem hiding this comment.
If Heading supported data attrs, would we have included this extra div wrapper here?
This is one thing we need to keep in mind in the future: as we spread out data attrs like this, we're kind of forcing ourselves to keep it this way forever once consumers start writing code that depends on this. For example, if we happen to remove the div and move the data attrs to Header in the future, that could break consumer code where css selectors might be implicitly depending on the nested structure as it stands today. Not saying we shouldn't do this, just spreading awareness that this impacts our flexibility in the future 👍
Notably, the exact same concept and problem already exists with UNSAFE props and styles, data attrs is no different. We just need to be careful and consider future us.
Anyway, nothing to action here!
There was a problem hiding this comment.
Yes, if Heading accepted data attributes (via ...rest + filterDataAttributes), PageTitle could pass them straight through without needing a wrapper div. I added it because I needed somewhere to put the data attributes.
I think we could improve that by "expanding" Heading props, but it's another thing that feels out of scope of Page component. Would you like me to log a ticket to allow Heading to accept data attributes?
There was a problem hiding this comment.
I'm not sure if we're tracking individual components' lack of data attrs as tickets. Agreed it's out of scope and is one of those things you discover usually during implementation.
All good, nothing to action here today 👍
|
|
||
| return ( | ||
| <div className={styles.primaryAction} ref={ref} {...dataAttrs}> | ||
| {children ?? ( |
There was a problem hiding this comment.
this API feels a little strange
<Page.PrimaryAction label="hello">
<Button label="goodbye" />
</Page.PrimaryAction>
"hello" does nothing but the props don't really tell me that. it's one or the other, and ideally our types/props mirror that.
also, it's a bit strange to me that we have 0 required props on our PrimaryAction. is it really ok for nothing to be provided, or do we have a minimum set of props that we ask of an instance?
There was a problem hiding this comment.
Agreed.. Popover has some good examples that solve this problem. See Popover.DismissButton which is the same concept, wrapping another component by default, or allows children instead using this prop type:
export type PopoverDismissButtonProps = XOR<
PopoverDismissButtonWithChildren,
PopoverDismissButtonWithoutChildren
>;There was a problem hiding this comment.
Good catch. I adopted the similar pattern as in Popover.DismissButton - PageActionProps is now a discriminated union: you can either provide children for a custom element or label (required) for the default Button. TS will flag you if you try to do both or neither.
Done in 6e22467.
| <div className={styles.primaryAction} ref={ref} {...dataAttrs}> | ||
| {children ?? ( | ||
| <Button | ||
| label={label ?? ""} |
There was a problem hiding this comment.
curious about the fallback here. when would we want an empty label?
I wonder if there aren't some minimum props that we enforce via types and props. does our Page Button have different requirements than a standard Button?
There was a problem hiding this comment.
Is that for label-less icon buttons perhaps? https://atlantis.getjobber.com/components/Button
Question of whether or not an icon button should be supported on Page is valid though. Even if we require label, they could provide their own children to bypass that though, which is fine from a flexibility standpoint.
There was a problem hiding this comment.
hmm I suppose that's allowed even today since our action props are referencing Button props, so it's valid to have an icon-only button.
unless we have a reason not to, we can assume that's still valid for now. oh wait, is the empty string fallback there to appease the types of Button?
if they are, and we have a good grasp of what should and should not be present on our actions, we could use the sub component Button invocation that has weaker type requirements, but would let us avoid setting fallbacks if we don't think they are realistic or useful
There was a problem hiding this comment.
oh wait, is the empty string fallback there to appease the types of Button?
Ah yeah, it is required it looks like, even for icon-only buttons.
Anyway, my suggestion here would make the prop types more strict based on whether children are supplied or not, so this optional label problem would be solved by that.
There was a problem hiding this comment.
This is no longer relevant. The label ?? "" fallback was there when label was optional. After the discriminated union change, when you're in the PageActionWithDefaults "branch", label is required, so there's no need for a fallback.
I changed how we type PageAction props in 6e22467.
| readonly children: ReactNode; | ||
| readonly externalLinks?: boolean; | ||
| }) { | ||
| if (typeof children === "string") { |
There was a problem hiding this comment.
I'm not sure about this type checking against string. what are we looking for with these?
I ask because of how translations are implemented. I might be totally fine with the default text size, styling and everything - in fact that's exactly what I want. I don't want to worry about duplicating it.
howver, I want to use a <Trans> Hola amigo</Trans> as the value.
because this isn't a string, I now have to go into Page, find what Intro would have rendered, copy that and implement it but in a way that is fragile. if Page.Intro ever updates, my copied version will NOT stay in sync
There was a problem hiding this comment.
overall, I'm not sure we should be checking for string types.
I guess I would ask what we're trying to identify with these checks?
if it's default vs custom, I would be tempted to find an alternative way that still allows passing children, and not checking the types.
There was a problem hiding this comment.
here's what might happen if I used a fragment based translation component
ignore the warnings, and imagine they are normal <Trans> components wrapping the text
I lose all the Page opinions and defaults on the text 😢 except on Title, which keeps rendering the children no matter what, and is partially what we might want to lean towards as our solution
There was a problem hiding this comment.
I guess I would ask what we're trying to identify with these checks?
So the reason for this check is that Markdown can only accept string as its children.

Your concern about the internalization is valid, however, it would not work in case the consumers wanted to provide a markdown formatting. In legacy Page we are providing the subtitle to Markdown's content, which is always a string. So this condition mimics that. So in case of Trans, we could either let consumers provide their own styling, or do something like:
function PageSubtitle({ children, ...rest }: PageSubtitleProps) {
const dataAttrs = filterDataAttributes(rest);
return (
<div className={styles.subtitle} {...dataAttrs}>
<Text size="large" variation="subdued">
<Emphasis variation="bold">
{typeof children === "string" ? (
<Markdown content={children} basicUsage={true} />
) : (
children
)}
</Emphasis>
</Text>
</div>
);
}The typeof children === "string" condition would still be necessary, but at least the children will always be wrapped with Text and Emphasis. Would that work? (same would apply for PageIntro).
…ocumentation - Simplified Page component stories by removing the Content wrapper around Text elements. - Updated PageNotes and VisualTestPagePage to reflect the same structure for consistency.
- Moved `titleMetaData` from args into the render function directly, since it is a React element containing Symbols that Storybook's Controls panel can't serialize to JSON.
| > | ||
| <Content> | ||
| <Text>Page content here</Text> | ||
| </Content> |
There was a problem hiding this comment.
Looks like PageLegacy also wraps children in Content:
<Content>{children}</Content>
So is the extra Content necessary here? Do consumers normally supply Content to all Pages?
There was a problem hiding this comment.
Looks like most consumers actually do supply Content themselves. And looks like even before, the old Page also did the same thing.
So I think we're good here.. just odd that double Content is required... but maybe it just works fine when nested unnecessarily?
There was a problem hiding this comment.
I believe I have address this (or a portion of this) in a previous commit, where I removed the redundant <Content> from stories since Page.Body wraps in Content internally.
But I also asked LLM to help me understand the usage of multiple/nested Contents in Page. Basically it said that the nested Content is intentional - each level spaces different things:
- The outer Content spaces the header section from the body section
- The inner Content spaces the titlebar from the intro text
- The body Content spaces the consumer's children from each other
<div className={pageStyles}> ← page wrapper
<Content> ← outer: spaces header from body
<Content> ← inner: spaces titlebar from intro
<div className={titleBar}>...</div>
{intro && <Text>...</Text>}
</Content>
<Content>{children}</Content> ← body: spaces consumer's children
</Content>
</div>
I looked in JFE, and the usage is really mixed... Some wrap in <Content> (Clients, Requests with custom spacing), some don't (Jobs, Timesheets).
I asked LLM if this nesting can be problematic (or redundant):
Is the double Content a problem? No. Nesting Content is by design — the margin-bottom on :not(:last-child) only affects direct children. A nested Content inside a parent Content is just one child of the parent, so the parent spaces it from siblings. The inner Content then independently spaces its own children. They don't interfere with each other.
So I guess the conclusion is that it's correct and intentional 🤷♂️.
Storybook v9 has unresolved issues with custom media queries, React element serialization in Controls, and required children typing. Keeping all Page stories in v7 for now with composable API stories added alongside the existing ones.
- share PageActionProps interface for PrimaryAction and SecondaryAction instead of duplicated inline definitions.
- follows the Popover.DismissButton pattern: PageActionProps is now a union of PageActionWithChildren and PageActionWithDefaults. Consumers either provide `children` for a custom element, or `label (required)` for the default Button. TS prevents mixing both or providing neither. - creates a `hasCustomChildren` type guard in types.ts that helps determine which invocation is used (with children or prop defaults).
@ZakaryH , this is a pretty thorough and large write-up and I have looked into every point you raised. I'm struggling a bit with how to respond best. We can get on a call to decide what to do with the points that you raised. But I will try my best to provide my thoughts concisely in a single comment.
I chose the prop approach because I though that it was simpler and
I used the same CSS classes, so this behaviour is inherited from whatever we have on
This is a big one. While I agree that from an extensibility perspective it is a vaild concern, I think we could do it later if the need arises. For the immediate need (TSR menu links), the current
This one can be big or small depending on what we want to do. If you check Storybook, the "incorrect" alignment happens only on I was able to pinpoint it to the fact that legacy Page uses Floating UI with
If I understood your concern correctly, the current |
… metadata to be a sub-component - Replace metadata prop with Page.TitleMetaData sub-component - Split actions into positional slots (PrimarySlot, SecondarySlot, TertiarySlot) and opinionated buttons (PrimaryAction, SecondaryAction) - Page.Menu no longer owns its positional wrapper, goes inside TertiarySlot - Remove typeof children === "string" checks from Subtitle and Intro; default styling always applies, fixing i18n/Trans compatibility - Remove hasCustomChildren type guard and discriminated union (no longer needed)
…ns, Intro and subtitle
…acy to composable migration steps






Motivations
We chatted in a recent meeting about refactoring Page to be built from composed parts + using data-attributes.
Using visual regression tests as a refactor helper, I was able to refactor page to keep the exact same structure as it does today while exposing a variety of new compound pieces to build in a compositional way if required.
Each compound piece accepts data-attributes, so in the edge case where someone needs very specific data-attributes on pieces of Page, they can now accomplish that! :)
Changes
Code Notes
We could prooooobably break down that ternary statement in PageTitleMeta that's deciding what to render. That still feels a bit smelly to me.
Testing
Page should work completely 100% as-is with no changes by consumers.
However, you should also now be able to build composed pages for advanced use cases as well!
Changes can be
tested via Pre-release
In Atlantis we use Github's built in pull request reviews.