多维表格
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.
 
 
 
 
 
 

579 lines
19 KiB

<script lang="ts" setup>
import tinycolor from 'tinycolor2'
import { 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'
const props = defineProps<{
group: Group
loadGroups: (params?: any, group?: Group) => 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>
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 scrollLeft = toRef(props, 'scrollLeft')
const { isViewDataLoading, isPaginationLoading } = storeToRefs(useViewsStore())
const { gridViewCols } = useViewColumnsOrThrow()
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())
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 = (key: any) => {
key = Array.isArray(key) ? key : [key]
if (key.length > 0 && vGroup.value.children) {
if (!oldActiveGroups.value.includes(key[key.length - 1])) {
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?.length) props.loadGroups({}, grp)
} else {
if (!grp.rows?.length || grp.count !== 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 } : {}),
})
}
}
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) {
await props.loadGroups({}, 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.LastModifiedBy,
].includes(column?.uidt)
const expandGroup = (key: string) => {
if (Array.isArray(_activeGroupKeys.value)) {
_activeGroupKeys.value.push(`group-panel-${key}`)
} else {
_activeGroupKeys.value = [`group-panel-${key}`]
}
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 = () => {
_activeGroupKeys.value = vGroup.value.children?.map((g) => `group-panel-${g.key}`) ?? []
if (vGroup.value.children) {
vGroup.value.children.forEach((g) => {
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 - 26}px`
case 2:
return `${baseValue - 17}px`
case 1:
return `${baseValue - 8}px`
default:
return `${baseValue}px`
}
}
if (_depth === 0) {
if (tempScrollLeft < 29) {
// The equation is calculated on trial and error basis
return `${baseValue + tempScrollLeft - (53 / 29) * 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 / 15) * tempScrollLeft}px`
}
return getSubGroupWidth(maxDepth)
}
if (_depth === 2) {
if (tempScrollLeft < 15) {
// The equation is calculated on trial and error basis
return `${baseValue + tempScrollLeft - 18 - (19 / 15) * 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="[i, 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),
'border-b-1': _depth === (maxDepth ?? 1) - 1 && activeGroups.includes(grp.key),
}"
class="flex !sticky w-full items-center rounded-b-lg group select-none transition-all !rounded-t-[8px] !h-10"
>
<div
:style="`width:${computedWidth};`"
class="!sticky flex justify-between !h-10 border-r-1 pr-2 border-gray-300 overflow-clip items-center !left-2"
>
<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) ? '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 class="flex 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)" @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>
</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"
:expand-form="expandForm"
:pagination-fixed-size="fullPage ? props.viewWidth : undefined"
:pagination-hide-sidebars="true"
:scroll-left="props.scrollLeft || _scrollLeft"
:view-width="viewWidth"
:scrollable="scrollable"
:full-page="fullPage"
/>
<GroupBy
v-else
:group="grp"
:load-groups="loadGroups"
:load-group-data="_loadGroupData"
:load-group-page="loadGroupPage"
:group-wrapper-change-page="groupWrapperChangePage"
:row-height="rowHeight"
:redistribute-rows="redistributeRows"
:expand-form="expandForm"
:view-width="viewWidth"
:depth="_depth + 1"
:max-depth="maxDepth"
:scroll-left="scrollBump"
:full-page="fullPage"
/>
</a-collapse-panel>
</a-collapse>
</div>
</div>
</div>
<LazySmartsheetPagination
v-if="vGroup.root"
v-model:pagination-data="vGroup.paginationData"
align-count-on-right
custom-label="groups"
show-api-timing
:change-page="(p: number) => groupWrapperChangePage(p, vGroup)"
:style="`${props.depth && props.depth > 0 ? 'border-radius: 0 0 8px 8px !important;' : ''}`"
></LazySmartsheetPagination>
<LazySmartsheetPagination
v-else
v-model:pagination-data="vGroup.paginationData"
align-count-on-right
custom-label="groups"
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'
: ''
}`"
: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>