mirror of https://github.com/nocodb/nocodb
Browse Source
* feat: infinite scroll wip * feat: implement column create * feat: improve scroll performance and minor bugs * fix: optimize cache clear fix: preserver selected items from cache clear * feat: add keyboard support * fix: get rid of unwanted data * feat: infinite scroll * fix: reload visible data * fix: rowIndex Sync * fix: row height fix * fix: performance issues * fix: small issues * fix: stablize scrolling * fix: scroll fails to load new data * fix: best part is no part remove bunch of manual handling and move to computedProperty * fix: load data as chunks instead of offset * fix: deboucne 500 ms * feat: safe chunk clearing * feat: working infinite table(wip) * fix: handle delete selected range of rows * fix: update types * fix: nuxt warnings * fix: table fixes * feat: undo-redo support for infiniteTable * fix: fix addEmptyRow * fix: groupby fixes * fix: refactor visibleDtaa computed * fix: cache clear * fix: invalid offset error * fix: add empty row elem * fix: rows not loading at end * fix: refactor * fix: more tests passing * fix: perf optimizations * fix: couple tests * fix: row height tests * fix: row height tests * fix: row height tests * fix: sync row comment count * fix: fixes * fix: lot of tests * fix: update the row placeholder columns size calculation * fix: update the totalRows on loadData * fix: tests when count is 0 * fix: hide placeholder if rowHeight is small * fix: not required imo as infinite scroll is implemented * fix: links tests * fix: filter tests * fix: insert after test fix: Row: Create, Update, Delete fix: Row height fix: Create column tests * fix: error, timezone bug fix: shared view data not loading after 100 * fix: ignore shifting. this fixes errors in rows, which has some mandatory required cells * fix: keyboardShortcuts test * fix: project collaboration test * fix: increase local cache fix: records empty on switching to full screen mode fix: links issue on new records * fix: row and col margin for improved data rendering * fix: addEmptyRow to table bottom * fix: update gridShare test fix: shared view grid feat: new count endpoint public * fix: undo-redo test failing * fix: bulkUpdate chore: disabled bulkUpdate tests for now * fix: slow searchBtn * fix: limit max selection * fix: limit selection to 100 * fix: initial chunk load to 100 * fix: couple fixes * fix: couple fixes * fix: row expand * fix: scrollto Top and scrollTo Bottom on Shift Cmd Down/Up * fix: drop support for cmd A * fix: failing tests * fix: error on clicking resize col * fix: premature fetching * fix: deleteSelected not working properly * fix: test build * fix: test build * fix: throttled 500 * fix: scroll related issues fix: added transitions * fix: scroll related issues * fix: decrease col margin * fix: increase local cache and update Buffer * fix: decrease throttle * fix: improve scroll performance * fix: improve scroll performance * fix: improve scroll performance * fix: fixes * feat: beta toggle show * feat: beta toggle show * fix: hold scroll action * fix: sync visible data reloadVisibleDataHook * fix: refactor useBetaFeatureToggle fix: useMultiSelect in table * fix: dynamically reduce margin while loading records * fix: some bugs * fix: data loading in infinitescroll * fix: shared view and search issues * feat: betaToggles menu * fix: scroll showing up in aggregation * fix: text * fix: implement shifting in addEmptyRow * fix: calculate slices on rowHeight modified * fix: keep invalid cells until another row selected * fix: remove row if filter gets failed * fix: update styles * fix: move filter handling to nocodb-sdk * fix: user field filter * fix: sort order * fix:user field sorting * fix: update virtual cols * fix: updated sort handling * fix: updated sort handling * fix: updated sort handling for bulkUpdate and undo-redo * fix: unit tests * fix: deleteSelectedRecords fails * fix: chunkstates errors * fix: sort bugs * fix: scroll position * fix: delete selectedrange of records * fix: improved chunk management * fix: sync toggle states across tabs * fix: sync between tabs * fix: limit issues * fix: update issues * fix: zIndex * fix: minor fixes * fix: cmd arrow issue * fix: bulkdelete index issues * fix: empty rows at top * fix: queue add new record behaviour * fix: resolve rowObj addEmptyRow * fix: typo * fix: clear indexes * fix: reload if width is zero * fix: manual handling * fix: remove console * fix: prefetch when scroll from below * fix: refactor fixes * fix:undo-redo with sort --------- Co-authored-by: mertmit <mertmit99@gmail.com>pull/9781/head
Anbarasu
2 weeks ago
committed by
GitHub
43 changed files with 6317 additions and 660 deletions
@ -0,0 +1,110 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
interface Props { |
||||||
|
value?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
const props = defineProps<Props>() |
||||||
|
|
||||||
|
const { toggleFeature, features, isEngineeringModeOn } = useBetaFeatureToggle() |
||||||
|
|
||||||
|
const value = useVModel(props, 'value') |
||||||
|
|
||||||
|
const selectedFeatures = ref<Record<string, boolean>>({}) |
||||||
|
|
||||||
|
const saveExperimentalFeatures = () => { |
||||||
|
features.value.forEach((feature) => { |
||||||
|
if (selectedFeatures.value[feature.id] !== feature.enabled) { |
||||||
|
toggleFeature(feature.id) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
onMounted(() => { |
||||||
|
selectedFeatures.value = Object.fromEntries(features.value.map((feature) => [feature.id, feature.enabled])) |
||||||
|
}) |
||||||
|
|
||||||
|
const isChanged = computed(() => { |
||||||
|
return features.value.some((feature) => selectedFeatures.value[feature.id] !== feature.enabled) |
||||||
|
}) |
||||||
|
|
||||||
|
watch(value, (val) => { |
||||||
|
if (val) { |
||||||
|
selectedFeatures.value = Object.fromEntries(features.value.map((feature) => [feature.id, feature.enabled])) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const clickCount = ref(0) |
||||||
|
const clickTimer = ref<NodeJS.Timeout | undefined>(undefined) |
||||||
|
const handleClick = () => { |
||||||
|
clickCount.value++ |
||||||
|
|
||||||
|
if (clickCount.value === 1) { |
||||||
|
if (clickTimer.value) clearTimeout(clickTimer.value) |
||||||
|
clickTimer.value = setTimeout(() => { |
||||||
|
clickCount.value = 0 |
||||||
|
}, 3000) |
||||||
|
} |
||||||
|
|
||||||
|
if (clickCount.value >= 3) { |
||||||
|
isEngineeringModeOn.value = !isEngineeringModeOn.value |
||||||
|
clickCount.value = 0 |
||||||
|
if (clickTimer.value) { |
||||||
|
clearTimeout(clickTimer.value) |
||||||
|
clickTimer.value = undefined |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
onUnmounted(() => { |
||||||
|
if (clickTimer.value) clearTimeout(clickTimer.value) |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<NcModal v-model:visible="value" size="small"> |
||||||
|
<div class="flex flex-col gap-3"> |
||||||
|
<div> |
||||||
|
<h1 class="text-base text-gray-800 font-semibold"> |
||||||
|
<component :is="iconMap.bulb" class="text-gray-500 h-5 mr-1 pb-1" @click="handleClick" /> |
||||||
|
{{ $t('general.featurePreview') }} |
||||||
|
</h1> |
||||||
|
<div class="text-gray-600 leading-5"> |
||||||
|
{{ $t('labels.toggleExperimentalFeature') }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div |
||||||
|
ref="contentRef" |
||||||
|
class="border-1 rounded-md min-h-[50px] max-h-[234px] nc-scrollbar-md overflow-y-auto border-gray-200" |
||||||
|
> |
||||||
|
<template v-for="feature in features" :key="feature.id"> |
||||||
|
<div |
||||||
|
v-if="!feature?.isEngineering || isEngineeringModeOn" |
||||||
|
class="border-b-1 px-3 flex gap-2 flex-col py-2 border-gray-200 last:border-b-0" |
||||||
|
> |
||||||
|
<div class="flex items-center justify-between"> |
||||||
|
<div class="text-gray-800 font-medium"> |
||||||
|
{{ feature.title }} |
||||||
|
</div> |
||||||
|
<NcSwitch v-model:checked="selectedFeatures[feature.id]" /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="text-gray-500 leading-4 text-[13px]"> |
||||||
|
{{ feature.description }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex w-full gap-2 justify-end"> |
||||||
|
<NcButton type="secondary" size="small" @click="value = false"> |
||||||
|
{{ $t('general.cancel') }} |
||||||
|
</NcButton> |
||||||
|
|
||||||
|
<NcButton :disabled="!isChanged" size="small" @click="saveExperimentalFeatures"> |
||||||
|
{{ $t('general.save') }} |
||||||
|
</NcButton> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</NcModal> |
||||||
|
</template> |
File diff suppressed because it is too large
Load Diff
@ -1,22 +1,121 @@ |
|||||||
import { reactive } from 'vue' |
import { onMounted, ref } from 'vue' |
||||||
|
import { createSharedComposable } from '@vueuse/core' |
||||||
|
|
||||||
const storedValue = localStorage.getItem('betaFeatureToggleState') |
const FEATURES = [ |
||||||
|
{ |
||||||
|
id: 'infinite_scrolling', |
||||||
|
title: 'Infinite scrolling', |
||||||
|
description: 'Effortlessly browse large datasets with infinite scrolling.', |
||||||
|
enabled: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'geodata_column', |
||||||
|
title: 'Geodata column', |
||||||
|
description: 'Enable the geodata column.', |
||||||
|
enabled: false, |
||||||
|
isEngineering: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'form_support_column_scanning', |
||||||
|
title: 'Scanner for filling data in forms', |
||||||
|
description: 'Enable scanner to fill data in forms.', |
||||||
|
enabled: false, |
||||||
|
isEngineering: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'extensions', |
||||||
|
title: 'Extensions', |
||||||
|
description: 'Extensions allows you to add new features or functionalities to the NocoDB platform.', |
||||||
|
enabled: false, |
||||||
|
isEngineering: true, |
||||||
|
}, |
||||||
|
] |
||||||
|
|
||||||
const initialToggleState = storedValue ? JSON.parse(storedValue) : false |
export const FEATURE_FLAG = Object.fromEntries(FEATURES.map((feature) => [feature.id.toUpperCase(), feature.id])) as Record< |
||||||
|
Uppercase<(typeof FEATURES)[number]['id']>, |
||||||
|
(typeof FEATURES)[number]['id'] |
||||||
|
> |
||||||
|
|
||||||
const betaFeatureToggleState = reactive({ show: initialToggleState }) |
type FeatureId = (typeof FEATURES)[number]['id'] |
||||||
|
type Feature = (typeof FEATURES)[number] |
||||||
|
|
||||||
const toggleBetaFeature = () => { |
const STORAGE_KEY = 'featureToggleStates' |
||||||
betaFeatureToggleState.show = !betaFeatureToggleState.show |
|
||||||
localStorage.setItem('betaFeatureToggleState', JSON.stringify(betaFeatureToggleState.show)) |
|
||||||
} |
|
||||||
|
|
||||||
const _useBetaFeatureToggle = () => { |
export const useBetaFeatureToggle = createSharedComposable(() => { |
||||||
return { |
const features = ref<Feature[]>(structuredClone(FEATURES)) |
||||||
betaFeatureToggleState, |
|
||||||
toggleBetaFeature, |
const featureStates = computed(() => { |
||||||
|
return features.value.reduce((acc, feature) => { |
||||||
|
acc[feature.id] = feature.enabled |
||||||
|
return acc |
||||||
|
}, {} as Record<FeatureId, boolean>) |
||||||
|
}) |
||||||
|
|
||||||
|
const { $e } = useNuxtApp() |
||||||
|
|
||||||
|
const isEngineeringModeOn = ref(false) |
||||||
|
|
||||||
|
const saveFeatures = () => { |
||||||
|
try { |
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(features.value)) |
||||||
|
window.dispatchEvent(new StorageEvent('storage', { key: STORAGE_KEY })) |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to save features:', error) |
||||||
|
} |
||||||
} |
} |
||||||
} |
|
||||||
|
|
||||||
const useBetaFeatureToggle = createSharedComposable(_useBetaFeatureToggle) |
const toggleFeature = (id: FeatureId) => { |
||||||
export { useBetaFeatureToggle } |
const feature = features.value.find((f) => f.id === id) |
||||||
|
if (feature) { |
||||||
|
feature.enabled = !feature.enabled |
||||||
|
$e(`a:feature-preview:${id}:${feature.enabled ? 'on' : 'off'}`) |
||||||
|
saveFeatures() |
||||||
|
} else { |
||||||
|
console.error(`Feature ${id} not found`) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const isFeatureEnabled = (id: FeatureId) => featureStates.value[id] ?? false |
||||||
|
|
||||||
|
const initializeFeatures = () => { |
||||||
|
try { |
||||||
|
const stored = localStorage.getItem(STORAGE_KEY) |
||||||
|
if (stored) { |
||||||
|
const parsedFeatures = JSON.parse(stored) as Partial<Feature>[] |
||||||
|
features.value = FEATURES.map((defaultFeature) => ({ |
||||||
|
...defaultFeature, |
||||||
|
enabled: parsedFeatures.find((f) => f.id === defaultFeature.id)?.enabled ?? defaultFeature.enabled, |
||||||
|
})) |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to initialize features:', error) |
||||||
|
} |
||||||
|
saveFeatures() |
||||||
|
} |
||||||
|
|
||||||
|
const handleStorageEvent = (event: StorageEvent) => { |
||||||
|
if (event.key === STORAGE_KEY && event.newValue !== null) { |
||||||
|
if (JSON.parse(event.newValue) !== features.value) { |
||||||
|
initializeFeatures() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
onMounted(() => { |
||||||
|
initializeFeatures() |
||||||
|
window.addEventListener('storage', handleStorageEvent) |
||||||
|
}) |
||||||
|
|
||||||
|
onUnmounted(() => { |
||||||
|
window.removeEventListener('storage', handleStorageEvent) |
||||||
|
}) |
||||||
|
|
||||||
|
onMounted(initializeFeatures) |
||||||
|
|
||||||
|
return { |
||||||
|
features, |
||||||
|
toggleFeature, |
||||||
|
isFeatureEnabled, |
||||||
|
isEngineeringModeOn, |
||||||
|
} |
||||||
|
}) |
||||||
|
@ -0,0 +1,303 @@ |
|||||||
|
import type { Api, ColumnType, PaginatedType, TableType, ViewType } from 'nocodb-sdk' |
||||||
|
import type { ComputedRef, Ref } from 'vue' |
||||||
|
import type { EventHook } from '@vueuse/core' |
||||||
|
import { NavigateDir, type Row } from '#imports' |
||||||
|
|
||||||
|
const formatData = (list: Record<string, any>[], pageInfo: PaginatedType) => |
||||||
|
list.map((row, index) => ({ |
||||||
|
row: { ...row }, |
||||||
|
oldRow: { ...row }, |
||||||
|
rowMeta: { |
||||||
|
// Calculate the rowIndex based on the offset and the index of the row
|
||||||
|
rowIndex: (pageInfo.page - 1) * pageInfo.pageSize + index, |
||||||
|
}, |
||||||
|
})) |
||||||
|
|
||||||
|
export function useGridViewData( |
||||||
|
_meta: Ref<TableType | undefined> | ComputedRef<TableType | undefined>, |
||||||
|
viewMeta: Ref<ViewType | undefined> | ComputedRef<(ViewType & { id: string }) | undefined>, |
||||||
|
where?: ComputedRef<string | undefined>, |
||||||
|
reloadVisibleDataHook?: EventHook<void>, |
||||||
|
) { |
||||||
|
const tablesStore = useTablesStore() |
||||||
|
const { activeTableId, activeTable } = storeToRefs(tablesStore) |
||||||
|
|
||||||
|
const meta = computed(() => _meta.value || activeTable.value) |
||||||
|
|
||||||
|
const metaId = computed(() => _meta.value?.id || activeTableId.value) |
||||||
|
|
||||||
|
const { t } = useI18n() |
||||||
|
|
||||||
|
const optimisedQuery = useState('optimisedQuery', () => true) |
||||||
|
|
||||||
|
const router = useRouter() |
||||||
|
|
||||||
|
const route = router.currentRoute |
||||||
|
|
||||||
|
const { appInfo, gridViewPageSize } = useGlobal() |
||||||
|
|
||||||
|
const appInfoDefaultLimit = gridViewPageSize.value || appInfo.value.defaultLimit || 25 |
||||||
|
|
||||||
|
const _paginationData = ref<PaginatedType>({ page: 1, pageSize: appInfoDefaultLimit }) |
||||||
|
|
||||||
|
const excludePageInfo = ref(false) |
||||||
|
|
||||||
|
const isPublic = inject(IsPublicInj, ref(false)) |
||||||
|
|
||||||
|
const { base } = storeToRefs(useBase()) |
||||||
|
|
||||||
|
const { fetchSharedViewData, paginationData: sharedPaginationData } = useSharedView() |
||||||
|
|
||||||
|
const { $api } = useNuxtApp() |
||||||
|
|
||||||
|
const { sorts, nestedFilters } = useSmartsheetStoreOrThrow() |
||||||
|
|
||||||
|
const { isUIAllowed } = useRoles() |
||||||
|
|
||||||
|
const routeQuery = computed(() => route.value.query as Record<string, string>) |
||||||
|
|
||||||
|
const paginationData = computed({ |
||||||
|
get: () => (isPublic.value ? sharedPaginationData.value : _paginationData.value), |
||||||
|
set: (value) => { |
||||||
|
if (isPublic.value) { |
||||||
|
sharedPaginationData.value = value |
||||||
|
} else { |
||||||
|
_paginationData.value = value |
||||||
|
} |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
const { |
||||||
|
insertRow, |
||||||
|
updateRowProperty, |
||||||
|
addEmptyRow, |
||||||
|
deleteRow, |
||||||
|
deleteRowById, |
||||||
|
deleteSelectedRows, |
||||||
|
deleteRangeOfRows, |
||||||
|
updateOrSaveRow, |
||||||
|
cachedRows, |
||||||
|
clearCache, |
||||||
|
totalRows, |
||||||
|
bulkUpdateRows, |
||||||
|
bulkUpdateView, |
||||||
|
removeRowIfNew, |
||||||
|
syncCount, |
||||||
|
selectedRows, |
||||||
|
chunkStates, |
||||||
|
isRowSortRequiredRows, |
||||||
|
clearInvalidRows, |
||||||
|
applySorting, |
||||||
|
} = useInfiniteData({ |
||||||
|
meta, |
||||||
|
viewMeta, |
||||||
|
callbacks: { |
||||||
|
loadData, |
||||||
|
syncVisibleData, |
||||||
|
}, |
||||||
|
where, |
||||||
|
}) |
||||||
|
|
||||||
|
function syncVisibleData() { |
||||||
|
reloadVisibleDataHook?.trigger() |
||||||
|
} |
||||||
|
|
||||||
|
function getExpandedRowIndex(): number { |
||||||
|
const rowId = routeQuery.value.rowId |
||||||
|
if (!rowId) return -1 |
||||||
|
|
||||||
|
for (const [_index, row] of cachedRows.value.entries()) { |
||||||
|
if (extractPkFromRow(row.row, meta.value?.columns as ColumnType[]) === rowId) { |
||||||
|
return row.rowMeta.rowIndex! |
||||||
|
} |
||||||
|
} |
||||||
|
return -1 |
||||||
|
} |
||||||
|
|
||||||
|
const isLastRow = computed(() => { |
||||||
|
const expandedRowIndex = getExpandedRowIndex() |
||||||
|
if (expandedRowIndex === -1) return false |
||||||
|
|
||||||
|
return expandedRowIndex === totalRows.value - 1 |
||||||
|
}) |
||||||
|
|
||||||
|
const isFirstRow = computed(() => { |
||||||
|
const expandedRowIndex = getExpandedRowIndex() |
||||||
|
if (expandedRowIndex === -1) return false |
||||||
|
|
||||||
|
return expandedRowIndex === 0 |
||||||
|
}) |
||||||
|
|
||||||
|
const queryParams = computed(() => ({ |
||||||
|
offset: ((paginationData.value.page ?? 0) - 1) * (paginationData.value.pageSize ?? appInfoDefaultLimit), |
||||||
|
limit: paginationData.value.pageSize ?? appInfoDefaultLimit, |
||||||
|
where: where?.value ?? '', |
||||||
|
})) |
||||||
|
|
||||||
|
async function loadAggCommentsCount(formattedData: Array<Row>) { |
||||||
|
if (!isUIAllowed('commentCount') || isPublic.value) 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.utils.commentCount({ |
||||||
|
ids, |
||||||
|
fk_model_id: metaId.value as string, |
||||||
|
}) |
||||||
|
|
||||||
|
formattedData.forEach((row) => { |
||||||
|
const cachedRow = Array.from(cachedRows.value.values()).find( |
||||||
|
(cachedRow) => cachedRow.rowMeta.rowIndex === row.rowMeta.rowIndex, |
||||||
|
) |
||||||
|
if (!cachedRow) return |
||||||
|
|
||||||
|
const id = extractPkFromRow(row.row, meta.value?.columns as ColumnType[]) |
||||||
|
const count = aggCommentCount?.find((c: Record<string, any>) => c.row_id === id)?.count || 0 |
||||||
|
cachedRow.rowMeta.commentCount = +count |
||||||
|
}) |
||||||
|
} catch (e) { |
||||||
|
console.error('Failed to load aggregate comment count:', e) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function loadData( |
||||||
|
params: Parameters<Api<any>['dbViewRow']['list']>[4] & { |
||||||
|
limit?: number |
||||||
|
offset?: number |
||||||
|
} = {}, |
||||||
|
): Promise<Row[] | undefined> { |
||||||
|
if ((!base?.value?.id || !metaId.value || !viewMeta.value?.id) && !isPublic.value) return |
||||||
|
|
||||||
|
try { |
||||||
|
const response = !isPublic.value |
||||||
|
? await $api.dbViewRow.list('noco', base.value.id!, metaId.value!, viewMeta.value!.id!, { |
||||||
|
...queryParams.value, |
||||||
|
...params, |
||||||
|
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }), |
||||||
|
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }), |
||||||
|
where: where?.value, |
||||||
|
...(excludePageInfo.value ? { excludeCount: 'true' } : {}), |
||||||
|
} as any) |
||||||
|
: await fetchSharedViewData( |
||||||
|
{ |
||||||
|
sortsArr: sorts.value, |
||||||
|
filtersArr: nestedFilters.value, |
||||||
|
where: where?.value, |
||||||
|
offset: params.offset, |
||||||
|
limit: params.limit, |
||||||
|
}, |
||||||
|
{ |
||||||
|
isInfiniteScroll: true, |
||||||
|
}, |
||||||
|
) |
||||||
|
|
||||||
|
const data = formatData(response.list, response.pageInfo) |
||||||
|
|
||||||
|
if (response.pageInfo.totalRows) { |
||||||
|
totalRows.value = response.pageInfo.totalRows |
||||||
|
} |
||||||
|
|
||||||
|
loadAggCommentsCount(data) |
||||||
|
|
||||||
|
return data |
||||||
|
} catch (error: any) { |
||||||
|
if (error?.response?.data.error === 'INVALID_OFFSET_VALUE') { |
||||||
|
return [] |
||||||
|
} |
||||||
|
if (error?.response?.data?.error === 'FORMULA_ERROR') { |
||||||
|
await tablesStore.reloadTableMeta(metaId.value as string) |
||||||
|
return loadData(params) |
||||||
|
} |
||||||
|
|
||||||
|
console.error(error) |
||||||
|
message.error(await extractSdkResponseErrorMsg(error)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const navigateToSiblingRow = async (dir: NavigateDir) => { |
||||||
|
const expandedRowIndex = getExpandedRowIndex() |
||||||
|
if (expandedRowIndex === -1) return |
||||||
|
|
||||||
|
const sortedIndices = Array.from(cachedRows.value.keys()).sort((a, b) => a - b) |
||||||
|
let siblingIndex = sortedIndices.findIndex((index) => index === expandedRowIndex) + (dir === NavigateDir.NEXT ? 1 : -1) |
||||||
|
|
||||||
|
// Skip unsaved rows
|
||||||
|
while ( |
||||||
|
siblingIndex >= 0 && |
||||||
|
siblingIndex < sortedIndices.length && |
||||||
|
cachedRows.value.get(sortedIndices[siblingIndex])?.rowMeta?.new |
||||||
|
) { |
||||||
|
siblingIndex += dir === NavigateDir.NEXT ? 1 : -1 |
||||||
|
} |
||||||
|
|
||||||
|
// Check if we've gone out of bounds
|
||||||
|
if (siblingIndex < 0 || siblingIndex >= totalRows.value) { |
||||||
|
return message.info(t('msg.info.noMoreRecords')) |
||||||
|
} |
||||||
|
|
||||||
|
// If the sibling row is not in cachedRows, load more data
|
||||||
|
if (siblingIndex >= sortedIndices.length) { |
||||||
|
await loadData({ |
||||||
|
offset: sortedIndices[sortedIndices.length - 1] + 1, |
||||||
|
limit: 10, |
||||||
|
}) |
||||||
|
sortedIndices.push( |
||||||
|
...Array.from(cachedRows.value.keys()) |
||||||
|
.filter((key) => !sortedIndices.includes(key)) |
||||||
|
.sort((a, b) => a - b), |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
// Extract the row id of the sibling row
|
||||||
|
const siblingRow = cachedRows.value.get(sortedIndices[siblingIndex]) |
||||||
|
if (siblingRow) { |
||||||
|
const rowId = extractPkFromRow(siblingRow.row, meta.value?.columns as ColumnType[]) |
||||||
|
if (rowId) { |
||||||
|
await router.push({ |
||||||
|
query: { |
||||||
|
...routeQuery.value, |
||||||
|
rowId, |
||||||
|
}, |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
cachedRows, |
||||||
|
loadData, |
||||||
|
paginationData, |
||||||
|
queryParams, |
||||||
|
insertRow, |
||||||
|
updateRowProperty, |
||||||
|
addEmptyRow, |
||||||
|
deleteRow, |
||||||
|
deleteRowById, |
||||||
|
deleteSelectedRows, |
||||||
|
deleteRangeOfRows, |
||||||
|
updateOrSaveRow, |
||||||
|
bulkUpdateRows, |
||||||
|
bulkUpdateView, |
||||||
|
loadAggCommentsCount, |
||||||
|
syncCount, |
||||||
|
removeRowIfNew, |
||||||
|
navigateToSiblingRow, |
||||||
|
getExpandedRowIndex, |
||||||
|
optimisedQuery, |
||||||
|
isLastRow, |
||||||
|
isFirstRow, |
||||||
|
clearCache, |
||||||
|
totalRows, |
||||||
|
selectedRows, |
||||||
|
syncVisibleData, |
||||||
|
chunkStates, |
||||||
|
clearInvalidRows, |
||||||
|
applySorting, |
||||||
|
isRowSortRequiredRows, |
||||||
|
} |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,7 @@ |
|||||||
|
export class NcSDKError extends Error { |
||||||
|
constructor(message: string) { |
||||||
|
super(message); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export class BadRequest extends NcSDKError {} |
@ -0,0 +1,320 @@ |
|||||||
|
import { ColumnType, FilterType } from '~/lib/Api'; |
||||||
|
import { UITypes } from '~/lib/index'; |
||||||
|
import { BadRequest, NcSDKError } from '~/lib/errorUtils'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Converts a flat array of filter objects into a nested tree structure |
||||||
|
* @param {FilterType[]} items - Array of filter objects |
||||||
|
* @returns {FilterType[]} - Nested tree structure |
||||||
|
*/ |
||||||
|
export function buildFilterTree(items: FilterType[]) { |
||||||
|
const itemMap = new Map(); |
||||||
|
const rootItems: FilterType[] = []; |
||||||
|
|
||||||
|
// Map items with IDs and handle items without IDs
|
||||||
|
items.forEach((item) => { |
||||||
|
if (item.id) { |
||||||
|
itemMap.set(item.id, { ...item, children: [] }); |
||||||
|
} else { |
||||||
|
// Items without IDs go straight to root level
|
||||||
|
rootItems.push({ ...item, children: [] }); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Build parent-child relationships for items with IDs
|
||||||
|
items.forEach((item) => { |
||||||
|
// Skip items without IDs as they're already in rootItems
|
||||||
|
if (!item.id) return; |
||||||
|
|
||||||
|
const mappedItem = itemMap.get(item.id); |
||||||
|
|
||||||
|
if (item.fk_parent_id === null) { |
||||||
|
rootItems.push(mappedItem); |
||||||
|
} else { |
||||||
|
const parent = itemMap.get(item.fk_parent_id); |
||||||
|
if (parent) { |
||||||
|
parent.children.push(mappedItem); |
||||||
|
} else { |
||||||
|
// If parent is not found, treat as root item
|
||||||
|
rootItems.push(mappedItem); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
return rootItems; |
||||||
|
} |
||||||
|
|
||||||
|
export const GROUPBY_COMPARISON_OPS = <const>[ |
||||||
|
// these are used for groupby
|
||||||
|
'gb_eq', |
||||||
|
'gb_null', |
||||||
|
]; |
||||||
|
export const COMPARISON_OPS = <const>[ |
||||||
|
'eq', |
||||||
|
'neq', |
||||||
|
'not', |
||||||
|
'like', |
||||||
|
'nlike', |
||||||
|
'empty', |
||||||
|
'notempty', |
||||||
|
'null', |
||||||
|
'notnull', |
||||||
|
'checked', |
||||||
|
'notchecked', |
||||||
|
'blank', |
||||||
|
'notblank', |
||||||
|
'allof', |
||||||
|
'anyof', |
||||||
|
'nallof', |
||||||
|
'nanyof', |
||||||
|
'gt', |
||||||
|
'lt', |
||||||
|
'gte', |
||||||
|
'lte', |
||||||
|
'ge', |
||||||
|
'le', |
||||||
|
'in', |
||||||
|
'isnot', |
||||||
|
'is', |
||||||
|
'isWithin', |
||||||
|
'btw', |
||||||
|
'nbtw', |
||||||
|
]; |
||||||
|
|
||||||
|
export const IS_WITHIN_COMPARISON_SUB_OPS = <const>[ |
||||||
|
'pastWeek', |
||||||
|
'pastMonth', |
||||||
|
'pastYear', |
||||||
|
'nextWeek', |
||||||
|
'nextMonth', |
||||||
|
'nextYear', |
||||||
|
'pastNumberOfDays', |
||||||
|
'nextNumberOfDays', |
||||||
|
]; |
||||||
|
|
||||||
|
export const COMPARISON_SUB_OPS = <const>[ |
||||||
|
'today', |
||||||
|
'tomorrow', |
||||||
|
'yesterday', |
||||||
|
'oneWeekAgo', |
||||||
|
'oneWeekFromNow', |
||||||
|
'oneMonthAgo', |
||||||
|
'oneMonthFromNow', |
||||||
|
'daysAgo', |
||||||
|
'daysFromNow', |
||||||
|
'exactDate', |
||||||
|
...IS_WITHIN_COMPARISON_SUB_OPS, |
||||||
|
]; |
||||||
|
|
||||||
|
export function extractFilterFromXwhere( |
||||||
|
str: string | string[], |
||||||
|
aliasColObjMap: { [columnAlias: string]: ColumnType }, |
||||||
|
throwErrorIfInvalid = false |
||||||
|
): FilterType[] { |
||||||
|
if (!str) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
// if array treat it as `and` group
|
||||||
|
if (Array.isArray(str)) { |
||||||
|
// calling recursively for nested query
|
||||||
|
const nestedFilters = [].concat( |
||||||
|
...str.map((s) => |
||||||
|
extractFilterFromXwhere(s, aliasColObjMap, throwErrorIfInvalid) |
||||||
|
) |
||||||
|
); |
||||||
|
|
||||||
|
// If there's only one filter, return it directly
|
||||||
|
if (nestedFilters.length === 1) { |
||||||
|
return nestedFilters; |
||||||
|
} |
||||||
|
|
||||||
|
// Otherwise, wrap it in an AND group
|
||||||
|
return [ |
||||||
|
{ |
||||||
|
is_group: true, |
||||||
|
logical_op: 'and', |
||||||
|
children: nestedFilters, |
||||||
|
}, |
||||||
|
]; |
||||||
|
} else if (typeof str !== 'string' && throwErrorIfInvalid) { |
||||||
|
throw new Error( |
||||||
|
'Invalid filter format. Expected string or array of strings.' |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
let nestedArrayConditions = []; |
||||||
|
|
||||||
|
let openIndex = str.indexOf('(('); |
||||||
|
|
||||||
|
if (openIndex === -1) openIndex = str.indexOf('(~'); |
||||||
|
|
||||||
|
let nextOpenIndex = openIndex; |
||||||
|
|
||||||
|
let closingIndex = str.indexOf('))'); |
||||||
|
|
||||||
|
// if it's a simple query simply return array of conditions
|
||||||
|
if (openIndex === -1) { |
||||||
|
if (str && str != '~not') |
||||||
|
nestedArrayConditions = str.split( |
||||||
|
/(?=~(?:or(?:not)?|and(?:not)?|not)\()/ |
||||||
|
); |
||||||
|
return extractCondition( |
||||||
|
nestedArrayConditions || [], |
||||||
|
aliasColObjMap, |
||||||
|
throwErrorIfInvalid |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// iterate until finding right closing
|
||||||
|
while ( |
||||||
|
(nextOpenIndex = str |
||||||
|
.substring(0, closingIndex) |
||||||
|
.indexOf('((', nextOpenIndex + 1)) != -1 |
||||||
|
) { |
||||||
|
closingIndex = str.indexOf('))', closingIndex + 1); |
||||||
|
} |
||||||
|
|
||||||
|
if (closingIndex === -1) |
||||||
|
throw new Error( |
||||||
|
`${str |
||||||
|
.substring(0, openIndex + 1) |
||||||
|
.slice(-10)} : Closing bracket not found` |
||||||
|
); |
||||||
|
|
||||||
|
// getting operand starting index
|
||||||
|
const operandStartIndex = str.lastIndexOf('~', openIndex); |
||||||
|
const operator = |
||||||
|
operandStartIndex != -1 |
||||||
|
? str.substring(operandStartIndex + 1, openIndex) |
||||||
|
: ''; |
||||||
|
const lhsOfNestedQuery = str.substring(0, openIndex); |
||||||
|
|
||||||
|
nestedArrayConditions.push( |
||||||
|
...extractFilterFromXwhere( |
||||||
|
lhsOfNestedQuery, |
||||||
|
aliasColObjMap, |
||||||
|
throwErrorIfInvalid |
||||||
|
), |
||||||
|
// calling recursively for nested query
|
||||||
|
{ |
||||||
|
is_group: true, |
||||||
|
logical_op: operator, |
||||||
|
children: extractFilterFromXwhere( |
||||||
|
str.substring(openIndex + 1, closingIndex + 1), |
||||||
|
aliasColObjMap |
||||||
|
), |
||||||
|
}, |
||||||
|
// RHS of nested query(recursion)
|
||||||
|
...extractFilterFromXwhere( |
||||||
|
str.substring(closingIndex + 2), |
||||||
|
aliasColObjMap, |
||||||
|
throwErrorIfInvalid |
||||||
|
) |
||||||
|
); |
||||||
|
return nestedArrayConditions; |
||||||
|
} |
||||||
|
|
||||||
|
// mark `op` and `sub_op` any for being assignable to parameter of type
|
||||||
|
export function validateFilterComparison(uidt: UITypes, op: any, sub_op?: any) { |
||||||
|
if (!COMPARISON_OPS.includes(op) && !GROUPBY_COMPARISON_OPS.includes(op)) { |
||||||
|
throw new BadRequest(`${op} is not supported.`); |
||||||
|
} |
||||||
|
|
||||||
|
if (sub_op) { |
||||||
|
if ( |
||||||
|
![ |
||||||
|
UITypes.Date, |
||||||
|
UITypes.DateTime, |
||||||
|
UITypes.CreatedTime, |
||||||
|
UITypes.LastModifiedTime, |
||||||
|
].includes(uidt) |
||||||
|
) { |
||||||
|
throw new BadRequest( |
||||||
|
`'${sub_op}' is not supported for UI Type'${uidt}'.` |
||||||
|
); |
||||||
|
} |
||||||
|
if (!COMPARISON_SUB_OPS.includes(sub_op)) { |
||||||
|
throw new BadRequest(`'${sub_op}' is not supported.`); |
||||||
|
} |
||||||
|
if ( |
||||||
|
(op === 'isWithin' && !IS_WITHIN_COMPARISON_SUB_OPS.includes(sub_op)) || |
||||||
|
(op !== 'isWithin' && IS_WITHIN_COMPARISON_SUB_OPS.includes(sub_op)) |
||||||
|
) { |
||||||
|
throw new BadRequest(`'${sub_op}' is not supported for '${op}'`); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function extractCondition( |
||||||
|
nestedArrayConditions, |
||||||
|
aliasColObjMap, |
||||||
|
throwErrorIfInvalid |
||||||
|
) { |
||||||
|
return nestedArrayConditions?.map((str) => { |
||||||
|
let [logicOp, alias, op, value] = |
||||||
|
str.match(/(?:~(and|or|not))?\((.*?),(\w+),(.*)\)/)?.slice(1) || []; |
||||||
|
|
||||||
|
if (!alias && !op && !value) { |
||||||
|
// try match with blank filter format
|
||||||
|
[logicOp, alias, op, value] = |
||||||
|
str.match(/(?:~(and|or|not))?\((.*?),(\w+)\)/)?.slice(1) || []; |
||||||
|
} |
||||||
|
|
||||||
|
// handle isblank and isnotblank filter format
|
||||||
|
switch (op) { |
||||||
|
case 'is': |
||||||
|
if (value === 'blank') { |
||||||
|
op = 'blank'; |
||||||
|
value = undefined; |
||||||
|
} else if (value === 'notblank') { |
||||||
|
op = 'notblank'; |
||||||
|
value = undefined; |
||||||
|
} |
||||||
|
break; |
||||||
|
case 'isblank': |
||||||
|
case 'is_blank': |
||||||
|
op = 'blank'; |
||||||
|
break; |
||||||
|
case 'isnotblank': |
||||||
|
case 'is_not_blank': |
||||||
|
case 'is_notblank': |
||||||
|
op = 'notblank'; |
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
let sub_op = null; |
||||||
|
|
||||||
|
if (aliasColObjMap[alias]) { |
||||||
|
if ( |
||||||
|
[ |
||||||
|
UITypes.Date, |
||||||
|
UITypes.DateTime, |
||||||
|
UITypes.LastModifiedTime, |
||||||
|
UITypes.CreatedTime, |
||||||
|
].includes(aliasColObjMap[alias].uidt) |
||||||
|
) { |
||||||
|
value = value?.split(','); |
||||||
|
// the first element would be sub_op
|
||||||
|
sub_op = value?.[0]; |
||||||
|
// remove the first element which is sub_op
|
||||||
|
value?.shift(); |
||||||
|
value = value?.[0]; |
||||||
|
} else if (op === 'in') { |
||||||
|
value = value.split(','); |
||||||
|
} |
||||||
|
|
||||||
|
validateFilterComparison(aliasColObjMap[alias].uidt, op, sub_op); |
||||||
|
} else if (throwErrorIfInvalid) { |
||||||
|
throw new NcSDKError('INVALID_FILTER'); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
comparison_op: op, |
||||||
|
...(sub_op && { comparison_sub_op: sub_op }), |
||||||
|
fk_column_id: aliasColObjMap[alias]?.id, |
||||||
|
logical_op: logicOp, |
||||||
|
value, |
||||||
|
}; |
||||||
|
}); |
||||||
|
} |
Loading…
Reference in new issue