mirror of
https://github.com/nocodb/nocodb.git
synced 2026-05-17 17:33:45 +00:00
feat(panel): gate side panel behind experimental flag, drop in-menu toggle
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user