feat(panel): gate side panel behind experimental flag, drop in-menu toggle

This commit is contained in:
Ramesh Mane
2026-05-13 12:39:52 +00:00
parent 1531fba0f0
commit d5b4ca244f
6 changed files with 45 additions and 156 deletions

View File

@@ -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 () => {
</NcMenuItem>
</template>
</PermissionsTooltip>
<NcMenuItem v-if="visibleMoreOptions.modeToggle" data-testid="nc-expanded-form-toggle-mode" @click="onToggleMode">
<div
v-e="[`c:row-expand:toggle-mode:${expandedFormMode === 'panel' ? 'modal' : 'panel'}`]"
class="flex gap-2 items-center"
>
<GeneralIcon :icon="expandedFormMode === 'panel' ? 'expand' : 'sidebar'" class="cursor-pointer w-4 h-4" />
<span class="-ml-0.25">
{{ expandedFormMode === 'panel' ? $t('labels.switchToExpandedForm') : $t('labels.switchToSidePanel') }}
</span>
</div>
</NcMenuItem>
<NcDivider v-if="visibleMoreOptions.showDeleteDivider" />
<NcTooltip v-if="visibleMoreOptions.deleteRecord && meta?.synced" placement="left">
<template #title>
@@ -462,10 +341,4 @@ const onConfirmDeleteRowClick = async () => {
:row-id="primaryKey"
/>
<SmartsheetExpandedFormDiscardChangesModal
v-model="showSwitchDiscardModal"
:loading="isSaving"
@discard="onDiscardAndSwitch"
@save-and-continue="onSaveAndSwitch"
/>
</template>

View File

@@ -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]

View File

@@ -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:

View File

@@ -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. -->
<div
v-if="isExpandedFormPanelOpen && isGrid"
v-show="!isSmartTextActive"
style="display: contents"
>
<div v-if="isExpandedFormPanelOpen && isGrid" v-show="!isSmartTextActive" style="display: contents">
<SmartsheetGridExpandedFormPanel />
</div>
</Pane>

View File

@@ -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<

View File

@@ -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<ExpandedFormMode>(STORAGE_KEY, 'panel')
const { isFeatureEnabled } = useBetaFeatureToggle()
const toggle = () => {
mode.value = mode.value === 'panel' ? 'modal' : 'panel'
}
const mode = computed<ExpandedFormMode>(() =>
isFeatureEnabled(FEATURE_FLAG.EXPANDED_RECORD_PANEL) ? 'panel' : 'modal',
)
return { mode, toggle }
return { mode }
})