Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
62c793b
fix(nc-gui): debounce viewColumnList + row-color reloads on FIELD_RELOAD
pranavxc May 11, 2026
1c3ed70
feat(auth): enforce single active session per user on login
pranavxc May 19, 2026
f2a1203
fix(auth): bypass single-session enforcement under PLAYWRIGHT_TEST
pranavxc May 19, 2026
dee00e9
refactor(auth): extract single-session gate into overridable method
pranavxc May 19, 2026
ce5fd6d
fix(nc-gui): replace ECharts vintage palette with NocoDB brand palette
rameshmane7218 May 19, 2026
eafc664
fix(nc-gui): preserve group path in comment deep-link
dstala May 20, 2026
2e99c78
fix(nc-gui): resolve group context for path-less comment deep-links
dstala May 20, 2026
5675d26
fix(nc-gui): reconcile group path + prev/next for path-less deep-links
dstala May 20, 2026
ea68f41
fix(nc-gui): keep comment deep-link anchored across loader remounts
rameshmane7218 May 20, 2026
0d213de
fix(nc-gui): refresh BT/MO cell in expanded form after link/unlink
pranavxc May 20, 2026
f8eee2b
fix(nc-gui): guard rowStoreCurrentRow before mutating in link/unlink
rameshmane7218 May 20, 2026
f4fa062
Merge pull request #13868 from nocodb/nc-fix/dashboard-widget-improve…
DarkPhoenix2704 May 20, 2026
317a4ff
Merge pull request #13865 from nocodb/nc-fix/comments-link
dstala May 20, 2026
2de8992
Merge pull request #13869 from nocodb/nc-fix-issue-6778
DarkPhoenix2704 May 20, 2026
613a716
Merge pull request #13863 from nocodb/nc-fix/expanded-form-mto-refresh
pranavxc May 20, 2026
986f4e7
Merge pull request #13873 from nocodb/nc-feat/single-session-login
dstala May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ const commentInputRef = ref<any>()

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<string | null>(null)

const router = useRouter()

const baseUsers = computed(() => (meta.value?.base_id ? basesUser.value.get(meta.value?.base_id) || [] : []))
Expand All @@ -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
Expand Down Expand Up @@ -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}`,
),
)
}
Expand All @@ -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()
Expand Down Expand Up @@ -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)
})
Expand Down Expand Up @@ -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()
})
},
Expand Down
109 changes: 102 additions & 7 deletions packages/nc-gui/components/smartsheet/grid/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
})
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
},
Expand Down Expand Up @@ -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
Expand All @@ -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)
})

Expand Down
10 changes: 8 additions & 2 deletions packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down
14 changes: 14 additions & 0 deletions packages/nc-gui/composables/useLTARStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 16 additions & 20 deletions packages/nc-gui/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading
Loading