From 6108d08ce0027d75b58020adb15e3c02a9a0efae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:10:32 +0000 Subject: [PATCH 1/2] fix(app-shell): metadata history Back to item opens the item under every route nesting Outside Studio's "All metadata" area the history page built its back link as a route-relative `../NAME`, which drops the whole matched route: with an active app it landed on the `:type` list named after the item, with no app on a path at the app root, and it dropped the query. Resolve `..` against the URL path instead and keep the search, so the link opens .../metadata/TYPE/NAME with ?package= and ?scope= kept. The Studio area link is unchanged. Claude-Session: https://claude.ai/code/session_01QsR2jSipqJ9BXYCu6y2jgs Co-authored-by: Claude --- .changeset/history-back-to-item-28626.md | 8 + ...ourceHistoryPage.backToItem-28626.test.tsx | 164 ++++++++++++++++++ .../views/item-tools/ResourceHistoryPage.tsx | 12 +- 3 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 .changeset/history-back-to-item-28626.md create mode 100644 ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.backToItem-28626.test.tsx diff --git a/.changeset/history-back-to-item-28626.md b/.changeset/history-back-to-item-28626.md new file mode 100644 index 00000000000..885d55cfbe9 --- /dev/null +++ b/.changeset/history-back-to-item-28626.md @@ -0,0 +1,8 @@ +--- +'@object-ui/app-shell': patch +--- + +Clause-②: no +Metadata-protocol: none + +A metadata item's history page now opens the item again from **Back to item**. Outside Studio's "All metadata" area (Setup's datasource history, with or without an active app, and the exported `MetadataResourceRouter`) the link used to resolve against the matched route, so it landed on a type list named after the item, or on a path at the app root, and dropped the query. It now opens `…/metadata/TYPE/NAME` with the query the page was opened with (`?package=`, `?scope=environment`). The Studio area's link is unchanged. diff --git a/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.backToItem-28626.test.tsx b/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.backToItem-28626.test.tsx new file mode 100644 index 00000000000..1538096374d --- /dev/null +++ b/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.backToItem-28626.test.tsx @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28626 — the routed history page's "Back to item" opens the item + * it is the history of, `…/metadata/TYPE/NAME`, with the query the item page + * reads (`?package=`, `?scope=environment`), under every route nesting that + * still mounts the page. + * + * Setup keeps one metadata type on its own pages, the datasource (ADR-0146 + * D1, `SETUP_KEPT_METADATA_TYPE`), and `AppContent` mounts its history page + * under two nestings: `/apps/:appName/*` → `metadata` → `:type/:name/history` + * with an active app, and `/apps/:appName/*` → `metadata/:type/:name/history` + * with none. The exported `MetadataResourceRouter` mounts it under a splat, + * `component/:ns/:name/*`, with the type in `?type=`. Under Studio's "All + * metadata" area the link is built from the area's root and is the control. + * + * Real: the page and React Router. Stubbed: the metadata client and types. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { MetadataAreaHostContext, type MetadataAreaHost } from '../metadata-admin/metadataAreaHost'; + +const client = { history: vi.fn(async () => ({ events: [] })) }; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => client, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [{ type: 'datasource', label: 'Datasource', domain: 'system' }], + }), +})); +vi.mock('../metadata-admin/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataLocale: () => 'en-US', +})); + +import { MetadataResourceHistoryPage } from './ResourceHistoryPage'; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function Here() { + const { pathname, search } = useLocation(); + return {`${pathname}${search}`}; +} + +const item =
; +const history = ; + +/** `AppContent`'s branch with an active app: the history route nests in `metadata`. */ +function WithApp() { + return ( + + + } /> + + + + + ); +} + +/** `AppContent`'s branch with no app: one flat route per page. */ +function NoApp() { + return ( + + } /> + + + + ); +} + +function renderUnderApps(branch: React.ReactNode, url: string) { + return render( + + + + + } /> + + , + ); +} + +const backToItem = () => fireEvent.click(screen.getByRole('button', { name: /Back to item/ })); + +describe('"Back to item" on the routed history page (objectstack#28626)', () => { + it('with an active app it opens the item, not a type list named after it', async () => { + renderUnderApps(, '/apps/setup/metadata/datasource/warehouse/history'); + backToItem(); + expect(await screen.findByTestId('item-page')).toBeInTheDocument(); + expect(screen.getByTestId('here')).toHaveTextContent(/^\/apps\/setup\/metadata\/datasource\/warehouse$/); + }); + + it('with no app it opens the item, not a root path named after it', async () => { + renderUnderApps(, '/apps/setup/metadata/datasource/warehouse/history'); + backToItem(); + expect(await screen.findByTestId('item-page')).toBeInTheDocument(); + expect(screen.getByTestId('here')).toHaveTextContent(/^\/apps\/setup\/metadata\/datasource\/warehouse$/); + }); + + it('keeps the query the item page reads, under both nestings', async () => { + for (const branch of [, ]) { + renderUnderApps(branch, '/apps/crm/metadata/datasource/warehouse/history?package=com.example.crm&scope=environment'); + backToItem(); + expect(await screen.findByTestId('item-page')).toBeInTheDocument(); + expect(screen.getByTestId('here')).toHaveTextContent( + /^\/apps\/crm\/metadata\/datasource\/warehouse\?package=com\.example\.crm&scope=environment$/, + ); + cleanup(); + } + }); + + it('keeps an encoded item name encoded', async () => { + renderUnderApps(, '/apps/setup/metadata/datasource/sales%20db/history'); + backToItem(); + expect(await screen.findByTestId('item-page')).toBeInTheDocument(); + expect(screen.getByTestId('here')).toHaveTextContent(/^\/apps\/setup\/metadata\/datasource\/sales%20db$/); + }); + + it('under the exported resource router (a splat, type in the query) it opens the item with its ?type=', async () => { + renderUnderApps( + + } /> + , + '/apps/crm/component/metadata/resource/warehouse/history?type=datasource', + ); + backToItem(); + expect(screen.getByTestId('here')).toHaveTextContent( + /^\/apps\/crm\/component\/metadata\/resource\/warehouse\?type=datasource$/, + ); + }); + + it('under Studio\'s "All metadata" area it still opens the area\'s item page (control)', async () => { + const host: MetadataAreaHost = { packageId: 'com.example.crm', readOnly: false, basePath: '/studio/com.example.crm/metadata' }; + render( + + + + + + + + + + + , + ); + backToItem(); + expect(await screen.findByTestId('item-page')).toBeInTheDocument(); + expect(screen.getByTestId('here')).toHaveTextContent( + /^\/studio\/com\.example\.crm\/metadata\/flow\/lead_route\?package=com\.example\.crm$/, + ); + }); +}); diff --git a/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx b/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx index 4155b148f5b..5add6677eea 100644 --- a/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx +++ b/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx @@ -95,9 +95,13 @@ export function MetadataResourceHistoryPage({ // item page with the query it came with (its `?package=` and scope). const areaHost = useMetadataAreaHost(); const { search } = useLocation(); - const backToItem = areaHost - ? `${areaHost.basePath}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${search}` - : `../${encodeURIComponent(name)}`; + // Anywhere else the item page is this URL minus its `history` segment, + // with the same query, under whatever route nesting mounted the page: a + // `..` resolved against the URL path, never against the matched route. + const goBackToItem = () => + areaHost + ? navigate(`${areaHost.basePath}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${search}`) + : navigate({ pathname: '..', search }, { relative: 'path' }); return ( navigate(backToItem)} + onClick={goBackToItem} > Back to item From da4f9be7ea498bdea5e5aae725ebbb082ce3d276 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:12:37 +0000 Subject: [PATCH 2/2] fix(app-shell): cut the history segment from the URL as spelled A relative `..` resolved against the URL path still hands back the item name decoded, because React Router resolves against the decoded match pathname. Drop the URL's last segment from location.pathname instead, so an encoded name stays encoded. Claude-Session: https://claude.ai/code/session_01QsR2jSipqJ9BXYCu6y2jgs Co-authored-by: Claude --- .../src/views/item-tools/ResourceHistoryPage.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx b/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx index 5add6677eea..04c56a1ec46 100644 --- a/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx +++ b/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx @@ -94,14 +94,16 @@ export function MetadataResourceHistoryPage({ // read-only package offers no rollback, and "Back to item" names the area's // item page with the query it came with (its `?package=` and scope). const areaHost = useMetadataAreaHost(); - const { search } = useLocation(); - // Anywhere else the item page is this URL minus its `history` segment, - // with the same query, under whatever route nesting mounted the page: a - // `..` resolved against the URL path, never against the matched route. + const { pathname, search } = useLocation(); + // Anywhere else the item page is this URL minus its last (`history`) + // segment, with the same query, under whatever route nesting mounted the + // page. Cut from the URL as it is spelled, never resolved against the + // matched route: that drops the whole route, and a relative `..` hands back + // the item name decoded. const goBackToItem = () => areaHost ? navigate(`${areaHost.basePath}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${search}`) - : navigate({ pathname: '..', search }, { relative: 'path' }); + : navigate({ pathname: pathname.replace(/\/[^/]+\/?$/, ''), search }); return (