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
10 changes: 10 additions & 0 deletions .changeset/27356-setup-lists-lead-with-labels.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[] = [
{ 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<Record<string, unknown>>()),
useMetadataClient: () => CLIENT,
useMetadataTypes: () => ({ loading: false, error: null, entries: [ENTRY] }),
}));

vi.mock('./i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadataLocale: () => 'en-US',
}));

vi.mock('../../hooks/useCanAuthorMetadata', () => ({
useCanAuthorMetadata: () => true,
}));

vi.mock('../../hooks/useTenancyPosture', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useTenancyPosture: () => 'single',
}));

vi.mock('../../providers/ExpressionProvider', async (importOriginal) => {
const actual = await importOriginal<Record<string, any>>();
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(
<MemoryRouter initialEntries={[`/apps/${appName}/metadata/${TYPE}?scope=environment`]}>
<Routes>
<Route path="/apps/:appName/metadata/:type" element={<MetadataResourceListPage />} />
</Routes>
</MemoryRouter>,
);
}

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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
59 changes: 48 additions & 11 deletions ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -826,13 +841,13 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
}
>
<tr className={HEAD_ROW_TINT}>
{columns.map((c) => (
{shownColumns.map((c, ci) => (
<th
key={c.key}
className={cn('px-3 text-left font-medium', inSetup ? 'h-9 py-0' : 'py-2')}
style={c.width ? { width: c.width } : undefined}
>
{localizeColumnLabel(c)}
{localizeColumnLabel(ci === 0 && labelColumn ? labelColumn : c)}
</th>
))}
<th className={cn('px-3 text-right font-medium w-[80px]', inSetup ? 'h-9 py-0' : 'py-2')}>{t('engine.list.col.source', locale)}</th>
Expand Down Expand Up @@ -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 (
<td key={c.key} className={cellClass}>
{ci === 0 ? (
Expand All @@ -935,12 +952,32 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
<AlertTriangle className="w-3.5 h-3.5 text-amber-600 dark:text-amber-400 shrink-0" />
</span>
)}
<Link
to={rowHref}
className="text-primary hover:underline font-mono"
>
{cell}
</Link>
{labelColumn && hasLabel ? (
<span className="min-w-0">
<Link to={rowHref} className="text-primary hover:underline">
{labelColumn.render ? labelColumn.render(labelValue, row.item) : defaultCell(labelValue)}
</Link>
<span
className="block font-mono text-xs text-muted-foreground"
data-testid="metadata-row-api-name"
>
{cell}
</span>
</span>
) : (
<Link
to={rowHref}
className="text-primary hover:underline font-mono"
>
{cell}
</Link>
)}
</span>
) : 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.
<span className="line-clamp-2" title={value}>
{cell}
</span>
) : (
cell
Expand Down Expand Up @@ -1068,7 +1105,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
return (
<div className="flex flex-col h-full overflow-hidden">
<PageHeader
title={typeLabel}
title={setupTitle}
// `createElement`, not a JSX tag: the looked-up icon is a value
// (the convention ObjectView set).
icon={React.createElement(getIcon(config.iconName ?? 'layers'), { className: 'h-4 w-4' })}
Expand Down
31 changes: 31 additions & 0 deletions ui/packages/app-shell/src/views/metadata-admin/navEntryTitle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The title a Setup list page takes from the app's own navigation
* (objectstack#27356, row 5): the label of the entry that opens exactly this
* page, so the page reads "Positions" as the sidebar row that opened it does,
* in the served language, instead of the type registry's singular label.
*
* The entry is elected by `resolveActiveNavItem`, the one "which menu am I in"
* matcher, and counts only when its own address is this page's path: an entry
* that claims the page by prefix names a different page.
*/

import { resolveActiveNavItem, resolveHref, resolveNavItemLabel } from '@object-ui/layout';
import type { NavigationItem } from '@object-ui/types';

export function navEntryTitle(
app: unknown,
basePath: string,
pathname: string,
search: string,
t: (key: string, options?: any) => 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;
}
Loading