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
3 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 = () => { |
||||
betaFeatureToggleState.show = !betaFeatureToggleState.show |
||||
localStorage.setItem('betaFeatureToggleState', JSON.stringify(betaFeatureToggleState.show)) |
||||
} |
||||
const STORAGE_KEY = 'featureToggleStates' |
||||
|
||||
const _useBetaFeatureToggle = () => { |
||||
return { |
||||
betaFeatureToggleState, |
||||
toggleBetaFeature, |
||||
export const useBetaFeatureToggle = createSharedComposable(() => { |
||||
const features = ref<Feature[]>(structuredClone(FEATURES)) |
||||
|
||||
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) |
||||
export { useBetaFeatureToggle } |
||||
const toggleFeature = (id: FeatureId) => { |
||||
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