From b507609b223b07ee141f640a3e34a29b14fede0d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:46:17 +0000 Subject: [PATCH 1/5] feat(app-shell): the list view switcher lists the reader's own views and saves the list on screen as one WIP. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jVTq2EZveNYXjHFxWS --- .../app-shell/src/views/ObjectView.tsx | 139 +++++++- .../src/views/PersonalViewNameDialog.tsx | 101 ++++++ .../src/views/objectViewTitleSwitcher.tsx | 134 +++++-- .../app-shell/src/views/personalViews.ts | 335 ++++++++++++++++++ ui/packages/i18n/src/locales/ar.ts | 4 + ui/packages/i18n/src/locales/de.ts | 4 + ui/packages/i18n/src/locales/en.ts | 4 + ui/packages/i18n/src/locales/es.ts | 4 + ui/packages/i18n/src/locales/fr.ts | 4 + ui/packages/i18n/src/locales/ja.ts | 4 + ui/packages/i18n/src/locales/ko.ts | 4 + ui/packages/i18n/src/locales/pt.ts | 4 + ui/packages/i18n/src/locales/ru.ts | 4 + ui/packages/i18n/src/locales/zh.ts | 4 + 14 files changed, 725 insertions(+), 24 deletions(-) create mode 100644 ui/packages/app-shell/src/views/PersonalViewNameDialog.tsx create mode 100644 ui/packages/app-shell/src/views/personalViews.ts diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 95ed17e8bbb..af62b3f7c34 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,89 @@ 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`. + const personalViewsReady = personalViews.status === 'ready'; + const personalGrant = (action: 'create' | 'update') => !perms.isLoaded || perms.can(PERSONAL_VIEW_OBJECT, action); + 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 +948,14 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co setPinnedViewId: setPinnedViewChoice, appSegment: appName, viewTotal, + personalViewIds, + onSavePersonalView: personalViewsReady && personalGrant('create') && !!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 +1065,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 +1292,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 && (