Files
nocodb/packages/nc-gui/composables/useViewGroupBy.ts

788 lines
27 KiB
TypeScript

import {
type ColumnType,
CommonAggregations,
type GridType,
type LinkToAnotherRecordType,
type LookupType,
type TableType,
UITypesName,
type ViewType,
} from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import type { Ref } from 'vue'
import rfdc from 'rfdc'
import type { Group } from '../lib/types'
import { findKeyColor, valueToTitle } from '../utils/groupbyUtils'
const excludedGroupingUidt = [UITypes.Attachment, UITypes.QrCode, UITypes.Barcode, UITypes.Button]
const [useProvideViewGroupBy, useViewGroupBy] = useInjectionState(
(
view: Ref<ViewType | undefined>,
meta: Ref<TableType | undefined> | ComputedRef<TableType | undefined>,
where?: ComputedRef<string | undefined>,
isPublic = false,
) => {
const groupByLimit = 3
const clone = rfdc()
const { api } = useApi()
const { $api } = useNuxtApp()
const { appInfo } = useGlobal()
const { base } = storeToRefs(useBase())
const { sharedView, fetchSharedViewData, fetchBulkAggregatedData } = useSharedView()
const { gridViewCols } = useViewColumnsOrThrow()
const { getEvaluatedRowMetaRowColorInfo } = useViewRowColorRender()
const { getMeta, getPartialMeta } = useMetas()
const sharedViewPassword = inject(SharedViewPasswordInj, ref(null))
const { hasPersonalViewPermission } = usePersonalViewPermissions(view)
const canSyncGroupBy = hasPersonalViewPermission('groupBySync')
const localGroupBy = ref<{ column: ColumnType; sort: string; order: number }[] | null>(null)
const syncedGroupBy = computed<{ column: ColumnType; sort: string; order?: number }[]>(() => {
const tempGroupBy: { column: ColumnType; sort: string; order?: number }[] = []
Object.values(gridViewCols.value).forEach((col) => {
if (col.group_by) {
const column = meta?.value?.columns?.find((f) => f.id === col.fk_column_id)
if (column) {
tempGroupBy.push({
column,
sort: col.group_by_sort || 'asc',
order: col.group_by_order || 1,
})
}
}
})
tempGroupBy.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity))
return tempGroupBy
})
const groupBy = computed<{ column: ColumnType; sort: string; order?: number }[]>(() => {
// null = no override (use synced), [] = override with empty (no grouping)
if (localGroupBy.value !== null) {
return localGroupBy.value.map((e, i) => ({
column: e.column,
sort: e.sort,
order: e.order || i + 1,
}))
}
return syncedGroupBy.value
})
const isGroupBy = computed(() => !!groupBy.value.length)
const hideEmptyGroups = ref(parseProp((view.value?.view as GridType)?.meta).hide_empty_groups || false)
watch(
() => (view.value?.view as GridType)?.meta,
(newMeta) => {
hideEmptyGroups.value = parseProp(newMeta).hide_empty_groups || false
},
)
const { isUIAllowed } = useRoles()
const { sorts, nestedFilters } = useSmartsheetStoreOrThrow()
const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
const groupByGroupLimit = computed(() => {
return appInfo.value.defaultGroupByLimit?.limitGroup || 25
})
const groupByRecordLimit = computed(() => {
return appInfo.value.defaultGroupByLimit?.limitRecord || 10
})
const unsupportedLookups = ref<string[]>([])
const fieldsToGroupBy = computed(() => {
return clone(meta?.value?.columns || []).map((field) => {
if (
(field.uidt === UITypes.Lookup && field.id && unsupportedLookups.value.includes(field.id)) ||
excludedGroupingUidt.includes(field.uidt as UITypes)
) {
field.ncItemDisabled = true
field.ncItemTooltip = `This Field of type ${UITypesName[field.uidt]} not supported for grouping`
} else {
field.ncItemDisabled = false
field.ncItemTooltip = ''
}
return field
})
})
const rootGroup = ref<Group>({
key: 'root',
color: 'root',
count: 0,
column: {} as any,
nestedIn: [],
aggregations: {},
paginationData: { page: 1, pageSize: groupByGroupLimit.value },
nested: true,
children: [],
root: true,
})
async function groupWrapperChangePage(page: number, groupWrapper?: Group) {
groupWrapper = groupWrapper || rootGroup.value
if (!groupWrapper) return
groupWrapper.paginationData.page = page
await loadGroups(
{
offset: (page - 1) * (groupWrapper.paginationData.pageSize || groupByGroupLimit.value),
} as any,
groupWrapper,
)
}
const formatData = (list: Record<string, any>[]) =>
list.map((row) => ({
row: { ...row },
oldRow: { ...row },
rowMeta: {
...getEvaluatedRowMetaRowColorInfo(row),
},
}))
const colors = ref(enumColor.light)
const nextGroupColor = ref(colors.value[0])
const getNextColor = () => {
const tempColor = nextGroupColor.value
const index = colors.value.indexOf(nextGroupColor.value)
if (index === colors.value.length - 1) {
nextGroupColor.value = colors.value[0]
} else {
nextGroupColor.value = colors.value[index + 1]
}
return tempColor
}
const calculateNestedWhere = (nestedIn: GroupNestedIn[], existing = '') => {
return nestedIn.reduce((acc, curr) => {
if (curr.key === GROUP_BY_VARS.NULL) {
acc += `${acc.length ? '~and' : ''}(${curr.title},gb_null)`
} else if (curr.column_uidt === UITypes.Checkbox) {
acc += `${acc.length ? '~and' : ''}(${curr.title},${curr.key === GROUP_BY_VARS.TRUE ? 'checked' : 'notchecked'})`
} else if (
[UITypes.Date, UITypes.DateTime, UITypes.CreatedTime, UITypes.LastModifiedTime].includes(curr.column_uidt as UITypes)
) {
acc += `${acc.length ? '~and' : ''}(${curr.title},gb_eq,exactDate,${curr.key})`
} else if ([UITypes.User, UITypes.CreatedBy, UITypes.LastModifiedBy].includes(curr.column_uidt as UITypes)) {
try {
const value = JSON.parse(curr.key)
acc += `${acc.length ? '~and' : ''}(${curr.title},gb_eq,${(Array.isArray(value) ? value : [value])
.map((v: any) => v.id)
.join(',')})`
} catch (e) {
console.error(e)
}
} else {
acc += `${acc.length ? '~and' : ''}(${curr.title},gb_eq,${curr.key})`
}
return acc
}, existing)
}
const getSortParams = (sort: string) => {
if (sort === 'asc') {
return '+'
} else if (sort === 'desc') {
return '-'
} else if (sort === 'count-asc') {
return '~+'
} else if (sort === 'count-desc') {
return '~-'
}
}
const processGroupData = async (response: any, group?: Group) => {
group = group || rootGroup.value
const groupby = groupBy.value[group.nestedIn.length]
if (!groupby) return group
const tempList: Group[] = response.list
.reduce((acc: Group[], curr: Record<string, any>) => {
const keyExists = acc.find(
(a) => a.key === valueToTitle(curr[groupby.column.column_name!] ?? curr[groupby.column.title!], groupby.column),
)
if (keyExists) {
keyExists.count += +curr.count
keyExists.paginationData = {
page: 1,
pageSize: group.paginationData.pageSize || groupByGroupLimit.value,
totalRows: keyExists.count,
}
return acc
}
if (groupby.column.title && groupby.column.uidt) {
acc.push({
key: valueToTitle(curr[groupby.column.title!], groupby.column),
column: groupby.column,
count: +curr.count,
color: findKeyColor(curr[groupby.column.title!], groupby.column, getNextColor),
nestedIn: [
...group!.nestedIn,
{
title: groupby.column.title,
column_name: groupby.column.title!,
key: valueToTitle(curr[groupby.column.title!], groupby.column),
column_uidt: groupby.column.uidt,
column_id: groupby.column.id,
},
],
aggregations: curr.aggregations ?? {},
paginationData: {
page: 1,
pageSize:
group!.nestedIn.length < groupBy.value.length - 1
? group.paginationData.pageSize || groupByGroupLimit.value
: groupByRecordLimit.value,
totalRows: +curr.count,
},
nested: group!.nestedIn.length < groupBy.value.length - 1,
})
}
return acc
}, [])
.filter((g: Group) => !hideEmptyGroups.value || g.key !== GROUP_BY_VARS.NULL)
if (!group.children) group.children = []
for (const temp of tempList) {
const keyExists = group.children?.find((a) => a.key === temp.key)
if (keyExists) {
temp.paginationData = {
page: keyExists.paginationData.page || temp.paginationData.page,
pageSize: keyExists.paginationData.pageSize || temp.paginationData.pageSize,
totalRows: temp.count,
}
// update group
Object.assign(keyExists, temp)
continue
}
group.children.push(temp)
}
group.children = group.children.filter((c) => tempList.find((t) => t.key === c.key))
if (group.count <= (group.paginationData.pageSize ?? groupByGroupLimit.value)) {
group.children.sort((a, b) => {
const orderA = tempList.findIndex((t) => t.key === a.key)
const orderB = tempList.findIndex((t) => t.key === b.key)
return orderA - orderB
})
}
group.paginationData = response.pageInfo
// to cater the case like when querying with a non-zero offset
// the result page may point to the target page where the actual returned data don't display on
const expectedPage = Math.max(1, Math.ceil(group.paginationData.totalRows! / group.paginationData.pageSize!))
if (expectedPage < group.paginationData.page!) {
await groupWrapperChangePage(expectedPage, group)
}
return group
}
async function loadGroups(
params: any = {},
group?: Group,
_options?: {
triggerChildOnly: boolean
},
) {
try {
group = group || rootGroup.value
if (!base?.value?.id || !view.value?.id || !view.value?.fk_model_id || !group) return
if (groupBy.value.length === 0) {
group.children = []
return
}
if (group.nestedIn.length > groupBy.value.length) return
const groupby = groupBy.value[group.nestedIn.length]
const nestedWhere = calculateNestedWhere(group.nestedIn, where?.value)
const effectiveWhere =
hideEmptyGroups.value && groupby?.column?.title
? `${nestedWhere}${nestedWhere ? '~and' : ''}(${groupby.column.title},notblank)`
: nestedWhere
if (!groupby || !groupby.column.title) return
if (isPublic && !sharedView.value?.uuid) {
return
}
if (groupby.column.uidt === UITypes.LinkToAnotherRecord) {
const relatedTableMeta = await getMeta(
base.value?.id as string,
(groupby.column.colOptions as LinkToAnotherRecordType).fk_related_model_id as string,
)
if (!relatedTableMeta) return
group.displayValueProp = (relatedTableMeta.columns?.find((c) => c.pv) || relatedTableMeta.columns?.[0])?.title || ''
}
// if (!options?.triggerChildOnly) {
const response = !isPublic
? await api.dbViewRow.groupBy('noco', base.value.id, view.value.fk_model_id, view.value.id, {
offset: ((group.paginationData.page ?? 0) - 1) * groupByGroupLimit.value,
limit: groupByGroupLimit.value,
...params,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
where: `${effectiveWhere}`,
sort: `${getSortParams(groupby.sort)}${groupby.column.title}`,
column_name: groupby.column.title,
} as any)
: await api.public.dataGroupBy(
sharedView.value!.uuid!,
{
offset: ((group.paginationData.page ?? 0) - 1) * groupByGroupLimit.value,
limit: groupByGroupLimit.value,
...params,
where: effectiveWhere,
sort: `${getSortParams(groupby.sort)}${groupby.column.title}`,
column_name: groupby.column.title,
sortsArr: sorts.value,
filtersArr: nestedFilters.value,
},
{
headers: {
'xc-password': sharedViewPassword.value,
},
},
)
group = await processGroupData(response, group)
// }
if (group?.children?.length && !appInfo.value.disableGroupByAggregation) {
const aggregationAliasMapper = new AliasMapper()
const aggregation = Object.values(gridViewCols.value)
.map((f) => ({
field: f.fk_column_id!,
type: f.aggregation ?? CommonAggregations.None,
}))
.filter((f) => f.type !== CommonAggregations.None)
const aggregationParams = (group.children ?? []).map((child) => {
return {
where: calculateNestedWhere(child.nestedIn, where?.value),
alias: aggregationAliasMapper.generateAlias(child.key),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
}
})
let aggResponse = {}
if (aggregation.length) {
aggResponse = !isPublic
? await api.internal.postOperation(
meta.value!.fk_workspace_id!,
meta.value!.base_id!,
{
operation: 'bulkAggregate',
tableId: meta.value!.id,
viewId: view.value!.id,
baseId: meta.value!.base_id!,
aggregation,
},
aggregationParams,
)
: await fetchBulkAggregatedData(
{
aggregation,
},
aggregationParams,
)
await aggregationAliasMapper.process(aggResponse, (originalKey, value) => {
const child = (group?.children ?? []).find((c) => c.key.toString() === (originalKey as any).toString())
if (child) {
Object.assign(child.aggregations, value)
}
})
}
}
} catch (e) {
console.log(e)
message.error(await extractSdkResponseErrorMsg(e))
}
}
async function loadGroupData(group: Group, force = false, params: any = {}) {
try {
if (!base?.value?.id || !view.value?.id || !view.value?.fk_model_id) return
if (group.children && !force) return
if (!group.paginationData) {
group.paginationData = { page: 1, pageSize: groupByRecordLimit.value }
}
const nestedWhere = calculateNestedWhere(group.nestedIn, where?.value)
const query = {
offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByRecordLimit.value),
limit: group.paginationData.pageSize ?? groupByRecordLimit.value,
where: `${nestedWhere}`,
}
const response = !isPublic
? await api.dbViewRow.list('noco', base.value.id, view.value.fk_model_id, view.value.id, {
...query,
...params,
include_row_color: true,
getHiddenColumns: true,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
} as any)
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: nestedFilters.value, ...query }, { isGroupBy: true })
group.count = response.pageInfo.totalRows ?? 0
group.rows = formatData(response.list)
await loadAggCommentsCount(group.rows)
group.paginationData = response.pageInfo
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
async function loadGroupAggregation(
group: Group,
fields?: Array<{
field: string
type: string
}>,
) {
try {
if (!meta?.value?.id || !view.value?.id || !view.value?.fk_model_id || appInfo.value.disableGroupByAggregation) return
let filteredFields = fields
if (!fields) {
filteredFields = Object.values(gridViewCols.value).map((f) => ({
field: f.fk_column_id!,
type: f.aggregation ?? CommonAggregations.None,
}))
}
filteredFields = filteredFields?.filter((x) => x.type !== CommonAggregations.None)
if ((filteredFields && !filteredFields?.length) || !group.children?.length) return
const aliasMapper = new AliasMapper()
const aggregationParams = (group.children ?? []).map((child) => {
return {
where: calculateNestedWhere(child.nestedIn, where?.value),
alias: aliasMapper.generateAlias(child.key),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
}
})
const response = !isPublic
? await api.internal.postOperation(
(meta.value as any)!.fk_workspace_id!,
meta.value!.base_id!,
{
operation: 'bulkAggregate',
tableId: meta.value!.id,
viewId: view.value!.id,
baseId: meta.value!.base_id!,
...(filteredFields ? { aggregation: filteredFields } : {}),
},
aggregationParams,
)
: await fetchBulkAggregatedData(
{
...(filteredFields ? { aggregation: filteredFields } : {}),
},
aggregationParams,
)
await aliasMapper.process(response, (originalKey, value) => {
const child = (group.children ?? []).find((c) => c.key.toString() === originalKey.toString())
if (child) {
Object.assign(child.aggregations, value)
}
})
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const loadGroupPage = async (group: Group, p: number) => {
if (!group.paginationData) {
group.paginationData = { page: 1, pageSize: groupByRecordLimit.value }
}
group.paginationData.page = p
await loadGroupData(group, true)
}
const refreshNested = (group?: Group, nestLevel = 0) => {
group = group || rootGroup.value
if (!group) return
if (nestLevel < groupBy.value.length) {
group.nested = true
} else {
group.nested = false
}
if (group.nested) {
if (group?.rows) {
group.rows = []
}
} else {
if (group?.children) {
group.children = []
}
}
if (nestLevel > groupBy.value.length) return
for (const child of group.children || []) {
refreshNested(child, nestLevel + 1)
}
}
watch(
() => groupBy.value.length,
async () => {
if (!groupBy.value.length) {
nextTick(() => reloadViewDataHook?.trigger())
return
}
rootGroup.value.paginationData = { page: 1, pageSize: groupByGroupLimit.value }
rootGroup.value.column = {} as any
refreshNested()
nextTick(() => reloadViewDataHook?.trigger())
},
)
// Clear local group-bys on view change
watch(
() => view.value?.id,
() => {
localGroupBy.value = null
},
)
const findGroupByNestedIn = (nestedIn: GroupNestedIn[], group?: Group, nestLevel = 0): Group => {
group = group || rootGroup.value
if (nestLevel >= nestedIn.length) return group
const child = group.children?.find((g) => g.key === nestedIn[nestLevel].key)
if (child) {
if (child.nested) {
return findGroupByNestedIn(nestedIn, child, nestLevel + 1)
}
return child
}
return group
}
const parentGroup = (group: Group) => {
const parent = findGroupByNestedIn(group.nestedIn.slice(0, -1))
return parent
}
const modifyCount = (group: Group, countEffect: number) => {
if (!group) return
group.count += countEffect
// remove group if count is 0
if (group.count === 0) {
const parent = parentGroup(group)
if (parent) {
parent.children = parent.children?.filter((c) => c.key !== group.key)
}
}
if (group.root) return
modifyCount(parentGroup(group), countEffect)
}
const findGroupForRow = (row: Row, group?: Group, nestLevel = 0): { found: boolean; group: Group } => {
group = group || rootGroup.value
if (group.nested) {
const child = group.children?.find((g) => {
if (!groupBy.value[nestLevel].column.title) return undefined
return (
g.key ===
valueToTitle(row.row[groupBy.value[nestLevel].column.title!], groupBy.value[nestLevel].column, group.displayValueProp)
)
})
if (child) {
return findGroupForRow(row, child, nestLevel + 1)
}
return { found: false, group }
}
return { found: true, group }
}
const redistributeRows = (group?: Group) => {
group = group || rootGroup.value
if (!group) return
if (!group.nested && group.rows) {
group.rows.forEach((row) => {
const properGroup = findGroupForRow(row)
if (properGroup.found) {
if (properGroup.group !== group) {
if (properGroup.group) {
properGroup.group.rows?.push(row)
modifyCount(properGroup.group, 1)
}
if (group) {
group.rows?.splice(group!.rows.indexOf(row), 1)
modifyCount(group, -1)
}
}
} else {
if (group) {
group.rows?.splice(group!.rows.indexOf(row), 1)
modifyCount(group, -1)
} else {
rootGroup.value.rows?.splice(rootGroup.value.rows!.indexOf(row), 1)
}
// if (properGroup.group?.children) loadGroups({}, properGroup.group)
}
})
} else {
group.children?.forEach((g) => redistributeRows(g))
}
}
const loadDisallowedLookups = async () => {
const filteredLookupCols = []
try {
for (const col of meta?.value?.columns || []) {
if (col.uidt !== UITypes.Lookup) continue
let nextCol: ColumnType = col
let currentBaseId = meta.value?.base_id as string
// Check if the lookup column is an unsupported type
while (nextCol && nextCol.uidt === UITypes.Lookup) {
// Use the tracked base_id for the current table where nextCol resides
const lookupRelation = (await getMeta(currentBaseId, nextCol.fk_model_id as string))?.columns?.find(
(c) => c.id === (nextCol?.colOptions as LookupType).fk_relation_column_id,
)
if (!lookupRelation?.colOptions) break
let relatedTableMeta: TableType | null = null
const lookupRelColOpts = lookupRelation.colOptions as LinkToAnotherRecordType
const relatedTableId = lookupRelColOpts.fk_related_model_id as string
const relatedBaseId = lookupRelColOpts.fk_related_base_id || currentBaseId
try {
relatedTableMeta = await getMeta(relatedBaseId, relatedTableId, undefined, undefined, undefined, true)
} catch {
relatedTableMeta = await getPartialMeta(relatedBaseId, lookupRelation?.id, relatedTableId)
}
nextCol = relatedTableMeta?.columns?.find(
(c) => c.id === ((nextCol?.colOptions as LookupType).fk_lookup_column_id as string),
) as ColumnType
// Update currentBaseId for next iteration
currentBaseId = relatedBaseId
// if next column is same as root lookup column then break the loop
// since it's going to be a circular loop, and ignore the column
if (nextCol?.id === col.id) {
break
}
}
// Collect column if the final resolved column is an Attachment or circular/invalid
if ((nextCol?.uidt === UITypes.Attachment || !nextCol) && col.id) {
filteredLookupCols.push(col.id)
}
}
unsupportedLookups.value = filteredLookupCols
} catch (e) {
console.error(e)
}
}
async function loadAggCommentsCount(formattedData: Array<Row>) {
if (!isUIAllowed('commentCount') || isPublic) return
const ids = formattedData
.filter(({ rowMeta: { new: isNew } }) => !isNew)
.map(({ row }) => extractPkFromRow(row, meta?.value?.columns as ColumnType[]))
.filter(Boolean)
if (!ids.length) return
try {
const aggCommentCount = await $api.internal.getOperation((meta.value as any).fk_workspace_id!, meta.value!.base_id!, {
operation: 'commentCount',
fk_model_id: meta.value!.id as string,
ids,
})
formattedData.forEach((row) => {
const id = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
const count = aggCommentCount?.find((c: Record<string, any>) => c.row_id === id)?.count || 0
row.rowMeta = row.rowMeta ?? {}
row.rowMeta.commentCount = +count
})
} catch (e) {
console.error('Failed to load aggregate comment count:', e)
}
}
return {
rootGroup,
groupBy,
syncedGroupBy,
localGroupBy,
canSyncGroupBy,
isGroupBy,
hideEmptyGroups,
fieldsToGroupBy,
groupByLimit,
loadGroups,
loadGroupData,
loadGroupPage,
loadGroupAggregation,
groupWrapperChangePage,
redistributeRows,
loadDisallowedLookups,
}
},
'useViewGroupBy',
)
export { useProvideViewGroupBy }
export function useViewGroupByOrThrow() {
const viewColumns = useViewGroupBy()
if (viewColumns == null) throw new Error('Please call `useProvideViewGroupBy` on the appropriate parent component')
return viewColumns
}