diff --git a/packages/nc-gui/components/smartsheet/expanded-form/MoreOptionsMenu.vue b/packages/nc-gui/components/smartsheet/expanded-form/MoreOptionsMenu.vue index 92fc4a42d2..b059adbd97 100644 --- a/packages/nc-gui/components/smartsheet/expanded-form/MoreOptionsMenu.vue +++ b/packages/nc-gui/components/smartsheet/expanded-form/MoreOptionsMenu.vue @@ -40,8 +40,6 @@ const { t } = useI18n() const route = useRoute() -const router = useRouter() - const isPublic = inject(IsPublicInj, ref(false)) const injectedView = inject(ActiveViewInj, ref()) @@ -71,111 +69,6 @@ const { deleteRowById, } = expandedFormStore -const { mode: expandedFormMode, toggle: toggleExpandedFormMode } = useExpandedFormMode() - -// Panel store is provided by tabs/Smartsheet — only present when the user is on -// a grid view inside a Smartsheet tab. In other contexts (kanban modal, -// dashboard widgets) the toggle hides itself. -const expandedFormPanelStore = useExpandedFormPanel() - -// Switching between panel and modal only makes sense on EE desktop where both -// surfaces exist AND a panel store is in scope (i.e., grid view on Smartsheet). -const showModeToggle = computed( - () => isEeUI && !isMobileMode.value && !props.templateMode && !props.blueprintMode && !!expandedFormPanelStore, -) - -// State for "switch with unsaved changes" prompt. We capture the row + fromMode -// at click time because the active surface's store will be torn down before the -// new surface mounts; we can't read these refs again afterwards. -const showSwitchDiscardModal = ref(false) -let pendingSwitch: { fromMode: 'panel' | 'modal'; rowId: string; capturedRow: Row } | null = null - -const performSwitch = async ({ - fromMode, - rowId, - capturedRow, -}: { - fromMode: 'panel' | 'modal' - rowId: string - capturedRow: Row -}) => { - toggleExpandedFormMode() - - message.toast(fromMode === 'panel' ? t('msg.toast.expandedFormModeExpandedForm') : t('msg.toast.expandedFormModeSidePanel')) - - if (!expandedFormPanelStore) return - - // Both directions: closing the current surface clears rowId from the route - // (panel close watch in grid/index.vue, modal v-model setter). Re-push it - // after the close so the new surface stays addressable / reload-safe. - if (fromMode === 'panel') { - // panel → modal: route-based modal opens automatically once rowId is - // present and expandedFormOnRowIdDlg sees mode === 'modal'. - expandedFormPanelStore.closePanel() - await nextTick() - router.push({ query: { ...router.currentRoute.value.query, rowId } }) - } else { - // modal → panel: open the panel imperatively with the row data we already - // have. Resolve rowIndex via the grid's row navigator so prev/next + canvas - // active-row indicator work immediately. Falls back to undefined if the row - // isn't loaded (infinite-scroll cache miss). - emits('requestClose') - const idx = expandedFormPanelStore.rowNavigator.value?.findIndexByRowId?.(rowId) ?? -1 - expandedFormPanelStore.openPanel(capturedRow, idx >= 0 ? idx : undefined, undefined, rowId) - await nextTick() - router.push({ query: { ...router.currentRoute.value.query, rowId } }) - } -} - -const onToggleMode = () => { - const rowId = primaryKey.value - const capturedRow = _row.value - const fromMode = expandedFormMode.value - - if (!rowId || !expandedFormPanelStore) return - - // Without this guard, a fresh API fetch on the new surface would silently - // clobber any pending edits the user has made on the current surface. - if (changedColumns.value.size > 0) { - pendingSwitch = { fromMode, rowId, capturedRow } - showSwitchDiscardModal.value = true - return - } - - performSwitch({ fromMode, rowId, capturedRow }) -} - -const onDiscardAndSwitch = () => { - if (!pendingSwitch) return - clearColumns() - showSwitchDiscardModal.value = false - const target = pendingSwitch - pendingSwitch = null - performSwitch(target) -} - -const onSaveAndSwitch = async () => { - if (!pendingSwitch) return - isSaving.value = true - try { - if (isNew.value) { - await _save(rowState.value) - } else { - await _save() - await _loadRow() - } - await reloadViewDataTrigger?.trigger() - showSwitchDiscardModal.value = false - const target = pendingSwitch - pendingSwitch = null - await performSwitch(target) - } catch (e: any) { - message.error(await formatSaveError(e)) - } finally { - isSaving.value = false - } -} - const isRecordLinkCopied = ref(false) const showSendRecordModal = ref(false) @@ -191,7 +84,6 @@ const visibleMoreOptions = computed(() => { copyRecordUrl: false, sendRecord: false, duplicateRecord: false, - modeToggle: false, deleteRecord: false, showDeleteDivider: false, showMoreOptionsMenu: false, @@ -204,7 +96,6 @@ const visibleMoreOptions = computed(() => { copyRecordUrl: !isNew.value && !!primaryKey.value, sendRecord: appInfo.value.ee && !isNew.value && !!primaryKey.value && !isPublic.value, duplicateRecord: isUIAllowed('dataEdit', baseRoles.value) && !isSqlView.value && !isMobileMode.value, - modeToggle: showModeToggle.value, deleteRecord: !isNew.value && isUIAllowed('dataEdit', baseRoles.value) && !isSqlView.value, } @@ -221,7 +112,6 @@ const visibleMoreOptions = computed(() => { !result.reloadRecord && !result.sendRecord && !result.duplicateRecord && - !result.modeToggle && !result.deleteRecord, } }) @@ -385,17 +275,6 @@ const onConfirmDeleteRowClick = async () => { - -
- - - {{ expandedFormMode === 'panel' ? $t('labels.switchToExpandedForm') : $t('labels.switchToSidePanel') }} - -
-
diff --git a/packages/nc-gui/components/smartsheet/grid/canvas/composables/useCanvasRender.ts b/packages/nc-gui/components/smartsheet/grid/canvas/composables/useCanvasRender.ts index 8902a46ba0..1fca88de08 100644 --- a/packages/nc-gui/components/smartsheet/grid/canvas/composables/useCanvasRender.ts +++ b/packages/nc-gui/components/smartsheet/grid/canvas/composables/useCanvasRender.ts @@ -3260,9 +3260,7 @@ export function useCanvasRender({ // (useInfiniteGroups.ts:275), so non-leaf renders would otherwise // collapse onto the same empty-path key. const groupSelKey = group ? generateGroupPath(group).join('-') : '' - const groupSelOverride = groupSelKey - ? groupSelectionAggregations.value.get(groupSelKey) - : undefined + const groupSelOverride = groupSelKey ? groupSelectionAggregations.value.get(groupSelKey) : undefined const groupSelOutOfScope = !!groupSelOverride && !groupSelOverride.scopedTitles.has(column.title) const groupSelDisplayValue = groupSelOverride?.values[column.title] const aggDisplay = groupSelDisplayValue !== undefined ? groupSelDisplayValue : group?.aggregations[column.title] diff --git a/packages/nc-gui/components/smartsheet/grid/index.vue b/packages/nc-gui/components/smartsheet/grid/index.vue index 4fb318988f..2d1aca4385 100644 --- a/packages/nc-gui/components/smartsheet/grid/index.vue +++ b/packages/nc-gui/components/smartsheet/grid/index.vue @@ -263,13 +263,7 @@ const expandedFormOnRowIdDlg = computed({ // EE desktop in panel mode uses the side panel — modal stays closed (a separate watcher syncs the panel from the route). // Falls back to the modal when the grid isn't canvas-rendered, since the panel // depends on canvas-only contracts (getDataCache(path), highlight bar, etc.). - if ( - isEeUI && - !isMobileMode.value && - !isPublic.value && - expandedFormMode.value === 'panel' && - isCanvasRendering.value - ) + if (isEeUI && !isMobileMode.value && !isPublic.value && expandedFormMode.value === 'panel' && isCanvasRendering.value) return false // When ?cellCol points at a SmartText column the SmartText panel claims // the screen — expanded record dialog stays closed. @@ -350,7 +344,12 @@ watch( // restore both the row AND its group scope — without it prev/next would // walk across all groups instead of the user's group. const pathParam = routeQuery.value.path as string | undefined - const path = pathParam ? pathParam.split('-').map(Number).filter((n) => !Number.isNaN(n)) : [] + const path = pathParam + ? pathParam + .split('-') + .map(Number) + .filter((n) => !Number.isNaN(n)) + : [] const idx = expandedFormPanelRowNavigator.value?.findIndexByRowId?.(rowId, path) ?? -1 expandedFormPanelStore!.openPanel( { row: {}, oldRow: {}, rowMeta: {} } as Row, @@ -565,6 +564,20 @@ const isCanvasRendering = computed( ((isCanvasTableEnabled.value && !isGroupBy.value) || (isCanvasGroupByTableEnabled.value && isGroupBy.value)), ) +// Close the side panel when the experimental flag is toggled off mid-session. +// Without this the panel that was already mounted stays put even though +// expandedFormMode has flipped to 'modal'. We just close it — the existing +// panel-close watcher clears rowId from the URL, so the user lands back on the +// grid. Re-opening a record after the toggle uses the modal path normally. +watch( + () => expandedFormMode.value, + (newMode, oldMode) => { + if (oldMode === 'panel' && newMode === 'modal' && expandedFormPanelStore?.isOpen.value) { + expandedFormPanelStore.closePanel() + } + }, +) + const baseColor = computed(() => { switch (groupBy.value.length) { case 1: diff --git a/packages/nc-gui/components/tabs/Smartsheet.vue b/packages/nc-gui/components/tabs/Smartsheet.vue index 1acac0fb35..6c4c0ffbca 100644 --- a/packages/nc-gui/components/tabs/Smartsheet.vue +++ b/packages/nc-gui/components/tabs/Smartsheet.vue @@ -50,7 +50,9 @@ const expandedFormPanelStore = useProvideExpandedFormPanel() const isExpandedFormPanelOpen = computed(() => expandedFormPanelStore.isOpen.value) -const isExpandedFormPanelFullscreen = computed(() => expandedFormPanelStore.isOpen.value && expandedFormPanelStore.isFullscreen.value) +const isExpandedFormPanelFullscreen = computed( + () => expandedFormPanelStore.isOpen.value && expandedFormPanelStore.isFullscreen.value, +) // SmartText panel claims the right-side slot when ?cellCol points at a SmartText // column. Hide the expanded-record panel while that's true so we never show two @@ -389,11 +391,7 @@ watch(isViewsLoading, async () => { of the component) preserves the EFP's internal state across SmartText cell hops — otherwise users lose unsaved edits when they briefly open a SmartText cell from the grid. --> -
+
diff --git a/packages/nc-gui/composables/useBetaFeatureToggle.ts b/packages/nc-gui/composables/useBetaFeatureToggle.ts index 4fc9eec2ac..463b248413 100644 --- a/packages/nc-gui/composables/useBetaFeatureToggle.ts +++ b/packages/nc-gui/composables/useBetaFeatureToggle.ts @@ -178,6 +178,15 @@ const FEATURES = [ isEngineering: true, isEE: true, }, + { + id: 'expanded_record_panel', + title: 'Expanded record side panel', + description: 'Open expanded records in a resizable side panel beside the grid instead of a centered modal.', + enabled: true, + version: 2, + isEngineering: false, + isEE: true, + }, ] as const export const FEATURE_FLAG = Object.fromEntries(FEATURES.map((feature) => [feature.id.toUpperCase(), feature.id])) as Record< diff --git a/packages/nc-gui/composables/useExpandedFormMode.ts b/packages/nc-gui/composables/useExpandedFormMode.ts index c92fdb0327..38ea6d630f 100644 --- a/packages/nc-gui/composables/useExpandedFormMode.ts +++ b/packages/nc-gui/composables/useExpandedFormMode.ts @@ -1,18 +1,16 @@ -import { useStorage } from '@vueuse/core' - export type ExpandedFormMode = 'panel' | 'modal' -const STORAGE_KEY = 'nc-expanded-form-mode' - -// Browser-level preference (localStorage, not tied to user). CE is always -// 'modal' since the panel doesn't exist there. Mobile / public views ignore -// this preference and force the modal regardless. +// Mode is derived purely from the `expanded_record_panel` experimental feature +// flag — no in-product toggle. Users who want the old modal back can disable +// the flag from experimental features. CE never sees this composable resolve +// to 'panel' because the flag's `isEE: true` short-circuits there. Mobile and +// public views ignore the result and force the modal regardless. export const useExpandedFormMode = createSharedComposable(() => { - const mode = useStorage(STORAGE_KEY, 'panel') + const { isFeatureEnabled } = useBetaFeatureToggle() - const toggle = () => { - mode.value = mode.value === 'panel' ? 'modal' : 'panel' - } + const mode = computed(() => + isFeatureEnabled(FEATURE_FLAG.EXPANDED_RECORD_PANEL) ? 'panel' : 'modal', + ) - return { mode, toggle } + return { mode } })