diff --git a/.changeset/27356-setup-lists-lead-with-labels.md b/.changeset/27356-setup-lists-lead-with-labels.md new file mode 100644 index 00000000000..08b866ab3b1 --- /dev/null +++ b/.changeset/27356-setup-lists-lead-with-labels.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(setup): metadata lists under Setup take their title from the sidebar, lead with the label, and keep rows short + +Clause-②: no +Metadata-protocol: none + +A metadata list under Setup (Positions, Permission Sets and the other catalog pages) titled itself with the type's singular label ("Position"), led each row with the API name, and let a long description wrap a row to several lines on a phone. Under Setup the page now takes its title from the app's own navigation entry that opens it, so it reads as the sidebar row that opened it ("Positions", in the served language), and falls back to the type's label when no entry opens exactly that page. Each row leads with the item's label, with its API name beneath it in small monospace type, in place of a separate Label column; an item with no label keeps its API name as the link. A description shows two lines at most, with the whole text on hover. The same page outside Setup is unchanged. diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.setupLabelFirst-27356.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.setupLabelFirst-27356.test.tsx new file mode 100644 index 00000000000..6d9725dcf8f --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.setupLabelFirst-27356.test.tsx @@ -0,0 +1,156 @@ +/** + * objectstack#27356 (row 5) — a metadata list under Setup reads as an admin + * list: the header names the page as the app's navigation entry that opens it + * does ("Positions", not the type's singular "Position"), each row leads with + * the item's label with the API name beneath it, and a description takes two + * lines at most, so a row stays one row on a phone. + * + * The same page outside Setup is the control: the API name column, the Label + * column and the type's label as the title, as before. + */ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, cleanup, screen, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +// The activation column waits on `audienceAnchorsOf`'s dynamic import of this +// exact specifier; loaded here so its cost lands in the import phase. +import '@objectstack/spec/identity'; + +const TYPE = 'position'; +const LONG = 'Leads the sales organization and gives the final sign-off on the largest deals of the quarter.'; +const ITEMS: Record[] = [ + { name: 'sales_director', label: 'Sales Director', description: LONG, _packageId: 'com.example.crm' }, + { name: 'unlabelled', _packageId: 'com.example.crm' }, +]; + +/** Stable singleton — the page's load effect keys on the client identity. */ +const CLIENT = { + list: async (type: string) => (type === TYPE ? ITEMS : []), +}; + +const ENTRY = { type: TYPE, label: 'Position', domain: 'security', allowEnvironmentOverlay: false, allowRuntimeCreate: true }; + +/** The served Setup app: its Access Control group names the list "Positions". */ +let APP: unknown = null; + +vi.mock('./useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => CLIENT, + useMetadataTypes: () => ({ loading: false, error: null, entries: [ENTRY] }), +})); + +vi.mock('./i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataLocale: () => 'en-US', +})); + +vi.mock('../../hooks/useCanAuthorMetadata', () => ({ + useCanAuthorMetadata: () => true, +})); + +vi.mock('../../hooks/useTenancyPosture', async (importOriginal) => ({ + ...(await importOriginal>()), + useTenancyPosture: () => 'single', +})); + +vi.mock('../../providers/ExpressionProvider', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useExpressionContext: () => ({ ...actual.useExpressionContext(), app: APP }) }; +}); + +// Imported AFTER the mocks so the page picks them up. +import { MetadataResourceListPage } from './ResourceListPage'; + +beforeEach(() => { + APP = { + name: 'setup', + navigation: [ + { + id: 'grp_access', + type: 'group', + label: 'Access Control', + children: [ + { id: 'nav_positions', type: 'url', label: 'Positions', url: '/apps/setup/metadata/position?scope=environment' }, + { id: 'nav_permission_sets', type: 'url', label: 'Permission Sets', url: '/apps/setup/metadata/permission?scope=environment' }, + ], + }, + ], + }; + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ records: [], hasMore: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function mount(appName: string) { + return render( + + + } /> + + , + ); +} + +const headers = () => Array.from(document.querySelectorAll('thead th')).map((th) => th.textContent); + +describe('a metadata list under Setup leads with labels (objectstack#27356 row 5)', () => { + it('titles the page as the navigation entry that opens it', async () => { + mount('setup'); + await screen.findByRole('link', { name: 'Sales Director' }); + const header = screen.getByTestId('metadata-list-setup-header'); + expect(within(header).getByRole('heading', { level: 1 }).textContent).toBe('Positions'); + }); + + it('falls back to the type label when no entry opens exactly this page', async () => { + APP = { name: 'setup', navigation: [{ id: 'nav_users', type: 'url', label: 'Users', url: '/apps/setup/sys_user' }] }; + mount('setup'); + await screen.findByRole('link', { name: 'Sales Director' }); + const header = screen.getByTestId('metadata-list-setup-header'); + expect(within(header).getByRole('heading', { level: 1 }).textContent).toBe('Position'); + }); + + it('leads each row with the label, the API name beneath it, and no separate Label column', async () => { + mount('setup'); + const link = await screen.findByRole('link', { name: 'Sales Director' }); + const row = screen.getByTestId('metadata-row-sales_director'); + expect(within(row).getByTestId('metadata-row-api-name').textContent).toBe('sales_director'); + expect(link).not.toHaveClass('font-mono'); + expect(headers()[0]).toBe('Label'); + expect(headers().filter((h) => h === 'Label')).toHaveLength(1); + expect(headers()).not.toContain('Name'); + + // An item with no label keeps its API name as the link. + const bare = screen.getByTestId('metadata-row-unlabelled'); + expect(within(bare).getByRole('link', { name: 'unlabelled' })).toHaveClass('font-mono'); + expect(within(bare).queryByTestId('metadata-row-api-name')).toBeNull(); + }); + + it('clamps a description to two lines and keeps the whole text on hover', async () => { + mount('setup'); + await screen.findByRole('link', { name: 'Sales Director' }); + const row = screen.getByTestId('metadata-row-sales_director'); + const clamp = within(row).getByText(LONG); + expect(clamp).toHaveClass('line-clamp-2'); + expect(clamp).toHaveAttribute('title', LONG); + }); + + it('CONTROL — outside Setup the page keeps the API name column, the Label column and the type title', async () => { + mount('studio'); + const link = await screen.findByRole('link', { name: 'sales_director' }); + expect(link).toHaveClass('font-mono'); + expect(headers().slice(0, 2)).toEqual(['Name', 'Label']); + expect(screen.queryAllByTestId('metadata-row-api-name')).toHaveLength(0); + expect(screen.getByText(LONG)).not.toHaveClass('line-clamp-2'); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.setupPosture-12606.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.setupPosture-12606.test.tsx index 55de0483764..6d304d02bec 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.setupPosture-12606.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.setupPosture-12606.test.tsx @@ -93,7 +93,8 @@ const FILTERED_TILE = t('engine.list.filtered', 'en-US'); describe('the metadata-type page under the Setup route (objectui#12606)', () => { it('renders no breadcrumb and no stat tiles, and says the count in the header’s description', async () => { const { container } = mount('setup'); - await screen.findByRole('link', { name: 'everyone' }); + // Under Setup a row's link is its label (objectstack#27356). + await screen.findByRole('link', { name: 'Everyone' }); const header = screen.getByTestId('metadata-list-setup-header'); expect(header.getAttribute('data-posture')).toBe('setup'); diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx index 039f53c000c..1abb0737508 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx @@ -13,7 +13,7 @@ */ import * as React from 'react'; -import { Link, Navigate, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Plus, Search, RefreshCw, AlertTriangle, Lock, Copy } from 'lucide-react'; import { Button, @@ -51,6 +51,9 @@ import { Empty, EmptyTitle, EmptyDescription, EmptyValue } from '@object-ui/comp import { PageShell, WritabilityBadge } from './PageShell.js'; import { PageHeader } from '../../layout/PageHeader.js'; import { useInSetupApp } from '../../console/setupApp.js'; +import { useExpressionContext } from '../../providers/ExpressionProvider.js'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { navEntryTitle } from './navEntryTitle.js'; import { getIcon } from '../../utils/getIcon.js'; import { MetadataTypeActions } from './MetadataTypeActions.js'; import { CreatePackageDialog } from './PackagesPage.js'; @@ -187,6 +190,11 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string // the description. Read off the route's `:appName`; the same page anywhere // else (the directory, Studio's "Open in Setup" target) keeps its shell. const inSetup = useInSetupApp(); + // objectstack#27356 — under Setup the header names the page as the sidebar + // entry that opens it does ("Positions"); the type's label otherwise. + const { app } = useExpressionContext(); + const location = useLocation(); + const { t: navT, language } = useObjectTranslation(); // objectui#12183 — under a wall the security catalog is the platform // operator's: the server refuses this caller every write of the type, // whatever it holds (`catalog-scope.ts`). Not keyed on the scope: the server's @@ -577,6 +585,13 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string config.bundleMemberField, ); const typeLabel = translateMetadataType(type, locale, entry?.label ?? type); + const setupTitle = + (inSetup && navEntryTitle(app, `/apps/${appName}`, location.pathname, location.search, navT, language)) || typeLabel; + // objectstack#27356 — under Setup the label leads the identity cell, with the + // API name beneath it, in place of a separate Label column. + const primaryKey = config.primaryKey ?? 'name'; + const labelColumn = inSetup && columns[0]?.key === primaryKey ? columns.find((c) => c.key === 'label') : undefined; + const shownColumns = labelColumn ? columns.filter((c) => c !== labelColumn) : columns; // The type offers SOME runtime write channel (the editors' type tier), and — // in the environment scope — the caller holds the capability the metadata // door requires. The server refuses either way; this is the half that says @@ -826,13 +841,13 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string } > - {columns.map((c) => ( + {shownColumns.map((c, ci) => ( - {localizeColumnLabel(c)} + {localizeColumnLabel(ci === 0 && labelColumn ? labelColumn : c)} ))} {t('engine.list.col.source', locale)} @@ -910,9 +925,11 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string HOVER_ROW_TINT, )} > - {columns.map((c, ci) => { + {shownColumns.map((c, ci) => { const value = row.item[c.key]; const cell = c.render ? c.render(value, row.item) : defaultCell(value); + const labelValue = labelColumn ? row.item[labelColumn.key] : undefined; + const hasLabel = labelValue != null && labelValue !== ''; return ( {ci === 0 ? ( @@ -935,12 +952,32 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string )} - - {cell} - + {labelColumn && hasLabel ? ( + + + {labelColumn.render ? labelColumn.render(labelValue, row.item) : defaultCell(labelValue)} + + + {cell} + + + ) : ( + + {cell} + + )} + + ) : inSetup && c.key === 'description' && typeof value === 'string' && value !== '' ? ( + // objectstack#27356 — two lines at most, so a row + // stays a row on a phone; the whole text on hover. + + {cell} ) : ( cell @@ -1068,7 +1105,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string return (
string, + locale: string | undefined, +): string | undefined { + const navigation = app && typeof app === 'object' ? (app as { navigation?: unknown }).navigation : undefined; + if (!Array.isArray(navigation)) return undefined; + const item = resolveActiveNavItem(navigation as NavigationItem[], pathname, search, basePath); + if (!item || resolveHref(item, basePath).href.split(/[?#]/)[0] !== pathname) return undefined; + const label = resolveNavItemLabel(item, t, undefined, locale); + return label.trim() !== '' ? label : undefined; +}