Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 16 additions & 2 deletions packages/app-elements/src/helpers/useAppLinking.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import isEmpty from "lodash-es/isEmpty"
import { useCallback } from "react"
import { useCallback, useRef } from "react"
import { useLocation, useRouter, useSearch } from "wouter"
import { useTokenProvider } from "#providers/TokenProvider"
import type { TokenProviderClAppSlug } from "#providers/TokenProvider/types"
Expand Down Expand Up @@ -51,6 +51,19 @@ export function useAppLinking(): UseAppLinkingHook {
const [location, setLocation] = useLocation()
const search = useSearch()

/**
* `navigateTo` keeps a stable identity on purpose: it is called while rendering
* list rows, so re-creating it on every navigation would invalidate memoized
* rows and re-run any consumer effect that depends on it.
*
* That means it must not close over `location`/`search`, which change on every
* navigation — the entry saved for "go back" has to be the url at *click* time,
* not the one from the render that last recreated this callback. Reading them
* from a ref keeps both properties.
*/
const currentUrlRef = useRef({ location, search })
currentUrlRef.current = { location, search }

const navigateTo: UseAppLinkingHook["navigateTo"] = useCallback(
({ app, resourceId }) => {
const path = resourceId != null ? `/list/${resourceId}` : `/list`
Expand All @@ -77,11 +90,12 @@ export function useAppLinking(): UseAppLinkingHook {
// probably in a future we can use a query string param
window.location.assign(to)
} else {
const { location: from, search: fromSearch } = currentUrlRef.current
saveGoBackItem({
destinationApp: app,
resourceId,
returnToApp: currentAppSlug as TokenProviderClAppSlug,
location: `${location}${!isEmpty(search) ? `?${search}` : ""}`,
location: `${from}${!isEmpty(fromSearch) ? `?${fromSearch}` : ""}`,
})
setLocation(to)
}
Expand Down
11 changes: 8 additions & 3 deletions packages/app-elements/src/ui/atoms/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export interface StackProps {

function renderChild(child: ReactNode): JSX.Element {
return (
<div className="flex-1 flex flex-col items-start py-6 md:py-2 md:px-6 border-t md:border-l border-l-0 md:border-t-0 border-gray-100 first:border-l-0 first:border-t-0 md:first:-ml-6 md:last:-mr-6 print:border-t-0">
<div className="flex-1 flex flex-col items-start py-6 @xl:py-2 @xl:px-6 border-t @xl:border-l border-l-0 @xl:border-t-0 border-gray-100 first:border-l-0 first:border-t-0 @xl:first:-ml-6 @xl:last:-mr-6 print:border-t-0">
{child}
</div>
)
Expand All @@ -16,9 +16,14 @@ function Stack({ children, ...props }: StackProps): JSX.Element {
return (
<div
{...props}
className="border-t border-b border-gray-100 md:py-6 not-first:-mt-px" // make multiple stack possible even across different siblings
// `@container` + `@xl` variants lay the children out side by side based on
// the width available to the Stack rather than the viewport width, so it
// stays readable inside narrow containers such as a page sidebar.
// The 576px threshold sits below the 632px default content width, so a
// regular page keeps the horizontal layout it has always had.
className="@container border-t border-b border-gray-100 @xl:py-6 not-first:-mt-px" // make multiple stack possible even across different siblings
>
<div className="flex flex-col md:flex-row print:flex-row print:gap-4">
<div className="flex flex-col @xl:flex-row print:flex-row print:gap-4">
{Children.map(children, (child) => child != null && renderChild(child))}
</div>
</div>
Expand Down
84 changes: 83 additions & 1 deletion packages/app-elements/src/ui/composite/PageLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import cn from "classnames"
import type { ReactNode } from "react"
import { useTokenProvider } from "#providers/TokenProvider"
import type { ContainerProps } from "#ui/atoms/Container"
Expand All @@ -17,6 +18,51 @@ export type PageLayoutProps = Pick<
* Page content
*/
children: ReactNode
/**
* Secondary content, rendered in a column beside `children` on large screens
* and stacked below it on smaller ones.
*
* Meant for details pages, where the supporting information of a resource
* (customer, addresses, tags, metadata, …) sits next to its main content.
* Only the structure is provided: wrap the content in a `Card` with a
* `Section` per block to get the look used by the dashboard.
*
* Best paired with `fullWidth`, since the default content width leaves too
* little room for two columns.
*
* @example
* ```jsx
* <PageLayout
* title='Order #1234'
* fullWidth
* sidebar={
* <Card>
* <Section title='Customer'>...</Section>
* <Section title='Addresses'>...</Section>
* </Card>
* }
* >
* <OrderSummary />
* </PageLayout>
* ```
*/
sidebar?: ReactNode
/**
* Tail of the main content, rendered below `children` on large screens and
* below the `sidebar` once the layout collapses to a single column.
*
* Use it for sections that should stay last no matter the width, such as a
* timeline: `children` and `sidebar` alone would push the sidebar to the very
* bottom of the page when stacked.
*
* @example
* ```jsx
* <PageLayout sidebar={<Card>…</Card>} afterSidebar={<Timeline />}>
* <OrderSummary />
* </PageLayout>
* ```
*/
afterSidebar?: ReactNode
/**
* When mode is `test`, it will render a `TEST DATA` Badge to inform user api is working in test mode.
* Only if app is standalone mode.
Expand Down Expand Up @@ -48,6 +94,8 @@ export const PageLayout = withSkeletonTemplate<PageLayoutProps>(
description,
navigationButton,
children,
sidebar,
afterSidebar,
toolbar,
mode,
gap,
Expand All @@ -66,6 +114,11 @@ export const PageLayout = withSkeletonTemplate<PageLayoutProps>(
const { overlayFooter, ...rest } =
"overlayFooter" in props ? props : { ...props, overlayFooter: undefined }

// `false` is what a `condition && <Section />` prop evaluates to, which is
// common while a resource is still loading: treat it as no content, so no
// empty grid row is created.
const hasAfterSidebar = afterSidebar != null && afterSidebar !== false

const component = (
<>
<PageHeading
Expand All @@ -85,7 +138,36 @@ export const PageLayout = withSkeletonTemplate<PageLayoutProps>(
isLoading={isLoading}
delayMs={delayMs}
/>
{children}
{sidebar == null ? (
<>
{children}
{afterSidebar}
</>
) : (
// A grid rather than two flex columns, so that `afterSidebar` can be
// placed under `children` on the left while the sidebar keeps its own
// column: stacked, the natural source order then reads
// children → sidebar → afterSidebar.
// `min-w-0` stops wide content (tables, code blocks) in the main column
// from pushing the sidebar out of the viewport.
<div className="grid lg:grid-cols-[minmax(0,1fr)_380px] lg:gap-x-8 print:block">
<div className="min-w-0 lg:col-start-1 lg:row-start-1">
{children}
</div>
<aside
className={cn("self-start lg:col-start-2 lg:row-start-1", {
"lg:row-span-2": hasAfterSidebar,
})}
>
{sidebar}
</aside>
{hasAfterSidebar && (
<div className="min-w-0 lg:col-start-1 lg:row-start-2">
{afterSidebar}
</div>
)}
</div>
)}
{scrollToTop === true && <ScrollToTop />}
</>
)
Expand Down
Loading