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..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,10 +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(); - const backToItem = areaHost - ? `${areaHost.basePath}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${search}` - : `../${encodeURIComponent(name)}`; + 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: pathname.replace(/\/[^/]+\/?$/, ''), search }); return ( navigate(backToItem)} + onClick={goBackToItem} > Back to item