From 289a86bf4f289b58344e15edcdba40ae6c6dcd66 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 02:05:50 +0000 Subject: [PATCH 1/7] feat(plugin-detail): related panels lose collapse; hover handle, hide-only menu, empty-panel line WIP: panel header anatomy, the arrangement without collapsed, the empty panel line and the compact row's first column. Tests follow. Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt Co-authored-by: Claude --- .../src/utils/workspacePreference.ts | 19 ++- ui/packages/i18n/src/locales/ar.ts | 26 +-- ui/packages/i18n/src/locales/de.ts | 14 +- ui/packages/i18n/src/locales/en.ts | 14 +- ui/packages/i18n/src/locales/es.ts | 17 +- ui/packages/i18n/src/locales/fr.ts | 17 +- ui/packages/i18n/src/locales/ja.ts | 14 +- ui/packages/i18n/src/locales/ko.ts | 14 +- ui/packages/i18n/src/locales/pt.ts | 17 +- ui/packages/i18n/src/locales/ru.ts | 20 ++- ui/packages/i18n/src/locales/zh.ts | 14 +- .../plugin-detail/src/ArrangedRelatedTab.tsx | 19 +-- .../plugin-detail/src/RelatedCardMenu.tsx | 103 +++++++----- .../plugin-detail/src/RelatedCardStack.tsx | 136 +++------------- ui/packages/plugin-detail/src/RelatedList.tsx | 153 +++++++++++------- .../src/RelatedListEmptyFold.tsx | 72 ++++++++- ui/packages/plugin-detail/src/index.tsx | 1 + .../plugin-detail/src/relatedListCompact.ts | 32 ++-- .../src/renderers/record-related-list.tsx | 41 ++--- .../src/synth/relatedArrangement.ts | 90 +++++------ .../src/useRelatedArrangement.ts | 17 +- 21 files changed, 453 insertions(+), 397 deletions(-) diff --git a/ui/packages/app-shell/src/utils/workspacePreference.ts b/ui/packages/app-shell/src/utils/workspacePreference.ts index 1f2ce59fb9d..42dfe16dc52 100644 --- a/ui/packages/app-shell/src/utils/workspacePreference.ts +++ b/ui/packages/app-shell/src/utils/workspacePreference.ts @@ -8,6 +8,7 @@ import { useCallback, useSyncExternalStore } from 'react'; import { + RELATED_ARRANGEMENT_RECORD_VERSION, relatedArrangementFromRecord, relatedArrangementToRecord, type RelatedArrangement, @@ -157,18 +158,20 @@ export const RECORD_RELATED_COLUMN_PREFERENCE = defineWorkspacePreference({ name: 'record.related.arrangement', codec: versionedPreferenceCodec({ - version: 1, + version: RELATED_ARRANGEMENT_RECORD_VERSION, toRecord: relatedArrangementToRecord, fromRecord: relatedArrangementFromRecord, }), diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 93204d42cd3..796a4e7ccdb 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1478,19 +1478,25 @@ const ar = { owner: "المالك", relatedCardMenu: "المزيد لـ {{title}}", relatedCardEnlarge: "تكبير", - relatedCardMoveUp: "نقل لأعلى", - relatedCardMoveDown: "نقل لأسفل", relatedCardExpand: "توسيع", - relatedCardCollapse: "طي", relatedCardHide: "إخفاء", relatedCardNew: "جديد", - relatedCardCount: "السجلات: {{count}} · تكبير", - relatedCardCount_zero: "السجلات: {{count}} · تكبير", - relatedCardCount_one: "السجلات: {{count}} · تكبير", - relatedCardCount_two: "السجلات: {{count}} · تكبير", - relatedCardCount_few: "السجلات: {{count}} · تكبير", - relatedCardCount_many: "السجلات: {{count}} · تكبير", - relatedCardCount_other: "السجلات: {{count}} · تكبير", + relatedPanelViewAll: "عرض الكل ({{count}})", + relatedPanelViewAll_zero: "عرض الكل ({{count}})", + relatedPanelViewAll_one: "عرض الكل ({{count}})", + relatedPanelViewAll_two: "عرض الكل ({{count}})", + relatedPanelViewAll_few: "عرض الكل ({{count}})", + relatedPanelViewAll_many: "عرض الكل ({{count}})", + relatedPanelViewAll_other: "عرض الكل ({{count}})", + relatedPanelReorder: "نقل {{title}}", + relatedPanelReorderHint: "اسحب لإعادة الترتيب · ⌥↑ ⌥↓", + relatedEmptyFold: "قوائم فارغة أخرى: {{count}}", + relatedEmptyFold_zero: "قوائم فارغة أخرى: {{count}}", + relatedEmptyFold_one: "قوائم فارغة أخرى: {{count}}", + relatedEmptyFold_two: "قوائم فارغة أخرى: {{count}}", + relatedEmptyFold_few: "قوائم فارغة أخرى: {{count}}", + relatedEmptyFold_many: "قوائم فارغة أخرى: {{count}}", + relatedEmptyFold_other: "قوائم فارغة أخرى: {{count}}", relatedDefaultChanged: "تم تحديث الترتيب الافتراضي", relatedRestoreDefault: "استعادة الافتراضي", relatedRestoreDefaultOrder: "استعادة الترتيب الافتراضي", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 6e42688b142..510ca2ce6db 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1320,15 +1320,17 @@ const de = { owner: "Inhaber", relatedCardMenu: "Mehr für {{title}}", relatedCardEnlarge: "Vergrößern", - relatedCardMoveUp: "Nach oben", - relatedCardMoveDown: "Nach unten", relatedCardExpand: "Aufklappen", - relatedCardCollapse: "Zuklappen", relatedCardHide: "Ausblenden", relatedCardNew: "Neu", - relatedCardCount: "{{count}} Datensätze – vergrößern", - relatedCardCount_one: "{{count}} Datensatz – vergrößern", - relatedCardCount_other: "{{count}} Datensätze – vergrößern", + relatedPanelViewAll: "Alle {{count}} anzeigen", + relatedPanelViewAll_one: "Alle {{count}} anzeigen", + relatedPanelViewAll_other: "Alle {{count}} anzeigen", + relatedPanelReorder: "{{title}} verschieben", + relatedPanelReorderHint: "Zum Sortieren ziehen · ⌥↑ ⌥↓", + relatedEmptyFold: "{{count}} weitere leere Listen", + relatedEmptyFold_one: "{{count}} weitere leere Liste", + relatedEmptyFold_other: "{{count}} weitere leere Listen", relatedDefaultChanged: "Standardreihenfolge aktualisiert", relatedRestoreDefault: "Standard wiederherstellen", relatedRestoreDefaultOrder: "Standardreihenfolge wiederherstellen", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 87a2437601b..3b3bd70329c 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1508,15 +1508,17 @@ const en = { owner: 'Owner', relatedCardMenu: 'More for {{title}}', relatedCardEnlarge: 'Enlarge', - relatedCardMoveUp: 'Move up', - relatedCardMoveDown: 'Move down', relatedCardExpand: 'Expand', - relatedCardCollapse: 'Collapse', relatedCardHide: 'Hide', relatedCardNew: 'New', - relatedCardCount: '{{count}} records — enlarge', - relatedCardCount_one: '{{count}} record — enlarge', - relatedCardCount_other: '{{count}} records — enlarge', + relatedPanelViewAll: 'View all {{count}}', + relatedPanelViewAll_one: 'View all {{count}}', + relatedPanelViewAll_other: 'View all {{count}}', + relatedPanelReorder: 'Reorder {{title}}', + relatedPanelReorderHint: 'Drag to reorder · ⌥↑ ⌥↓', + relatedEmptyFold: '{{count}} more empty lists', + relatedEmptyFold_one: '{{count}} more empty list', + relatedEmptyFold_other: '{{count}} more empty lists', relatedDefaultChanged: 'Default order updated', relatedRestoreDefault: 'Restore default', relatedRestoreDefaultOrder: 'Restore default order', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index b54edfb9a83..3a385be1c9f 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1361,16 +1361,19 @@ const es = { owner: "Propietario", relatedCardMenu: "Más opciones de {{title}}", relatedCardEnlarge: "Ampliar", - relatedCardMoveUp: "Subir", - relatedCardMoveDown: "Bajar", relatedCardExpand: "Desplegar", - relatedCardCollapse: "Contraer", relatedCardHide: "Ocultar", relatedCardNew: "Nuevo", - relatedCardCount: "{{count}} registros · ampliar", - relatedCardCount_one: "{{count}} registro · ampliar", - relatedCardCount_many: "{{count}} de registros · ampliar", - relatedCardCount_other: "{{count}} registros · ampliar", + relatedPanelViewAll: "Ver todos ({{count}})", + relatedPanelViewAll_one: "Ver todos ({{count}})", + relatedPanelViewAll_many: "Ver todos ({{count}})", + relatedPanelViewAll_other: "Ver todos ({{count}})", + relatedPanelReorder: "Mover {{title}}", + relatedPanelReorderHint: "Arrastra para reordenar · ⌥↑ ⌥↓", + relatedEmptyFold: "{{count}} listas vacías más", + relatedEmptyFold_one: "{{count}} lista vacía más", + relatedEmptyFold_many: "{{count}} listas vacías más", + relatedEmptyFold_other: "{{count}} listas vacías más", relatedDefaultChanged: "Orden predeterminado actualizado", relatedRestoreDefault: "Restaurar predeterminado", relatedRestoreDefaultOrder: "Restaurar orden predeterminado", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 4d2fded8f2e..2ad6e83ec1d 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1358,16 +1358,19 @@ const fr = { owner: "Propriétaire", relatedCardMenu: "Plus pour {{title}}", relatedCardEnlarge: "Agrandir", - relatedCardMoveUp: "Monter", - relatedCardMoveDown: "Descendre", relatedCardExpand: "Déplier", - relatedCardCollapse: "Replier", relatedCardHide: "Masquer", relatedCardNew: "Nouveau", - relatedCardCount: "{{count}} enregistrements · agrandir", - relatedCardCount_one: "{{count}} enregistrement · agrandir", - relatedCardCount_many: "{{count}} enregistrements · agrandir", - relatedCardCount_other: "{{count}} enregistrements · agrandir", + relatedPanelViewAll: "Tout voir ({{count}})", + relatedPanelViewAll_one: "Tout voir ({{count}})", + relatedPanelViewAll_many: "Tout voir ({{count}})", + relatedPanelViewAll_other: "Tout voir ({{count}})", + relatedPanelReorder: "Déplacer {{title}}", + relatedPanelReorderHint: "Glisser pour réordonner · ⌥↑ ⌥↓", + relatedEmptyFold: "{{count}} autres listes vides", + relatedEmptyFold_one: "{{count}} autre liste vide", + relatedEmptyFold_many: "{{count}} autres listes vides", + relatedEmptyFold_other: "{{count}} autres listes vides", relatedDefaultChanged: "Ordre par défaut mis à jour", relatedRestoreDefault: "Rétablir par défaut", relatedRestoreDefaultOrder: "Rétablir l'ordre par défaut", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 1b6f5c2cfef..6d048cc7ff9 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1163,15 +1163,17 @@ const ja = { owner: "所有者", relatedCardMenu: "{{title}} のその他の操作", relatedCardEnlarge: "拡大", - relatedCardMoveUp: "上へ移動", - relatedCardMoveDown: "下へ移動", relatedCardExpand: "展開", - relatedCardCollapse: "折りたたむ", relatedCardHide: "非表示", relatedCardNew: "新規", - relatedCardCount: "{{count}} 件 · 拡大", - relatedCardCount_one: "{{count}} 件 · 拡大", - relatedCardCount_other: "{{count}} 件 · 拡大", + relatedPanelViewAll: "{{count}} 件をすべて表示", + relatedPanelViewAll_one: "{{count}} 件をすべて表示", + relatedPanelViewAll_other: "{{count}} 件をすべて表示", + relatedPanelReorder: "{{title}} を並べ替え", + relatedPanelReorderHint: "ドラッグで並べ替え · ⌥↑ ⌥↓", + relatedEmptyFold: "空の関連 {{count}} 件", + relatedEmptyFold_one: "空の関連 {{count}} 件", + relatedEmptyFold_other: "空の関連 {{count}} 件", relatedDefaultChanged: "既定の順序が更新されました", relatedRestoreDefault: "既定に戻す", relatedRestoreDefaultOrder: "既定の順序に戻す", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 6a415327261..b57a317ce0a 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1320,15 +1320,17 @@ const ko = { owner: "소유자", relatedCardMenu: "{{title}} 추가 작업", relatedCardEnlarge: "확대", - relatedCardMoveUp: "위로 이동", - relatedCardMoveDown: "아래로 이동", relatedCardExpand: "펼치기", - relatedCardCollapse: "접기", relatedCardHide: "숨기기", relatedCardNew: "새 항목", - relatedCardCount: "{{count}}개 레코드 · 확대", - relatedCardCount_one: "{{count}}개 레코드 · 확대", - relatedCardCount_other: "{{count}}개 레코드 · 확대", + relatedPanelViewAll: "{{count}}개 모두 보기", + relatedPanelViewAll_one: "{{count}}개 모두 보기", + relatedPanelViewAll_other: "{{count}}개 모두 보기", + relatedPanelReorder: "{{title}} 순서 변경", + relatedPanelReorderHint: "드래그하여 순서 변경 · ⌥↑ ⌥↓", + relatedEmptyFold: "빈 관련 항목 {{count}}개", + relatedEmptyFold_one: "빈 관련 항목 {{count}}개", + relatedEmptyFold_other: "빈 관련 항목 {{count}}개", relatedDefaultChanged: "기본 순서가 업데이트됨", relatedRestoreDefault: "기본값 복원", relatedRestoreDefaultOrder: "기본 순서 복원", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 06b841e6f53..266acc6ebe3 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1358,16 +1358,19 @@ const pt = { owner: "Proprietário", relatedCardMenu: "Mais para {{title}}", relatedCardEnlarge: "Ampliar", - relatedCardMoveUp: "Mover para cima", - relatedCardMoveDown: "Mover para baixo", relatedCardExpand: "Expandir", - relatedCardCollapse: "Recolher", relatedCardHide: "Ocultar", relatedCardNew: "Novo", - relatedCardCount: "{{count}} registros · ampliar", - relatedCardCount_one: "{{count}} registro · ampliar", - relatedCardCount_many: "{{count}} de registros · ampliar", - relatedCardCount_other: "{{count}} registros · ampliar", + relatedPanelViewAll: "Ver todos ({{count}})", + relatedPanelViewAll_one: "Ver todos ({{count}})", + relatedPanelViewAll_many: "Ver todos ({{count}})", + relatedPanelViewAll_other: "Ver todos ({{count}})", + relatedPanelReorder: "Mover {{title}}", + relatedPanelReorderHint: "Arraste para reordenar · ⌥↑ ⌥↓", + relatedEmptyFold: "Mais {{count}} listas vazias", + relatedEmptyFold_one: "Mais {{count}} lista vazia", + relatedEmptyFold_many: "Mais {{count}} listas vazias", + relatedEmptyFold_other: "Mais {{count}} listas vazias", relatedDefaultChanged: "Ordem padrão atualizada", relatedRestoreDefault: "Restaurar padrão", relatedRestoreDefaultOrder: "Restaurar ordem padrão", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 7ae453f4ce4..c00d19ecb64 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1232,17 +1232,21 @@ const ru = { owner: "Владелец", relatedCardMenu: "Ещё для «{{title}}»", relatedCardEnlarge: "Увеличить", - relatedCardMoveUp: "Переместить вверх", - relatedCardMoveDown: "Переместить вниз", relatedCardExpand: "Развернуть", - relatedCardCollapse: "Свернуть", relatedCardHide: "Скрыть", relatedCardNew: "Новое", - relatedCardCount: "{{count}} записей · увеличить", - relatedCardCount_one: "{{count}} запись · увеличить", - relatedCardCount_few: "{{count}} записи · увеличить", - relatedCardCount_many: "{{count}} записей · увеличить", - relatedCardCount_other: "{{count}} записи · увеличить", + relatedPanelViewAll: "Показать все ({{count}})", + relatedPanelViewAll_one: "Показать все ({{count}})", + relatedPanelViewAll_few: "Показать все ({{count}})", + relatedPanelViewAll_many: "Показать все ({{count}})", + relatedPanelViewAll_other: "Показать все ({{count}})", + relatedPanelReorder: "Переместить «{{title}}»", + relatedPanelReorderHint: "Перетащите, чтобы изменить порядок · ⌥↑ ⌥↓", + relatedEmptyFold: "Ещё пустых: {{count}}", + relatedEmptyFold_one: "Ещё пустых: {{count}}", + relatedEmptyFold_few: "Ещё пустых: {{count}}", + relatedEmptyFold_many: "Ещё пустых: {{count}}", + relatedEmptyFold_other: "Ещё пустых: {{count}}", relatedDefaultChanged: "Порядок по умолчанию обновлён", relatedRestoreDefault: "Восстановить по умолчанию", relatedRestoreDefaultOrder: "Восстановить порядок по умолчанию", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 83704909f90..28364474924 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1242,15 +1242,17 @@ const zh = { owner: '负责人', relatedCardMenu: '{{title}}的更多操作', relatedCardEnlarge: '放大', - relatedCardMoveUp: '上移', - relatedCardMoveDown: '下移', relatedCardExpand: '展开', - relatedCardCollapse: '折叠', relatedCardHide: '隐藏', relatedCardNew: '新', - relatedCardCount: '{{count}} 条记录 · 放大', - relatedCardCount_one: '{{count}} 条记录 · 放大', - relatedCardCount_other: '{{count}} 条记录 · 放大', + relatedPanelViewAll: '查看全部 {{count}} 条', + relatedPanelViewAll_one: '查看全部 {{count}} 条', + relatedPanelViewAll_other: '查看全部 {{count}} 条', + relatedPanelReorder: '移动「{{title}}」', + relatedPanelReorderHint: '拖动排序 · ⌥↑ ⌥↓', + relatedEmptyFold: '还有 {{count}} 个空关联', + relatedEmptyFold_one: '还有 {{count}} 个空关联', + relatedEmptyFold_other: '还有 {{count}} 个空关联', relatedDefaultChanged: '默认顺序已更新', relatedRestoreDefault: '恢复默认', relatedRestoreDefaultOrder: '恢复默认顺序', diff --git a/ui/packages/plugin-detail/src/ArrangedRelatedTab.tsx b/ui/packages/plugin-detail/src/ArrangedRelatedTab.tsx index c6aafb04746..b1101e29d25 100644 --- a/ui/packages/plugin-detail/src/ArrangedRelatedTab.tsx +++ b/ui/packages/plugin-detail/src/ArrangedRelatedTab.tsx @@ -14,17 +14,14 @@ import { useRelatedArrangement } from './useRelatedArrangement'; /** * The reader's arrangement as the tabbed tier's 关联 tab reads it - * (objectstack#27055, Target 7: 「The same order and collapsed state apply in - * the tabbed tier's 关联 tab」). Read by the `record:related_list` renderer: a - * collapsed list draws as its title + count (no rows read), every other list - * gets its chrome (the ⋯ menu), and the tab's LAST list draws the column's - * two lines after itself — the changed-default hint and the foot line - * (「已隐藏 N 张 · 显示」 · 「恢复默认顺序」). + * (objectstack#27055, Target 7: 「The same order … apply in the tabbed tier's + * 关联 tab」). Read by the `record:related_list` renderer: every list gets + * its chrome (the ⠿ handle's ⌥↑ / ⌥↓, the ⋯ menu's 隐藏), and the tab's LAST + * list draws the column's two lines after itself — the changed-default hint + * and the foot line (「已隐藏 N 张 · 显示」 · 「恢复默认顺序」). */ export interface RelatedArrangementScope { chromeOf: (id: RelatedCardId) => RelatedCardChrome | null; - isCollapsed: (id: RelatedCardId) => boolean; - expand: (id: RelatedCardId) => void; /** The list that draws the tab's closing lines. */ lastId: RelatedCardId | null; hiddenCount: number; @@ -67,8 +64,8 @@ const relatedListIdOf = (node: unknown): RelatedCardId | null => relatedListIdsI * Lays the reader's arrangement over a synthesized page's 关联 tab * (objectstack#27055): its lists in the reader's order, hidden lists left out, * any other child (the attachments) after them; and the scope the - * `record:related_list` renderer reads for the collapsed state, the chrome and - * the closing lines. A page with no 关联 tab is handed through unchanged. + * `record:related_list` renderer reads for the chrome and the closing lines. + * A page with no 关联 tab is handed through unchanged. */ export const ArrangedRelatedTab: React.FC<{ page: unknown; @@ -108,8 +105,6 @@ export const ArrangedRelatedTab: React.FC<{ const index = resolved.order.indexOf(id); return index >= 0 ? chromeOf(id, index) : null; }, - isCollapsed: (id) => resolved.collapsed.has(id), - expand: (id) => edit({ kind: 'collapse', id, collapsed: false }), lastId: resolved.order.length > 0 ? resolved.order[resolved.order.length - 1] : null, hiddenCount: resolved.hidden.length, showHidden: () => edit({ kind: 'show-hidden' }), diff --git a/ui/packages/plugin-detail/src/RelatedCardMenu.tsx b/ui/packages/plugin-detail/src/RelatedCardMenu.tsx index d95c44e6ca6..25f87dff396 100644 --- a/ui/packages/plugin-detail/src/RelatedCardMenu.tsx +++ b/ui/packages/plugin-detail/src/RelatedCardMenu.tsx @@ -12,26 +12,27 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + cn, } from '@object-ui/components'; -import { ArrowDown, ArrowUp, ChevronsDownUp, ChevronsUpDown, EyeOff, Maximize2, MoreHorizontal } from 'lucide-react'; +import { EyeOff, GripVertical, MoreHorizontal } from 'lucide-react'; import type { RelatedCardChrome } from './relatedListCompact'; import { useDetailTranslation } from './useDetailTranslation'; /** - * A related card's own `⋯` menu (objectstack#27055, Targets 7 and 10): the - * keyboard path to everything the pointer does — 上移 / 下移 (the drag's - * twin), 折叠 / 展开, 隐藏 — plus the enlarge. Drawn on the expanded card and - * on the collapsed one alike. + * A related panel's own `⋯` menu (objectstack#27524, Target 1): it holds + * 隐藏 and nothing else. Moving is the ⠿ handle's ({@link RelatedPanelHandle}); + * a panel does not collapse — its header row opens its whole list. */ export const RelatedCardMenu: React.FC<{ chrome: RelatedCardChrome; - /** The card's title, for the trigger's accessible name. */ + /** The panel's title, for the trigger's accessible name. */ title: string; - collapsed: boolean; - onExpand?: () => void; -}> = ({ chrome, title, collapsed, onExpand }) => { +}> = ({ chrome, title }) => { const { t } = useDetailTranslation(); return ( @@ -47,33 +48,7 @@ export const RelatedCardMenu: React.FC<{ - - {chrome.onEnlarge && ( - chrome.onEnlarge?.()}> - - {t('detail.relatedCardEnlarge', { defaultValue: 'Enlarge' })} - - )} - chrome.onMoveUp?.()}> - - {t('detail.relatedCardMoveUp', { defaultValue: 'Move up' })} - - chrome.onMoveDown?.()}> - - {t('detail.relatedCardMoveDown', { defaultValue: 'Move down' })} - - {collapsed ? ( - onExpand?.()}> - - {t('detail.relatedCardExpand', { defaultValue: 'Expand' })} - - ) : ( - chrome.onCollapse()}> - - {t('detail.relatedCardCollapse', { defaultValue: 'Collapse' })} - - )} - + chrome.onHide()}> {t('detail.relatedCardHide', { defaultValue: 'Hide' })} @@ -82,3 +57,57 @@ export const RelatedCardMenu: React.FC<{ ); }; + +/** + * A related panel's ⠿ handle (objectstack#27524, Target 1): the drag source + * on desktop (the stack draws the drop line) and, focused, the keyboard's way + * to reorder — ⌥↑ / ⌥↓ move the panel one place, and the tooltip says so. + * The panel header draws it beside + and ⋯, shown on hover or focus. + */ +export const RelatedPanelHandle: React.FC<{ + chrome: RelatedCardChrome; + /** The panel's title, for the handle's accessible name. */ + title: string; +}> = ({ chrome, title }) => { + const { t } = useDetailTranslation(); + const onKeyDown = (event: React.KeyboardEvent) => { + if (!event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) return; + const move = event.key === 'ArrowUp' ? chrome.onMoveUp : event.key === 'ArrowDown' ? chrome.onMoveDown : undefined; + if (!move) return; + event.preventDefault(); + event.stopPropagation(); + const panel = event.currentTarget.closest('[data-related-card-id]'); + move(); + // Moving the panel in the DOM drops the focus; the handle keeps it. + requestAnimationFrame(() => { + const handle = panel?.isConnected ? panel.querySelector('[data-related-panel-handle]') : null; + handle?.focus(); + }); + }; + return ( + + + + + + + {t('detail.relatedPanelReorderHint', { defaultValue: 'Drag to reorder · ⌥↑ ⌥↓' })} + + + + ); +}; diff --git a/ui/packages/plugin-detail/src/RelatedCardStack.tsx b/ui/packages/plugin-detail/src/RelatedCardStack.tsx index 40ae19eca5f..1663f304eba 100644 --- a/ui/packages/plugin-detail/src/RelatedCardStack.tsx +++ b/ui/packages/plugin-detail/src/RelatedCardStack.tsx @@ -7,17 +7,10 @@ */ import * as React from 'react'; -import { Badge, RelatedCountStore, Skeleton, cn, useRelatedCount } from '@object-ui/components'; +import { Skeleton } from '@object-ui/components'; import type { FieldContainerLike } from '@object-ui/core'; import { SchemaRenderer, toRenderableSchema, useRecordContext } from '@object-ui/react'; -import { ChevronRight } from 'lucide-react'; -import { - RELATED_CARD_DROP_LINE, - RelatedCardChromeContext, - RelatedCardCountContext, - type RelatedCardChrome, -} from './relatedListCompact'; -import { RelatedCardMenu } from './RelatedCardMenu'; +import { RelatedCardChromeContext, RelatedCardCountContext, type RelatedCardChrome } from './relatedListCompact'; import { relatedCardId, type RelatedArrangement, type RelatedCardId } from './synth/relatedArrangement'; import { useRelatedArrangement } from './useRelatedArrangement'; import { useDetailTranslation } from './useDetailTranslation'; @@ -61,75 +54,6 @@ function cardIdOf(node: unknown): RelatedCardId | null { return relatedCardId(p.objectName, p.relationshipField); } -/** - * A collapsed card: its title and its count, nothing else (objectstack#27055, - * Target 7: 「a collapsed card fetches no rows」). The count comes from the - * shared count store, the same one-row `$count` probe the tab strip's badges - * ask, so the card reads no rows. A stack that counted its cards up front - * hands the card its `count` (objectstack#27056), and the card asks nothing. - */ -export const CollapsedRelatedCard: React.FC<{ - node: PageNode; - chrome: RelatedCardChrome; - onExpand: () => void; - count?: number; -}> = ({ node, chrome, onExpand, count: counted }) => { - const p = propsOf(node); - const ctx = useRecordContext(); - const parentId = ctx?.recordId ?? undefined; - const dataSource = ctx?.dataSource as any; - const probed = useRelatedCount(p.objectName, p.relationshipField, parentId, p.filter); - const count = counted ?? probed; - const isCounted = counted !== undefined; - React.useEffect(() => { - if (isCounted || !dataSource || !parentId || typeof dataSource.find !== 'function') return; - void RelatedCountStore.fetch( - (object: string, query: any) => dataSource.find(object, query), - p.objectName, - p.relationshipField, - parentId, - p.filter, - ).catch(() => 0); - }, [isCounted, dataSource, parentId, p.objectName, p.relationshipField, p.filter]); - const title = typeof p.title === 'string' && p.title ? p.title : p.objectName; - return ( -
-
- - {chrome.fresh && } - {count !== undefined && ( - - {count} - - )} - - - -
-
- ); -}; - /** * A card whose count is still out (objectstack#27056, Target 3): its title and * a placeholder where the count goes, the height of a folded card, so the @@ -156,33 +80,27 @@ const PendingRelatedCard: React.FC<{ node: PageNode; chrome: RelatedCardChrome } ); }; -const FreshBadge: React.FC = () => { - const { t } = useDetailTranslation(); - return ( - - {t('detail.relatedCardNew', { defaultValue: 'New' })} - - ); -}; - /** - * The related column's cards in the reader's arrangement (objectstack#27055, - * Targets 4, 7 and 10). + * The related column's panels in the reader's arrangement (objectstack#27055, + * Targets 4, 7 and 10; objectstack#27524, Targets 1–3 and 10). * - * Each `record:related_list` is a card the reader arranges: on desktop its - * title row is a drag handle (a drop line marks where it lands); its `⋯` - * menu is the keyboard path — 上移 / 下移 / 折叠 / 隐藏; a collapsed card is - * its title and count and reads no rows. The order, collapsed and hidden - * state are the reader's (the host's workspace preference), laid over the - * default order by `resolveRelatedArrangement`; the column says when the - * default changed under a dragged order (「默认顺序已更新 · 恢复默认」), and its - * foot says how many cards are hidden (「已隐藏 N 张 · 显示」) and, once the - * reader has moved or hidden any, offers 「恢复默认顺序」 (objectstack#27241). + * Each `record:related_list` is a panel the reader arranges: on desktop its + * ⠿ handle drags it (a drop line marks where it lands), and focused, ⌥↑ / + * ⌥↓ move it; its `⋯` menu hides it. A panel never collapses — its header + * opens its whole list. The order and the hidden set are the reader's (the + * host's workspace preference), laid over the default order by + * `resolveRelatedArrangement`; the column says when the default changed + * under a dragged order (「默认顺序已更新 · 恢复默认」), and its foot says how + * many panels are hidden (「已隐藏 N 张 · 显示」) and, once the reader has moved + * or hidden any, offers 「恢复默认顺序」, which restores the order and the + * hidden set (objectstack#27241). Panels at 0 rows fold into one line + * (`RelatedListEmptyFold`'s panel line, 「还有 N 个空关联 · … · 展开」). * - * The cards' counts are asked up front, together (objectstack#27056, - * `useRelatedCounts`): until they answer each card is a placeholder, then its - * header draws from its count, a card at 0 reads no rows, and a card above 0 - * reads its first rows once it is in view (`RelatedList`'s compact face). + * The panels' counts are asked up front, together (objectstack#27056, + * `useRelatedCounts`): until they answer each panel is a placeholder, then + * its header draws from its count, a panel at 0 reads no rows, and a panel + * above 0 reads its first rows once it is in view (`RelatedList`'s compact + * face). * * ⛔ No wrapper element per card: the lists stay DOM siblings, so the * empty-list fold (`RelatedListEmptyFold`, which reads its stack off the @@ -220,8 +138,8 @@ export const RelatedCardStack: React.FC = ({ onDragStart: setDragId, onDragEnd: endDrag, }); - // The cards the reader sees — collapsed ones too, whose line shows the - // count — are counted in one set; a hidden card is not drawn, so not asked. + // The panels the reader sees are counted in one set; a hidden panel is not + // drawn, so not asked. const countTargets: RelatedCountTarget[] = []; for (const id of resolved.order) { const target = relatedCountTargetOf(cards.get(id)); @@ -285,15 +203,7 @@ export const RelatedCardStack: React.FC = ({ const state = counts.stateOf(id); if (state.kind === 'pending') return ; const count = state.kind === 'counted' ? state.count : null; - return resolved.collapsed.has(id) ? ( - edit({ kind: 'collapse', id, collapsed: false })} - count={count ?? undefined} - /> - ) : ( + return ( diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index 8504bb7aa4a..e18965d9970 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -65,6 +65,8 @@ import { listViewPredicates, parentRelationshipFieldDef, PLATFORM_RECORD_COLUMNS, + recordDisplayValueAt, + resolveNameField, composeParentScopeFilter, isMultiValueRelationship, mergeFilterNodes, @@ -92,7 +94,7 @@ import { useSeenOnce, } from './relatedListCompact'; import { relatedCardId } from './synth/relatedArrangement'; -import { RelatedCardMenu } from './RelatedCardMenu'; +import { RelatedCardMenu, RelatedPanelHandle } from './RelatedCardMenu'; import { detectStatusField } from '@object-ui/types'; export interface RelatedListProps { @@ -1181,9 +1183,42 @@ export const RelatedList: React.FC = ({ return declared.find((key) => !redactedFields.has(key) && isDate(key)); }, [compact, objectSchema, columns, redactedFields]); + /** + * The compact row's first column when the child's name field IS the foreign + * key back to this record (objectstack#27524, Target 11): a product's line + * items named by their product all read the product page's own name, so the + * row names the first column that is not that key — the list's authored + * columns when it has any, else the child's fields in declaration order — + * skipping platform columns, redacted and unreadable fields. `undefined`: + * the row names the record as everywhere else. + */ + const compactNameField = React.useMemo((): string | undefined => { + if (!compact || !objectSchema || !referenceField) return undefined; + if (resolveNameField(objectSchema) !== referenceField) return undefined; + const relatedObjectName = objectName || api || ''; + const authored = dropRedactedColumns(Array.isArray(columns) ? columns : [], redactedFields) + .map(drawnColumnKey) + .filter((key): key is string => !!key); + const fields = objectSchema.fields; + const declared = Array.isArray(fields) + ? fields.map((f: any) => f?.name).filter((n: unknown): n is string => typeof n === 'string') + : fields && typeof fields === 'object' + ? Object.keys(fields) + : []; + return (authored.length > 0 ? authored : declared).find( + (key) => + key !== referenceField && + !redactedFields.has(key) && + !PLATFORM_RECORD_COLUMNS.has(key) && + parentRelationshipFieldDef(fields, key) !== undefined && + !(perms?.isLoaded && relatedObjectName && !perms.checkField(relatedObjectName, key, 'read')), + ); + }, [compact, objectSchema, referenceField, columns, redactedFields, objectName, api, perms]); + /** * The compact card's projection (objectstack#27056, Target 2): `id`, the - * record's name field and the chip — what the card draws, nothing more. A + * record's name field (or the column that stands in for it, + * `compactNameField`) and the chip — what the card draws, nothing more. A * field only when the child declares it (or it is a platform column) and * the principal may read it. `undefined` — the list's own projection — when * the name cannot be read off a known field: no child definition, a legacy @@ -1198,7 +1233,7 @@ export const RelatedList: React.FC = ({ if (!declaredName && !derivedName) return undefined; const relatedObjectName = objectName || api || ''; const projection = new Set(['id']); - for (const field of [declaredName, derivedName, compactChipField]) { + for (const field of [compactNameField ?? declaredName, compactNameField ? undefined : derivedName, compactChipField]) { if (!field || redactedFields.has(field)) continue; const known = parentRelationshipFieldDef(objectSchema.fields, field) !== undefined; if (!known && !PLATFORM_RECORD_COLUMNS.has(field)) continue; @@ -1206,7 +1241,7 @@ export const RelatedList: React.FC = ({ projection.add(field); } return Array.from(projection); - }, [compact, objectSchema, objectName, api, perms, redactedFields, compactChipField]); + }, [compact, objectSchema, objectName, api, perms, redactedFields, compactChipField, compactNameField]); // What goes on the wire: the card's projection, and the reference roots it // names, when it has one; the list's own otherwise. const wireSelect = compactSelect ?? selectFields; @@ -2619,9 +2654,7 @@ export const RelatedList: React.FC = ({ }, [type, paginatedData, tableColumns, effectiveColumns, schema, effectivePageSize, hasRowActions, hasCustomRowActions, rowActions, onRowAction, onRowEdit, onRowDelete, handleDeleteRow, handleRowClick, isMobile, compact, api, objectSchema, activeSort, handleTableSort]); const headerClassName = isCollapsible ? 'cursor-pointer select-none' : undefined; - const handleHeaderClick = arrangedFull - ? () => cardChrome!.onCollapse() - : isCollapsible ? () => setCollapsed((c) => !c) : undefined; + const handleHeaderClick = isCollapsible ? () => setCollapsed((c) => !c) : undefined; const SectionIcon = resolveIconComponent(icon); // Badge shows the COLLECTION size: the server total in windowed mode (only @@ -2755,14 +2788,17 @@ export const RelatedList: React.FC = ({ ); - // objectstack#27055, Target 4 — the compact card of the record page's - // related column: title + count + + in one row, no table header, up to - // three rows of the record's name and ONE status / date chip, ⤢ to enlarge - // the list in place. The filter and sort are the list's own; the read asks - // for the rows the card names and the fields it draws (objectstack#27056). - // No 「查看全部」 footer: the one way to the whole list is ⤢ (Target 9) — the - // child's list page is reached from navigation. A card counted at 0 is its - // title row alone. + // objectstack#27055, Target 4 — the panel of the record page's related + // column: its header, no table header, up to three rows of the record's + // name and ONE status / date chip. The filter and sort are the list's own; + // the read asks for the rows the panel names and the fields it draws + // (objectstack#27056). A panel counted at 0 is its header alone. + // + // objectstack#27524, Target 1 — the header is the title and the count + // chip; the row (or the chip) opens the whole list, and on hover the + // title's end shows › with 「查看全部 N 条」. On hover or focus its right + // end shows ⠿ (drag, ⌥↑ / ⌥↓), + and ⋯ (隐藏 alone). No collapse, no ⤢: + // the whole list is the enlarged face. Nothing is reserved on the left. if (compact) { const rows = paginatedData.slice(0, COMPACT_RELATED_ROWS); const chipOf = (row: any): React.ReactNode => { @@ -2788,10 +2824,19 @@ export const RelatedList: React.FC = ({ ); }; - const enlarge = cardChrome?.onEnlarge; + const open = cardChrome?.onEnlarge; // Rows still to come: one placeholder per row the count says the card // names, so the card does not grow when they land. const pendingRows = countAnswers && cardCount! > 0 ? Math.min(cardCount!, COMPACT_RELATED_ROWS) : 0; + const countChip = !loadFailure && ( + + {recordCount} + + ); + const viewAll = t('detail.relatedPanelViewAll', { count: recordCount, defaultValue: 'View all {{count}}' }); return (
= ({ aria-busy={pendingRows > 0 || undefined} >
open() : undefined} > - {title} + {open ? ( + + ) : ( + <> + {title} + {countChip} + + )} {cardChrome?.fresh && ( {t('detail.relatedCardNew', { defaultValue: 'New' })} )} - {!loadFailure && ( - enlarge ? ( - - ) : ( - - {recordCount} - - ) - )} - + + {cardChrome && } {onNew && ( )} - {enlarge && ( - - )} - {cardChrome && } + {cardChrome && }
{loadFailure ? ( @@ -2892,7 +2932,9 @@ export const RelatedList: React.FC = ({
{t('detail.noRelatedRecords')}
) : ( rows.map((row: any, index: number) => { - const name = getRecordDisplayName(objectSchema, row) || String(row?.id ?? ''); + const name = + (compactNameField ? recordDisplayValueAt(row, compactNameField) : getRecordDisplayName(objectSchema, row)) || + String(row?.id ?? ''); return (
= ({ {add.label || t('detail.add', { defaultValue: 'Add' })} )} - {arrangedFull && } + {arrangedFull && } + {arrangedFull && } {bodyTable && enlargeRelated && ownRelationshipId && ( +

+ ); +}; + /** The one line naming a stack's folded lists, each with its New action. */ const EmptyFoldLine: React.FC<{ entries: FoldStackEntry[]; standalone: boolean }> = ({ entries, standalone }) => { const { t } = useDetailTranslation(); diff --git a/ui/packages/plugin-detail/src/index.tsx b/ui/packages/plugin-detail/src/index.tsx index cc1e378eadc..38d6c8d83fd 100644 --- a/ui/packages/plugin-detail/src/index.tsx +++ b/ui/packages/plugin-detail/src/index.tsx @@ -252,6 +252,7 @@ export { EMPTY_RELATED_ARRANGEMENT, RELATED_CARD_NEW_BADGE_MS, editRelatedArrangement, + RELATED_ARRANGEMENT_RECORD_VERSION, relatedArrangementFromRecord, relatedArrangementToRecord, relatedCardId, diff --git a/ui/packages/plugin-detail/src/relatedListCompact.ts b/ui/packages/plugin-detail/src/relatedListCompact.ts index 3e6c6f2f34d..7d1ccf154a2 100644 --- a/ui/packages/plugin-detail/src/relatedListCompact.ts +++ b/ui/packages/plugin-detail/src/relatedListCompact.ts @@ -10,10 +10,11 @@ import * as React from 'react'; /** * `true` inside the record page's related column (objectstack#26678): a - * fixed rail 320 px wide, where a related list draws as a compact card — - * title + count + + in one row, no table header, up to three rows of name + - * one status / date chip, ⤢ to enlarge (objectstack#27055, Target 4) — - * instead of a table cut off at the column's edge. + * fixed rail 320 px wide, where a related list draws as a panel — its header + * (title + count, the ⠿ + ⋯ controls on hover, objectstack#27524 Target 1), + * no table header, up to three rows of name + one status / date chip + * (objectstack#27055, Target 4) — instead of a table cut off at the column's + * edge. * * Set by `RecordPageColumns` only, so a related list anywhere else — the * Related tab, an authored page — draws exactly as before. Package-internal: @@ -25,25 +26,30 @@ export const RelatedListCompactContext = React.createContext(false); export const COMPACT_RELATED_ROWS = 3; /** - * The reader's controls over one related card (objectstack#27055, Targets 7, - * 9 and 10), handed to the card by the arranged stack that draws it. Absent - * outside that stack: the card then has no menu, no drag and no enlarge. + * The reader's controls over one related panel (objectstack#27055, Targets 7, + * 9 and 10; objectstack#27524, Target 1), handed to the panel by the arranged + * stack that draws it. Absent outside that stack: the panel then has no + * handle, no menu and no way to its whole list. */ export interface RelatedCardChrome { /** The relationship id, `CHILD_OBJECT.FK_FIELD`. */ id: string; /** New since the reader's last drag — the 「新」 badge. */ fresh: boolean; - /** Collapse the card to its title + count. */ - onCollapse: () => void; - /** Move one place up / down in the column; absent at the edge. */ + /** + * Move one place up / down in the column — ⌥↑ / ⌥↓ on the focused ⠿ + * handle, the drag's keyboard twin; absent at the edge. + */ onMoveUp?: () => void; onMoveDown?: () => void; - /** Hide the card (「隐藏」). */ + /** Hide the panel (「隐藏」, the ⋯ menu's one item). */ onHide: () => void; - /** Enlarge the list in place (⤢, the count chip) — `?related=object.field`. */ + /** + * Open the panel's whole list — its header row or its count chip + * (「查看全部 N 条」). The host's `?related=object.field`. + */ onEnlarge?: () => void; - /** Spread onto the title row: the card's drag handle (desktop only). */ + /** Spread onto the ⠿ handle: the panel's drag source (desktop only). */ dragHandleProps?: React.HTMLAttributes & { draggable?: boolean }; } diff --git a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx index e17a0e75423..71bf4821c9f 100644 --- a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -37,8 +37,8 @@ import { import type { RecordRelatedListComponentProps } from '@object-ui/types'; import { RelatedList } from '../RelatedList'; import { RelatedArrangementScopeContext } from '../ArrangedRelatedTab'; -import { CollapsedRelatedCard, RelatedDefaultChangedHint, RelatedFootLine } from '../RelatedCardStack'; -import { RelatedCardChromeContext } from '../relatedListCompact'; +import { RelatedDefaultChangedHint, RelatedFootLine } from '../RelatedCardStack'; +import { RelatedCardChromeContext, RelatedListCompactContext } from '../relatedListCompact'; import { relatedCardId } from '../synth/relatedArrangement'; import { useRecordAriaProps } from './recordComponentAria'; import { ParentRecordBinding } from './recordBinding'; @@ -227,6 +227,8 @@ const RecordRelatedListBody: React.FC = ({ }, []); const i18n = useSafeFieldLabel(); const { language } = useObjectTranslation(); + // A panel of the record page's related column (the fold rule below). + const panel = React.useContext(RelatedListCompactContext); const objectName = schema.objectName; @@ -610,10 +612,14 @@ const RecordRelatedListBody: React.FC = ({ * May this list fold into its stack's empty line when it has no records * (#25895, `RelatedListEmptyFold.tsx`)? Only when that line can carry * everything its row offers, which is New alone: an `add` picker, child - * toolbar actions or an authoring notice keep the list's own row. + * toolbar actions or an authoring notice keep the list's own row. A panel + * of the related column folds with its `add` and its actions too: the + * panel line's 展开 brings it back with them (objectstack#27524, Target 2). */ const foldable = - !schema.add && !(toolbarActions && toolbarActions.length > 0) && !showActionRefusals && !showUnservedActions; + !showActionRefusals && + !showUnservedActions && + (panel || (!schema.add && !(toolbarActions && toolbarActions.length > 0))); return ( = elementDataSourceBlock((props) => { const { schema } = props; - const { language } = useObjectTranslation(); // objectstack#27055 — inside the tabbed tier's 关联 tab the reader's - // arrangement applies (`ArrangedRelatedTab`): a collapsed list draws as its - // title + count and reads no rows; every other list carries its chrome; the - // tab's last list closes it with the changed-default hint and the foot line - // (the hidden cards, 「恢复默认顺序」). + // arrangement applies (`ArrangedRelatedTab`): every list carries its chrome; + // the tab's last list closes it with the changed-default hint and the foot + // line (the hidden lists, 「恢复默认顺序」). const scope = React.useContext(RelatedArrangementScopeContext); const arrangedId = scope && typeof schema?.objectName === 'string' && typeof schema?.relationshipField === 'string' @@ -899,25 +903,6 @@ export const RecordRelatedListRenderer: React.FC /> ); - if (scope.isCollapsed(arrangedId)) { - // `arrangedId` is set only when the schema names both keys. - const block = schema!; - const node = { - type: 'record:related_list', - properties: { - objectName: block.objectName, - relationshipField: block.relationshipField, - title: pickLocalized(block.title, language) || block.objectName, - ...(block.filter ? { filter: block.filter } : {}), - }, - }; - return ( - <> - scope.expand(arrangedId)} /> - {lines} - - ); - } return ( <> diff --git a/ui/packages/plugin-detail/src/synth/relatedArrangement.ts b/ui/packages/plugin-detail/src/synth/relatedArrangement.ts index 4c7724584f4..881af065eaa 100644 --- a/ui/packages/plugin-detail/src/synth/relatedArrangement.ts +++ b/ui/packages/plugin-detail/src/synth/relatedArrangement.ts @@ -5,28 +5,34 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * The reader's arrangement of the record page's related cards - * (objectstack#27055, Targets 7 and 10). + * The reader's arrangement of the record page's related panels + * (objectstack#27055, Targets 7 and 10; objectstack#27524, Target 10). * - * Related cards are the reader's to arrange: reorder (drag, or 上移 / 下移), - * collapse to title + count, hide. What a reader chose is kept per user × - * object in the console's workspace-preference store, never metadata; this - * module is the pure rule that lays the author's default order and the - * reader's choice over each other, so the column, the tabbed tier's 关联 tab - * and the tests read one answer. + * Related panels are the reader's to arrange: reorder (drag the ⠿ handle, or + * ⌥↑ / ⌥↓ on it) and hide. A panel never collapses: its header switches to + * its tab, which is its enlarged face (objectstack#27524). What a reader + * chose is kept per user × object in the console's workspace-preference + * store, never metadata; this module is the pure rule that lays the author's + * default order and the reader's choice over each other, so the column, the + * tabbed tier's 关联 tab and the tests read one answer. * * ## The stored value is a DIFF against the default order * - * `{ order, collapsed, hidden, basedOn }`, relationship ids being - * `object.field` (the child object and its FK field): + * `{ order, hidden, basedOn }` (stored as `{ v, order, hidden, basedOn, seen }`, + * the codec adding `v`), relationship ids being `object.field` (the child + * object and its FK field): * * - `order` — the order of the reader's last drag; EMPTY while the reader - * never dragged, which is "follow the default" (a collapse or a hide alone - * does not freeze the order); - * - `collapsed` / `hidden` — the cards the reader collapsed / hid; + * never dragged, which is "follow the default" (a hide alone does not + * freeze the order); + * - `hidden` — the panels the reader hid; * - `basedOn` — the hash of the default order the reader's `order` was laid * over ({@link relatedDefaultOrderHash}). * + * The earlier shape also carried `collapsed`; it is gone with no + * compatibility read (the codec's version moved, so a record of the old shape + * is "no choice"). + * * ## The merge rules (the maintainer's, on the card, each pinned by a test) * * 1. A reader who never dragged sees the default order. @@ -34,16 +40,16 @@ * author's default never reorders them — only 「恢复默认顺序」 does. * 3. A relationship new since the reader last dragged is inserted after the * nearest card that precedes it in the default order and exists in the - * reader's order (first in default → first), carries a 「新」 badge for - * 48 h, and takes the default collapsed state. + * reader's order (first in default → first) and carries a 「新」 badge for + * 48 h. * 4. A relationship that no longer exists is dropped from `order` (and from - * `collapsed` / `hidden`) on the next write. + * `hidden`) on the next write. * 5. When `basedOn` differs from the current default's hash, the column * says so: 「默认顺序已更新 · 恢复默认」. * 6. Hidden cards leave the column; its last line 「已隐藏 N 张 · 显示」 * brings them back. * - * Rule 3's 48 h needs a clock the four keys cannot carry, so the value has + * Rule 3's 48 h needs a clock the three keys cannot carry, so the value has * one more member, `seen` — when this reader first saw each new relationship * (epoch ms). It is written once per new relationship, by the host, and * dropped with the relationship. @@ -65,7 +71,6 @@ export type RelatedCardId = string; /** The reader's stored arrangement — see the module header. */ export interface RelatedArrangement { order: RelatedCardId[]; - collapsed: RelatedCardId[]; hidden: RelatedCardId[]; basedOn: string; /** When the reader first saw each relationship new since their last drag (epoch ms). */ @@ -74,12 +79,11 @@ export interface RelatedArrangement { /** * The arrangement of a reader who chose nothing: the default order, every - * card open and shown. Equivalent to no stored value in every rule — the - * host's fallback while the store holds no choice. + * panel shown. Equivalent to no stored value in every rule — the host's + * fallback while the store holds no choice. */ export const EMPTY_RELATED_ARRANGEMENT: RelatedArrangement = Object.freeze({ order: [], - collapsed: [], hidden: [], basedOn: '', }) as RelatedArrangement; @@ -113,7 +117,6 @@ export interface ResolvedRelatedArrangement { order: RelatedCardId[]; /** Every existing card in display order, hidden ones in their place. */ fullOrder: RelatedCardId[]; - collapsed: ReadonlySet; hidden: RelatedCardId[]; /** Cards that carry the 「新」 badge (rule 3). */ fresh: ReadonlySet; @@ -132,18 +135,11 @@ export interface ResolvedRelatedArrangement { rearranged: boolean; } -/** - * Lay the reader's stored arrangement over the default order (rules 1–6). - * - * `defaultCollapsed` is the default collapsed state of each card (a card the - * page draws collapsed until the reader opens it); every card is open by - * default when it is omitted. - */ +/** Lay the reader's stored arrangement over the default order (rules 1–6). */ export function resolveRelatedArrangement( defaultOrder: readonly RelatedCardId[], stored: RelatedArrangement | undefined, now: number, - defaultCollapsed: ReadonlySet = new Set(), ): ResolvedRelatedArrangement { const exists = new Set(defaultOrder); const dragged = !!stored && stored.order.length > 0; @@ -174,13 +170,6 @@ export function resolveRelatedArrangement( }); } const hiddenSet = new Set((stored?.hidden ?? []).filter((id) => exists.has(id))); - const collapsed = new Set(); - for (const id of fullOrder) { - // A new relationship takes the default collapsed state (rule 3); every - // other card the state the reader's last write recorded for it. - const fromDefault = !stored || newSinceDrag.includes(id); - if (fromDefault ? defaultCollapsed.has(id) : stored!.collapsed.includes(id)) collapsed.add(id); - } // Rule 3's badge runs on the reader's clock, not on the order: a // relationship new since the last drag is fresh until stamped, and a stamped // one stays fresh for 48 h from its stamp — a later drag, which takes it @@ -201,7 +190,6 @@ export function resolveRelatedArrangement( return { order: fullOrder.filter((id) => !hiddenSet.has(id)), fullOrder, - collapsed, hidden, fresh, unseen, @@ -216,14 +204,12 @@ export type RelatedArrangementEdit = | { kind: 'move'; id: RelatedCardId; to: number } | { kind: 'move-up'; id: RelatedCardId } | { kind: 'move-down'; id: RelatedCardId } - | { kind: 'collapse'; id: RelatedCardId; collapsed: boolean } | { kind: 'hide'; id: RelatedCardId } | { kind: 'show-hidden' } | { kind: 'restore-default-order' } /** * 「恢复默认顺序」 (objectstack#27241): the default order with every card - * shown — the column a reader who never arranged it sees; collapsed state - * stays the reader's. + * shown — the column a reader who never arranged it sees. */ | { kind: 'restore-default-arrangement' } | { kind: 'mark-seen'; ids: RelatedCardId[]; at: number }; @@ -241,9 +227,8 @@ export function editRelatedArrangement( stored: RelatedArrangement | undefined, edit: RelatedArrangementEdit, now: number, - defaultCollapsed: ReadonlySet = new Set(), ): RelatedArrangement { - const resolved = resolveRelatedArrangement(defaultOrder, stored, now, defaultCollapsed); + const resolved = resolveRelatedArrangement(defaultOrder, stored, now); const exists = new Set(defaultOrder); const hash = relatedDefaultOrderHash(defaultOrder); const keepSeen = (seen: Record | undefined) => { @@ -257,7 +242,6 @@ export function editRelatedArrangement( // until the reader drags again. const base: RelatedArrangement = { order: resolved.dragged ? stored!.order.filter((id, index, all) => exists.has(id) && all.indexOf(id) === index) : [], - collapsed: resolved.fullOrder.filter((id) => resolved.collapsed.has(id)), hidden: [...resolved.hidden], basedOn: resolved.dragged ? stored!.basedOn : hash, seen: keepSeen(stored?.seen), @@ -289,11 +273,6 @@ export function editRelatedArrangement( if (at < 0) return finish(base); return moveTo(edit.id, edit.kind === 'move-up' ? at - 1 : at + 1); } - case 'collapse': { - const collapsed = base.collapsed.filter((id) => id !== edit.id); - if (edit.collapsed && exists.has(edit.id)) collapsed.push(edit.id); - return finish({ ...base, collapsed: resolved.fullOrder.filter((id) => collapsed.includes(id)) }); - } case 'hide': if (!exists.has(edit.id) || base.hidden.includes(edit.id)) return finish(base); return finish({ ...base, hidden: resolved.fullOrder.filter((id) => id === edit.id || base.hidden.includes(id)) }); @@ -313,6 +292,13 @@ export function editRelatedArrangement( } } +/** + * The version of the stored record (`v`, added by the host's codec). Version + * 1 carried `collapsed`; version 2 does not, and a version-1 record is "no + * choice" — no compatibility read (objectstack#27524, Target 10). + */ +export const RELATED_ARRANGEMENT_RECORD_VERSION = 2; + /** * Read a stored record into an arrangement, or `undefined` for one this * module does not read (the codec's `fromRecord`). Strict: every list is an @@ -322,9 +308,8 @@ export function relatedArrangementFromRecord(record: Record): R const ids = (value: unknown): string[] | undefined => Array.isArray(value) && value.every((v) => typeof v === 'string') ? (value as string[]) : undefined; const order = ids(record.order); - const collapsed = ids(record.collapsed); const hidden = ids(record.hidden); - if (!order || !collapsed || !hidden || typeof record.basedOn !== 'string') return undefined; + if (!order || !hidden || typeof record.basedOn !== 'string') return undefined; let seen: Record | undefined; if (record.seen !== undefined) { if (!record.seen || typeof record.seen !== 'object' || Array.isArray(record.seen)) return undefined; @@ -334,14 +319,13 @@ export function relatedArrangementFromRecord(record: Record): R seen[id] = at; } } - return { order, collapsed, hidden, basedOn: record.basedOn, ...(seen ? { seen } : {}) }; + return { order, hidden, basedOn: record.basedOn, ...(seen ? { seen } : {}) }; } /** The stored record of an arrangement (the codec's `toRecord`). */ export function relatedArrangementToRecord(value: RelatedArrangement): Record { return { order: value.order, - collapsed: value.collapsed, hidden: value.hidden, basedOn: value.basedOn, ...(value.seen && Object.keys(value.seen).length > 0 ? { seen: value.seen } : {}), diff --git a/ui/packages/plugin-detail/src/useRelatedArrangement.ts b/ui/packages/plugin-detail/src/useRelatedArrangement.ts index 076cf21cf9a..b4a0d1519dc 100644 --- a/ui/packages/plugin-detail/src/useRelatedArrangement.ts +++ b/ui/packages/plugin-detail/src/useRelatedArrangement.ts @@ -27,7 +27,7 @@ export interface UseRelatedArrangementOptions { onArrangementChange?: (next: RelatedArrangement) => void; /** Enlarge a card's list in place (the host's `?related=`). */ onEnlarge?: (id: RelatedCardId) => void; - /** Hand each card a drag handle (desktop column tiers). */ + /** Make each panel's ⠿ handle a drag source (desktop column tiers). */ draggable?: boolean; /** The drag's start / end, for the stack that draws the drop line. */ onDragStart?: (id: RelatedCardId) => void; @@ -42,10 +42,11 @@ export interface RelatedArrangementControls { } /** - * The reader's arrangement of a page's related cards, live (objectstack#27055, - * Targets 7 and 10): the stored value laid over the default order, the edits - * written back through the host, and each card's chrome (⋯ menu, collapse, - * hide, enlarge, drag handle). One hook for the column tiers' card stack and + * The reader's arrangement of a page's related panels, live (objectstack#27055, + * Targets 7 and 10; objectstack#27524, Targets 1 and 10): the stored value + * laid over the default order, the edits written back through the host, and + * each panel's chrome (the ⠿ handle's drag and ⌥↑ / ⌥↓, the ⋯ menu's hide, + * the way to the whole list). One hook for the column tiers' panel stack and * the tabbed tier's 关联 tab, so both read and write the same arrangement. * * A relationship new since the reader's last drag is stamped as seen the @@ -84,7 +85,6 @@ export function useRelatedArrangement({ const chromeOf = (id: RelatedCardId, index: number): RelatedCardChrome => ({ id, fresh: resolved.fresh.has(id), - onCollapse: () => edit({ kind: 'collapse', id, collapsed: true }), onMoveUp: index > 0 ? () => edit({ kind: 'move-up', id }) : undefined, onMoveDown: index < resolved.order.length - 1 ? () => edit({ kind: 'move-down', id }) : undefined, onHide: () => edit({ kind: 'hide', id }), @@ -96,6 +96,11 @@ export function useRelatedArrangement({ onDragStart: (event: React.DragEvent) => { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', id); + // The handle is a few pixels; the reader drags the whole panel. + const panel = (event.currentTarget as HTMLElement).closest('[data-related-card-id]'); + if (panel && typeof event.dataTransfer.setDragImage === 'function') { + event.dataTransfer.setDragImage(panel, 16, 16); + } onDragStart?.(id); }, onDragEnd: () => onDragEnd?.(), From 96652743f23488daae167a447c29b4cb3b56052e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 02:19:36 +0000 Subject: [PATCH 2/7] wip(plugin-detail): related panels tests, changeset, header click guard Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt Co-authored-by: Claude --- .../27524-record-page-related-panels.md | 26 +++ ...reference.relatedArrangement-27524.test.ts | 53 +++++ ui/packages/plugin-detail/src/RelatedList.tsx | 8 +- .../__tests__/RelatedCardStack-27055.test.tsx | 29 ++- .../RelatedList.compactColumn-26678.test.tsx | 14 +- .../RelatedPanel.header-27524.test.tsx | 199 ++++++++++++++++++ ...rd-related-list.emptyPanels-27524.test.tsx | 128 +++++++++++ .../synth/__tests__/highlights-27524.test.ts | 48 +++++ .../relatedArrangement-27055.test.ts | 58 ++--- 9 files changed, 511 insertions(+), 52 deletions(-) create mode 100644 .changeset/27524-record-page-related-panels.md create mode 100644 ui/packages/app-shell/src/utils/workspacePreference.relatedArrangement-27524.test.ts create mode 100644 ui/packages/plugin-detail/src/__tests__/RelatedPanel.header-27524.test.tsx create mode 100644 ui/packages/plugin-detail/src/renderers/__tests__/record-related-list.emptyPanels-27524.test.tsx create mode 100644 ui/packages/plugin-detail/src/synth/__tests__/highlights-27524.test.ts diff --git a/.changeset/27524-record-page-related-panels.md b/.changeset/27524-record-page-related-panels.md new file mode 100644 index 00000000000..ee089597ee5 --- /dev/null +++ b/.changeset/27524-record-page-related-panels.md @@ -0,0 +1,26 @@ +--- +'@object-ui/plugin-detail': patch +'@object-ui/i18n': patch +'@object-ui/app-shell': patch +--- + +feat(plugin-detail)!: the record page's related panels lose collapse — the header opens the whole list, ⠿ + ⋯ on hover, ⋯ holds Hide alone, empty panels fold into one line, and the stored arrangement drops `collapsed` + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +The record page's related column is a stack of same-shaped panels (record page v4). + +- **The panel header** is the title, then the count chip. The header row (or the chip) opens the panel's whole list; on hover the title's end shows › and its tooltip reads "View all N". On hover or focus the right end shows ⠿ (drag to reorder; focused, ⌥↑ / ⌥↓ move the panel), + and ⋯, which now holds Hide and nothing else. There is no collapse and no ⤢, and nothing is reserved on the left. +- **Empty panels** (0 rows) fold into one line after the others: "N more empty lists · name · … · Expand". Expand draws them again in their places. The Related tab of an authored page keeps its own line. +- **The compact row's first column** skips the foreign key that points back at the record on the page: a product's line items, whose name field is the product, are named by their next column. +- **The stored arrangement** (`record.related.arrangement`, per reader × object) is `{ v, order, hidden, basedOn, seen }`. `collapsed` is removed with no compatibility read: a reader's earlier record reads as no choice, and the panels show in the default order. + +**BREAKING** for a TypeScript consumer of `@object-ui/plugin-detail`'s arrangement API; nothing an author declares changes. + +- FROM `RelatedArrangement.collapsed` / `ResolvedRelatedArrangement.collapsed` TO nothing: a panel has no collapsed state. Drop the member from any value you build. +- FROM the `{ kind: 'collapse', id, collapsed }` edit TO `{ kind: 'hide', id }`, or no edit at all. +- FROM `resolveRelatedArrangement(order, stored, now, defaultCollapsed)` / `editRelatedArrangement(order, stored, edit, now, defaultCollapsed)` TO the same calls without the last argument. +- The i18n keys `detail.relatedCardMoveUp`, `detail.relatedCardMoveDown`, `detail.relatedCardCollapse` and `detail.relatedCardCount` leave every pack; `detail.relatedPanelViewAll`, `detail.relatedPanelReorder`, `detail.relatedPanelReorderHint` and `detail.relatedEmptyFold` arrive in all ten. diff --git a/ui/packages/app-shell/src/utils/workspacePreference.relatedArrangement-27524.test.ts b/ui/packages/app-shell/src/utils/workspacePreference.relatedArrangement-27524.test.ts new file mode 100644 index 00000000000..ccfd773bd6c --- /dev/null +++ b/ui/packages/app-shell/src/utils/workspacePreference.relatedArrangement-27524.test.ts @@ -0,0 +1,53 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27524, Target 10 — the stored arrangement of the record page's + * related panels is `{ v, order, hidden, basedOn, seen }`. `collapsed` left + * the shape with no compatibility read: a record of the earlier version (the + * one that carried it) is "no choice", and the reader gets the default order + * again. This file runs in the node project (no `window`). + */ + +import { describe, it, expect } from 'vitest'; +import { + RECORD_RELATED_ARRANGEMENT_PREFERENCE, + objectPreferenceScope, + readWorkspacePreference, + writeWorkspacePreference, +} from './workspacePreference'; + +const lead = objectPreferenceScope('crm_lead'); +const KEY = 'os:pref:record.related.arrangement@object:crm_lead'; + +const memoryStorage = () => { + const kept = new Map(); + return { + kept, + getItem: (key: string) => kept.get(key) ?? null, + setItem: (key: string, value: string) => void kept.set(key, value), + }; +}; + +describe('record.related.arrangement — no `collapsed`, no compatibility read (objectstack#27524)', () => { + it('writes { order, hidden, basedOn, seen } and the version, and reads it back', () => { + const storage = memoryStorage(); + const value = { order: ['task.lead_id', 'note.parent_id'], hidden: ['call.lead_id'], basedOn: 'abcd1234', seen: { 'task.lead_id': 1 } }; + writeWorkspacePreference(RECORD_RELATED_ARRANGEMENT_PREFERENCE, lead, value, storage); + const stored = JSON.parse(storage.kept.get(KEY)!); + expect(Object.keys(stored).sort()).toEqual(['basedOn', 'hidden', 'order', 'seen', 'v']); + expect(stored.v).toBe(2); + expect(readWorkspacePreference(RECORD_RELATED_ARRANGEMENT_PREFERENCE, lead, storage)).toEqual(value); + }); + + it('reads no choice from a record of the shape that carried `collapsed`', () => { + const earlier = JSON.stringify({ order: ['note.parent_id'], collapsed: ['task.lead_id'], hidden: [], basedOn: 'abcd1234', v: 1 }); + const storage = { getItem: (key: string) => (key === KEY ? earlier : null) }; + expect(readWorkspacePreference(RECORD_RELATED_ARRANGEMENT_PREFERENCE, lead, storage)).toBeUndefined(); + }); +}); diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index e18965d9970..84c6d5ab9a2 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -2850,8 +2850,12 @@ export const RelatedList: React.FC = ({ className={cn('group/panel-head flex h-9 items-center gap-1.5 px-3 text-[12.5px] font-semibold', open && 'cursor-pointer')} data-related-panel-head="" // The row is the target: a click on its free space opens the list - // too. Its own controls stop their clicks. - onClick={open ? () => open() : undefined} + // too. Its own controls stop their clicks; a click inside the ⋯ + // menu's portaled content bubbles here through React yet lands + // outside the row in the DOM, and is not the row's. + onClick={open ? (event) => { + if (event.currentTarget.contains(event.target as Node)) open(); + } : undefined} > {open ? ( } {chrome?.onMoveDown && } - {chrome && } {chrome && } {chrome?.fresh && new}
@@ -93,7 +94,7 @@ describe('RelatedCardStack — the reader arranges the related cards (objectstac expect(cardOrder(container)).toEqual(['call.lead_id', 'task.lead_id', 'note.parent_id']); }); - it('上移 / 下移 write the new order through the host', () => { + it('the handle’s ⌥↑ / ⌥↓ write the new order through the host', () => { const onWrite = vi.fn(); const { container } = render(); fireEvent.click(screen.getByText('down task')); @@ -104,16 +105,10 @@ describe('RelatedCardStack — the reader arranges the related cards (objectstac expect(screen.queryByText('down call')).toBeNull(); }); - it('a collapsed card is drawn without the list — it reads no rows', () => { + it('a panel carries no collapse: every shown panel mounts its list (objectstack#27524, Target 1)', () => { const { container } = render(); - fireEvent.click(screen.getByText('collapse note')); - const collapsed = container.querySelector('[data-related-card-collapsed]'); - expect(collapsed?.getAttribute('data-related-card-id')).toBe('note.parent_id'); - // The list block for `note` is not mounted at all. - expect(container.querySelector('[data-node="record:related_list"][data-related-card-id="note.parent_id"]')).toBeNull(); - // Expanding mounts it again. - fireEvent.click(collapsed!.querySelector('button[aria-expanded="false"]')!); - expect(container.querySelector('[data-node="record:related_list"][data-related-card-id="note.parent_id"]')).not.toBeNull(); + expect(container.querySelectorAll('[data-node="record:related_list"]')).toHaveLength(DEFAULT.length); + expect(container.querySelector('[data-related-card-collapsed]')).toBeNull(); }); it('a hidden card leaves the column; the last line brings them back', () => { diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedList.compactColumn-26678.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedList.compactColumn-26678.test.tsx index f575fcbcc26..201bd659ddc 100644 --- a/ui/packages/plugin-detail/src/__tests__/RelatedList.compactColumn-26678.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RelatedList.compactColumn-26678.test.tsx @@ -98,18 +98,22 @@ describe('RelatedList in the related column (objectstack#26678)', () => { expect(screen.queryByText(/View all/i)).not.toBeInTheDocument(); }); - it('offers ⤢ and the ⋯ menu only with the arranged stack\'s chrome, and ⤢ enlarges', async () => { + it('offers the handle, the ⋯ menu and the way to the whole list only with the arranged stack\'s chrome (objectstack#27524)', async () => { renderList(true); await waitFor(() => expect(screen.getByText('Call Ada')).toBeInTheDocument()); - expect(document.querySelector('[data-related-card-enlarge]')).toBeNull(); + expect(document.querySelector('[data-related-panel-open]')).toBeNull(); + expect(document.querySelector('[data-related-panel-handle]')).toBeNull(); expect(document.querySelector('[data-related-card-menu]')).toBeNull(); document.body.innerHTML = ''; const onEnlarge = vi.fn(); - renderList(true, { chrome: { id: 'task.lead_id', fresh: true, onCollapse: vi.fn(), onHide: vi.fn(), onEnlarge } }); + renderList(true, { chrome: { id: 'task.lead_id', fresh: true, onHide: vi.fn(), onEnlarge } }); await waitFor(() => expect(screen.getByText('Call Ada')).toBeInTheDocument()); - (document.querySelector('[data-related-card-enlarge]') as HTMLButtonElement).click(); - (document.querySelector('[data-related-card-count]') as HTMLButtonElement).click(); + // No ⤢ any more: the header (title + count chip) opens the whole list. + expect(document.querySelector('[data-related-card-enlarge]')).toBeNull(); + (document.querySelector('[data-related-panel-open]') as HTMLButtonElement).click(); + (document.querySelector('[data-related-card-count]') as HTMLElement).click(); expect(onEnlarge).toHaveBeenCalledTimes(2); + expect(document.querySelector('[data-related-panel-handle]')).not.toBeNull(); expect(document.querySelector('[data-related-card-menu]')).not.toBeNull(); expect(document.querySelector('[data-related-card-new]')).not.toBeNull(); }); diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedPanel.header-27524.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedPanel.header-27524.test.tsx new file mode 100644 index 00000000000..9b5a3fae3e2 --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/RelatedPanel.header-27524.test.tsx @@ -0,0 +1,199 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27524 — a related panel of the record page's related column, + * drawn by the REAL `RelatedList` compact face with the arranged stack's + * chrome: + * + * - Target 1: the header is the title, then the count chip; the row (or the + * chip) opens the whole list, and its title says 「查看全部 N 条」; ⠿ + ⋯ sit + * at the RIGHT end, drawn on hover or focus; ⋯ holds 隐藏 alone; no + * collapse, no ⤢; ⌥↑ / ⌥↓ on the focused handle move the panel. + * - Target 11: the compact row's first column skips the foreign key that + * points back at this record. + */ +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { RelatedList } from '../RelatedList'; +import { RelatedCardChromeContext, RelatedListCompactContext, type RelatedCardChrome } from '../relatedListCompact'; + +// Desktop, pinned rather than inherited: below 768 px a table list renders as a gallery. +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); +afterEach(cleanup); + +const fields = { + name: { type: 'text', label: 'Name' }, + status: { type: 'select', label: 'Status', options: [{ value: 'open', label: 'Open' }] }, +}; +const rows = [ + { id: 't1', name: 'Call Ada', status: 'open' }, + { id: 't2', name: 'Send deck', status: 'open' }, +]; + +const chromeOf = (over: Partial = {}): RelatedCardChrome => ({ + id: 'task.lead_id', + fresh: false, + onHide: vi.fn(), + onEnlarge: vi.fn(), + onMoveUp: vi.fn(), + onMoveDown: vi.fn(), + ...over, +}); + +function renderPanel(chrome: RelatedCardChrome, extra: { onNew?: () => void } = {}) { + return render( + + + rows), getObjectSchema: vi.fn(async () => ({ name: 'task', fields })) } as any} + /> + + , + ); +} + +const head = () => document.querySelector('[data-related-panel-head]') as HTMLElement; + +describe('the related panel header (objectstack#27524, Target 1)', () => { + it('is the title, then the count chip; nothing reserved on the left, no chevron toggle, no ⤢', async () => { + renderPanel(chromeOf(), { onNew: vi.fn() }); + await waitFor(() => expect(screen.getByText('Call Ada')).toBeInTheDocument()); + const open = head().querySelector('[data-related-panel-open]')!; + expect(head().firstElementChild).toBe(open); + expect(open.firstElementChild?.textContent).toBe('Tasks'); + expect(open.querySelector('[data-related-card-count]')?.textContent).toBe('2'); + // No collapse toggle and no enlarge button anywhere in the header (the ⋯ + // trigger's own aria-expanded is the menu's). + expect(head().querySelector('[aria-expanded]:not([data-related-card-menu])')).toBeNull(); + expect(head().querySelector('[data-related-card-enlarge]')).toBeNull(); + // The title says where the row goes: 「查看全部 N 条」. + expect(open.getAttribute('title')).toBe('View all 2'); + }); + + it('the header row, its title and its count chip open the whole list', async () => { + const chrome = chromeOf(); + renderPanel(chrome); + await waitFor(() => expect(screen.getByText('Call Ada')).toBeInTheDocument()); + fireEvent.click(head()); + fireEvent.click(head().querySelector('[data-related-panel-open]')!); + fireEvent.click(head().querySelector('[data-related-card-count]')!); + expect(chrome.onEnlarge).toHaveBeenCalledTimes(3); + }); + + it('⠿ + ⋯ sit at the right end, shown on hover or focus; their clicks do not open the list', async () => { + const chrome = chromeOf(); + const onNew = vi.fn(); + renderPanel(chrome, { onNew }); + await waitFor(() => expect(screen.getByText('Call Ada')).toBeInTheDocument()); + const controls = head().querySelector('[data-related-panel-controls]') as HTMLElement; + expect(head().lastElementChild).toBe(controls); + expect(controls.className).toMatch(/\bopacity-0\b/); + expect(controls.className).toContain('group-hover/panel-head:opacity-100'); + expect(controls.className).toContain('group-focus-within/panel-head:opacity-100'); + const order = Array.from(controls.querySelectorAll('button')).map((b) => + b.hasAttribute('data-related-panel-handle') ? 'handle' : b.hasAttribute('data-related-card-menu') ? 'menu' : b.getAttribute('data-testid'), + ); + expect(order).toEqual(['handle', 'related-list-new', 'menu']); + fireEvent.click(controls.querySelector('[data-testid="related-list-new"]')!); + fireEvent.click(controls.querySelector('[data-related-panel-handle]')!); + expect(onNew).toHaveBeenCalledTimes(1); + expect(chrome.onEnlarge).not.toHaveBeenCalled(); + }); + + it('⋯ holds 隐藏 and nothing else', async () => { + const chrome = chromeOf(); + renderPanel(chrome); + await waitFor(() => expect(screen.getByText('Call Ada')).toBeInTheDocument()); + const user = userEvent.setup(); + await user.click(head().querySelector('[data-related-card-menu]')!); + const items = await screen.findAllByRole('menuitem'); + expect(items.map((item) => item.textContent)).toEqual(['Hide']); + await user.click(items[0]); + expect(chrome.onHide).toHaveBeenCalledTimes(1); + expect(chrome.onEnlarge).not.toHaveBeenCalled(); + }); + + it('⌥↑ / ⌥↓ on the focused handle move the panel; a bare arrow does not', async () => { + const chrome = chromeOf(); + renderPanel(chrome); + await waitFor(() => expect(screen.getByText('Call Ada')).toBeInTheDocument()); + const handle = head().querySelector('[data-related-panel-handle]') as HTMLButtonElement; + expect(handle.getAttribute('aria-label')).toBe('Reorder Tasks'); + expect(handle.getAttribute('aria-keyshortcuts')).toBe('Alt+ArrowUp Alt+ArrowDown'); + act(() => handle.focus()); + fireEvent.keyDown(handle, { key: 'ArrowUp' }); + expect(chrome.onMoveUp).not.toHaveBeenCalled(); + fireEvent.keyDown(handle, { key: 'ArrowUp', altKey: true }); + fireEvent.keyDown(handle, { key: 'ArrowDown', altKey: true }); + expect(chrome.onMoveUp).toHaveBeenCalledTimes(1); + expect(chrome.onMoveDown).toHaveBeenCalledTimes(1); + }); + + it('the handle is the drag source on desktop, not the header row', async () => { + const onDragStart = vi.fn(); + renderPanel(chromeOf({ dragHandleProps: { draggable: true, onDragStart } })); + await waitFor(() => expect(screen.getByText('Call Ada')).toBeInTheDocument()); + expect(head().getAttribute('draggable')).toBeNull(); + const handle = head().querySelector('[data-related-panel-handle]')!; + expect(handle.getAttribute('draggable')).toBe('true'); + }); +}); + +describe('the compact row’s first column (objectstack#27524, Target 11)', () => { + // A product's line items: the child's name field IS the key back to the product. + const lineFields = { + product: { type: 'lookup', label: 'Product', reference: 'product' }, + opportunity: { type: 'lookup', label: 'Opportunity', reference: 'opportunity' }, + quantity: { type: 'number', label: 'Qty' }, + }; + const lineRows = [ + { id: 'l1', product: { id: 'p1', name: 'Widget' }, opportunity: { id: 'o1', name: 'Acme renewal' }, quantity: 2 }, + { id: 'l2', product: { id: 'p1', name: 'Widget' }, opportunity: { id: 'o2', name: 'Globex pilot' }, quantity: 5 }, + ]; + const renderLines = (referenceField: string) => + render( + + lineRows), + getObjectSchema: vi.fn(async () => ({ name: 'line_item', nameField: 'product', fields: lineFields })), + } as any} + /> + , + ); + const names = () => + Array.from(document.querySelectorAll('[data-related-card-row]')).map((row) => row.firstElementChild?.textContent); + + it('on the product page, names the first column that is not the key back to it', async () => { + renderLines('product'); + await waitFor(() => expect(names()).toEqual(['Acme renewal', 'Globex pilot'])); + }); + + it('on the opportunity page, the name field is not that key: the row names the record as everywhere else', async () => { + renderLines('opportunity'); + await waitFor(() => expect(names()).toEqual(['Widget', 'Widget'])); + }); +}); diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/record-related-list.emptyPanels-27524.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/record-related-list.emptyPanels-27524.test.tsx new file mode 100644 index 00000000000..4075135e41b --- /dev/null +++ b/ui/packages/plugin-detail/src/renderers/__tests__/record-related-list.emptyPanels-27524.test.tsx @@ -0,0 +1,128 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * objectstack#27524, Target 2 — in the record page's related column the + * related lists are panels, and the empty ones (0 rows) fold into ONE line: + * 「还有 N 个空关联 · 名字 · … · 展开」. The non-empty panels keep their order; + * 展开 brings the empty ones back in their places, each with its own header + * controls, so the line carries no New — and a panel with an `add` picker + * folds too. Unlike the Related tab's line (#25895, pinned in + * `record-related-list.emptyFold-25895.test.tsx`), a column whose panels are + * all empty folds them as well. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { + RecordContextProvider, + RelatedRecordActionsProvider, + SchemaRenderer, + SchemaRendererProvider, + type RelatedRecordActionsValue, +} from '@object-ui/react'; +import type { DataSource } from '@object-ui/types'; +import { RelatedListCompactContext } from '../../relatedListCompact'; +// Registers `record:related_list`, the block under test. +import '../../index'; + +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +// A panel reads its rows once it is seen; with no IntersectionObserver every +// panel is seen at once (the case `RelatedList.compactLazy-27056` pins). +beforeEach(() => { + vi.stubGlobal('IntersectionObserver', undefined); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +type Rows = Record>>; + +const makeDataSource = (rows: Rows) => ({ + find: vi.fn(async (object: string) => { + const data = rows[object] ?? []; + return { data, total: data.length }; + }), + getObjectSchema: vi.fn(async (name: string) => ({ name, fields: { name: { type: 'text', label: 'Name' } } })), + onMutation: () => () => {}, +}); + +const panel = (objectName: string, title: string, extra: Record = {}) => ({ + type: 'record:related_list', + properties: { objectName, title, relationshipField: 'contact_id', columns: ['name'], ...extra }, +}); + +function mount(nodes: object[], rows: Rows) { + const ds = makeDataSource(rows); + const host: RelatedRecordActionsValue = { resolve: () => ({ onCreate: () => {} }) }; + const r = render( + + + + + {/* The related column: the panels are siblings in one container. */} + + + + + , + ); + return { ...r, ds }; +} + +const line = () => screen.queryByTestId('record-related-panels-empty-fold'); +/** The panels drawn in the column, by title, in document order. */ +const shownPanels = () => + Array.from(document.querySelectorAll('[data-related-card]')) + .filter((el) => !el.closest('[hidden]')) + .map((el) => el.getAttribute('aria-label')); + +describe('the empty panels fold into one line (objectstack#27524, Target 2)', () => { + it('「还有 N 个空关联 · 名字 · … · 展开」 after the panels with rows, which keep their order', async () => { + const { ds } = mount( + [panel('task', 'Tasks'), panel('quote', 'Quotes'), panel('opportunity', 'Opportunities'), panel('contract', 'Contracts', { add: { picker: { object: 'contract' } } })], + { task: [{ id: 't1', name: 'Call Ada' }], opportunity: [{ id: 'o1', name: 'Acme renewal' }] }, + ); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(4)); + await waitFor(() => expect(line()).not.toBeNull()); + expect(shownPanels()).toEqual(['Tasks', 'Opportunities']); + const text = line()!.textContent!.replace(/\s+/g, ' '); + // The picker panel folds too: 展开 brings its + back with it. + expect(text).toBe('2 more empty lists·Quotes·Contracts·Expand'); + // The line names the panels; it carries no New of its own. + expect(line()!.querySelectorAll('button')).toHaveLength(1); + }); + + it('展开 brings the empty panels back in their places, and the line goes', async () => { + const { ds } = mount([panel('task', 'Tasks'), panel('quote', 'Quotes'), panel('opportunity', 'Opportunities')], { + task: [{ id: 't1', name: 'Call Ada' }], + }); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(3)); + await waitFor(() => expect(line()).not.toBeNull()); + fireEvent.click(line()!.querySelector('[data-related-empty-fold-expand]')!); + expect(line()).toBeNull(); + expect(shownPanels()).toEqual(['Tasks', 'Quotes', 'Opportunities']); + }); + + it('a column whose panels are all empty folds them too', async () => { + const { ds } = mount([panel('task', 'Tasks'), panel('quote', 'Quotes')], {}); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(line()).not.toBeNull()); + expect(shownPanels()).toEqual([]); + expect(line()!.textContent!.replace(/\s+/g, ' ')).toBe('2 more empty lists·Tasks·Quotes·Expand'); + }); +}); diff --git a/ui/packages/plugin-detail/src/synth/__tests__/highlights-27524.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/highlights-27524.test.ts new file mode 100644 index 00000000000..8e556205ace --- /dev/null +++ b/ui/packages/plugin-detail/src/synth/__tests__/highlights-27524.test.ts @@ -0,0 +1,48 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27524, Target 12 — the record header's highlights are the + * object's declared `highlightFields` minus the name field (the title), at + * most six. Nothing is invented: an object with no status field shows none. + * The declared sets are HotCRM's, as the v4 boards were audited against them. + */ + +import { describe, expect, it } from 'vitest'; +import { RECORD_HIGHLIGHTS_MAX, deriveHighlightFields } from '../buildDefaultPageSchema'; + +const text = { type: 'text' }; + +describe('highlights = the declared highlightFields minus the name field, max 6 (objectstack#27524, Target 12)', () => { + it('a lead: the full name is the title, the rest are its highlights in the declared order', () => { + const lead = { + name: 'crm_lead', + nameField: 'full_name', + highlightFields: ['full_name', 'company', 'email', 'status', 'owner_id'], + fields: { full_name: text, company: text, email: { type: 'email' }, status: { type: 'select' }, owner_id: { type: 'user' } }, + }; + expect(deriveHighlightFields(lead as never, 'status')).toEqual(['company', 'email', 'status', 'owner_id']); + }); + + it('a contact declares no status field, and none is invented', () => { + const contact = { + name: 'crm_contact', + nameField: 'full_name', + highlightFields: ['full_name', 'email', 'crm_account', 'phone'], + fields: { full_name: text, email: { type: 'email' }, crm_account: { type: 'lookup' }, phone: { type: 'phone' } }, + }; + expect(deriveHighlightFields(contact as never, null)).toEqual(['email', 'crm_account', 'phone']); + }); + + it('a longer declared list is cut at six, after the title is taken out', () => { + const names = ['name', 'a', 'b', 'c', 'd', 'e', 'f', 'g']; + const wide = { name: 'x', nameField: 'name', highlightFields: names, fields: Object.fromEntries(names.map((n) => [n, text])) }; + expect(RECORD_HIGHLIGHTS_MAX).toBe(6); + expect(deriveHighlightFields(wide as never, null)).toEqual(['a', 'b', 'c', 'd', 'e', 'f']); + }); +}); diff --git a/ui/packages/plugin-detail/src/synth/__tests__/relatedArrangement-27055.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/relatedArrangement-27055.test.ts index 9799c6f76d5..be9edd364c1 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/relatedArrangement-27055.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/relatedArrangement-27055.test.ts @@ -11,10 +11,13 @@ * related-card arrangement, one `describe` per rule as the maintainer stated * them on the card (2026-10-28, on 「如果让用户自己定义排序,那和默认顺序冲突怎么办? * 开发了新的子表之后怎么办」 and 「有需要隐藏某个子表的场景吗?怎么处理」). + * objectstack#27524, Target 10 — the arrangement is `{ order, hidden, + * basedOn, seen }`: a panel no longer collapses, and `collapsed` is gone. */ import { describe, expect, it } from 'vitest'; import { + RELATED_ARRANGEMENT_RECORD_VERSION, RELATED_CARD_NEW_BADGE_MS, editRelatedArrangement, relatedArrangementFromRecord, @@ -47,12 +50,12 @@ describe('rule 1 — a reader who never dragged sees the default order', () => { expect(resolveRelatedArrangement(DEFAULT, undefined, NOW).order).toEqual(DEFAULT); }); - it('a collapse or a hide alone does not freeze the order', () => { - const collapsedOnly = editRelatedArrangement(DEFAULT, undefined, { kind: 'collapse', id: B, collapsed: true }, NOW); - expect(collapsedOnly.order).toEqual([]); - // The author then reorders the default: the collapse-only reader follows it. - expect(resolveRelatedArrangement([C, A, B], collapsedOnly, NOW).order).toEqual([C, A, B]); - expect(resolveRelatedArrangement([C, A, B], collapsedOnly, NOW).collapsed.has(B)).toBe(true); + it('a hide alone does not freeze the order', () => { + const hiddenOnly = editRelatedArrangement(DEFAULT, undefined, { kind: 'hide', id: B }, NOW); + expect(hiddenOnly.order).toEqual([]); + // The author then reorders the default: the hide-only reader follows it. + expect(resolveRelatedArrangement([C, A, B], hiddenOnly, NOW).order).toEqual([C, A]); + expect(resolveRelatedArrangement([C, A, B], hiddenOnly, NOW).hidden).toEqual([B]); }); }); @@ -82,7 +85,7 @@ describe("rule 2 — dragged cards keep the reader's order; a later default chan }); }); -describe('rule 3 — a new relationship: after its nearest placed default predecessor, 「新」 for 48 h, default collapsed state', () => { +describe('rule 3 — a new relationship: after its nearest placed default predecessor, 「新」 for 48 h', () => { it('inserted after the nearest card preceding it in the default order', () => { // Default grows to A B D C; the reader dragged C first. D's nearest // predecessor in the default that the reader's order holds is B. @@ -108,24 +111,19 @@ describe('rule 3 — a new relationship: after its nearest placed default predec it('a reader who never dragged gets no badge — every card is the default', () => { expect(resolveRelatedArrangement([A, B, C, D], undefined, NOW).fresh.size).toBe(0); }); - - it('takes the default collapsed state', () => { - const resolved = resolveRelatedArrangement([A, B, C, D], dragged(), NOW, new Set([D])); - expect(resolved.collapsed.has(D)).toBe(true); - expect(resolveRelatedArrangement([A, B, C, D], dragged(), NOW).collapsed.has(D)).toBe(false); - }); }); describe('rule 4 — a relationship that no longer exists is dropped on the next write', () => { - it('drops it from order, collapsed and hidden', () => { + it('drops it from order and hidden', () => { let stored = dragged(); - stored = editRelatedArrangement(DEFAULT, stored, { kind: 'collapse', id: B, collapsed: true }, NOW); + stored = editRelatedArrangement(DEFAULT, stored, { kind: 'hide', id: B }, NOW); stored = editRelatedArrangement(DEFAULT, stored, { kind: 'hide', id: A }, NOW); // B is gone from the default; the next write drops it everywhere. - const next = editRelatedArrangement([A, C], stored, { kind: 'collapse', id: C, collapsed: true }, NOW); + const next = editRelatedArrangement([A, C], stored, { kind: 'show-hidden' }, NOW); expect(next.order).toEqual([C, A]); - expect(next.collapsed).toEqual([C]); - expect(next.hidden).toEqual([A]); + expect(next.hidden).toEqual([]); + const hidden = editRelatedArrangement([A, C], stored, { kind: 'mark-seen', ids: [], at: NOW }, NOW); + expect(hidden.hidden).toEqual([A]); }); it('reading alone already leaves it out of the column', () => { @@ -170,9 +168,17 @@ describe('the stored record', () => { it('round-trips, and refuses a malformed record', () => { const value = editRelatedArrangement([A, B, C, D], dragged(), { kind: 'mark-seen', ids: [D], at: NOW }, NOW); expect(relatedArrangementFromRecord(relatedArrangementToRecord(value))).toEqual(value); - expect(relatedArrangementFromRecord({ order: [1], collapsed: [], hidden: [], basedOn: 'x' })).toBeUndefined(); - expect(relatedArrangementFromRecord({ order: [], collapsed: [], hidden: [] })).toBeUndefined(); - expect(relatedArrangementFromRecord({ order: [], collapsed: [], hidden: [], basedOn: 'x', seen: { a: 'now' } })).toBeUndefined(); + expect(relatedArrangementFromRecord({ order: [1], hidden: [], basedOn: 'x' })).toBeUndefined(); + expect(relatedArrangementFromRecord({ order: [], hidden: [] })).toBeUndefined(); + expect(relatedArrangementFromRecord({ order: [], hidden: [], basedOn: 'x', seen: { a: 'now' } })).toBeUndefined(); + }); + + it('is { order, hidden, basedOn, seen } — no `collapsed` (objectstack#27524, Target 10)', () => { + const value = editRelatedArrangement([A, B, C, D], dragged(), { kind: 'mark-seen', ids: [D], at: NOW }, NOW); + expect(Object.keys(relatedArrangementToRecord(value)).sort()).toEqual(['basedOn', 'hidden', 'order', 'seen']); + expect('collapsed' in value).toBe(false); + // The codec stamps the version; the shape that carried `collapsed` was 1. + expect(RELATED_ARRANGEMENT_RECORD_VERSION).toBe(2); }); it('carries no `seen` key when nothing was seen', () => { @@ -181,10 +187,8 @@ describe('the stored record', () => { }); describe('「恢复默认顺序」 is a standing entry once the column is not the default one (objectstack#27241)', () => { - it('rearranged: false for a reader who never chose, or only collapsed', () => { + it('rearranged: false for a reader who never chose', () => { expect(resolveRelatedArrangement(DEFAULT, undefined, NOW).rearranged).toBe(false); - const collapsedOnly = editRelatedArrangement(DEFAULT, undefined, { kind: 'collapse', id: B, collapsed: true }, NOW); - expect(resolveRelatedArrangement(DEFAULT, collapsedOnly, NOW).rearranged).toBe(false); }); it('rearranged: true after a move or a hide; a move back to the default order reads as the default', () => { @@ -196,17 +200,15 @@ describe('「恢复默认顺序」 is a standing entry once the column is not th expect(resolveRelatedArrangement(DEFAULT, back, NOW).rearranged).toBe(false); }); - it('restore-default-arrangement: the default order, every card shown, basedOn the current default; collapse stays', () => { + it('restore-default-arrangement: the default order, every card shown, basedOn the current default', () => { const moved = dragged(); const hidden = editRelatedArrangement(DEFAULT, moved, { kind: 'hide', id: A }, NOW); - const collapsed = editRelatedArrangement(DEFAULT, hidden, { kind: 'collapse', id: B, collapsed: true }, NOW); // The author has since reordered the default too. const current = [B, A, C]; - const restored = editRelatedArrangement(current, collapsed, { kind: 'restore-default-arrangement' }, NOW); + const restored = editRelatedArrangement(current, hidden, { kind: 'restore-default-arrangement' }, NOW); expect(restored.order).toEqual([]); expect(restored.hidden).toEqual([]); expect(restored.basedOn).toBe(relatedDefaultOrderHash(current)); - expect(restored.collapsed).toEqual([B]); const resolved = resolveRelatedArrangement(current, restored, NOW); expect(resolved.order).toEqual(current); expect(resolved.rearranged).toBe(false); From e8a0e9f9fd7000186e42918962250895718ff055 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 02:37:05 +0000 Subject: [PATCH 3/7] wip(plugin-detail): changeset names the runtime-interface-only disposition Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt Co-authored-by: Claude --- .changeset/27524-record-page-related-panels.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.changeset/27524-record-page-related-panels.md b/.changeset/27524-record-page-related-panels.md index ee089597ee5..2dfa831553e 100644 --- a/.changeset/27524-record-page-related-panels.md +++ b/.changeset/27524-record-page-related-panels.md @@ -9,7 +9,7 @@ feat(plugin-detail)!: the record page's related panels lose collapse — the hea Clause-②: yes (narrowing) Metadata-protocol: none - + The record page's related column is a stack of same-shaped panels (record page v4). @@ -18,9 +18,6 @@ The record page's related column is a stack of same-shaped panels (record page v - **The compact row's first column** skips the foreign key that points back at the record on the page: a product's line items, whose name field is the product, are named by their next column. - **The stored arrangement** (`record.related.arrangement`, per reader × object) is `{ v, order, hidden, basedOn, seen }`. `collapsed` is removed with no compatibility read: a reader's earlier record reads as no choice, and the panels show in the default order. -**BREAKING** for a TypeScript consumer of `@object-ui/plugin-detail`'s arrangement API; nothing an author declares changes. +**BREAKING** (`@object-ui/plugin-detail`, TypeScript only): `RelatedArrangement` and `ResolvedRelatedArrangement` no longer carry `collapsed`, `RelatedArrangementEdit` no longer has the `collapse` kind, and `resolveRelatedArrangement` / `editRelatedArrangement` no longer take a last `defaultCollapsed` argument. The only caller is the console's own arranged column. A caller still passing one gets a compile error naming it; the fix is to drop it, since a panel has no collapsed state to set. `RELATED_ARRANGEMENT_RECORD_VERSION` (2) is new. -- FROM `RelatedArrangement.collapsed` / `ResolvedRelatedArrangement.collapsed` TO nothing: a panel has no collapsed state. Drop the member from any value you build. -- FROM the `{ kind: 'collapse', id, collapsed }` edit TO `{ kind: 'hide', id }`, or no edit at all. -- FROM `resolveRelatedArrangement(order, stored, now, defaultCollapsed)` / `editRelatedArrangement(order, stored, edit, now, defaultCollapsed)` TO the same calls without the last argument. - The i18n keys `detail.relatedCardMoveUp`, `detail.relatedCardMoveDown`, `detail.relatedCardCollapse` and `detail.relatedCardCount` leave every pack; `detail.relatedPanelViewAll`, `detail.relatedPanelReorder`, `detail.relatedPanelReorderHint` and `detail.relatedEmptyFold` arrive in all ten. From b8c4395be25d3908d9d5e29202c3942933c3b0b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 03:12:07 +0000 Subject: [PATCH 4/7] fix(plugin-detail): a panel's controls stay drawn while its menu is open Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt Co-authored-by: Claude --- ui/packages/plugin-detail/src/RelatedList.tsx | 4 +++- .../src/__tests__/RelatedPanel.header-27524.test.tsx | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index 0211f521b87..6568a812c6b 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -2893,8 +2893,10 @@ export const RelatedList: React.FC = ({ {t('detail.relatedCardNew', { defaultValue: 'New' })} )} + {/* Drawn on hover or focus, and while the ⋯ menu is open: its + content is portaled, so neither holds there. */} {cardChrome && } diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedPanel.header-27524.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedPanel.header-27524.test.tsx index 9b5a3fae3e2..3f3e26bf838 100644 --- a/ui/packages/plugin-detail/src/__tests__/RelatedPanel.header-27524.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RelatedPanel.header-27524.test.tsx @@ -125,6 +125,11 @@ describe('the related panel header (objectstack#27524, Target 1)', () => { await user.click(head().querySelector('[data-related-card-menu]')!); const items = await screen.findAllByRole('menuitem'); expect(items.map((item) => item.textContent)).toEqual(['Hide']); + // The menu's content is portaled: the controls stay drawn while it is open. + expect(head().querySelector('[data-related-card-menu]')?.getAttribute('data-state')).toBe('open'); + expect((head().querySelector('[data-related-panel-controls]') as HTMLElement).className).toContain( + 'has-[[data-state=open]]:opacity-100', + ); await user.click(items[0]); expect(chrome.onHide).toHaveBeenCalledTimes(1); expect(chrome.onEnlarge).not.toHaveBeenCalled(); From 1d1d2d6e6fe9a3834369bda36db33d5f7c8a93b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 04:09:29 +0000 Subject: [PATCH 5/7] fix(plugin-detail): the panel header row is one button stretched over the row, not a clickable div Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt Co-authored-by: Claude --- ui/packages/plugin-detail/src/RelatedList.tsx | 18 +++++++----------- .../RelatedPanel.header-27524.test.tsx | 18 ++++++++++++++---- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index 6568a812c6b..b5596ea99b1 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -2857,23 +2857,19 @@ export const RelatedList: React.FC = ({ aria-busy={pendingRows > 0 || undefined} >
{ - if (event.currentTarget.contains(event.target as Node)) open(); - } : undefined} > {open ? ( + // The row is the target: the button's ::after stretches over the + // whole header, so a click on its free space opens the list too, + // and the row stays one control. The controls sit above it.