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.
419 lines
15 KiB
419 lines
15 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' |
|
import type { Group, Row } from '#imports' |
|
import { GROUP_BY_VARS, computed, ref } from '#imports' |
|
|
|
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 { isViewDataLoading, isPaginationLoading } = storeToRefs(useViewsStore()) |
|
|
|
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 > scrollWidth) { |
|
return scrollWidth - props.viewWidth |
|
} |
|
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) |
|
</script> |
|
|
|
<template> |
|
<div class="flex flex-col h-full w-full"> |
|
<div |
|
ref="wrapper" |
|
class="flex flex-col h-full w-full scrollbar-thin-dull overflow-auto" |
|
:style="`${!vGroup.root && vGroup.nested ? 'padding-left: 12px; padding-right: 12px;' : ''}`" |
|
@scroll="onScroll" |
|
> |
|
<div |
|
ref="scrollable" |
|
class="flex flex-col" |
|
:class="{ 'my-2': vGroup.root !== true }" |
|
:style="`${vGroup.root === true ? 'width: fit-content' : 'width: 100%'}`" |
|
> |
|
<div v-if="vGroup.root === true" class="flex sticky top-0 z-5"> |
|
<div |
|
class="bumper mb-2" |
|
style="background-color: #f9f9fa; border-color: #e7e7e9; border-bottom-width: 1px" |
|
:style="{ 'padding-left': `${(maxDepth || 1) * 13}px` }" |
|
></div> |
|
<Table ref="tableHeader" class="mb-2" :data="[]" :header-only="true" /> |
|
</div> |
|
<div :class="{ 'px-[12px]': vGroup.root === true }"> |
|
<a-collapse |
|
v-model:activeKey="_activeGroupKeys" |
|
class="!bg-transparent w-full nc-group-wrapper" |
|
:bordered="false" |
|
destroy-inactive-panel |
|
@change="findAndLoadSubGroup" |
|
> |
|
<a-collapse-panel |
|
v-for="[i, grp] of Object.entries(vGroup?.children ?? [])" |
|
:key="`group-panel-${grp.key}`" |
|
class="!border-1 nc-group rounded-[12px]" |
|
:class="{ 'mb-4': vGroup.children && +i !== vGroup.children.length - 1 }" |
|
:style="`background: rgb(${245 - _depth * 10}, ${245 - _depth * 10}, ${245 - _depth * 10})`" |
|
:show-arrow="false" |
|
> |
|
<template #header> |
|
<div class="flex !sticky left-[15px]"> |
|
<div class="flex items-center"> |
|
<span role="img" aria-label="right" class="anticon anticon-right ant-collapse-arrow"> |
|
<GeneralIcon |
|
icon="chevronDown" |
|
:style="`${activeGroups.includes(grp.key) ? 'transform: rotate(360deg)' : 'transform: rotate(270deg)'}`" |
|
></GeneralIcon> |
|
</span> |
|
</div> |
|
<div class="flex items-center"> |
|
<div class="flex flex-col"> |
|
<div class="flex gap-2"> |
|
<div class="text-xs nc-group-column-title"> |
|
{{ grp.column.title }} |
|
</div> |
|
<div class="text-xs text-gray-400 nc-group-row-count">({{ $t('datatype.Count') }}: {{ grp.count }})</div> |
|
</div> |
|
<div class="flex mt-1"> |
|
<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" |
|
:style="{ |
|
'color': tinycolor.isReadable(grp.color || '#ccc', '#fff', { |
|
level: 'AA', |
|
size: 'large', |
|
}) |
|
? '#fff' |
|
: tinycolor.mostReadable(grp.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(), |
|
'font-size': '14px', |
|
'font-weight': 500, |
|
}" |
|
> |
|
<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> |
|
</div> |
|
</template> |
|
<GroupByTable |
|
v-if="!grp.nested && grp.rows" |
|
: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" |
|
: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" |
|
: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 12px 12px !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 12px 12px !important;' : ''}margin-left: ${scrollBump}px;`" |
|
:fixed-size="fullPage ? props.viewWidth : undefined" |
|
></LazySmartsheetPagination> |
|
</div> |
|
</template> |
|
|
|
<style scoped lang="scss"> |
|
:deep(.ant-collapse-content > .ant-collapse-content-box) { |
|
padding: 0px !important; |
|
border-radius: 0 0 12px 12px !important; |
|
} |
|
|
|
:deep(.ant-collapse-item > .ant-collapse-header) { |
|
border-radius: 12px !important; |
|
background: white; |
|
} |
|
|
|
:deep(.ant-collapse-item-active > .ant-collapse-header) { |
|
border-radius: 12px 12px 0 0 !important; |
|
background: white; |
|
border-bottom: solid 1px lightgray; |
|
} |
|
|
|
:deep(.ant-collapse-borderless > .ant-collapse-item:last-child) { |
|
border-radius: 12px !important; |
|
} |
|
|
|
:deep(.ant-collapse > .ant-collapse-item:last-child) { |
|
border-radius: 12px !important; |
|
} |
|
</style>
|
|
|