|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectstack#26889 — the Interfaces pillar lists a package's views, pages, |
| 5 | + * dashboards and reports whether or not a navigation entry names them, and a |
| 6 | + * package with no app still lists what it ships. |
| 7 | + * |
| 8 | + * The rail used to be the app's navigation tree and nothing else, so an item |
| 9 | + * no entry opens (a list view off the menu, a record page, a module package's |
| 10 | + * dashboard) could not be opened in Studio at all, and a package with no app |
| 11 | + * showed only its "no app yet" notice. Under the tree there is now one section |
| 12 | + * per type with the items no entry opens; each row opens its item in the |
| 13 | + * pillar's canvas, read-only on a read-only package. |
| 14 | + * |
| 15 | + * - an item a nav entry opens keeps its tree row and is not listed again (a |
| 16 | + * page / dashboard entry's target, and the view an `object` entry's |
| 17 | + * `viewName` names); |
| 18 | + * - a draft-only item is listed, by the label its `_drafts` row carries; |
| 19 | + * - the aggregated view container the server keeps for dual-read is not an |
| 20 | + * item (Setup › Metadata leaves it out the same way); |
| 21 | + * - the "no app yet" notice stays, with the sections under it. |
| 22 | + */ |
| 23 | +import '@testing-library/jest-dom/vitest'; |
| 24 | +import * as React from 'react'; |
| 25 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 26 | +import { act, render, screen, fireEvent, cleanup, within } from '@testing-library/react'; |
| 27 | +import { MemoryRouter } from 'react-router-dom'; |
| 28 | + |
| 29 | +const PKG = 'com.acme.crm'; |
| 30 | + |
| 31 | +const NAV = [ |
| 32 | + { id: 'nav_home', type: 'page', label: 'Home menu', pageName: 'home' }, |
| 33 | + { id: 'nav_ops', type: 'dashboard', label: 'Ops board', dashboardName: 'ops' }, |
| 34 | + { id: 'nav_my_leads', type: 'object', label: 'My leads', objectName: 'crm_lead', viewName: 'mine' }, |
| 35 | +]; |
| 36 | + |
| 37 | +/** What the mocked server holds, per metadata type: published rows and `_drafts` headers. */ |
| 38 | +const server = vi.hoisted(() => ({ |
| 39 | + published: new Map<string, Array<Record<string, unknown>>>(), |
| 40 | + drafts: new Map<string, Array<Record<string, unknown>>>(), |
| 41 | +})); |
| 42 | + |
| 43 | +const mockClient = vi.hoisted(() => ({ |
| 44 | + list: vi.fn(async (type: string) => server.published.get(type) ?? []), |
| 45 | + listDrafts: vi.fn(async (opts?: { type?: string }) => server.drafts.get(opts?.type ?? '') ?? []), |
| 46 | + listTypes: vi.fn(async () => ({ entries: [] })), |
| 47 | + get: vi.fn(async () => null), |
| 48 | + references: vi.fn(async () => []), |
| 49 | + layered: vi.fn(async (type: string, name: string) => { |
| 50 | + const eff = (server.published.get(type) ?? []).find((row) => row.name === name) ?? null; |
| 51 | + return { code: null, overlay: eff, overlayScope: eff ? 'env' : null, effective: eff, editable: true, deletable: true, resettable: false, lock: 'none' }; |
| 52 | + }), |
| 53 | + getDraft: vi.fn(async (type: string, name: string) => { |
| 54 | + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); |
| 55 | + }), |
| 56 | + save: vi.fn(async (type: string, name: string, item: unknown) => ({ type, name, item })), |
| 57 | + publish: vi.fn(async () => ({ success: true })), |
| 58 | + reset: vi.fn(async () => ({})), |
| 59 | +})); |
| 60 | + |
| 61 | +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { |
| 62 | + const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>(); |
| 63 | + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; |
| 64 | +}); |
| 65 | + |
| 66 | +vi.mock('@object-ui/react', async (importOriginal) => { |
| 67 | + const mod = await importOriginal<typeof import('@object-ui/react')>(); |
| 68 | + return { ...mod, useAdapter: () => ({}) }; |
| 69 | +}); |
| 70 | + |
| 71 | +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), dismiss: vi.fn() } })); |
| 72 | + |
| 73 | +// The `view` resource's list filter (the aggregated container is no item) is |
| 74 | +// registered here, as the console registers it. |
| 75 | +import '../../services/builtinComponents'; |
| 76 | +import { InterfacesPillar } from './StudioDesignSurface'; |
| 77 | +import { registerMetadataPreview, type MetadataPreviewProps } from '../metadata-admin/preview-registry'; |
| 78 | +import { STUDIO_RAIL_ROW_ACTIVE } from './studio-tokens'; |
| 79 | + |
| 80 | +vi.stubGlobal( |
| 81 | + 'fetch', |
| 82 | + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), |
| 83 | +); |
| 84 | + |
| 85 | +/** A canvas that says what it was opened on, and whether it may write. */ |
| 86 | +function StubPreview({ type, name, onPatch }: MetadataPreviewProps) { |
| 87 | + return ( |
| 88 | + <div data-testid="stub-canvas" data-writable={onPatch ? 'yes' : 'no'}> |
| 89 | + {`${type}:${name}`} |
| 90 | + </div> |
| 91 | + ); |
| 92 | +} |
| 93 | +for (const type of ['page', 'dashboard', 'report', 'view']) registerMetadataPreview(type, StubPreview); |
| 94 | + |
| 95 | +const page = (name: string, label: string, extra: Record<string, unknown> = {}) => ({ name, label, type: 'app', ...extra }); |
| 96 | + |
| 97 | +function seedWithApp() { |
| 98 | + server.published.set('app', [{ name: 'crm_app', label: 'CRM', navigation: NAV }]); |
| 99 | + server.published.set('page', [page('home', 'Home'), page('lead_detail', 'Lead Detail', { type: 'record', object: 'crm_lead' })]); |
| 100 | + server.published.set('dashboard', [ |
| 101 | + { name: 'ops', label: 'Ops' }, |
| 102 | + { name: 'sales_overview', label: 'Sales Overview' }, |
| 103 | + ]); |
| 104 | + server.published.set('report', [{ name: 'pipeline', label: 'Pipeline' }]); |
| 105 | + server.published.set('view', [ |
| 106 | + { name: 'crm_lead.mine', label: 'Mine', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } }, |
| 107 | + { name: 'crm_lead.hot', label: 'Hot Leads', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } }, |
| 108 | + // The container the server keeps beside the expanded items: no item of its own. |
| 109 | + { name: 'crm_lead', object: 'crm_lead', listViews: { hot: { columns: ['name'] } } }, |
| 110 | + ]); |
| 111 | + server.drafts.set('page', [{ name: 'welcome', label: 'Welcome', packageId: PKG }]); |
| 112 | +} |
| 113 | + |
| 114 | +function seedNoApp() { |
| 115 | + server.published.set('dashboard', [{ name: 'service_overview', label: 'Service Overview' }]); |
| 116 | + server.published.set('page', [page('case_detail', 'Case Detail', { type: 'record', object: 'crm_case' })]); |
| 117 | +} |
| 118 | + |
| 119 | +beforeEach(() => { |
| 120 | + server.published.clear(); |
| 121 | + server.drafts.clear(); |
| 122 | + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); |
| 123 | +}); |
| 124 | + |
| 125 | +afterEach(cleanup); |
| 126 | + |
| 127 | +function renderPillar(props: { readOnly?: boolean } = {}) { |
| 128 | + render( |
| 129 | + <MemoryRouter initialEntries={[`/studio/${PKG}/interfaces`]}> |
| 130 | + <InterfacesPillar packageId={PKG} packageLabel="Acme CRM" {...props} /> |
| 131 | + </MemoryRouter>, |
| 132 | + ); |
| 133 | +} |
| 134 | + |
| 135 | +/** The sections under the tree, once the package's items are read. */ |
| 136 | +async function offNav(): Promise<HTMLElement> { |
| 137 | + return screen.findByTestId('if-off-nav', undefined, { timeout: 8000 }); |
| 138 | +} |
| 139 | + |
| 140 | +/** Every rail row's `type · name`, in rail order. */ |
| 141 | +function railTitles(scope: HTMLElement): string[] { |
| 142 | + return within(scope) |
| 143 | + .getAllByRole('button') |
| 144 | + .map((b) => b.getAttribute('title') ?? '') |
| 145 | + .filter((title) => title.includes(' · ')); |
| 146 | +} |
| 147 | + |
| 148 | +describe('objectstack#26889 — Interfaces lists what no navigation entry names', () => { |
| 149 | + it('lists the views, pages, dashboards and reports no entry opens, one section per type', async () => { |
| 150 | + seedWithApp(); |
| 151 | + renderPillar(); |
| 152 | + const sections = await offNav(); |
| 153 | + expect(within(sections).getByTestId('if-off-nav-page')).toBeInTheDocument(); |
| 154 | + expect(railTitles(sections)).toEqual([ |
| 155 | + 'page · lead_detail', |
| 156 | + 'page · welcome', |
| 157 | + 'dashboard · sales_overview', |
| 158 | + 'report · pipeline', |
| 159 | + 'view · crm_lead.hot', |
| 160 | + ]); |
| 161 | + // A draft-only page reads as the label its `_drafts` row carries. |
| 162 | + expect(within(sections).getByRole('button', { name: 'Welcome' })).toHaveAttribute('title', 'page · welcome'); |
| 163 | + // Each type was read for this package, published and draft. |
| 164 | + for (const type of ['view', 'page', 'dashboard', 'report']) { |
| 165 | + expect(mockClient.list).toHaveBeenCalledWith(type, { packageId: PKG }); |
| 166 | + expect(mockClient.listDrafts).toHaveBeenCalledWith({ packageId: PKG, type }); |
| 167 | + } |
| 168 | + }); |
| 169 | + |
| 170 | + it('an item a nav entry opens is listed once, in the tree, and never again', async () => { |
| 171 | + seedWithApp(); |
| 172 | + renderPillar(); |
| 173 | + const sections = await offNav(); |
| 174 | + const rail = sections.closest('nav') as HTMLElement; |
| 175 | + const titles = railTitles(rail); |
| 176 | + expect(titles.filter((t) => t === 'page · home')).toHaveLength(1); |
| 177 | + expect(titles.filter((t) => t === 'dashboard · ops')).toHaveLength(1); |
| 178 | + // The view the `My leads` entry names (`viewName: 'mine'`) is that entry's row. |
| 179 | + expect(titles).not.toContain('view · crm_lead.mine'); |
| 180 | + expect(within(sections).queryByText('Home')).toBeNull(); |
| 181 | + expect(within(sections).queryByText('Mine')).toBeNull(); |
| 182 | + // The aggregated container is not listed as a view. |
| 183 | + expect(titles).not.toContain('view · crm_lead'); |
| 184 | + }); |
| 185 | + |
| 186 | + it('a view, a record page and a dashboard no entry names each open in the canvas', async () => { |
| 187 | + seedWithApp(); |
| 188 | + renderPillar(); |
| 189 | + const sections = await offNav(); |
| 190 | + for (const [label, opened] of [ |
| 191 | + ['Hot Leads', 'view:crm_lead.hot'], |
| 192 | + ['Lead Detail', 'page:lead_detail'], |
| 193 | + ['Sales Overview', 'dashboard:sales_overview'], |
| 194 | + ] as const) { |
| 195 | + fireEvent.click(within(sections).getByRole('button', { name: label })); |
| 196 | + expect(await screen.findByText(opened, undefined, { timeout: 8000 })).toBeInTheDocument(); |
| 197 | + expect(screen.getByTestId('if-breadcrumb')).toHaveTextContent(label); |
| 198 | + // The row it was opened from is the active one, and only it. |
| 199 | + const active = within(sections).getAllByRole('button').filter((b) => b.className.includes(STUDIO_RAIL_ROW_ACTIVE)); |
| 200 | + expect(active.map((b) => b.textContent)).toEqual([label]); |
| 201 | + } |
| 202 | + }); |
| 203 | + |
| 204 | + it('a package with no app keeps its notice and lists its dashboard and page, which open', async () => { |
| 205 | + seedNoApp(); |
| 206 | + renderPillar(); |
| 207 | + const sections = await offNav(); |
| 208 | + expect(screen.getByText('This package has no app yet.')).toBeInTheDocument(); |
| 209 | + expect(screen.getByText('This package has no app yet')).toBeInTheDocument(); |
| 210 | + expect(railTitles(sections)).toEqual(['page · case_detail', 'dashboard · service_overview']); |
| 211 | + |
| 212 | + fireEvent.click(within(sections).getByRole('button', { name: 'Service Overview' })); |
| 213 | + expect(await screen.findByText('dashboard:service_overview', undefined, { timeout: 8000 })).toBeInTheDocument(); |
| 214 | + // The canvas shows the item now, not the empty state; the rail keeps its notice. |
| 215 | + expect(screen.queryByText('This package has no app yet')).toBeNull(); |
| 216 | + expect(screen.getByText('This package has no app yet.')).toBeInTheDocument(); |
| 217 | + |
| 218 | + fireEvent.click(within(sections).getByRole('button', { name: 'Case Detail' })); |
| 219 | + expect(await screen.findByText('page:case_detail', undefined, { timeout: 8000 })).toBeInTheDocument(); |
| 220 | + }); |
| 221 | + |
| 222 | + it('a read-only package lists them too, and opens them read-only', async () => { |
| 223 | + seedNoApp(); |
| 224 | + renderPillar({ readOnly: true }); |
| 225 | + const sections = await offNav(); |
| 226 | + fireEvent.click(within(sections).getByRole('button', { name: 'Service Overview' })); |
| 227 | + const canvas = await screen.findByTestId('stub-canvas', undefined, { timeout: 8000 }); |
| 228 | + expect(canvas).toHaveTextContent('dashboard:service_overview'); |
| 229 | + expect(canvas).toHaveAttribute('data-writable', 'no'); |
| 230 | + expect(mockClient.save).not.toHaveBeenCalled(); |
| 231 | + }); |
| 232 | + |
| 233 | + it('CONTROL: a writable package opens them writable', async () => { |
| 234 | + seedNoApp(); |
| 235 | + renderPillar(); |
| 236 | + const sections = await offNav(); |
| 237 | + fireEvent.click(within(sections).getByRole('button', { name: 'Service Overview' })); |
| 238 | + expect(await screen.findByTestId('stub-canvas', undefined, { timeout: 8000 })).toHaveAttribute('data-writable', 'yes'); |
| 239 | + }); |
| 240 | + |
| 241 | + it('CONTROL: a package whose every item an entry opens draws no sections', async () => { |
| 242 | + server.published.set('app', [{ name: 'crm_app', label: 'CRM', navigation: NAV }]); |
| 243 | + server.published.set('page', [page('home', 'Home')]); |
| 244 | + server.published.set('dashboard', [{ name: 'ops', label: 'Ops' }]); |
| 245 | + renderPillar(); |
| 246 | + await screen.findByRole('button', { name: 'Home menu Page' }, { timeout: 8000 }); |
| 247 | + await vi.waitFor(() => expect(mockClient.listDrafts).toHaveBeenCalledWith({ packageId: PKG, type: 'view' })); |
| 248 | + // Every read settled, and what it answered rendered. |
| 249 | + await act(async () => { |
| 250 | + await Promise.all(mockClient.listDrafts.mock.results.map((r) => r.value)); |
| 251 | + await new Promise((r) => setTimeout(r, 50)); |
| 252 | + }); |
| 253 | + expect(screen.queryByTestId('if-off-nav')).toBeNull(); |
| 254 | + // CONTROL of the control: the same package with one more dashboard lists it. |
| 255 | + cleanup(); |
| 256 | + server.published.set('dashboard', [{ name: 'ops', label: 'Ops' }, { name: 'extra', label: 'Extra' }]); |
| 257 | + renderPillar(); |
| 258 | + expect(railTitles(await offNav())).toEqual(['dashboard · extra']); |
| 259 | + }); |
| 260 | +}); |
0 commit comments