Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/history-back-to-item-28626.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
useMetadataClient: () => client,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'datasource', label: 'Datasource', domain: 'system' }],
}),
}));
vi.mock('../metadata-admin/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadataLocale: () => 'en-US',
}));

import { MetadataResourceHistoryPage } from './ResourceHistoryPage';

afterEach(() => {
cleanup();
vi.clearAllMocks();
});

function Here() {
const { pathname, search } = useLocation();
return <output data-testid="here">{`${pathname}${search}`}</output>;
}

const item = <div data-testid="item-page" />;
const history = <MetadataResourceHistoryPage />;

/** `AppContent`'s branch with an active app: the history route nests in `metadata`. */
function WithApp() {
return (
<Routes>
<Route path="metadata">
<Route path=":type" element={<div data-testid="type-list" />} />
<Route path=":type/:name" element={item} />
<Route path=":type/:name/history" element={history} />
</Route>
</Routes>
);
}

/** `AppContent`'s branch with no app: one flat route per page. */
function NoApp() {
return (
<Routes>
<Route path="metadata/:type" element={<div data-testid="type-list" />} />
<Route path="metadata/:type/:name" element={item} />
<Route path="metadata/:type/:name/history" element={history} />
</Routes>
);
}

function renderUnderApps(branch: React.ReactNode, url: string) {
return render(
<MemoryRouter initialEntries={[url]}>
<Here />
<Routes>
<Route path="/apps/:appName/*" element={branch} />
<Route path="/studio/*" element={<div data-testid="studio" />} />
</Routes>
</MemoryRouter>,
);
}

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(<WithApp />, '/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(<NoApp />, '/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 [<WithApp key="with" />, <NoApp key="none" />]) {
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(<WithApp />, '/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(
<Routes>
<Route path="component/:ns/:name/*" element={<MetadataResourceHistoryPage type="datasource" name="warehouse" />} />
</Routes>,
'/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(
<MetadataAreaHostContext.Provider value={host}>
<MemoryRouter initialEntries={['/studio/com.example.crm/metadata/flow/lead_route/history?package=com.example.crm']}>
<Here />
<Routes>
<Route path="/studio/:pkg/metadata">
<Route path=":type/:name" element={item} />
<Route path=":type/:name/history" element={history} />
</Route>
</Routes>
</MemoryRouter>
</MetadataAreaHostContext.Provider>,
);
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$/,
);
});
});
16 changes: 11 additions & 5 deletions ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<PageShell
Expand All @@ -110,7 +116,7 @@ export function MetadataResourceHistoryPage({
<Button
variant="ghost"
size="sm"
onClick={() => navigate(backToItem)}
onClick={goBackToItem}
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to item
Expand Down
Loading