多维表格
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

656 lines
22 KiB

<script lang="ts" setup>
import tinycolor from 'tinycolor2'
import { CommonAggregations, UITypes, dateFormats, parseStringDateTime, timeFormats } from 'nocodb-sdk'
import Table from './Table.vue'
import GroupBy from './GroupBy.vue'
import GroupByTable from './GroupByTable.vue'
import GroupByLabel from './GroupByLabel.vue'
import type { Group } from '~/lib/types'
const props = defineProps<{
group: Group
loadGroups: (
params?: any,
group?: Group,
options?: {
triggerChildOnly?: boolean
},
) => Promise<void>
loadGroupData: (group: Group, force?: boolean, params?: any) => Promise<void>
loadGroupPage: (group: Group, p: number) => Promise<void>
groupWrapperChangePage: (page: number, groupWrapper?: Group) => Promise<void>
loadGroupAggregation: (
group: Group,
fields?: Array<{
field: string
type: string
}>,
) => Promise<void>
redistributeRows?: (group?: Group) => void
viewWidth?: number
scrollLeft?: number
fullPage?: boolean
depth?: number
maxDepth?: number
rowHeight?: number
expandForm?: (row: Row, state?: Record<string, any>, fromToolbar?: boolean) => void
}>()
const emits = defineEmits(['update:paginationData'])
const vGroup = useVModel(props, 'group', emits)
const meta = inject(MetaInj, ref())
const fields = inject(FieldsInj, ref())
const { gridViewPageSize } = useGlobal()
const scrollLeft = toRef(props, 'scrollLeft')
const { isViewDataLoading, isPaginationLoading } = storeToRefs(useViewsStore())
const { gridViewCols } = useViewColumnsOrThrow()
const reloadAggregate = inject(ReloadAggregateHookInj, createEventHook())
const displayField = computed(() => {
return meta.value?.columns?.find((c) => c.pv)
})
const viewDisplayField = computed(() => {
if (!displayField.value || !displayField.value.id)
return {
width: '100px',
}
return gridViewCols.value[displayField.value.id]
})
const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
reloadAggregate?.on(async (_fields) => {
if (!fields.value?.length) return
if (!_fields || !_fields?.fields.length) {
await props.loadGroupAggregation(vGroup.value)
}
if (_fields?.fields) {
const fieldAggregateMapping = _fields.fields.reduce((acc, field) => {
const f = fields.value.find((f) => f.title === field.title)
if (!f?.id) return acc
acc[f.id] = field.aggregation ?? gridViewCols.value[f.id].aggregation ?? CommonAggregations.None
return acc
}, {} as Record<string, string>)
await props.loadGroupAggregation(
vGroup.value,
Object.entries(fieldAggregateMapping).map(([field, type]) => ({
field,
type,
})),
)
}
})
const _loadGroupData = async (group: Group, force?: boolean, params?: any) => {
isViewDataLoading.value = true
isPaginationLoading.value = true
await props.loadGroupData(group, force, params)
isViewDataLoading.value = false
isPaginationLoading.value = false
}
const _depth = props.depth ?? 0
const wrapper = ref<HTMLElement | undefined>()
const scrollable = ref<HTMLElement | undefined>()
const tableHeader = ref<HTMLElement | undefined>()
const fullPage = computed<boolean>(() => {
return props.fullPage ?? (tableHeader.value?.offsetWidth ?? 0) > (props.viewWidth ?? 0)
})
const _activeGroupKeys = ref<string[] | string>()
const activeGroups = computed<string[]>(() => {
if (!_activeGroupKeys.value) return []
if (Array.isArray(_activeGroupKeys.value)) {
return _activeGroupKeys.value.map((k) => k.replace('group-panel-', ''))
} else {
return [_activeGroupKeys.value.replace('group-panel-', '')]
}
})
const oldActiveGroups = ref<string[]>([])
const findAndLoadSubGroup = async (key: any) => {
key = Array.isArray(key) ? key : [key]
if (key.length > 0 && vGroup.value.children) {
if (!oldActiveGroups.value.includes(key[key.length - 1])) {
// wait until children loads since it may be still loading in background
// todo: implement a better way to handle this
await until(() => vGroup.value.children?.length > 0).toBeTruthy({
timeout: 10000,
})
const k = key[key.length - 1].replace('group-panel-', '')
const grp = vGroup.value.children.find((g) => `${g.key}` === k)
if (grp) {
if (grp.nested) {
if (!grp.children[0].children?.length) {
props.loadGroups({}, grp, {
triggerChildOnly: true,
})
}
} else {
if (!grp.rows?.length) _loadGroupData(grp)
}
}
}
}
oldActiveGroups.value = key
}
const reloadViewDataHandler = (params: void | { shouldShowLoading?: boolean | undefined; offset?: number | undefined }) => {
if (vGroup.value.nested) {
props.loadGroups({ ...(params?.offset !== undefined ? { offset: params.offset } : {}) }, vGroup.value)
} else {
_loadGroupData(vGroup.value, true, {
...(params?.offset !== undefined ? { offset: params.offset } : {}),
})
props.loadGroupAggregation(vGroup.value)
}
}
onMounted(async () => {
reloadViewDataHook?.on(reloadViewDataHandler)
})
onBeforeUnmount(async () => {
reloadViewDataHook?.off(reloadViewDataHandler)
})
watch([() => vGroup.value.key], async (n, o) => {
if (n !== o) {
if (!vGroup.value.nested) {
await _loadGroupData(vGroup.value, true)
} else if (vGroup.value.nested) {
await props.loadGroups({}, vGroup.value)
}
}
})
onMounted(async () => {
if (vGroup.value.root === true && !vGroup.value?.children?.length) {
await props.loadGroups(
{
limit: gridViewPageSize.value,
},
vGroup.value,
)
}
})
if (vGroup.value.root === true) provide(ScrollParentInj, wrapper)
const _scrollLeft = ref<number>()
const scrollBump = computed<number>(() => {
if (vGroup.value.root === true) {
return _scrollLeft.value ?? 0
} else {
if (props.scrollLeft && props.viewWidth && scrollable.value) {
const scrollWidth = scrollable.value.scrollWidth + 12 + 12
if (props.scrollLeft + props.viewWidth + 20 > scrollWidth) {
return scrollWidth - props.viewWidth - 20
}
return Math.max(Math.min(scrollWidth - props.viewWidth, (props.scrollLeft ?? 0) - 12), 0)
}
return 0
}
})
const onScroll = (e: Event) => {
if (!vGroup.value.root) return
_scrollLeft.value = (e.target as HTMLElement).scrollLeft
}
// a method to parse group key if grouped column type is LTAR or Lookup
// in these 2 scenario it will return json array or `___` separated value
const parseKey = (group: Group) => {
let key = group.key.toString()
// parse json array key if it's a lookup or link to another record
if ((key && group.column?.uidt === UITypes.Lookup) || group.column?.uidt === UITypes.LinkToAnotherRecord) {
try {
key = JSON.parse(key)
} catch {
// if parsing try to split it by `___` (for sqlite)
return key.split('___')
}
}
// show the groupBy dateTime field title format as like cell format
if (key && group.column?.uidt === UITypes.DateTime) {
return [
parseStringDateTime(
key,
`${parseProp(group.column?.meta)?.date_format ?? dateFormats[0]} ${
parseProp(group.column?.meta)?.time_format ?? timeFormats[0]
}`,
),
]
}
// show the groupBy time field title format as like cell format
if (key && group.column?.uidt === UITypes.Time) {
return [parseStringDateTime(key, timeFormats[0], false)]
}
if (key && [UITypes.User, UITypes.CreatedBy, UITypes.LastModifiedBy].includes(group.column?.uidt as UITypes)) {
try {
const parsedKey = JSON.parse(key)
return [parsedKey]
} catch {
return null
}
}
return [key]
}
const shouldRenderCell = (column) =>
[
UITypes.Lookup,
UITypes.Attachment,
UITypes.Barcode,
UITypes.QrCode,
UITypes.Links,
UITypes.User,
UITypes.DateTime,
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.CreatedBy,
UITypes.LongText,
UITypes.LastModifiedBy,
].includes(column?.uidt)
const expandGroup = async (key: string) => {
if (Array.isArray(_activeGroupKeys.value)) {
_activeGroupKeys.value.push(`group-panel-${key}`)
} else {
_activeGroupKeys.value = [`group-panel-${key}`]
}
await findAndLoadSubGroup(`group-panel-${key}`)
}
const collapseGroup = (key: string) => {
if (Array.isArray(_activeGroupKeys.value)) {
_activeGroupKeys.value = _activeGroupKeys.value.filter((k) => k !== `group-panel-${key}`)
} else {
_activeGroupKeys.value = []
}
}
const collapseAllGroup = () => {
_activeGroupKeys.value = []
}
const expandAllGroup = async () => {
_activeGroupKeys.value = vGroup.value.children?.map((g) => `group-panel-${g.key}`) ?? []
if (vGroup.value.children) {
await Promise.all(
vGroup.value.children.map((g) => {
return findAndLoadSubGroup(`group-panel-${g.key}`)
}),
)
}
}
const computedWidth = computed(() => {
// 55 is padding and margin of column header. 9 is padding of each level of nesting
const baseValue = Number((viewDisplayField.value?.width ?? '').replace('px', '')) + 55 + props.maxDepth * 9
const maxDepth = props.maxDepth ?? 1
// The _scrollLeft is calculated only on root and passed down to nested groups
const tempScrollLeft = vGroup.value.root ? _scrollLeft.value ?? 0 : scrollLeft.value ?? 0
const getSubGroupWidth = (depth: number) => {
switch (depth) {
case 3:
return `${baseValue - 18}px`
case 2:
return `${baseValue - 9}px`
case 1:
default:
return `${baseValue}px`
}
}
if (_depth === 0) {
if (tempScrollLeft < 29) {
// The equation is calculated on trial and error basis
return `${baseValue + tempScrollLeft - (0.02 * (tempScrollLeft * tempScrollLeft) + 1.07 * tempScrollLeft)}px`
}
return getSubGroupWidth(maxDepth)
}
if (_depth === 1) {
if (tempScrollLeft < 30) {
// The equation is calculated on trial and error basis
return `${baseValue + tempScrollLeft - 9 - (23 / 20) * tempScrollLeft}px`
}
return getSubGroupWidth(maxDepth)
}
if (_depth === 2) {
if (tempScrollLeft <= 14) {
// The equation is calculated on trial and error basis
return `${baseValue + tempScrollLeft - 18 - tempScrollLeft}px`
}
return getSubGroupWidth(maxDepth)
}
// TODO: We only allow 3 levels of nesting for now
// We only allow 3 levels of nesting for now
// If we add support for more levels, we need to adjust the width calculation
// for each level
return `${baseValue}px`
})
const bgColor = computed(() => {
if (props.maxDepth === 3) {
switch (_depth) {
case 2:
return '#F9F9FA'
case 1:
return '#F4F4F5'
default:
return '#F1F1F1'
}
}
if (props.maxDepth === 2) {
switch (_depth) {
case 1:
return '#F9F9FA'
default:
return '#F4F4F5'
}
}
if (props.maxDepth === 1) {
return '#F9F9FA'
}
return '#F9F9FA'
})
</script>
<template>
<div
ref="wrapper"
:class="{ 'overflow-y-auto': vGroup.root === true }"
class="h-full"
:style="`${!vGroup.root && vGroup.nested ? 'padding-left: 8px; padding-right: 8px;' : ''}`"
@scroll="onScroll"
>
<div ref="scrollable" :style="`${vGroup.root === true ? 'width: fit-content' : 'width: 100%'}`">
<div v-if="vGroup.root === true" class="flex sticky top-0 z-5">
<div
class="border-b-1 border-gray-200 mb-2"
style="background-color: #f4f4f5"
:style="{ 'padding-left': `${(maxDepth || 1) * 9}px` }"
></div>
<Table ref="tableHeader" class="mb-2" :data="[]" :hide-checkbox="true" :header-only="true" />
</div>
<div :class="{ 'pl-2': vGroup.root === true }">
<a-collapse
v-model:activeKey="_activeGroupKeys"
class="nc-group-wrapper !rounded-lg"
:bordered="false"
@change="findAndLoadSubGroup"
>
<a-collapse-panel
v-for="[_, grp] of Object.entries(vGroup?.children ?? [])"
:key="`group-panel-${grp.key}`"
class="!border-1 border-gray-300 nc-group rounded-[8px] mb-2"
:style="`background: ${bgColor};`"
:show-arrow="false"
>
<template #header>
<div
:class="{
'!rounded-b-none': activeGroups.includes(grp.key.toString()),
'!border-b-1': _depth === (maxDepth ?? 1) - 1 && activeGroups.includes(grp.key.toString()),
}"
class="flex !sticky w-full items-center rounded-b-lg select-none transition-all !rounded-t-[8px] !h-10"
>
<div
:class="{
'!rounded-bl-[8px]': !activeGroups.includes(grp.key.toString()),
}"
:style="`width:${computedWidth};background: ${bgColor};`"
class="!sticky flex z-10 justify-between !h-9.8 border-r-1 !rounded-tl-[8px] group pr-2 border-gray-300 overflow-clip items-center !left-0"
>
<div class="flex items-center">
<NcButton class="!border-0 !shadow-none !bg-transparent !hover:bg-transparent" type="secondary" size="small">
<GeneralIcon
icon="chevronDown"
class="transition-all"
:style="`${
activeGroups.includes(grp.key.toString()) ? 'transform: rotate(360deg)' : 'transform: rotate(270deg)'
}`"
/>
</NcButton>
<div class="flex">
<template v-if="grp.column.uidt === 'MultiSelect'">
<a-tag
v-for="[tagIndex, tag] of Object.entries(grp.key.split(','))"
:key="`panel-tag-${grp.column.id}-${tag}`"
class="!py-0 !px-[12px] !rounded-[12px]"
:color="grp.color.split(',')[+tagIndex]"
>
<span
class="nc-group-value"
:style="{
'color': tinycolor.isReadable(grp.color.split(',')[+tagIndex] || '#ccc', '#fff', {
level: 'AA',
size: 'large',
})
? '#fff'
: tinycolor
.mostReadable(grp.color.split(',')[+tagIndex] || '#ccc', ['#0b1d05', '#fff'])
.toHex8String(),
'font-size': '14px',
'font-weight': 500,
}"
>
{{ tag in GROUP_BY_VARS.VAR_TITLES ? GROUP_BY_VARS.VAR_TITLES[tag] : tag }}
</span>
</a-tag>
</template>
<div
v-else-if="!(grp.key in GROUP_BY_VARS.VAR_TITLES) && shouldRenderCell(grp.column)"
class="flex min-w-[100px] flex-wrap"
>
<template v-for="(val, ind) of parseKey(grp)" :key="ind">
<GroupByLabel v-if="val" :column="grp.column" :model-value="val" />
<span v-else class="text-gray-400">No mapped value</span>
</template>
</div>
<a-tag
v-else
:key="`panel-tag-${grp.column.id}-${grp.key}`"
class="!py-0 !px-[12px]"
:class="`${grp.column.uidt === 'SingleSelect' ? '!rounded-[12px]' : '!rounded-[6px]'}`"
:color="grp.color"
>
<span
class="nc-group-value font-semibold text-[13px]"
:style="{
color: tinycolor.isReadable(grp.color || '#ccc', '#fff', {
level: 'AA',
size: 'large',
})
? '#fff'
: tinycolor.mostReadable(grp.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
}"
>
<template v-if="grp.key in GROUP_BY_VARS.VAR_TITLES">{{ GROUP_BY_VARS.VAR_TITLES[grp.key] }}</template>
<template v-else>
{{ parseKey(grp)?.join(', ') }}
</template>
</span>
</a-tag>
</div>
</div>
<div
:style="`background: linear-gradient(to right, hsla(0, 0%, 97%, 0), ${bgColor} 18%);`"
class="flex !h-10 absolute right-0 pl-8 pr-2 items-center"
>
<div class="text-xs group-hover:hidden text-gray-500 nc-group-row-count">
<span>
{{ $t('datatype.Count') }}
</span>
<span class="text-[#374151] ml-2"> {{ grp.count }} </span>
</div>
<NcDropdown class="!hidden !group-hover:block">
<NcButton size="small" type="text" @click.stop>
<GeneralIcon icon="threeDotVertical" />
</NcButton>
<template #overlay>
<NcMenu>
<NcMenuItem v-if="activeGroups.includes(grp.key.toString())" @click="collapseGroup(grp.key)">
<GeneralIcon icon="minimize" />
Collapse group
</NcMenuItem>
<NcMenuItem v-else @click="expandGroup(grp.key)">
<GeneralIcon icon="maximize" />
Expand group
</NcMenuItem>
<NcMenuItem @click="expandAllGroup">
<GeneralIcon icon="maximizeAll" />
Expand all
</NcMenuItem>
<NcMenuItem @click="collapseAllGroup">
<GeneralIcon icon="minimizeAll" />
Collapse all
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
</div>
</div>
<SmartsheetGridAggregation
:scroll-left="props.scrollLeft || _scrollLeft"
:max-depth="maxDepth"
:group="grp"
:depth="_depth"
/>
</div>
</template>
<GroupByTable
v-if="!grp.nested && grp.rows"
:group="grp"
:max-depth="maxDepth"
:depth="depth"
:load-groups="loadGroups"
:load-group-data="_loadGroupData"
:load-group-page="loadGroupPage"
:group-wrapper-change-page="groupWrapperChangePage"
:row-height="rowHeight"
:redistribute-rows="redistributeRows"
:pagination-fixed-size="fullPage ? props.viewWidth : undefined"
:pagination-hide-sidebars="true"
:scroll-left="props.scrollLeft || _scrollLeft"
:view-width="viewWidth"
:scrollable="scrollable"
/>
<GroupBy
v-else
:group="grp"
:load-groups="loadGroups"
:load-group-data="_loadGroupData"
:load-group-page="loadGroupPage"
:group-wrapper-change-page="groupWrapperChangePage"
:row-height="rowHeight"
:load-group-aggregation="loadGroupAggregation"
:redistribute-rows="redistributeRows"
:view-width="viewWidth"
:depth="_depth + 1"
:max-depth="maxDepth"
:scroll-left="scrollBump"
:full-page="fullPage"
/>
</a-collapse-panel>
</a-collapse>
</div>
</div>
</div>
<LazySmartsheetGridPaginationV2
v-if="vGroup.root"
v-model:pagination-data="vGroup.paginationData"
:show-size-changer="true"
:scroll-left="_scrollLeft"
custom-label="groups"
:depth="maxDepth"
:change-page="(p: number) => groupWrapperChangePage(p, vGroup)"
/>
<LazySmartsheetPagination
v-else
v-model:pagination-data="vGroup.paginationData"
align-count-on-right
custom-label="groups"
align-left
show-api-timing
:change-page="(p: number) => groupWrapperChangePage(p, vGroup)"
:hide-sidebars="true"
:style="`${
props.depth && props.depth > 0
? 'border-radius: 0 0 8px 8px !important; background: transparent; border-top: 0px; height: 24px; padding-bottom: 8px;'
: ''
}`"
:fixed-size="undefined"
></LazySmartsheetPagination>
</template>
<style scoped lang="scss">
:deep(.ant-collapse-content > .ant-collapse-content-box) {
padding: 0px !important;
border-radius: 0 0 8px 8px !important;
}
:deep(.ant-collapse) {
@apply !border-gray-300 !bg-transparent;
}
:deep(.ant-collapse-item) {
@apply !border-gray-300;
}
:deep(.ant-collapse-header) {
@apply !p-0 !border-gray-300 !rounded-lg;
}
:deep(.ant-collapse-item-active > .ant-collapse-header) {
border-radius: 8px 8px 0 0 !important;
}
:deep(.ant-collapse-borderless > .ant-collapse-item:last-child) {
border-radius: 8px !important;
}
</style>