Skip to content
Closed
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
32 changes: 31 additions & 1 deletion apps/docs/docs/framework/admin-ui/perspectives.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ The perspectives sidebar stays the default save path, and the button is off by d
| Prop | Purpose |
|------|---------|
| `showSaveViewButton` | Renders the built-in “Save view” button next to the views switcher. Off by default; the recommended path. |
| `viewApiRef` | Imperative handle: `getCurrentSettings()`, `getDirtyState()`, `saveCurrentView(input?)`, `openViewsSidebar()`. For hosts that render their own control. |
| `viewApiRef` | Imperative handle: `getCurrentSettings()`, `getDirtyState()`, `saveCurrentView(input?)`, `openViewsSidebar()`, `activatePerspective(id)`, `getPerspectives()`. For hosts that render their own control. |
| `onColumnsDirtyChange` | Read-only callback fired whenever the unsaved-changes state changes. Only fires for tables that wire `perspective`. |

A custom `toolbar` prop replaces the built-in toolbar entirely, so `showSaveViewButton` has no effect there. Such a host renders the same affordance from `viewApiRef` and `onColumnsDirtyChange` — still without patching `DataTable`:
Expand Down Expand Up @@ -113,6 +113,36 @@ const [dirty, setDirty] = React.useState<DataTableViewDirtyState | null>(null)

`openViewsSidebar()` is a no-op for a user without the `perspectives.use` feature (the sidebar closes again on the next render), so gate a host-rendered “Manage views” action on the same permission instead of expecting an error. The dirty state and the imperative handle are only computed for tables that pass at least one of these three props — a table that opts out pays nothing.

## Rendering your own view switcher

A host that wants its own switcher — tabs above the table instead of the built-in “Views” dropdown — reads the list and activates a view through the same handle, and subscribes to changes with `perspective.onPerspectivesChange`:

```tsx
const viewApi = React.useRef<DataTableViewApi | null>(null)
const [views, setViews] = React.useState<DataTablePerspectivesState | null>(null)
const allViews = [...(views?.perspectives ?? []), ...(views?.rolePerspectives ?? [])]

<Tabs
value={views?.activePerspectiveId ?? 'all'}
onValueChange={(id) => viewApi.current?.activatePerspective(id === 'all' ? null : id)}
>
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
{allViews.map((view) => (
<TabsTrigger key={view.id} value={view.id}>{view.name}</TabsTrigger>
))}
</TabsList>
</Tabs>

<DataTable
perspective={{ tableId: 'sales:orders', onPerspectivesChange: setViews }}
viewApiRef={viewApi}
…
/>
```

`activatePerspective(id)` takes the same path as picking a view in the built-in switcher, for personal and role views alike; `null` clears to “No view”, and an unknown id does nothing. `getPerspectives()` returns `{ perspectives, rolePerspectives, activePerspectiveId }` — empty arrays while the list loads — and `onPerspectivesChange` pushes the same shape whenever the list or the active view changes. A user without the `perspectives.use` feature gets the empty state and `activatePerspective` is a no-op. The built-in dropdown and sidebar are unchanged by any of this.

## API surface

`/api/perspectives/{tableId}`
Expand Down
71 changes: 67 additions & 4 deletions packages/ui/src/backend/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,20 @@ export type DataTablePerspectiveConfig = {
activePerspectiveId?: string | null
initialSettings?: PerspectiveSettings | null
}
/**
* Called whenever the set of views or the active view changes — the push
* counterpart of `DataTableViewApi.getPerspectives`, so a host rendering its
* own view switcher can re-render without polling. A user without the
* `perspectives.use` feature receives the empty state.
*/
onPerspectivesChange?: (state: DataTablePerspectivesState) => void
}

/** The saved views a table knows, as its built-in views switcher sees them. */
export type DataTablePerspectivesState = {
perspectives: PerspectiveDto[]
rolePerspectives: RolePerspectiveDto[]
activePerspectiveId: string | null
}

/** Input for the imperative `saveCurrentView` action. */
Expand Down Expand Up @@ -290,6 +304,14 @@ export type DataTableViewApi = {
* the same permission rather than expecting an error.
*/
openViewsSidebar: () => void
/**
* Activates a saved view (personal or role) by id — the same path the
* built-in switcher takes. `null` clears to "No view". An unknown id, or a
* user without the `perspectives.use` feature, is a no-op.
*/
activatePerspective: (perspectiveId: string | null) => void
/** The views the table currently knows, as the built-in switcher sees them. Empty while loading. */
getPerspectives: () => DataTablePerspectivesState
Comment thread
maxidragon marked this conversation as resolved.
Resolved
}

export type BulkAction<T = Record<string, unknown>> = {
Expand Down Expand Up @@ -351,9 +373,10 @@ export type DataTableProps<T extends RowData> = {
onColumnsDirtyChange?: (state: DataTableViewDirtyState) => void
/**
* Imperative handle for the view/perspective surface (`saveCurrentView`,
* `getDirtyState`, `getCurrentSettings`, `openViewsSidebar`). Pairs with
* `onColumnsDirtyChange` so a host can render a "Save view" button in its own
* toolbar instead of relying on the built-in one.
* `getDirtyState`, `getCurrentSettings`, `openViewsSidebar`,
* `activatePerspective`, `getPerspectives`). Pairs with `onColumnsDirtyChange`
* and `perspective.onPerspectivesChange` so a host can render its own "Save
* view" button or view switcher instead of relying on the built-in ones.
*/
viewApiRef?: React.Ref<DataTableViewApi | null>
/**
Expand Down Expand Up @@ -494,6 +517,11 @@ const EMPTY_FILTER_DEFS: FilterDef[] = []
const EMPTY_FILTER_VALUES: FilterValues = Object.freeze({}) as FilterValues
/** Stand-in for the live view settings on tables that never opt into the view API. */
const EMPTY_VIEW_SETTINGS: PerspectiveSettings = Object.freeze({}) as PerspectiveSettings
const EMPTY_PERSPECTIVES_STATE: DataTablePerspectivesState = Object.freeze({
perspectives: [],
rolePerspectives: [],
activePerspectiveId: null,
}) as DataTablePerspectivesState

// Directional shadow utilities for sticky table cells. `border-collapse: collapse`
// blocks `box-shadow` on `<td>`/`<th>`, so we paint the shadow as a pseudo-element
Expand Down Expand Up @@ -2394,6 +2422,39 @@ export function DataTable<T extends RowData>({
applyPerspectiveSettings(item.settings, item.id)
}, [applyPerspectiveSettings])

// Gated exactly like the built-in switcher: a user without `perspectives.use`
// never sees views there, so a host-rendered switcher must not either — even
// when the server pre-seeded the list through `initialState.response`.
const perspectivesState = React.useMemo<DataTablePerspectivesState>(() => {
if (!canUsePerspectives) return EMPTY_PERSPECTIVES_STATE
return {
perspectives: perspectiveData?.perspectives ?? EMPTY_PERSPECTIVES_STATE.perspectives,
rolePerspectives: perspectiveData?.rolePerspectives ?? EMPTY_PERSPECTIVES_STATE.rolePerspectives,
activePerspectiveId,
}
}, [canUsePerspectives, perspectiveData, activePerspectiveId])

const activatePerspective = React.useCallback((perspectiveId: string | null) => {
if (!canUsePerspectives) return
if (perspectiveId === null) {
applyPerspectiveSettings({}, null)
return
}
const item = perspectivesState.perspectives.find((entry) => entry.id === perspectiveId)
?? perspectivesState.rolePerspectives.find((entry) => entry.id === perspectiveId)
if (item) handlePerspectiveActivate(item)
}, [canUsePerspectives, perspectivesState, applyPerspectiveSettings, handlePerspectiveActivate])

const perspectivesStateRef = React.useRef(perspectivesState)
const onPerspectivesChangeRef = React.useRef(perspectiveConfig?.onPerspectivesChange)
React.useLayoutEffect(() => {
perspectivesStateRef.current = perspectivesState
onPerspectivesChangeRef.current = perspectiveConfig?.onPerspectivesChange
}, [perspectivesState, perspectiveConfig?.onPerspectivesChange])
React.useEffect(() => {
onPerspectivesChangeRef.current?.(perspectivesState)
}, [perspectivesState])

const handlePerspectiveSave = React.useCallback(async (input: { name: string; isDefault: boolean; applyToRoles: string[]; setRoleDefault: boolean; perspectiveId?: string | null; settings?: PerspectiveSettings }) => {
const normalizedRoles = Array.from(new Set(input.applyToRoles))
await savePerspectiveMutation.mutateAsync({
Expand Down Expand Up @@ -2535,7 +2596,9 @@ export function DataTable<T extends RowData>({
getDirtyState: () => viewDirtyStateRef.current,
saveCurrentView,
openViewsSidebar: () => setPerspectiveOpen(true),
}), [getCurrentSettings, saveCurrentView])
activatePerspective,
getPerspectives: () => perspectivesStateRef.current,
}), [getCurrentSettings, saveCurrentView, activatePerspective])

const handleSaveViewClick = React.useCallback(async () => {
const result = await saveCurrentView()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/** @jest-environment jsdom */
import * as React from 'react'
import { DataTable, type DataTablePerspectivesState, type DataTableViewApi } from '../DataTable'
import type { ColumnDef, SortingState } from '@tanstack/react-table'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
import { render, waitFor, act } from '@testing-library/react'
import type { FilterValues } from '../FilterBar'
import type { PerspectivesIndexResponse } from '@open-mercato/shared/modules/perspectives/types'

jest.mock('next/navigation', () => ({
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
}))

jest.mock('../injection/useInjectionDataWidgets', () => ({
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
}))

jest.mock('../injection/useGuardedMutation', () => ({
useGuardedMutation: () => ({
runMutation: async ({ operation }: { operation: () => Promise<unknown> }) => operation(),
retryLastMutation: async () => false,
}),
}))

jest.mock('../utils/apiCall', () => ({
apiCall: async () => ({
ok: true,
status: 200,
result: undefined,
response: { ok: true, status: 200 } as Response,
cacheStatus: null,
}),
withScopedApiRequestHeaders: async (_headers: Record<string, string>, run: () => Promise<unknown>) => run(),
}))

jest.mock('../PerspectiveSidebar', () => ({
PerspectiveSidebar: () => null,
}))

type Row = { id: string; name: string; status: string }

const OPEN_VIEW = {
id: 'persp-open',
name: 'Open',
tableId: 'test-table',
settings: { filters: { status: 'open' } },
isDefault: false,
createdAt: 'now',
updatedAt: '2026-08-06T00:00:00.000Z',
}

const CLOSED_VIEW = {
id: 'persp-closed',
name: 'Closed',
tableId: 'test-table',
settings: {
filters: { status: 'closed' },
sorting: [{ id: 'name', desc: true }],
columnVisibility: { status: false },
},
isDefault: false,
createdAt: 'now',
updatedAt: '2026-08-06T00:00:00.000Z',
}

const ROLE_VIEW = {
id: 'role-view',
name: 'Team view',
tableId: 'test-table',
settings: { filters: { status: 'pending' } },
isDefault: false,
roleId: 'role-sales',
roleName: 'Sales',
createdAt: 'now',
updatedAt: '2026-08-06T00:00:00.000Z',
} as unknown as PerspectivesIndexResponse['rolePerspectives'][number]

const INDEX_RESPONSE: PerspectivesIndexResponse = {
tableId: 'test-table',
perspectives: [OPEN_VIEW, CLOSED_VIEW],
defaultPerspectiveId: null,
rolePerspectives: [ROLE_VIEW],
manageableRolePerspectives: [],
roles: [],
canApplyToRoles: false,
}

const columns: ColumnDef<Row>[] = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'status', header: 'Status' },
]

function renderTable({ perspectivesFeature = 'granted' }: { perspectivesFeature?: 'granted' | 'denied' } = {}) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: Infinity, gcTime: Infinity, retry: false },
mutations: { retry: false },
},
})
queryClient.setQueryData(['feature-check', 'perspectives'], {
use: perspectivesFeature === 'granted',
roleDefaults: perspectivesFeature === 'granted',
})
// Seeded even for the denied case: the list is then in the query cache, and
// the view API must still hand the host nothing.
queryClient.setQueryData(['table-perspectives', 'test-table'], INDEX_RESPONSE)

const apiRef = React.createRef<DataTableViewApi | null>() as React.MutableRefObject<DataTableViewApi | null>
const onFiltersApply = jest.fn<void, [FilterValues]>()
const onSortingChange = jest.fn<void, [SortingState]>()
const pushed: DataTablePerspectivesState[] = []

render(
<QueryClientProvider client={queryClient}>
<I18nProvider locale="en" dict={{}}>
<DataTable<Row>
columns={columns}
data={[]}
onFiltersApply={onFiltersApply}
onSortingChange={onSortingChange}
perspective={{
tableId: 'test-table',
onPerspectivesChange: (state) => { pushed.push(state) },
}}
viewApiRef={apiRef}
/>
</I18nProvider>
</QueryClientProvider>,
)

return { apiRef, onFiltersApply, onSortingChange, pushed }
}

const lastCall = <T,>(mock: jest.Mock<void, [T]>): T | undefined => mock.mock.calls[mock.mock.calls.length - 1]?.[0]

describe('DataTable view API — host-driven view switching', () => {
beforeEach(() => {
window.localStorage.clear()
for (const entry of document.cookie.split(';')) {
const name = entry.split('=')[0]?.trim()
if (name) document.cookie = `${name}=; Max-Age=0; Path=/`
}
})

it('applies a view activated by id through the host callbacks', async () => {
const { apiRef, onFiltersApply, onSortingChange } = renderTable()
// With no default set, the table activates the first saved view on load.
await waitFor(() => expect(apiRef.current?.getPerspectives().activePerspectiveId).toBe('persp-open'))

act(() => { apiRef.current!.activatePerspective('persp-closed') })

await waitFor(() => expect(apiRef.current?.getPerspectives().activePerspectiveId).toBe('persp-closed'))
expect(lastCall(onFiltersApply)).toEqual({ status: 'closed' })
expect(lastCall(onSortingChange)).toEqual([{ id: 'name', desc: true }])
expect(apiRef.current?.getCurrentSettings().columnVisibility).toEqual({ status: false })
})

it('activates a role view by id', async () => {
const { apiRef, onFiltersApply } = renderTable()
await waitFor(() => expect(apiRef.current?.getPerspectives().activePerspectiveId).toBe('persp-open'))

act(() => { apiRef.current!.activatePerspective('role-view') })

await waitFor(() => expect(apiRef.current?.getPerspectives().activePerspectiveId).toBe('role-view'))
expect(lastCall(onFiltersApply)).toEqual({ status: 'pending' })
})

it('clears to no view on null, the way the built-in "All views" entry does', async () => {
const { apiRef, onFiltersApply, onSortingChange } = renderTable()
await waitFor(() => expect(apiRef.current?.getPerspectives().activePerspectiveId).toBe('persp-open'))
act(() => { apiRef.current!.activatePerspective('persp-closed') })
await waitFor(() => expect(apiRef.current?.getPerspectives().activePerspectiveId).toBe('persp-closed'))

act(() => { apiRef.current!.activatePerspective(null) })

await waitFor(() => expect(apiRef.current?.getPerspectives().activePerspectiveId).toBeNull())
expect(lastCall(onFiltersApply)).toEqual({})
expect(lastCall(onSortingChange)).toEqual([])
expect(apiRef.current?.getCurrentSettings().columnVisibility).toBeUndefined()
})

it('ignores an unknown id', async () => {
const { apiRef, onFiltersApply, onSortingChange } = renderTable()
await waitFor(() => expect(apiRef.current?.getPerspectives().activePerspectiveId).toBe('persp-open'))
const filterCalls = onFiltersApply.mock.calls.length
const sortingCalls = onSortingChange.mock.calls.length

expect(() => act(() => { apiRef.current!.activatePerspective('no-such-view') })).not.toThrow()

await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) })
expect(apiRef.current?.getPerspectives().activePerspectiveId).toBe('persp-open')
expect(onFiltersApply.mock.calls.length).toBe(filterCalls)
expect(onSortingChange.mock.calls.length).toBe(sortingCalls)
})

it('reports the known views and pushes every change of the list or the active view', async () => {
const { apiRef, pushed } = renderTable()
await waitFor(() => expect(apiRef.current?.getPerspectives().activePerspectiveId).toBe('persp-open'))

expect(apiRef.current?.getPerspectives()).toEqual({
perspectives: [OPEN_VIEW, CLOSED_VIEW],
rolePerspectives: [ROLE_VIEW],
activePerspectiveId: 'persp-open',
})
await waitFor(() => expect(pushed[pushed.length - 1]).toEqual(apiRef.current?.getPerspectives()))
const pushedBeforeActivation = pushed.length

act(() => { apiRef.current!.activatePerspective('persp-closed') })

await waitFor(() => expect(pushed.length).toBeGreaterThan(pushedBeforeActivation))
expect(pushed[pushed.length - 1]).toEqual({
perspectives: [OPEN_VIEW, CLOSED_VIEW],
rolePerspectives: [ROLE_VIEW],
activePerspectiveId: 'persp-closed',
})
})

it('hands a user without the perspectives feature an empty state and ignores activation', async () => {
const { apiRef, onFiltersApply, onSortingChange, pushed } = renderTable({ perspectivesFeature: 'denied' })
await waitFor(() => expect(apiRef.current).not.toBeNull())

const empty = { perspectives: [], rolePerspectives: [], activePerspectiveId: null }
expect(apiRef.current?.getPerspectives()).toEqual(empty)
await waitFor(() => expect(pushed.length).toBeGreaterThan(0))
expect(pushed.every((state) => JSON.stringify(state) === JSON.stringify(empty))).toBe(true)

const filterCalls = onFiltersApply.mock.calls.length
const sortingCalls = onSortingChange.mock.calls.length
act(() => {
apiRef.current!.activatePerspective('persp-closed')
apiRef.current!.activatePerspective(null)
})

await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) })
expect(apiRef.current?.getPerspectives()).toEqual(empty)
expect(onFiltersApply.mock.calls.length).toBe(filterCalls)
expect(onSortingChange.mock.calls.length).toBe(sortingCalls)
})
})
Loading