mirror of https://github.com/nocodb/nocodb
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.
581 lines
20 KiB
581 lines
20 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(() => { |
|
console.log(props.group.key, 'props.maxDepth', props.maxDepth, _depth) |
|
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]" |
|
:style="`background: ${bgColor};`" |
|
:class="{ 'mb-2': vGroup.children && +i !== vGroup.children.length - 1 }" |
|
: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>
|
|
|