diff --git a/.changeset/29635-personal-views-ui.md b/.changeset/29635-personal-views-ui.md new file mode 100644 index 00000000000..d9cb36a0a95 --- /dev/null +++ b/.changeset/29635-personal-views-ui.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(app-shell): a list's view switcher lists the reader's own views (My views), and any reader can save the list on screen as one, rename it, delete it and pin it as their default + +Clause-②: yes (widening) +Metadata-protocol: none + +The Console list's view switcher reads the signed-in reader's own `sys_personal_view` rows for the object through the data API, filtered to their own rows, and lists them in a **My views** section beside the views the packages and the environment declare. **Save as my view**, at the end of the dropdown, is offered to any reader the server lets save one, not to administrators only: it saves the open view's body with the reader's filter, sort, grouping, column order, hidden columns and density laid over it, as a spec `ListView` bound to the object (the shape the view config panel saves), and opens it. Each of the reader's rows offers **Rename** (a data API update of its `label`) and **Delete**, which goes through the owner's own-row door `DELETE /api/v1/personal-views/:id`, never the data API's delete. The row's 📌 is the existing per-object pin, so a pinned personal view is the reader's default and the next open resolves it; a pinned or addressed view that has since been deleted falls back to the default view with no error. A reader whose read is refused (an external member, today) is offered neither the section nor the save. While the reader's views are read, the list waits only when the address or the pin names a view it does not hold yet; any other open paints at once. + +The widening is three new `view.switcher` keys in every `@object-ui/i18n` locale pack (`myViews`, `saveAsMyView`, `myViewName`). The switcher's new optional props are module-internal: `@object-ui/app-shell`'s entry does not export the switcher. diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 6856f1badc8..684171dea35 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -400,6 +400,15 @@ The split pane beside an open record keeps the same switcher. - **☆ Star** puts a view in the sidebar's **Favorites** section of the app, where the user can drag it into place. A view your app's navigation already opens (an `object` entry with that `viewName`) is in the menu already, so its star is disabled. +- **My views** are each user's own. **Save as my view**, at the end of the dropdown, saves the + list on screen under a name the user gives it: the open view with the user's filter, sort, + grouping, column order, hidden columns and density laid over it. The search term and the + filter chips are not saved. The views appear in their own **My views** section, where each + row can be renamed or deleted, and **📌 Pin** on one makes it that user's default. Nobody else + sees them, and an administrator's **Manage views** does not list them. They are rows of the + `sys_personal_view` object, not view metadata, so a package never carries them. A user the + server grants that object nothing (an external member, today) is offered neither the section + nor the save. ## Form View diff --git a/ui/packages/app-shell/src/views/ObjectView.hostRerenderRefetch-10046.test.tsx b/ui/packages/app-shell/src/views/ObjectView.hostRerenderRefetch-10046.test.tsx index 6d8ca70e86c..7fe647cb4cf 100644 --- a/ui/packages/app-shell/src/views/ObjectView.hostRerenderRefetch-10046.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.hostRerenderRefetch-10046.test.tsx @@ -152,8 +152,8 @@ function objectsWith(allView: Record) { let listQueries = 0; function makeDataSource() { return { - find: vi.fn(async (_object: string, params: any) => { - if (params?.$top !== 0) listQueries++; + find: vi.fn(async (object: string, params: any) => { + if (object === OBJECT_NAME && params?.$top !== 0) listQueries++; return { data: [], total: 0 }; }), findOne: vi.fn(async () => null), diff --git a/ui/packages/app-shell/src/views/ObjectView.listUrlState-11860.test.tsx b/ui/packages/app-shell/src/views/ObjectView.listUrlState-11860.test.tsx index f6a0115b485..0b5b6fbbb25 100644 --- a/ui/packages/app-shell/src/views/ObjectView.listUrlState-11860.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.listUrlState-11860.test.tsx @@ -138,8 +138,8 @@ let rows: Array> = []; function makeDataSource() { const ds: any = { - find: vi.fn(async (_object: string, params: any) => { - if (params?.$top !== 0) listQueries.push(params); + find: vi.fn(async (object: string, params: any) => { + if (object === OBJ && params?.$top !== 0) listQueries.push(params); if (failWith) throw failWith; return { data: rows, total: rows.length }; }), diff --git a/ui/packages/app-shell/src/views/ObjectView.objectDocSwapNoRefetch-27626.test.tsx b/ui/packages/app-shell/src/views/ObjectView.objectDocSwapNoRefetch-27626.test.tsx index 11cd574dfbe..df5bb78a923 100644 --- a/ui/packages/app-shell/src/views/ObjectView.objectDocSwapNoRefetch-27626.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.objectDocSwapNoRefetch-27626.test.tsx @@ -108,8 +108,8 @@ function objectDocuments(columns: string[] = ['name', 'stage']) { let listQueries = 0; function makeDataSource() { return { - find: vi.fn(async (_object: string, params: any) => { - if (params?.$top !== 0) listQueries++; + find: vi.fn(async (object: string, params: any) => { + if (object === OBJECT_NAME && params?.$top !== 0) listQueries++; return { data: [], total: 0 }; }), findOne: vi.fn(async () => null), diff --git a/ui/packages/app-shell/src/views/ObjectView.personalViews-29635.test.tsx b/ui/packages/app-shell/src/views/ObjectView.personalViews-29635.test.tsx new file mode 100644 index 00000000000..ff486d6c33b --- /dev/null +++ b/ui/packages/app-shell/src/views/ObjectView.personalViews-29635.test.tsx @@ -0,0 +1,380 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#29635 — the reader's own views (My views) in the list's view + * switcher, on the `sys_personal_view` rows the server step landed. + * + * Everything here is the real chain: the real `ObjectView`, its real title + * switcher, the real plugin `ObjectView` and `ListView`. The data source is a + * recording double, and the owner's delete door is the page's `fetch`. + * + * WHAT IS PINNED. + * - The My views section lists the reader's own rows of this object, read + * through the data API filtered by object and owner, and no one else's. + * - Save as my view creates a row whose `config` is a spec `ListView` for + * this object carrying the list on screen, and opens it: its body reaches + * the list's query. + * - Rename goes through the data API's update; Delete through the owner's + * door, `DELETE /api/v1/personal-views/:id`, never the data API's delete. + * - 📌 on a personal row is the reader's default: it writes the per-object + * pin, and the next open of the bare address opens that view. + * - A pin naming a personal view that has since been deleted opens the + * default view, with no error. + * - The read never delays a plain open: with no pin and no addressed view + * the list paints while the read is still out. It waits only for a pinned + * view it does not hold yet. + * - A refused read (no grant on the object) draws neither the section nor + * Save as my view, and the list is unaffected. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, cleanup, screen, waitFor, within, fireEvent } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; +import { ListViewSchema } from '@objectstack/spec/ui'; + +/** + * The reader's grants. Unloaded (every action allowed) unless a case loads + * them; `denied` lists the `object:action` pairs a loaded set refuses. + */ +const grants = vi.hoisted(() => ({ isLoaded: false, denied: new Set() })); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + // Stable identities — `ListView` names `perms` in its fetch dependencies. + const perms = { + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + get isLoaded() { return grants.isLoaded; }, + hasCapabilities: () => true, + can: (object: string, action: string) => !grants.denied.has(`${object}:${action}`), + cannot: (object: string, action: string) => grants.denied.has(`${object}:${action}`), + }; + const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] }; + return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Sales Rep' }, activeOrganization: { id: 'org1', name: 'Acme' } }), + // A plain member: nothing here is an administrator's. + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +import { ObjectView } from './ObjectView'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; + +/** Every request the page sends through `fetch`: the owner's door among them. */ +const fetchCalls: Array<{ url: string; method: string }> = []; +vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => { + fetchCalls.push({ url: String(input), method: (init?.method ?? 'GET').toUpperCase() }); + if (/\/api\/v1\/personal-views\//.test(String(input))) { + return new Response(JSON.stringify({ success: true, data: { id: 'x', deleted: true } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }); +})); + +const LEAD = { + name: 'crm_lead', + label: 'Lead', + pluralLabel: 'Leads', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + status: { type: 'text', label: 'Status' }, + city: { type: 'text', label: 'City' }, + }, + listViews: { + v1: { name: 'v1', label: 'View 1', type: 'grid', columns: ['name', 'status', 'city'] }, + v2: { name: 'v2', label: 'View 2', type: 'grid', columns: ['name'] }, + }, +}; + +const ROWS = [{ id: 'lead_1', name: 'Ada', status: 'open', city: 'Paris' }]; + +/** A stored personal view body, as the server keeps it. */ +const body = (extra: Record = {}) => ({ + type: 'grid', + columns: ['name', 'city'], + data: { provider: 'object', object: 'crm_lead' }, + ...extra, +}); + +const OWN = { id: 'pv_mine', user_id: 'u1', object_name: 'crm_lead', label: 'Paris leads', config: body({ filter: [{ field: 'city', operator: 'equals', value: 'Paris' }] }) }; +/** Another member's row (what a platform administrator's wildcard would read). */ +const FOREIGN = { id: 'pv_theirs', user_id: 'u2', object_name: 'crm_lead', label: 'Not mine', config: body() }; +/** The reader's own row of another object. */ +const OTHER_OBJECT = { id: 'pv_other', user_id: 'u1', object_name: 'crm_account', label: 'Accounts of mine', config: body({ data: { provider: 'object', object: 'crm_account' } }) }; + +type Find = (resource: string, params?: any) => Promise; + +function mountLeads( + entry: string, + { personalRows = [OWN, FOREIGN, OTHER_OBJECT], personalFind }: { personalRows?: unknown[]; personalFind?: Find } = {}, +) { + const objects = [LEAD]; + const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); + // The double stores what the page writes, so a read after a write answers it. + const stored = personalRows.map((r) => ({ ...(r as Record) })); + const dataSource = { + find: vi.fn(async (resource: string, params?: any) => { + if (resource === 'sys_personal_view') { + if (personalFind) return personalFind(resource, params); + return { data: stored, total: stored.length }; + } + return { data: ROWS, total: ROWS.length }; + }), + findOne: vi.fn(async () => null), + getObjectSchema: vi.fn(async (name: string) => objects.find((o) => o.name === name) ?? null), + create: vi.fn(async (resource: string, data: Record) => { + const row = { id: 'pv_new', ...data }; + if (resource === 'sys_personal_view') stored.push(row); + return row; + }), + update: vi.fn(async (resource: string, id: string, patch: Record) => { + const row = resource === 'sys_personal_view' ? stored.find((r) => r.id === id) : undefined; + if (row) Object.assign(row, patch); + return row ?? {}; + }), + delete: vi.fn(async () => ({})), + }; + const view = render( + + + + + {}} />} + /> + {}} />} + /> + + + + , + ); + return { ...view, dataSource }; +} + +/** The list's toolbar has drawn: its Filter control is on screen. */ +const listToolbar = () => + waitFor(() => expect(screen.getByTestId('toolbar-filter-trigger')).toBeInTheDocument(), { timeout: 5000 }); + +/** The reader's pinned view of `crm_lead`, as the workspace preference keeps it. */ +const PIN_KEY = 'os:pref:list.view.pinned@object:crm_lead'; + +const switcher = () => screen.getByTestId('list-view-switcher'); +const openSwitcher = () => { + fireEvent.click(switcher()); + return screen.getByTestId('list-view-switcher-menu'); +}; +const sectionRowIds = (testId: string) => + Array.from(screen.getByTestId(testId).querySelectorAll('[data-testid^="view-switcher-row-"]')).map((el) => + el.getAttribute('data-testid')!.replace('view-switcher-row-', '')); + +/** The `crm_lead` reads the list sent, in order (the footer's `$top: 0` count read is not one). */ +const leadReads = (dataSource: ReturnType['dataSource']) => + dataSource.find.mock.calls + .filter(([resource, params]) => resource === 'crm_lead' && params?.$top !== 0) + .map(([, params]) => params); + +beforeEach(() => { + grants.isLoaded = false; + grants.denied.clear(); + localStorage.clear(); + fetchCalls.length = 0; + Object.defineProperty(window, 'innerWidth', { value: 1440, configurable: true, writable: true }); +}); + +afterEach(() => { + cleanup(); +}); + +describe('objectstack#29635 — My views in the list view switcher', () => { + it('lists the reader\'s own rows of this object, read through the data API by object and owner, and no one else\'s', async () => { + const { dataSource } = mountLeads('/apps/crm/crm_lead/view/v1'); + await listToolbar(); + + const reads = dataSource.find.mock.calls.filter(([resource]) => resource === 'sys_personal_view'); + expect(reads.length).toBeGreaterThan(0); + expect(reads[0][1]).toMatchObject({ $filter: { object_name: 'crm_lead', user_id: 'u1' } }); + + const menu = openSwitcher(); + await waitFor(() => expect(within(menu).getByTestId('list-view-switcher-mine')).toBeInTheDocument()); + expect(sectionRowIds('list-view-switcher-mine')).toEqual(['pv_mine']); + expect(within(menu).getByTestId('list-view-switcher-mine')).toHaveTextContent('My views'); + expect(within(menu).getByTestId('list-view-switcher-mine')).toHaveTextContent('Paris leads'); + // The object's own views stay in All views, and nobody else's row is anywhere. + expect(sectionRowIds('list-view-switcher-all')).toEqual(['v1', 'v2']); + expect(within(menu).queryByTestId('view-switcher-row-pv_theirs')).toBeNull(); + expect(within(menu).queryByTestId('view-switcher-row-pv_other')).toBeNull(); + }); + + it('opening a personal view runs its stored filter in the list\'s query', async () => { + const { dataSource } = mountLeads('/apps/crm/crm_lead/view/pv_mine'); + await listToolbar(); + expect(switcher()).toHaveTextContent('Paris leads'); + await waitFor(() => expect(JSON.stringify(leadReads(dataSource).at(-1))).toContain('Paris')); + }); + + it('Save as my view stores the list on screen as a ListView for this object, and opens it', async () => { + const sort = encodeURIComponent(JSON.stringify([{ field: 'status', order: 'desc' }])); + const { dataSource } = mountLeads(`/apps/crm/crm_lead/view/v1?uf__sort=${sort}`); + await listToolbar(); + + const menu = openSwitcher(); + await waitFor(() => expect(within(menu).getByTestId('list-view-switcher-save-mine')).toBeInTheDocument()); + fireEvent.click(within(menu).getByTestId('list-view-switcher-save-mine')); + const dialog = await screen.findByTestId('personal-view-name-dialog'); + fireEvent.change(within(dialog).getByTestId('personal-view-name-input'), { target: { value: ' Hot leads ' } }); + const readsBefore = leadReads(dataSource).length; + fireEvent.click(within(dialog).getByTestId('personal-view-name-submit')); + + await waitFor(() => expect(dataSource.create).toHaveBeenCalledTimes(1)); + const [resource, row] = dataSource.create.mock.calls[0]; + expect(resource).toBe('sys_personal_view'); + expect(row).toMatchObject({ user_id: 'u1', object_name: 'crm_lead', label: 'Hot leads' }); + // The body the server validates: a ListView bound to this object, carrying the list's sort. + expect(ListViewSchema.safeParse(row.config).success).toBe(true); + expect(row.config.data).toEqual({ provider: 'object', object: 'crm_lead' }); + expect(row.config.sort).toEqual([{ field: 'status', order: 'desc' }]); + expect(row.config.columns).toEqual(['name', 'status', 'city']); + + // It opens at once, on an address that carries no list state of its own, + // so the sort the list then reads by is the stored body's. + await waitFor(() => expect(switcher()).toHaveTextContent('Hot leads')); + await waitFor(() => expect(screen.queryByTestId('personal-view-name-dialog')).toBeNull()); + await waitFor(() => expect(leadReads(dataSource).length).toBeGreaterThan(readsBefore)); + expect(leadReads(dataSource).at(-1).$orderby).toEqual([{ field: 'status', order: 'desc' }]); + }); + + it('Rename goes through the data API\'s update, Delete through the owner\'s door — never the data API\'s delete', async () => { + const { dataSource } = mountLeads('/apps/crm/crm_lead/view/v1'); + await listToolbar(); + + let menu = openSwitcher(); + await waitFor(() => expect(within(menu).getByTestId('list-view-switcher-mine')).toBeInTheDocument()); + const mineRow = () => within(screen.getByTestId('list-view-switcher-mine')).getByTestId('view-switcher-row-pv_mine'); + fireEvent.click(within(mineRow()).getByTestId('view-switcher-rename')); + const dialog = await screen.findByTestId('personal-view-name-dialog'); + expect(within(dialog).getByTestId('personal-view-name-input')).toHaveValue('Paris leads'); + fireEvent.change(within(dialog).getByTestId('personal-view-name-input'), { target: { value: 'Paris only' } }); + fireEvent.click(within(dialog).getByTestId('personal-view-name-submit')); + await waitFor(() => expect(dataSource.update).toHaveBeenCalledWith('sys_personal_view', 'pv_mine', { label: 'Paris only' })); + + menu = openSwitcher(); + await waitFor(() => expect(within(menu).getByTestId('list-view-switcher-mine')).toHaveTextContent('Paris only')); + fireEvent.click(within(mineRow()).getByTestId('view-switcher-delete')); + const confirm = await screen.findByRole('alertdialog'); + fireEvent.click(within(confirm).getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(fetchCalls.filter((c) => c.method === 'DELETE')).toEqual([ + { url: '/api/v1/personal-views/pv_mine', method: 'DELETE' }, + ])); + expect(dataSource.delete).not.toHaveBeenCalled(); + }); + + it('📌 on a personal row is the reader\'s default: it writes the pin, and the next open of the bare address opens it', async () => { + const first = mountLeads('/apps/crm/crm_lead'); + await listToolbar(); + expect(switcher()).toHaveTextContent('View 1'); + + const menu = openSwitcher(); + await waitFor(() => expect(within(menu).getByTestId('list-view-switcher-mine')).toBeInTheDocument()); + const row = within(screen.getByTestId('list-view-switcher-mine')).getByTestId('view-switcher-row-pv_mine'); + fireEvent.click(within(row).getByTestId('view-switcher-pin')); + expect(JSON.parse(localStorage.getItem(PIN_KEY)!)).toEqual({ view: 'pv_mine', v: 1 }); + first.unmount(); + + mountLeads('/apps/crm/crm_lead'); + await listToolbar(); + expect(switcher()).toHaveTextContent('Paris leads'); + }); + + it('a pinned personal view that has since been deleted opens the default view, with no error', async () => { + localStorage.setItem(PIN_KEY, JSON.stringify({ view: 'pv_gone', v: 1 })); + mountLeads('/apps/crm/crm_lead', { personalRows: [OWN] }); + await listToolbar(); + expect(switcher()).toHaveTextContent('View 1'); + expect(screen.queryByTestId('view-refused')).toBeNull(); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('the read never holds a plain open: with nothing pinned, a read still out leaves the default view painted', async () => { + mountLeads('/apps/crm/crm_lead', { personalFind: () => new Promise(() => {}) }); + await listToolbar(); + expect(switcher()).toHaveTextContent('View 1'); + }); + + it('a pinned view the list does not hold yet waits for the read, then opens', async () => { + localStorage.setItem(PIN_KEY, JSON.stringify({ view: 'pv_mine', v: 1 })); + let land: (value: unknown) => void = () => {}; + const { dataSource } = mountLeads('/apps/crm/crm_lead', { + personalFind: () => new Promise((resolve) => { land = resolve; }), + }); + await waitFor(() => expect(dataSource.find.mock.calls.some(([r]) => r === 'sys_personal_view')).toBe(true)); + // No list of the default view is drawn or read while the pin may name a personal view. + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByTestId('toolbar-filter-trigger')).toBeNull(); + expect(leadReads(dataSource)).toHaveLength(0); + + land({ data: [OWN] }); + await listToolbar(); + expect(switcher()).toHaveTextContent('Paris leads'); + }); + + it('a refused read draws neither My views nor Save as my view, and the list is unaffected', async () => { + mountLeads('/apps/crm/crm_lead/view/v1', { + personalFind: async () => { throw Object.assign(new Error('Permission denied'), { status: 403 }); }, + }); + await listToolbar(); + const menu = openSwitcher(); + await new Promise((r) => setTimeout(r, 0)); + expect(within(menu).queryByTestId('list-view-switcher-mine')).toBeNull(); + expect(within(menu).queryByTestId('list-view-switcher-save-mine')).toBeNull(); + expect(sectionRowIds('list-view-switcher-all')).toEqual(['v1', 'v2']); + }); + + it('a reader who may save a view but cannot read the list\'s object is not offered Save as my view', async () => { + grants.isLoaded = true; + grants.denied.add('crm_lead:read'); + mountLeads('/apps/crm/crm_lead/view/v1'); + await listToolbar(); + const menu = openSwitcher(); + // The reader's own views were read: the section is drawn, Save is not. + await waitFor(() => expect(within(menu).getByTestId('list-view-switcher-mine')).toBeInTheDocument()); + expect(within(menu).queryByTestId('list-view-switcher-save-mine')).toBeNull(); + }); + + it('CONTROL: with the same loaded grants and the list\'s object readable, Save as my view is offered', async () => { + grants.isLoaded = true; + mountLeads('/apps/crm/crm_lead/view/v1'); + await listToolbar(); + const menu = openSwitcher(); + await waitFor(() => expect(within(menu).getByTestId('list-view-switcher-mine')).toBeInTheDocument()); + expect(within(menu).getByTestId('list-view-switcher-save-mine')).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/app-shell/src/views/ObjectView.stableFilterIdentity-7237.test.tsx b/ui/packages/app-shell/src/views/ObjectView.stableFilterIdentity-7237.test.tsx index 0b47cd6abfe..bb9c4b99b8c 100644 --- a/ui/packages/app-shell/src/views/ObjectView.stableFilterIdentity-7237.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.stableFilterIdentity-7237.test.tsx @@ -150,8 +150,8 @@ function GanttRecorder({ schema }: { schema: { filter?: unknown; sort?: unknown let listQueries = 0; function makeDataSource() { return { - find: vi.fn(async (_object: string, params: any) => { - if (params?.$top !== 0) listQueries++; + find: vi.fn(async (object: string, params: any) => { + if (object === OBJECT_NAME && params?.$top !== 0) listQueries++; return { data: [], total: 0 }; }), findOne: vi.fn(async () => null), diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 95ed17e8bbb..b2b1993188a 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -76,6 +76,10 @@ import { ObjectViewRecordOverlay } from './objectViewRecordOverlay.js'; import { useObjectViewListRenderer } from './objectViewListRenderer.js'; import { ListPersonalizedMarker } from './ListPersonalizedMarker.js'; import { useObjectViewViewCrud } from './objectViewViewCrud.js'; +import { PERSONAL_VIEW_OBJECT, buildPersonalViewConfig, personalViewEntry, usePersonalViews } from './personalViews.js'; +import { PersonalViewNameDialog } from './PersonalViewNameDialog.js'; +import { parseListStateParams } from './userFilterUrlState.js'; +import { FILTER_FOLD_REFUSAL_KEYS } from './viewFilterFold.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling // `objectView*.ts` modules above. Every name this file exported stays @@ -394,6 +398,16 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // listViews. const [savedViews, setSavedViews] = useState([]); + // objectstack#29635 — the reader's own views of this object, My views + // (`sys_personal_view` rows, `./personalViews.ts`). They join the list's + // views below, so the switcher, the pin and the address resolve them like + // any other view; a reader the server grants nothing reads none. + const personalViews = usePersonalViews({ dataSource, objectName: objectDef.name, userId: user?.id, perms }); + const personalViewIds = useMemo( + () => new Set(personalViews.views.map((v) => v.id)), + [personalViews.views], + ); + // objectui#12418 — the signed-in user's OWN list state for this object: // the tab order, and per view the sort, the Filter panel's conditions and // the column layout. A toolbar change writes here (`sys_user_preference` @@ -515,6 +529,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co type: 'grid', }), }); + // The reader's own views, after the object's (objectstack#29635). A + // row id never carries a `.`, so it never names a package view; one + // that collides with a view id here is not added a second time. + for (const personal of personalViews.views) { + if (!viewList.some((v) => v.id === personal.id)) viewList.push(personalViewEntry(personal) as (typeof viewList)[number]); + } // Apply default columns to any grid-like view that has no explicit // columns (e.g. saved views created via "Add View" before the user @@ -573,7 +593,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co }); return { list: viewList, derivedColumnViewIds: derived }; - }, [objectDef, savedViews, viewOverrides, t, orgAttribution, prefViewOrderKey]); + }, [objectDef, savedViews, viewOverrides, t, orgAttribution, prefViewOrderKey, personalViews.views]); // Active View State — merge saved draft if available for this view. // Resolution priority: URL viewId → ?view= → the reader's pinned view → @@ -620,7 +640,15 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // name is the key), and not in draft preview, whose list is the draft // world while the probe reads the published one. A refusal replaces the // list below; any other answer keeps the fall-back to the default view. - const missedViewName = requestedViewId && !resolvedViewId && !previewDrafts + // objectstack#29635 — while the reader's own views are being read, a + // requested or pinned view the list does not hold yet may be one of them: + // the list waits for that read (`awaitingPersonalViews`), and the refusal + // probe is not asked. Any other open paints at once, and My views joins + // the switcher when the read lands. + const personalViewsLoading = personalViews.status === 'loading'; + const awaitingPersonalViews = personalViewsLoading + && ((!!requestedViewId && !resolvedViewId) || (!!pinnedViewChoice && !pinnedViewId)); + const missedViewName = requestedViewId && !resolvedViewId && !previewDrafts && !personalViewsLoading ? (requestedViewId.includes('.') ? requestedViewId : `${objectDef.name}.${requestedViewId}`) : null; const viewAccess = useMetaItemRefusal('view', missedViewName); @@ -703,6 +731,91 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co baseView, }); + // objectstack#29635 — Save as my view, and Rename and Delete on the + // reader's own views. Save is offered to any reader the server lets save + // one (`sys_personal_view` read and create), not to admins only; the name + // is asked in `PersonalViewNameDialog`. A reader who cannot read the + // list's own object is offered no Save: there is no list to keep. + const personalViewsReady = personalViews.status === 'ready'; + const personalGrant = (action: 'create' | 'update') => !perms.isLoaded || perms.can(PERSONAL_VIEW_OBJECT, action); + const listReadable = !perms.isLoaded || perms.can(objectDef.name, 'read'); + const [personalViewDialog, setPersonalViewDialog] = useState<{ mode: 'save' } | { mode: 'rename'; viewId: string } | null>(null); + const openPersonalView = (id: string) => { + if (viewId) navigate(`../${id}`, { relative: 'path' }); + else navigate(`view/${id}`); + }; + /** Save the list on screen as one of the reader's own views, and open it. */ + const handleSavePersonalView = async (label: string): Promise => { + if (!activeView) return false; + // The list's toolbar state is the address's (`uf_*`), through the same + // field gate its seed reads it with. + const acceptField = (field: string) => + !!objectDef.fields?.[field] + && (!perms.isLoaded || perms.checkField(objectDef.name, field, 'read')); + const built = buildPersonalViewConfig(objectDef.name, activeView, { + url: parseListStateParams(searchParams, acceptField), + preference: listPrefs.preference.perView?.[activeView.id], + }); + if (!built.ok) { + toast.error(t('form.saveError'), { description: t(FILTER_FOLD_REFUSAL_KEYS[built.reason]) }); + return false; + } + try { + openPersonalView(await personalViews.save(label, built.config)); + return true; + } catch (err) { + console.error('[ObjectView] Failed to save a personal view:', err); + toast.error(t('form.saveError'), { + description: err instanceof Error ? err.message : String(err), + classNames: { description: 'whitespace-pre-line' }, + }); + return false; + } + }; + const handleRenamePersonalView = async (id: string, label: string): Promise => { + try { + await personalViews.rename(id, label); + return true; + } catch (err) { + console.error('[ObjectView] Failed to rename a personal view:', err); + toast.error(t('objectViewActions.renameFailed'), { + description: err instanceof Error ? err.message : String(err), + classNames: { description: 'whitespace-pre-line' }, + }); + return false; + } + }; + /** Delete one of the reader's own views through the owner's door; the list falls back to the default view. */ + const handleDeletePersonalView = async (id: string) => { + const target = personalViews.views.find((v) => v.id === id); + if (!target) return; + const confirmed = await confirmHandler( + t('console.objectView.deleteViewConfirm', { name: target.label }), + { + title: t('console.objectView.deleteViewTitle'), + confirmText: t('console.objectView.delete'), + cancelText: t('console.objectView.cancel'), + }, + ); + if (!confirmed) return; + try { + await personalViews.remove(id); + // The reader's pin on it goes with it, and an address naming it + // gives way to the list's own default. + if (pinnedViewChoice === id) setPinnedViewChoice(null); + if (id === activeViewId && viewId) navigate('../..', { relative: 'path' }); + } catch (err) { + console.error('[ObjectView] Failed to delete a personal view:', err); + toast.error(t('objectViewActions.deleteFailed'), { + description: err instanceof Error ? err.message : String(err), + classNames: { description: 'whitespace-pre-line' }, + }); + } + }; + const renamingPersonalView = personalViewDialog?.mode === 'rename' + ? personalViews.views.find((v) => v.id === personalViewDialog.viewId) + : undefined; + // Current user — also used below for `{current_user_id}` filter-token // substitution. The schema-action handlers (toast/navigate/api/flow/server) // now live in the shared `useConsoleActionRuntime` hook (declared earlier). @@ -837,6 +950,14 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co setPinnedViewId: setPinnedViewChoice, appSegment: appName, viewTotal, + personalViewIds, + onSavePersonalView: personalViewsReady && personalGrant('create') && listReadable && !!activeView && !viewAccess.refusal + ? () => setPersonalViewDialog({ mode: 'save' }) + : undefined, + onRenamePersonalView: personalViewsReady && personalGrant('update') + ? (id: string) => setPersonalViewDialog({ mode: 'rename', viewId: id }) + : undefined, + onDeletePersonalView: personalViewsReady ? (id: string) => { void handleDeletePersonalView(id); } : undefined, }); const handleListTotalChange = useCallback((total: number) => { if (!activeViewId) return; @@ -946,7 +1067,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co const onListViewAction = useCallback((...args) => listHandlers.current.handleViewAction(...args), []); const onListEdit = useCallback((record: any) => listHandlers.current.onEdit?.(record), []); const listView = useMemo( - () => refusedViewFace ?? (viewAccess.checking ? null : ( + () => refusedViewFace ?? (viewAccess.checking || awaitingPersonalViews ? null : ( )), - [refusedViewFace, viewAccess.checking, objectViewSchema, dataSource, mergedViews, activeViewId, onListViewChange, onListEdit, handleRowClick, renderListView, onListCreateView, onListViewAction], + [refusedViewFace, viewAccess.checking, awaitingPersonalViews, objectViewSchema, dataSource, mergedViews, activeViewId, onListViewChange, onListEdit, handleRowClick, renderListView, onListCreateView, onListViewAction], ); // The page column — header, list, panels — that the record drawer draws @@ -1173,6 +1294,18 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co objectDef={objectDef} onCreate={(cfg) => handleViewCreate(cfg)} /> + { if (!open) setPersonalViewDialog(null); }} + title={personalViewDialog?.mode === 'rename' + ? t('view.rename', { defaultValue: 'Rename' }) + : t('view.switcher.saveAsMyView', { defaultValue: 'Save as my view' })} + initialName={renamingPersonalView?.label ?? ''} + onSubmit={(name) => (personalViewDialog?.mode === 'rename' + ? handleRenamePersonalView(personalViewDialog.viewId, name) + : handleSavePersonalView(name))} + t={t} + /> ); diff --git a/ui/packages/app-shell/src/views/PersonalViewNameDialog.tsx b/ui/packages/app-shell/src/views/PersonalViewNameDialog.tsx new file mode 100644 index 00000000000..3ced0c375f0 --- /dev/null +++ b/ui/packages/app-shell/src/views/PersonalViewNameDialog.tsx @@ -0,0 +1,101 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The name prompt of the reader's own views (My views, `./personalViews.ts`): + * Save as my view asks for a name, and Rename asks for a new one. + * + * The dialog writes nothing. It hands the trimmed name to `onSubmit` and waits: + * it closes only when the save resolves `true`, and keeps the reader's input + * when it resolves `false` (the caller has said why), as `CreateViewDialog` + * does. An empty name cannot be submitted. + */ + +import { useEffect, useId, useRef, useState } from 'react'; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from '@object-ui/components'; +import type { useObjectTranslation } from '@object-ui/i18n'; + +export interface PersonalViewNameDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The dialog's title: Save as my view, or Rename. */ + title: string; + /** The name the input opens with (the view's current name on a rename). */ + initialName: string; + /** Save the name. Resolves whether it was saved; `false` keeps the dialog open. */ + onSubmit: (name: string) => Promise; + t: ReturnType['t']; +} + +export function PersonalViewNameDialog({ open, onOpenChange, title, initialName, onSubmit, t }: PersonalViewNameDialogProps) { + const inputId = useId(); + const [name, setName] = useState(initialName); + const [submitting, setSubmitting] = useState(false); + const submittingRef = useRef(false); + + // Each opening starts from the name it was opened with. + useEffect(() => { + if (open) setName(initialName); + }, [open, initialName]); + + const trimmed = name.trim(); + const submit = async () => { + if (!trimmed || submittingRef.current) return; + submittingRef.current = true; + setSubmitting(true); + try { + if (await onSubmit(trimmed)) onOpenChange(false); + } finally { + submittingRef.current = false; + setSubmitting(false); + } + }; + + return ( + + + + {title} + +
{ + e.preventDefault(); + void submit(); + }} + > + + setName(e.target.value)} + data-testid="personal-view-name-input" + /> +
+ + + + +
+
+ ); +} diff --git a/ui/packages/app-shell/src/views/objectViewTitleSwitcher.tsx b/ui/packages/app-shell/src/views/objectViewTitleSwitcher.tsx index 2f78785e509..4f9e9a26dd1 100644 --- a/ui/packages/app-shell/src/views/objectViewTitleSwitcher.tsx +++ b/ui/packages/app-shell/src/views/objectViewTitleSwitcher.tsx @@ -14,9 +14,16 @@ * - 📌, the reader's pin: the pinned view is the one this object's list opens * when the address names none (`LIST_PINNED_VIEW_PREFERENCE`, per object). * - * The dropdown ends with New view (the Create View dialog) and, for an admin, - * Manage views (`ManageViewsDialog`): the rename, delete, shared default and - * configure acts that each tab's ▾ menu used to carry. + * The reader's own views, My views (objectstack#29635, `./personalViews.ts`), + * are a section of their own, My views, beside the views the object's + * packages and environment declare. Their rows offer Rename and Delete too, + * and their 📌 is the same pin: a personal view pinned is the reader's default. + * + * The dropdown ends with Save as my view (the list on screen saved as one of + * the reader's own views, offered to any reader the server lets save one), + * New view (the Create View dialog) and, for an admin, Manage views + * (`ManageViewsDialog`): the rename, delete, shared default and configure acts + * that each tab's ▾ menu used to carry. * * Below `sm` the page header is not drawn. On a phone the same switcher leads * the list's toolbar row (`ListView`'s `toolbarLeading`) or heads a chart @@ -30,7 +37,7 @@ import type { ViewTabItem } from '@object-ui/plugin-view'; import { resolveViewId } from '@object-ui/core'; import { useDisplayLocale, pickLocalized } from '@object-ui/i18n'; import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; -import { Check, ChevronDown, ListOrdered, Pin, Plus, Star } from 'lucide-react'; +import { BookmarkPlus, Check, ChevronDown, ListOrdered, Pencil, Pin, Plus, Star, Trash2 } from 'lucide-react'; import { viewRowId } from '../utils/viewIdentity.js'; import { useRecentItems } from '../hooks/useRecentItems.js'; import { useListViewFavorites, type ListViewFavorites } from '../layout/listViewFavorites.js'; @@ -53,6 +60,8 @@ export interface TitleSwitcherView { type: string; /** Authored in the object's metadata (read-only), as opposed to a view saved at runtime. */ authored: boolean; + /** One of the reader's own views (My views, a `sys_personal_view` row). */ + personal?: boolean; } export interface ObjectViewTitleSwitcherProps { @@ -80,12 +89,22 @@ export interface ObjectViewTitleSwitcherProps { onAddView?: () => void; /** Manage views: the admin's dialog. Absent ⇒ not offered. */ onManageViews?: () => void; + /** Save as my view: the list on screen as one of the reader's own views. Absent ⇒ not offered. */ + onSavePersonalView?: () => void; + /** Rename one of the reader's own views. Absent ⇒ not offered. */ + onRenamePersonalView?: (viewId: string) => void; + /** Delete one of the reader's own views. Absent ⇒ not offered. */ + onDeletePersonalView?: (viewId: string) => void; t: TFn; } -/** The authored views first, then the saved ones, each in the list's order. */ +/** The authored views first, then the saved ones, each in the list's order; the reader's own last. */ function authoredFirst(views: readonly TitleSwitcherView[]): TitleSwitcherView[] { - return [...views.filter((v) => v.authored), ...views.filter((v) => !v.authored)]; + return [ + ...views.filter((v) => v.authored), + ...views.filter((v) => !v.authored && !v.personal), + ...views.filter((v) => v.personal), + ]; } function SectionLabel({ children, icon }: { children: ReactNode; icon?: ReactNode }) { @@ -111,6 +130,9 @@ export function ObjectViewTitleSwitcher({ viewTypeIcons = VIEW_TYPE_ICONS, onAddView, onManageViews, + onSavePersonalView, + onRenamePersonalView, + onDeletePersonalView, t, }: ObjectViewTitleSwitcherProps) { const [open, setOpen] = useState(false); @@ -120,6 +142,8 @@ export function ObjectViewTitleSwitcher({ const byId = useMemo(() => new Map(views.map((v) => [v.id, v])), [views]); const all = useMemo(() => authoredFirst(views), [views]); + const shared = all.filter((v) => !v.personal); + const mine = all.filter((v) => v.personal); const pinned = pinnedViewId ? byId.get(pinnedViewId) : undefined; const recent = recentViewIds .filter((id) => id !== pinned?.id) @@ -152,6 +176,9 @@ export function ObjectViewTitleSwitcher({ const pinLabel = isPinned ? t('view.switcher.unpin', { defaultValue: 'Unpin: the list opens its default view again' }) : t('view.switcher.pin', { defaultValue: 'Pin: open this view by default' }); + const renameLabel = t('view.rename', { defaultValue: 'Rename' }); + const deleteLabel = t('view.deleteView', { defaultValue: 'Delete View' }); + const iconButton = 'flex h-7 w-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground max-sm:h-11 max-sm:w-11 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'; // Shown on hover or focus, and always while set, so a starred or pinned // view says so without a hover. // A touch screen has no hover, so there they always show. @@ -215,6 +242,30 @@ export function ObjectViewTitleSwitcher({ > + {view.personal && onRenamePersonalView && ( + + )} + {view.personal && onDeletePersonalView && ( + + )} ); }; @@ -283,15 +334,34 @@ export function ObjectViewTitleSwitcher({ {recent.map((v) => row(v, 'recent'))} )} -
- {t('view.switcher.all', { defaultValue: 'All views' })} - {all.map((v) => row(v, 'all'))} -
+ {mine.length > 0 && ( +
+ {t('view.switcher.myViews', { defaultValue: 'My views' })} + {mine.map((v) => row(v, 'mine'))} +
+ )} + {shared.length > 0 && ( +
+ {t('view.switcher.all', { defaultValue: 'All views' })} + {shared.map((v) => row(v, 'all'))} +
+ )} )} - {(onAddView || onManageViews) && ( + {(onSavePersonalView || onAddView || onManageViews) && (
+ {onSavePersonalView && ( + + )} {onAddView && (