From cebf1803194afc4f683d460b3d471800c81235af Mon Sep 17 00:00:00 2001 From: Matteo Alessani Date: Thu, 6 Aug 2026 19:17:02 +0200 Subject: [PATCH 1/2] feat: add sidebar and afterSidebar prop to layout --- packages/app-elements/src/ui/atoms/Stack.tsx | 11 ++- .../src/ui/composite/PageLayout.tsx | 84 ++++++++++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/packages/app-elements/src/ui/atoms/Stack.tsx b/packages/app-elements/src/ui/atoms/Stack.tsx index bb1f79975..3990396e0 100644 --- a/packages/app-elements/src/ui/atoms/Stack.tsx +++ b/packages/app-elements/src/ui/atoms/Stack.tsx @@ -6,7 +6,7 @@ export interface StackProps { function renderChild(child: ReactNode): JSX.Element { return ( -
+
{child}
) @@ -16,9 +16,14 @@ function Stack({ children, ...props }: StackProps): JSX.Element { return (
-
+
{Children.map(children, (child) => child != null && renderChild(child))}
diff --git a/packages/app-elements/src/ui/composite/PageLayout.tsx b/packages/app-elements/src/ui/composite/PageLayout.tsx index 570e8c7e3..3ac8a4b97 100644 --- a/packages/app-elements/src/ui/composite/PageLayout.tsx +++ b/packages/app-elements/src/ui/composite/PageLayout.tsx @@ -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" @@ -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 + * + *
...
+ *
...
+ * + * } + * > + * + *
+ * ``` + */ + 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 + * …} afterSidebar={}> + * + * + * ``` + */ + 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. @@ -48,6 +94,8 @@ export const PageLayout = withSkeletonTemplate( description, navigationButton, children, + sidebar, + afterSidebar, toolbar, mode, gap, @@ -66,6 +114,11 @@ export const PageLayout = withSkeletonTemplate( const { overlayFooter, ...rest } = "overlayFooter" in props ? props : { ...props, overlayFooter: undefined } + // `false` is what a `condition &&
` 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 = ( <> ( 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. +
+
+ {children} +
+ + {hasAfterSidebar && ( +
+ {afterSidebar} +
+ )} +
+ )} {scrollToTop === true && } ) From f94e55a1ba35fa2883aacd0d7152e25b876cb480 Mon Sep 17 00:00:00 2001 From: Matteo Alessani Date: Fri, 7 Aug 2026 15:14:30 +0200 Subject: [PATCH 2/2] fix: use right back to list url --- .../app-elements/src/helpers/useAppLinking.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/app-elements/src/helpers/useAppLinking.ts b/packages/app-elements/src/helpers/useAppLinking.ts index 35283a336..ec7fb3318 100644 --- a/packages/app-elements/src/helpers/useAppLinking.ts +++ b/packages/app-elements/src/helpers/useAppLinking.ts @@ -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" @@ -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` @@ -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) }