diff --git a/packages/nc-gui/components/smartsheet/expanded-form/Sidebar/Comments.vue b/packages/nc-gui/components/smartsheet/expanded-form/Sidebar/Comments.vue index 344e84804a96..be8686c8dfd6 100644 --- a/packages/nc-gui/components/smartsheet/expanded-form/Sidebar/Comments.vue +++ b/packages/nc-gui/components/smartsheet/expanded-form/Sidebar/Comments.vue @@ -51,6 +51,16 @@ const commentInputRef = ref() const comment = ref('') +// Holds the deep-linked commentId for the lifetime of the current row's +// comment session. Side-panel mode triggers loadComments twice (via +// triggerRowLoad AND the activity-tab watcher), which toggles +// isCommentsLoading on/off and unmounts+remounts the wrapper element. +// On remount, the URL has already been stripped of `commentId`, so we +// need our own copy to know whether to anchor on the deep-link or fall +// back to bottom-scroll. Cleared when the user posts a comment or +// switches rows (primaryKey changes). +const deepLinkCommentId = ref(null) + const router = useRouter() const baseUsers = computed(() => (meta.value?.base_id ? basesUser.value.get(meta.value?.base_id) || [] : [])) @@ -77,6 +87,10 @@ const saveComment = async () => { } } + // User is posting — drop any pending deep-link so the scroll watchers + // scroll to their new comment at the bottom instead of jumping back. + deepLinkCommentId.value = null + isCommentMode.value = true // Optimistic Insert @@ -116,11 +130,16 @@ const saveComment = async () => { const copyComment = async (comment: CommentType) => { const viewId = activeView.value?.fk_model_id === meta.value?.id ? activeView.value?.id : undefined + // Mirror copy-record-URL: include &path=… so the link still resolves when + // the source view is grouped (without it the deep-link can't open the row + // in another tab). + const pathParam = route.query?.path ? `&path=${route.query.path}` : '' + await copy( encodeURI( `${dashboardUrl?.value}/${route.params.typeOrId}/${route.params.baseId}/${meta.value?.id}${ viewId ? `/${viewId}` : '' - }?rowId=${primaryKey.value}&commentId=${comment.id}`, + }?rowId=${primaryKey.value}&commentId=${comment.id}${pathParam}`, ), ) } @@ -135,6 +154,40 @@ function scrollToComment(commentId: string) { } } +function tryScrollToDeepLinkComment(): boolean { + const id = deepLinkCommentId.value + if (!id) return false + const el = document.querySelector(`.${id}`) + if (!el) return false + scrollToComment(id) + hoveredCommentId.value = id + onClickOutside(el as HTMLDivElement, handleResetHoverEffect) + return true +} + +// Capture commentId from the URL into our own ref before stripping it, +// so subsequent wrapper remounts (caused by isCommentsLoading toggles) +// still know there's a deep-link in progress. Also fires when the user +// clicks another notification while the panel is already open. +watch( + () => route.query.commentId, + (newId) => { + if (!newId) return + deepLinkCommentId.value = newId as string + // Preserve `path` so the group context survives a reload after the + // comment deep-link is consumed; only commentId needs to be stripped. + const { commentId: _drop, ...rest } = router.currentRoute.value.query + router.push({ query: rest }) + }, + { immediate: true }, +) + +// Reset deep-link state when row changes so a new row defaults back to +// the usual scroll-to-bottom behavior on first open. +watch(primaryKey, () => { + deepLinkCommentId.value = null +}) + function onCancel(e: KeyboardEvent) { if (!isEditing.value) return e.preventDefault() @@ -213,25 +266,20 @@ function handleResetHoverEffect() { hoveredCommentId.value = null } -watch(commentsWrapperEl, () => { +watch(commentsWrapperEl, (el) => { + if (!el) return setTimeout(() => { nextTick(() => { - const query = router.currentRoute.value.query - const commentId = query.commentId - if (commentId) { - router.push({ - query: { - rowId: query.rowId, - }, - }) - scrollToComment(commentId as string) - - hoveredCommentId.value = commentId as string - - onClickOutside(document.querySelector(`.${hoveredCommentId.value}`)! as HTMLDivElement, handleResetHoverEffect) - } else { - scrollComments() + // Deep-link in progress (initial mount OR remount after the loader + // toggled) — anchor on the linked comment. If the comment isn't in + // the DOM yet, the length watcher will retry once comments load. + // Either way, skip the bottom-scroll so we don't jump away from the + // linked comment. + if (deepLinkCommentId.value) { + tryScrollToDeepLinkComment() + return } + scrollComments() }) }, 100) }) @@ -297,6 +345,12 @@ watch( () => comments.value?.length, () => { nextTick(() => { + // Deep-link target still in scope (only cleared on user post / row + // change) — keep anchoring on it rather than snapping to bottom. + if (deepLinkCommentId.value) { + tryScrollToDeepLinkComment() + return + } scrollComments() }) }, diff --git a/packages/nc-gui/components/smartsheet/grid/index.vue b/packages/nc-gui/components/smartsheet/grid/index.vue index 21a6e0a0577a..ba68ee1e8fe0 100644 --- a/packages/nc-gui/components/smartsheet/grid/index.vue +++ b/packages/nc-gui/components/smartsheet/grid/index.vue @@ -405,13 +405,27 @@ 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 + let path = pathParam ? pathParam .split('-') .map(Number) .filter((n) => !Number.isNaN(n)) : [] - const idx = expandedFormPanelRowNavigator.value?.findIndexByRowId?.(rowId, path) ?? -1 + let idx = expandedFormPanelRowNavigator.value?.findIndexByRowId?.(rowId, path) ?? -1 + + // Comment-mention notifications produce URLs like `?rowId=…&commentId=…` + // with no `path` — the notification can't know which view/group the user + // was in when the comment was written. Fall back to a cross-group lookup + // so the panel still opens scoped to the correct group instead of + // landing index-less (which breaks prev/next + the canvas highlight). + if (idx === -1 && !pathParam) { + const location = expandedFormPanelRowNavigator.value?.findRowLocation?.(rowId) + if (location) { + idx = location.index + path = location.path + } + } + expandedFormPanelStore!.openPanel( { row: {}, oldRow: {}, rowMeta: {} } as Row, idx >= 0 ? idx : undefined, @@ -480,6 +494,24 @@ watch( }, ) +// Sync `path` to the URL when activePath changes without activeRowId +// changing — i.e. the path-less deep-link recovery flow updated the group +// scope after the row's group cache loaded. Without this the URL stays as +// `?rowId=X` and a refresh would replay the entire recovery dance instead +// of resolving the group directly. +watch( + () => expandedFormPanelStore?.activePath.value, + (newPath) => { + if (!isExpandedFormPanelOpen.value) return + if (isSyncingPanelRoute.value) return + const path = newPath ?? [] + const pathStr = path.length === 0 ? undefined : path.join('-') + if ((routeQuery.value.path ?? undefined) === pathStr) return + setSyncingRoute() + router.push({ query: { ...routeQuery.value, path: pathStr } }).finally(clearSyncingRoute) + }, +) + watch([isExpandedFormPanelOpen, () => expandedFormPanelStore?.activeRowIndex.value], () => { eventBus.emit(SmartsheetStoreEvents.TRIGGER_RE_RENDER) }) @@ -562,6 +594,29 @@ expandedFormPanelRowNavigator.value = { } return pData.value.findIndex((row: Row) => extractPkFromRow(row.row, cols) === rowId) }, + findRowLocation: (rowId: string) => { + const cols = meta.value?.columns as ColumnType[] | undefined + if (!cols) return null + if (isInfiniteScrollingEnabled.value) { + for (const [idx, row] of cachedRows.value) { + if (extractPkFromRow(row.row, cols) === rowId) return { index: idx, path: [] } + } + for (const [key, cache] of groupDataCache.value) { + for (const [idx, row] of cache.cachedRows.value) { + if (extractPkFromRow(row.row, cols) === rowId) { + const path = key + .split('-') + .map(Number) + .filter((n) => !Number.isNaN(n)) + return { index: idx, path } + } + } + } + return null + } + const idx = pData.value.findIndex((row: Row) => extractPkFromRow(row.row, cols) === rowId) + return idx >= 0 ? { index: idx, path: [] } : null + }, } watch([windowSize, leftSidebarWidth], updateViewWidth) @@ -602,13 +657,16 @@ watch( // Skip the initial async load. At mount gridViewCols is empty so groupBy is // []; once the persisted view config arrives, groupBy transitions to its real - // value. If a deep link (?rowId=X&path=A-B) opened the panel with a path - // matching the just-loaded structure, the path was authored against this very - // structure and is still valid — don't tear it down. User-initiated changes - // still close because either oldKey is non-empty or the depth no longer matches. + // value. Two deep-link shapes need to survive that transition: + // - `?rowId=X&path=A-B` — path matches the just-loaded depth (matched). + // - `?rowId=X&commentId=Y` (comment-mention notifications) — URL carries + // no path at all, so the panel opened with activePath=[]. The path + // will be recovered later when the row's group cache loads; closing + // here would strip rowId from the URL before that ever happens. + // User-initiated group changes still close because oldKey is non-empty. const newDepth = newKey ? newKey.split('|').length : 0 const panelDepth = expandedFormPanelStore.activePath.value?.length ?? 0 - if (!oldKey && panelDepth > 0 && panelDepth === newDepth) return + if (!oldKey && (panelDepth === 0 || panelDepth === newDepth)) return expandedFormPanelStore.closePanel() }, @@ -640,6 +698,21 @@ const resolveActiveRowIndex = () => { const path = expandedFormPanelStore.activePath.value const idx = expandedFormPanelRowNavigator.value?.findIndexByRowId?.(rowId, path) ?? -1 if (idx === -1) { + // Path-less deep-link recovery: if a comment-mention notification + // opened the panel without group context (activePath=[]), the row + // won't be in the root cache. Before closing, try a cross-group + // lookup — the right group cache may have just loaded. + if ((path ?? []).length === 0) { + const location = expandedFormPanelRowNavigator.value?.findRowLocation?.(rowId) + if (location) { + expandedFormPanelStore.activePath.value = location.path + expandedFormPanelStore.activeRowIndex.value = location.index + return + } + // No recovery yet — leave the panel open; another data event + // will retry. Avoids closing on a transient empty-cache state. + return + } expandedFormPanelStore.closePanel() } else if (idx !== expandedFormPanelStore.activeRowIndex.value) { expandedFormPanelStore.activeRowIndex.value = idx @@ -649,7 +722,29 @@ const resolveActiveRowIndex = () => { watch(() => cachedRows.value.size, resolveActiveRowIndex) +// Group caches live behind a shallowRef (`groupDataCache`) — internal Map +// mutations don't trigger reactivity, so the cachedRows-only watch above +// never fires in group-by mode. Hook into the grid event bus so the +// path-less deep-link recovery path in resolveActiveRowIndex retries as +// each group's data lands. +const panelGroupPathRecoveryListener = (event: SmartsheetStoreEvents) => { + if ( + event !== SmartsheetStoreEvents.TRIGGER_RE_RENDER && + event !== SmartsheetStoreEvents.GROUP_BY_RELOAD && + event !== SmartsheetStoreEvents.DATA_RELOAD + ) { + return + } + if (!expandedFormPanelStore?.isOpen.value) return + if ((expandedFormPanelStore.activePath.value ?? []).length > 0) return + if (!isGroupBy.value) return + resolveActiveRowIndex() +} + +eventBus.on(panelGroupPathRecoveryListener) + onBeforeUnmount(() => { + eventBus.off(panelGroupPathRecoveryListener) if (resolveActiveRowIndexTimer) clearTimeout(resolveActiveRowIndexTimer) }) diff --git a/packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue b/packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue index 3131a495dd0d..48c793b7da5c 100644 --- a/packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue +++ b/packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue @@ -68,15 +68,21 @@ const viewStore = useViewsStore() const { updateViewMeta } = viewStore +// Coalesce bursts of FIELD_RELOAD into one viewColumnList fetch — a single +// column CUD can emit FIELD_RELOAD from the save site, the useColumnCreateStore +// path, and the realtime socket. Without this, each emit reissues the same +// `viewColumnList` call. See nocodb#6778. +const loadViewColumnsDebounced = useDebounceFn(loadViewColumns, 50) + const eventBusHandler = async (event: SmartsheetStoreEvents, payload?: any) => { if (event === SmartsheetStoreEvents.FIELD_RELOAD) { try { - await loadViewColumns() + await loadViewColumnsDebounced() } finally { payload?.callback?.() } } else if (event === SmartsheetStoreEvents.MAPPED_BY_COLUMN_CHANGE) { - loadViewColumns() + loadViewColumnsDebounced() } } diff --git a/packages/nc-gui/composables/useLTARStore.ts b/packages/nc-gui/composables/useLTARStore.ts index 5be5c35f031a..ea75550b87c7 100644 --- a/packages/nc-gui/composables/useLTARStore.ts +++ b/packages/nc-gui/composables/useLTARStore.ts @@ -834,6 +834,13 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState( if (!isSingleTargetRelation.value) { childrenListCount.value = childrenListCount.value - 1 } + + // Mirror the new-row branch: clear the linked record from the row store so + // BT/MO cells (which display the linked record directly off the row) refresh + // immediately. Reload paths are no-ops in EE, so this is the only signal. + if (isSingleTargetRelation.value && rowStoreCurrentRow) { + rowStoreCurrentRow.value.row[column.value.title!] = null + } } catch (e: any) { message.error(`${t('msg.error.unlinkFailed')}: ${await extractSdkResponseErrorMsg(e)}`) } finally { @@ -909,6 +916,13 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState( } excludedLinkedState.value.set(index, true) } + + // Mirror the new-row branch: write the picked record back to the row store so + // BT/MO cells (which display the linked record directly off the row) refresh + // immediately. Reload paths are no-ops in EE, so this is the only signal. + if (isSingleTargetRelation.value && rowStoreCurrentRow) { + rowStoreCurrentRow.value.row[column.value.title!] = row + } } catch (e: any) { message.error(`Linking failed: ${await extractSdkResponseErrorMsg(e)}`) } finally { diff --git a/packages/nc-gui/lib/constants.ts b/packages/nc-gui/lib/constants.ts index c252a7638b10..aa3748683b25 100644 --- a/packages/nc-gui/lib/constants.ts +++ b/packages/nc-gui/lib/constants.ts @@ -73,27 +73,23 @@ export const NC_CLOUD_URL = 'https://app.nocodb.com' export const clientMousePositionDefaultValue = { clientX: 0, clientY: 0 } +// NocoDB-branded categorical palette for dashboard widgets. +// Leads with brand blue (#3366ff) and steps ~30° around the wheel so +// adjacent slices stay distinguishable. Saturation/lightness tuned to +// feel calm rather than the loud default ECharts rainbow. export const CHART_COLORS = [ - '#2ec7c9', - '#b6a2de', - '#5ab1ef', - '#ffb980', - '#d87a80', - '#8d98b3', - '#e5cf0d', - '#97b552', - '#95706d', - '#dc69aa', - '#07a2a4', - '#9a7fd1', - '#588dd5', - '#f5994e', - '#c05050', - '#59678c', - '#c9ab00', - '#7eb00a', - '#6f5553', - '#c14089', + '#3366FF', // brand blue + '#36BFFF', // sky + '#22C7C9', // teal + '#22C55E', // green + '#FFCD56', // amber + '#FFA94D', // warm orange + '#FF6B6B', // coral + '#FF6B9D', // pink + '#B388EB', // soft purple + '#7C8FFF', // periwinkle + '#94A3B8', // slate + '#67E8F9', // light cyan ] /** Virtual section ID for views not assigned to any real section */ diff --git a/packages/nocodb/src/services/users/users.service.ts b/packages/nocodb/src/services/users/users.service.ts index 65d9cc9447e5..9567e3d16d95 100644 --- a/packages/nocodb/src/services/users/users.service.ts +++ b/packages/nocodb/src/services/users/users.service.ts @@ -684,6 +684,13 @@ export class UsersService { return base; } + // Test-only bypass: parallel Playwright workers share user@nocodb.com + // and would otherwise invalidate each other's sessions. EE overrides to + // add an operator-controlled opt-out as well. + protected shouldEnforceSingleSession(_req?: any): boolean { + return process.env.PLAYWRIGHT_TEST !== 'true'; + } + async setRefreshToken({ res, req }) { const userId = req.user?.id; @@ -695,12 +702,39 @@ export class UsersService { const refreshToken = randomTokenString(); - if (!user['token_version']) { - user['token_version'] = randomTokenString(); + // Single-session enforcement: rotate token_version and clear any existing + // refresh tokens so previously logged-in sessions for this user are + // invalidated as soon as this login completes. + // API tokens are unaffected — the JWT strategy short-circuits before the + // token_version check when `is_api_token` is set on the payload. + // + // The bypass conditions live in `shouldEnforceSingleSession`, which EE + // overrides to support deployment-specific opt-outs. + if (this.shouldEnforceSingleSession(req)) { + const newTokenVersion = randomTokenString(); await User.update(user.id, { - token_version: user['token_version'], + token_version: newTokenVersion, }); + + user.token_version = newTokenVersion; + // Mirror onto req.user so the genJwt() call that follows (in login()) + // signs the access token with the rotated version. + if (req.user) { + req.user.token_version = newTokenVersion; + } + + await UserRefreshToken.deleteAllUserToken(user.id); + } else if (!user.token_version) { + // Preserve legacy behavior: ensure token_version exists for users that + // pre-date the column or had it cleared. + user.token_version = randomTokenString(); + await User.update(user.id, { + token_version: user.token_version, + }); + if (req.user) { + req.user.token_version = user.token_version; + } } await UserRefreshToken.insert({