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
13 changes: 13 additions & 0 deletions .changeset/29635-personal-views-ui.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions content/docs/ui/views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,8 @@ function objectsWith(allView: Record<string, 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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ let rows: Array<Record<string, unknown>> = [];

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 };
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
141 changes: 137 additions & 4 deletions ui/packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -394,6 +398,16 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
// listViews.
const [savedViews, setSavedViews] = useState<any[]>([]);

// 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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 →
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<boolean> => {
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<boolean> => {
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).
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -946,7 +1067,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
const onListViewAction = useCallback<typeof handleViewAction>((...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 : (
<PluginObjectView
schema={objectViewSchema}
dataSource={dataSource}
Expand All @@ -961,7 +1082,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
onViewAction={onListViewAction}
/>
)),
[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
Expand Down Expand Up @@ -1173,6 +1294,18 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
objectDef={objectDef}
onCreate={(cfg) => handleViewCreate(cfg)}
/>
<PersonalViewNameDialog
open={!!personalViewDialog}
onOpenChange={(open) => { 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}
/>
</div>
</div>
);
Expand Down
Loading
Loading