From 8825b6335fa1fd9976e6958d42ae4800342e4ae0 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 16 Jul 2026 14:00:02 +0800 Subject: [PATCH] feat(grid): decompose data grid interaction surfaces --- apps/desktop/src/components/grid/DataGrid.vue | 4994 ++++------------- .../grid/DataGridBulkEditDialog.vue | 40 + .../grid/DataGridCellDetailDialog.vue | 200 + .../grid/DataGridCellDetailPanel.vue | 185 + .../components/grid/DataGridColumnHeader.vue | 104 + .../grid/DataGridConditionEditor.vue | 540 ++ .../components/grid/DataGridDetailDialogs.vue | 162 + .../components/grid/DataGridExportMenu.vue | 28 + .../components/grid/DataGridFilterBuilder.vue | 130 + .../grid/DataGridMongoJsonPreview.vue | 74 + .../components/grid/DataGridPagination.vue | 93 + .../components/grid/DataGridQueryControls.vue | 214 + .../src/components/grid/DataGridSearchBar.vue | 74 + .../src/components/grid/DataGridToolbar.vue | 255 + .../grid/__tests__/DataGridSurfaces.spec.ts | 299 + .../grid/__tests__/vueHostHarness.ts | 185 + .../__tests__/useDataGridAsyncSurface.spec.ts | 31 + .../__tests__/useDataGridAutoRefresh.spec.ts | 32 + .../useDataGridCanvasRuntime.spec.ts | 160 + .../__tests__/useDataGridColumnLayout.spec.ts | 141 + .../__tests__/useDataGridColumnResize.spec.ts | 4 +- .../useDataGridConditionEditor.spec.ts | 137 + .../useDataGridFilterBuilder.spec.ts | 37 + .../useDataGridResultLifecycle.spec.ts | 28 + .../__tests__/useDataGridSearch.spec.ts | 27 + .../__tests__/useDataGridSort.spec.ts | 27 + .../composables/useDataGridAsyncSurface.ts | 16 + .../src/composables/useDataGridAutoRefresh.ts | 51 + .../composables/useDataGridCanvasRuntime.ts | 116 + .../src/composables/useDataGridCellDetail.ts | 67 + .../composables/useDataGridColumnLayout.ts | 512 ++ .../composables/useDataGridConditionEditor.ts | 213 + .../composables/useDataGridFilterBuilder.ts | 93 + .../composables/useDataGridResultLifecycle.ts | 38 + .../src/composables/useDataGridScrollbars.ts | 126 + .../src/composables/useDataGridSearch.ts | 131 + .../src/composables/useDataGridSelection.ts | 5 +- .../src/composables/useDataGridSort.ts | 29 + .../dataGrid/dataGridClipboard.spec.ts | 25 +- .../dataGrid/dataGridContextMenu.spec.ts | 83 + .../dataGrid/dataGridEditAdapter.spec.ts | 18 + .../dataGridFilterBuilderPersistence.spec.ts | 24 + .../dataGrid/dataGridNavigation.spec.ts | 21 + .../dataGrid/dataGridRuntime.spec.ts | 46 + .../dataGrid/dataGridToolbar.spec.ts | 84 + .../src/lib/dataGrid/dataGridClipboard.ts | 17 + .../src/lib/dataGrid/dataGridContextMenu.ts | 216 + .../src/lib/dataGrid/dataGridEditAdapter.ts | 20 + .../dataGridFilterBuilderPersistence.ts | 39 + .../src/lib/dataGrid/dataGridNavigation.ts | 38 + .../src/lib/dataGrid/dataGridRuntime.ts | 84 + .../src/lib/dataGrid/dataGridToolbar.ts | 85 + package.json | 1 + .../app-tests/dataGridContextMenu.test.ts | 31 +- pnpm-lock.yaml | 65 +- vitest.config.ts | 2 + 56 files changed, 6446 insertions(+), 4051 deletions(-) create mode 100644 apps/desktop/src/components/grid/DataGridBulkEditDialog.vue create mode 100644 apps/desktop/src/components/grid/DataGridCellDetailDialog.vue create mode 100644 apps/desktop/src/components/grid/DataGridCellDetailPanel.vue create mode 100644 apps/desktop/src/components/grid/DataGridColumnHeader.vue create mode 100644 apps/desktop/src/components/grid/DataGridConditionEditor.vue create mode 100644 apps/desktop/src/components/grid/DataGridDetailDialogs.vue create mode 100644 apps/desktop/src/components/grid/DataGridExportMenu.vue create mode 100644 apps/desktop/src/components/grid/DataGridFilterBuilder.vue create mode 100644 apps/desktop/src/components/grid/DataGridMongoJsonPreview.vue create mode 100644 apps/desktop/src/components/grid/DataGridPagination.vue create mode 100644 apps/desktop/src/components/grid/DataGridQueryControls.vue create mode 100644 apps/desktop/src/components/grid/DataGridSearchBar.vue create mode 100644 apps/desktop/src/components/grid/DataGridToolbar.vue create mode 100644 apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts create mode 100644 apps/desktop/src/components/grid/__tests__/vueHostHarness.ts create mode 100644 apps/desktop/src/composables/__tests__/useDataGridAsyncSurface.spec.ts create mode 100644 apps/desktop/src/composables/__tests__/useDataGridAutoRefresh.spec.ts create mode 100644 apps/desktop/src/composables/__tests__/useDataGridCanvasRuntime.spec.ts create mode 100644 apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts create mode 100644 apps/desktop/src/composables/__tests__/useDataGridConditionEditor.spec.ts create mode 100644 apps/desktop/src/composables/__tests__/useDataGridFilterBuilder.spec.ts create mode 100644 apps/desktop/src/composables/__tests__/useDataGridResultLifecycle.spec.ts create mode 100644 apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts create mode 100644 apps/desktop/src/composables/__tests__/useDataGridSort.spec.ts create mode 100644 apps/desktop/src/composables/useDataGridAsyncSurface.ts create mode 100644 apps/desktop/src/composables/useDataGridAutoRefresh.ts create mode 100644 apps/desktop/src/composables/useDataGridCanvasRuntime.ts create mode 100644 apps/desktop/src/composables/useDataGridCellDetail.ts create mode 100644 apps/desktop/src/composables/useDataGridColumnLayout.ts create mode 100644 apps/desktop/src/composables/useDataGridConditionEditor.ts create mode 100644 apps/desktop/src/composables/useDataGridFilterBuilder.ts create mode 100644 apps/desktop/src/composables/useDataGridResultLifecycle.ts create mode 100644 apps/desktop/src/composables/useDataGridScrollbars.ts create mode 100644 apps/desktop/src/composables/useDataGridSearch.ts create mode 100644 apps/desktop/src/composables/useDataGridSort.ts create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenu.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/dataGridEditAdapter.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/dataGridFilterBuilderPersistence.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/dataGridNavigation.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/dataGridRuntime.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/dataGridToolbar.spec.ts create mode 100644 apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts create mode 100644 apps/desktop/src/lib/dataGrid/dataGridEditAdapter.ts create mode 100644 apps/desktop/src/lib/dataGrid/dataGridFilterBuilderPersistence.ts create mode 100644 apps/desktop/src/lib/dataGrid/dataGridNavigation.ts create mode 100644 apps/desktop/src/lib/dataGrid/dataGridRuntime.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 7b9ccd1de5..eee23d31c8 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -1,28 +1,3 @@ - - @@ -9086,13 +7252,7 @@ const gridContextMenuItems = computed(() => { - + setRowStatusFilter(String(value))"> @@ -9118,616 +7278,128 @@ const gridContextMenuItems = computed(() => { - - - - - - - - {{ filterButtonCount }} - - - - - - {{ t("grid.filter") }} - - - {{ t("grid.filterBuilderAddRule") }} - - - - - - - - {{ t("grid.localFiltersActive", { count: localFilterCount }) }} - - - - {{ t("grid.clearLocalFiltersShort") }} - - - - - - {{ summary.columnName }} - - - - , - {{ value }} - - - {{ t("grid.localFilterMoreValues", { count: summary.hiddenValueCount }) }} - - - - - - - - - - - - - - {{ rule.conjunction }} - - - - updateStructuredFilterRuleColumn(rule.id, String(value))"> - - - - - - {{ columnName }} - - - {{ t("grid.filterBuilderNoMatchingColumns") }} - - - - - - - - - updateStructuredFilterRule(rule.id, { mode: value as FilterMode })"> - - - - - - {{ t(option.labelKey) }} - - - - - - updateStructuredFilterRule(rule.id, { rawValue: String(value ?? '') })" - @keydown.enter.prevent="applyStructuredFilters" - /> - updateStructuredFilterRule(rule.id, { rawEndValue: String(value ?? '') })" - @keydown.enter.prevent="applyStructuredFilters" - /> - - - updateStructuredFilterRule(rule.id, { rawValue: String(value ?? '') })" - @keydown.enter.prevent="applyStructuredFilters" - /> - - {{ t("grid.filterBuilderNoValue") }} - - - - - - - - - - - - - - - - - {{ t("grid.filterBuilderEmpty") }} - - - - - {{ t("grid.clearFilter") }} - - - - {{ t("grid.resetFilterBuilder") }} - - - {{ t("grid.applyFilter") }} - - - - - - - WHERE - - - - - - - - - - - - - - - - - - {{ filterButtonCount }} - - - WHERE - - - - - - - - - - - - - - - - {{ sug.value }} - - - - - - {{ whereHistoryEmptyText }} - - - - - - - - - - ORDER BY - - - - - - - - - - - - - - - ORDER BY - - - - - - - - - - - - - - - - {{ sug.value }} - - - - - - {{ orderByHistoryEmptyText }} - - - - - - - - {{ conditionHistoryPreview.value }} - - - - - + - - - - - - {{ t("grid.queryEditReadOnly") }} - - - - {{ queryEditReadOnlyReason }} - - - - - - - {{ t("grid.keylessEditWarning") }} - - - - {{ t("grid.keylessEditWarningHint") }} - - - - - - - {{ t("grid.mongoJsonPreview") }} - - - {{ t("grid.mongoJsonPreview") }} - - - - - - - {{ t("grid.refresh") }} - - - {{ t("grid.refresh") }} ({{ shortcutMod }}+R) - - - - - - {{ autoRefreshEnabled ? `${autoRefreshIntervalSeconds}s` : t("tabs.autoRefreshShort") }} - - - - - - - {{ autoRefreshEnabled ? t("tabs.stopAutoRefresh") : t("tabs.startAutoRefresh") }} - - - - - {{ t("tabs.autoRefreshEvery", { seconds }) }} - - - - - - - - - - {{ t("grid.goToColumn") }} - - - - - - - - - - - - - {{ column.name }} - #{{ column.index + 1 }} - {{ column.comment }} - - {{ t("grid.noColumnsFound") }} - - - - - {{ t("grid.goToColumn") }} - - - - - - {{ t("grid.addRow") }} - - - {{ t("grid.addRow") }} ({{ shortcutMod }}+N) - - - + + + + + + + {{ t("grid.queryEditReadOnly") }} + + + + {{ queryEditReadOnlyReason }} + + + + + + + {{ t("grid.keylessEditWarning") }} + + + + {{ t("grid.keylessEditWarningHint") }} + + + - - - {{ t(previewLabelKey) }} + + {{ t("grid.mongoJsonPreview") }} - - {{ t(previewLabelKey) }} - - - - - - - - - {{ pendingChangeCount }} - - {{ t(saveActionMode.labelKey, { count: pendingChangeCount }) }} - - - {{ t(saveActionMode.tooltipKey, { count: pendingChangeCount }) }} ({{ saveShortcutLabel }}) - - - - - - {{ t(saveActionMode.secondaryActionKey) }} - - - {{ t(saveActionMode.secondaryActionKey) }} + {{ t("grid.mongoJsonPreview") }} - + + + + + + + + + {{ t("grid.goToColumn") }} + + + + + + + + + + + + + {{ column.name }} + #{{ column.index + 1 }} + {{ column.comment }} + + {{ t("grid.noColumnsFound") }} + + + + + {{ t("grid.goToColumn") }} + + + @@ -9737,44 +7409,23 @@ const gridContextMenuItems = computed(() => { - - - - - - - - - {{ sug }} - - - {{ currentMatchIndex + 1 }}/{{ searchMatches.length }} - 0 - - - - - + @@ -9982,367 +7633,348 @@ const gridContextMenuItems = computed(() => { # - - - - - - {{ col.name }} - - - {{ headerColumnType(col.name, col.actualColIdx) }} - - - {{ headerColumnComment(col.name) }} - - - - (headerSortMenuOpenColumn = value ? col.actualColIdx : null)" - @select="(value: string) => selectHeaderSort(value, col.name, col.actualColIdx)" - > - - - - - - - - - (headerActionMenuOpenColumn = value ? col.actualColIdx : null)" - @select="(value: string) => selectCompactColumnAction(value, col.actualColIdx)" - > - - - - - - - handleColumnFormatterOpenChange(value, col.actualColIdx)"> - - - - - - - - - - - - {{ t("grid.columnFormatterFor", { column: col.name }) }} - - - {{ t("grid.columnFormatterHint") }} - + + + + (headerSortMenuOpenColumn = value ? col.actualColIdx : null)" + @select="(value: string) => selectHeaderSort(value, col.name, col.actualColIdx)" + > + + + + + + + + + (headerActionMenuOpenColumn = value ? col.actualColIdx : null)" + @select="(value: string) => selectCompactColumnAction(value, col.actualColIdx)" + > + + + + + + + handleColumnFormatterOpenChange(value, col.actualColIdx)"> + + + + + + + + + + + + {{ t("grid.columnFormatterFor", { column: col.name }) }} - + + {{ t("grid.columnFormatterHint") }} + + + + + + {{ t("grid.formatterType") }} + + (formatterKind = value)"> + + + + + {{ t("grid.formatterDatetime") }} + {{ t("grid.formatterJsonPath") }} + {{ t("grid.formatterMask") }} + {{ t("grid.formatterCustomTemplate") }} + + + + + - {{ t("grid.formatterType") }} + {{ t("grid.formatterTimestampUnit") }} - (formatterKind = value)"> + (formatterDateUnit = value)"> - {{ t("grid.formatterDatetime") }} - {{ t("grid.formatterJsonPath") }} - {{ t("grid.formatterMask") }} - {{ t("grid.formatterCustomTemplate") }} + {{ t("grid.formatterUnitAuto") }} + {{ t("grid.formatterUnitSeconds") }} + {{ t("grid.formatterUnitMilliseconds") }} - - - - - {{ t("grid.formatterTimestampUnit") }} - - (formatterDateUnit = value)"> - - - - - {{ t("grid.formatterUnitAuto") }} - {{ t("grid.formatterUnitSeconds") }} - {{ t("grid.formatterUnitMilliseconds") }} - - - - - - {{ t("grid.formatterDatetimePattern") }} - - (formatterDatetimePattern = value)" - /> - - - - + - {{ t("grid.formatterJsonPathInput") }} - - - - - - - - {{ t("grid.formatterMaskPrefix") }} - - - - - - {{ t("grid.formatterMaskSuffix") }} - - - - - - - - - {{ t("grid.formatterSavedCustom") }} - - selectCustomFormatter(String(value))"> - - - - - {{ t("grid.formatterNewCustom") }} - - {{ formatter.name }} - - - - - - - {{ t("grid.formatterCustomName") }} - - - - - - {{ t("grid.formatterCustomTemplateInput") }} - - - - - {{ t("grid.formatterCustomTemplateHint") }} - - - - - - {{ t("grid.formatterPreview") }} - - - - {{ row.index }} - {{ row.raw }} - {{ row.formatted }} - + {{ t("grid.formatterDatetimePattern") }} + (formatterDatetimePattern = value)" + /> - - - {{ t("grid.clearFormatter") }} + + + {{ t("grid.formatterJsonPathInput") }} + + + + + + + + {{ t("grid.formatterMaskPrefix") }} + + + + + + {{ t("grid.formatterMaskSuffix") }} + + + + + + + + + {{ t("grid.formatterSavedCustom") }} + + selectCustomFormatter(String(value))"> + + + + + {{ t("grid.formatterNewCustom") }} + + {{ formatter.name }} + + + + + + + {{ t("grid.formatterCustomName") }} + + + + + + {{ t("grid.formatterCustomTemplateInput") }} + + + + + {{ t("grid.formatterCustomTemplateHint") }} + + + + + + {{ t("grid.formatterPreview") }} + + + + {{ row.index }} + {{ row.raw }} + {{ row.formatted }} + + + + + + + + {{ t("grid.clearFormatter") }} + + + + {{ t("dangerDialog.cancel") }} + + + {{ t("grid.saveFormatter") }} - - - {{ t("dangerDialog.cancel") }} - - - {{ t("grid.saveFormatter") }} - - - - - handleLocalFilterOpenChange(value, col.actualColIdx)"> - - - - - - + + + + handleLocalFilterOpenChange(value, col.actualColIdx)"> + + + + + + + + + + + {{ columnFilterPanelTitle(col.name) }} + + + + + + + + - - - - {{ columnFilterPanelTitle(col.name) }} - - - - - - - - - - {{ t("grid.value") }} - {{ t("grid.count") }} - - - - - {{ t("grid.loadingValues") }} - {{ serverFilterError }} - {{ t("grid.serverValuesLimited", { count: SERVER_COLUMN_FILTER_LIMIT }) }} + {{ t("grid.value") }} + {{ t("grid.count") }} + + + + + {{ t("grid.loadingValues") }} + {{ serverFilterError }} + {{ t("grid.serverValuesLimited", { count: SERVER_COLUMN_FILTER_LIMIT }) }} + + + + + + + + {{ option.label }} + + {{ option.count ?? "" }} + + + {{ + t("grid.moreValues", { + count: localFilterAllOptions.length - localFilterOptions.length, + }) + }} - - - - - - - {{ option.label }} - - {{ option.count ?? "" }} - - - {{ - t("grid.moreValues", { - count: localFilterAllOptions.length - localFilterOptions.length, - }) - }} - - - - - {{ t("grid.filterTypedValue", { value: localFilterTypedValue }) }} - - - - {{ t("grid.noSearchResults") }} - + + + + {{ t("grid.filterTypedValue", { value: localFilterTypedValue }) }} + + + + {{ t("grid.noSearchResults") }} - - - {{ t("grid.clearFilter") }} + + + + {{ t("grid.clearFilter") }} + + + + {{ t("dangerDialog.cancel") }} + + + {{ t("grid.applyFilter") }} - - - {{ t("dangerDialog.cancel") }} - - - {{ t("grid.applyFilter") }} - - - - - - - - + + + + + + - - - - - {{ t("grid.columnName") }} - - {{ col.name }} - - - - - - {{ t("grid.columnType") }} - {{ columnTypeMap.get(col.name) }} - - - {{ t("grid.columnComment") }} - {{ columnCommentMap.get(col.name) }} - - - + @@ -10876,186 +8508,35 @@ const gridContextMenuItems = computed(() => { - - - - - {{ t("grid.columnName") }} - - {{ activeCellDetail.column }} - - - - {{ t("grid.rowNumber") }} - {{ activeCellDetail.rowNumber }} - - - {{ t("grid.columnType") }} - - {{ activeCellDetail.type || "-" }} - - - - {{ t("grid.nullValue") }} - {{ activeCellDetail.value === null ? "true" : "false" }} - - - {{ t("grid.valueLength") }} - {{ activeCellDetail.length }} - - - {{ t("grid.columnComment") }} - - {{ activeCellDetail.comment || t("grid.noComment") }} - - - - - - {{ t("grid.columnName") }} - {{ activeCellDetail.column }} - - - - {{ t("grid.rowNumber") }} - {{ activeCellDetail.rowNumber }} - - - {{ t("grid.columnType") }} - - {{ activeCellDetail.type || "-" }} - - - - {{ t("grid.nullValue") }} - {{ activeCellDetail.value === null ? "true" : "false" }} - - - {{ t("grid.valueLength") }} - {{ activeCellDetail.length }} - - - - {{ t("grid.columnComment") }} - - {{ activeCellDetail.comment || t("grid.noComment") }} - - - - - - {{ t("grid.cellValue") }} - - - - {{ t("grid.compactJson") }} - - - - {{ t("grid.formattedJson") }} - - - - - - - - - - - - - - - - {{ t(`grid.binaryDownload.${mode}`) }} - - - - - - - - - - - - - - - - - - {{ t("grid.imagePreview") }} - - - - - - - - - - - - {{ t("dangerDialog.confirm") }} - - - {{ t("dangerDialog.cancel") }} - - - - - {{ activeCellDetail.rawValuePreview }} - - {{ - t("grid.largeValuePreviewHint", { - count: activeCellDetail.rawValuePreview.length, - }) - }} - - - - - - - - {{ t("dangerDialog.confirm") }} - - - {{ t("dangerDialog.cancel") }} - - - - {{ t("grid.setNull") }} - {{ t("grid.copyColumnName") }} - {{ t("grid.copySqlCondition") }} - - - - + {{ t("grid.hexViewer") }} @@ -11106,30 +8587,18 @@ const gridContextMenuItems = computed(() => { - - - - - - {{ t("grid.mongoJsonPreview") }} - - - - - - - - - - - {{ mongoJsonPreviewText }} - {{ t("grid.largeValuePreviewHint", { count: mongoJsonPreviewText.length }) }} - - - - {{ t("grid.mongoJsonPreviewEmpty") }} - - + @@ -11177,379 +8646,59 @@ const gridContextMenuItems = computed(() => { - - - {{ t("grid.selectionSum", { value: selectionSummarySumText }) }} - - {{ t("grid.selectionCells", { count: selectionSummary.cellCount }) }} - {{ t("grid.rows", { count: selectionSummary.rowCount }) }} - - - - - - {{ t("grid.allLoaded") }} - - - - - - {{ t("grid.customRowsPerPage") }} - - - - - - - - - {{ t("grid.applyPageSize") }} - - - - - - - - - - {{ currentPage }} - - - - - - - - - + - - - - - - {{ t("grid.cellDetails") }} - - + - - - - {{ t("grid.columnName") }} - {{ dialogCellDetail.column }} - - - {{ t("grid.rowNumber") }} - {{ dialogCellDetail.rowNumber }} - - - {{ t("grid.columnType") }} - - {{ dialogCellDetail.type || "-" }} - - - - {{ t("grid.valueLength") }} - {{ dialogCellDetail.length }} - - + - - {{ t("grid.columnComment") }} - - {{ dialogCellDetail.comment || t("grid.noComment") }} - - - - - - {{ t("grid.cellValue") }} - - - - {{ t("grid.formattedJson") }} - - - - - - - - - - - - - - - - {{ t(`grid.binaryDownload.${mode}`) }} - - - - - - - - - - - - - - - - - - - - - {{ dialogCellDetail.rawValuePreview }} - - {{ t("grid.largeValuePreviewHint", { count: dialogCellDetail.rawValuePreview.length }) }} - - - - - - - {{ t("grid.copyColumnName") }} - - - - - - - - - - {{ t("grid.rowDetailsFor", { row: rowDetail.rowNumber }) }} - - - - - {{ t("grid.columnsCount", { count: rowDetail.fields.length }) }} - - - - - - - - - - - {{ t("grid.fieldIndex") }} - {{ t("grid.columnName") }} - {{ t("grid.cellValue") }} - - - - - - {{ fieldIndex + 1 }} - - {{ field.column }} - - {{ field.type || "-" }} - - - {{ field.comment }} - - - - - {{ field.value === null ? t("grid.nullValue") : t("grid.valueLength") }}: - {{ field.value === null ? "true" : field.length }} - - - - - {{ field.rawValuePreview }} - - {{ t("grid.largeValuePreviewHint", { count: field.rawValuePreview.length }) }} - - - {{ t("grid.formattedJson") }} - {{ field.formattedJson }} - - - - - - - - - - - - {{ t("grid.detailSearchNoMatch") }} - - - - - - {{ t("grid.copyRow") }} - {{ t("grid.copyRowTsv") }} - - - - - - - - - - - {{ t("grid.columnDetailsFor", { column: columnDetail.column }) }} - - - - - - {{ t("grid.columnName") }} - {{ columnDetail.column }} - - - {{ t("grid.columnType") }} - - {{ columnDetail.type || "-" }} - - - - {{ t("grid.rowCount") }} - {{ columnDetail.fields.length }} - - - {{ t("grid.columnComment") }} - - {{ columnDetail.comment || t("grid.noComment") }} - - - - - - {{ t("grid.rowCount") }}: {{ columnDetail.fields.length }} - - - - - - - - - - - {{ t("grid.rowNumber") }} - {{ t("grid.cellValue") }} - - - - - - {{ field.rowNumber }} - - - {{ field.value === null ? t("grid.nullValue") : t("grid.valueLength") }}: - {{ field.value === null ? "true" : field.length }} - - - - - {{ field.rawValuePreview }} - - {{ t("grid.largeValuePreviewHint", { count: field.rawValuePreview.length }) }} - - - {{ t("grid.formattedJson") }} - {{ field.formattedJson }} - - - - - - - - - - - - {{ t("grid.detailSearchNoMatch") }} - - - - - - {{ t("grid.copyColumnValues") }} - {{ t("grid.copyColumnTsv") }} - - {{ t("grid.copyColumnName") }} - - - - - - - - {{ t("grid.bulkEditTitle") }} - - - - {{ t("grid.bulkEditDescription", { count: selectedCellCount }) }} - - - - - {{ t("dangerDialog.cancel") }} - {{ t("grid.applyBulkEdit") }} - - - + @@ -11584,9 +8733,9 @@ const gridContextMenuItems = computed(() => { :close-on-confirm="false" @confirm="confirmDropAllMongoIndexes" /> - + - + @@ -11786,212 +8935,6 @@ const gridContextMenuItems = computed(() => { min-width: 0; } -.data-grid-topbar-condition-label { - display: inline-flex; - flex-shrink: 0; - max-width: 5rem; - overflow: hidden; - white-space: nowrap; - font-family: var(--data-grid-condition-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); - font-size: 0.875rem; - font-weight: 600; - line-height: 1.5rem; - user-select: none; - opacity: 1; - transform: translateX(0); - transition: - max-width var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing), - opacity 240ms ease 60ms, - transform var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing), - color 240ms ease; -} - -.data-grid-topbar-condition-label--where { - color: rgb(37 99 235); -} - -.data-grid-topbar-condition-label--order { - color: rgb(234 88 12); -} - -:global(.dark) [data-grid-root] .data-grid-topbar-condition-label--where { - color: rgb(96 165 250); -} - -:global(.dark) [data-grid-root] .data-grid-topbar-condition-label--order { - color: rgb(251 146 60); -} - -.data-grid-topbar-condition-label--compact { - max-width: 0; - opacity: 0; - transform: translateX(-4px); -} - -.data-grid-topbar-condition-input, -.data-grid-topbar-condition-measure { - font-family: var(--data-grid-condition-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); - font-size: 0.875rem; - line-height: 1.5rem; - font-variant-ligatures: none; - font-feature-settings: - "liga" 0, - "calt" 0; -} - -.data-grid-topbar-condition-input { - width: 100%; - max-width: 100%; - box-sizing: border-box; - padding: 0 0.125rem; - overflow-x: auto; - overflow-y: hidden; - white-space: pre; - border: 0; - border-radius: 0; - appearance: none; - scrollbar-width: none; -} - -[data-grid-root].data-grid--dark .data-grid-topbar-condition-input { - color: rgb(244, 244, 245); - background-color: transparent !important; -} - -.data-grid-topbar-condition-input::-webkit-scrollbar { - display: none; -} - -.data-grid-topbar-condition-icon-control { - align-self: center; - margin-top: 0; -} - -.data-grid-topbar-condition-floating-controls { - top: var(--data-grid-condition-controls-top); - will-change: transform; -} - -.data-grid-topbar-condition-pane--expanded { - --data-grid-condition-font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - position: fixed; - z-index: 80; - right: auto; - bottom: auto; - box-sizing: border-box; - padding: 0.1875rem 0.5rem 0.1875rem 0.5rem; - overflow: hidden; - background: color-mix(in oklab, var(--background) 96%, var(--muted) 4%); - box-shadow: - inset 0 -1px 0 var(--border), - 0 8px 16px rgb(15 23 42 / 8%); - transition: - height 150ms ease, - box-shadow 150ms ease; - --data-grid-expanded-scrollbar-offset: 8px; - --data-grid-condition-controls-top: 0.125rem; - --data-grid-condition-input-top: 0.125rem; - --data-grid-condition-prefix-indent: 0px; - --data-grid-condition-suffix-width: 0px; -} - -[data-grid-root].data-grid--dark .data-grid-topbar-condition-pane--expanded { - background: rgb(24, 24, 27) !important; - color: rgb(244, 244, 245); - box-shadow: - inset 0 -1px 0 rgb(63, 63, 70), - 0 8px 16px rgb(0 0 0 / 32%); -} - -.data-grid-topbar-condition-input--expanded { - top: var(--data-grid-condition-input-top); - right: 0.5rem; - bottom: 0.125rem; - left: 0.5rem; - height: auto; - line-height: 1.5rem; - margin-left: 0; - width: calc(100% - 1rem + var(--data-grid-expanded-scrollbar-offset)); - max-width: none; - margin-right: calc(-1 * var(--data-grid-expanded-scrollbar-offset)); - padding: 0 calc(var(--data-grid-condition-suffix-width) + 0.5rem) 0.0625rem 0.125rem; - text-indent: var(--data-grid-condition-prefix-indent); - overflow-x: hidden; - overflow-y: auto; - white-space: pre-wrap; - overflow-wrap: anywhere; - border: 0; - border-radius: 0; - background: transparent; - scrollbar-width: thin; -} - -.data-grid-topbar-condition-input--expanded::-webkit-scrollbar { - display: initial; - width: 8px; -} - -.data-grid-topbar-condition-input::placeholder { - color: transparent; - transition: color 240ms ease; -} - -.data-grid-topbar-condition-input--where.data-grid-topbar-condition-input--compact::placeholder { - color: rgb(59 130 246 / 70%); -} - -.data-grid-topbar-condition-input--order.data-grid-topbar-condition-input--compact::placeholder { - color: rgb(249 115 22 / 70%); -} - -:global(.dark) [data-grid-root] .data-grid-topbar-condition-input--where.data-grid-topbar-condition-input--compact::placeholder { - color: rgb(147 197 253 / 70%); -} - -:global(.dark) [data-grid-root] .data-grid-topbar-condition-input--order.data-grid-topbar-condition-input--compact::placeholder { - color: rgb(253 186 116 / 70%); -} - -.data-grid-topbar-action-button { - align-items: center; - justify-content: center; - max-width: 9rem; - min-width: 1.25rem; - gap: 0; - line-height: 1; - overflow: hidden; - transition: - max-width var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing), - min-width var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing), - padding-inline var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing), - color 220ms ease, - background-color 220ms ease, - border-color 220ms ease; -} - -.data-grid-topbar-action-button--compact { - max-width: 1.25rem; - min-width: 1.25rem; - padding-inline: 0; -} - -.data-grid-topbar-action-button--commit.data-grid-topbar-action-button--compact { - overflow: visible; -} - -.data-grid-topbar-action-icon { - display: block; - align-self: center; - flex-shrink: 0; - transition: - margin-inline-end var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing), - transform var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing); -} - -.data-grid-topbar-action-button:not(.data-grid-topbar-action-button--compact) .data-grid-topbar-action-icon { - margin-inline-end: 0.25rem; -} - .data-grid-scroller, .data-grid-header-row, .data-grid-transpose-header, @@ -11999,33 +8942,6 @@ const gridContextMenuItems = computed(() => { font-size: var(--dbx-table-font-size, 13px); } -.data-grid-topbar-action-button--compact .data-grid-topbar-action-icon { - margin-inline-end: 0; - transform: scale(0.96); -} - -.data-grid-topbar-action-label { - display: inline-flex; - align-items: center; - height: 1rem; - max-width: 8rem; - overflow: hidden; - white-space: nowrap; - line-height: 1; - opacity: 1; - transform: translateX(0); - transition: - max-width var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing), - opacity 240ms ease 60ms, - transform var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing); -} - -.data-grid-topbar-action-label--compact { - max-width: 0; - opacity: 0; - transform: translateX(-4px); -} - .data-grid-topbar-scroll { scrollbar-width: none; scrollbar-gutter: auto; diff --git a/apps/desktop/src/components/grid/DataGridBulkEditDialog.vue b/apps/desktop/src/components/grid/DataGridBulkEditDialog.vue new file mode 100644 index 0000000000..4af477d989 --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridBulkEditDialog.vue @@ -0,0 +1,40 @@ + + + + + + {{ t("grid.bulkEditTitle") }} + + {{ t("grid.bulkEditDescription", { count: selectedCellCount }) }} + + + {{ t("dangerDialog.cancel") }}{{ t("grid.applyBulkEdit") }} + + + diff --git a/apps/desktop/src/components/grid/DataGridCellDetailDialog.vue b/apps/desktop/src/components/grid/DataGridCellDetailDialog.vue new file mode 100644 index 0000000000..7f2dd0f13b --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridCellDetailDialog.vue @@ -0,0 +1,200 @@ + + + + + + + + + {{ t("grid.cellDetails") }} + + + + + + + {{ t("grid.columnName") }} + {{ detail.column }} + + + {{ t("grid.rowNumber") }} + {{ detail.rowNumber }} + + + {{ t("grid.columnType") }} + {{ detail.type || "-" }} + + + {{ t("grid.valueLength") }} + {{ detail.length }} + + + + + {{ t("grid.columnComment") }} + {{ detail.comment || t("grid.noComment") }} + + + + + {{ t("grid.cellValue") }} + + + + {{ t("grid.formattedJson") }} + + + + + + + + + + + + + + + + {{ t(`grid.binaryDownload.${mode}`) }} + + + + + + + + + + + + + + + + + + + + + {{ detail.rawValuePreview }} + + {{ t("grid.largeValuePreviewHint", { count: detail.rawValuePreview.length }) }} + + + + + + + + + {{ t("grid.copyColumnName") }} + + + + + diff --git a/apps/desktop/src/components/grid/DataGridCellDetailPanel.vue b/apps/desktop/src/components/grid/DataGridCellDetailPanel.vue new file mode 100644 index 0000000000..dba473921d --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridCellDetailPanel.vue @@ -0,0 +1,185 @@ + + + + + + + + {{ t("grid.columnName") }} + {{ detail.column }} + + + {{ t("grid.rowNumber") }} + {{ detail.rowNumber }} + + + {{ t("grid.columnType") }} + {{ detail.type || "-" }} + + + {{ t("grid.nullValue") }} + {{ detail.value === null ? "true" : "false" }} + + + {{ t("grid.valueLength") }} + {{ detail.length }} + + + {{ t("grid.columnComment") }} + {{ detail.comment || t("grid.noComment") }} + + + + + {{ t("grid.columnName") }} + {{ detail.column }} + + + + {{ t("grid.rowNumber") }} + {{ detail.rowNumber }} + + + {{ t("grid.columnType") }} + {{ detail.type || "-" }} + + + {{ t("grid.nullValue") }} + {{ detail.value === null ? "true" : "false" }} + + + {{ t("grid.valueLength") }} + {{ detail.length }} + + + + {{ t("grid.columnComment") }} + {{ detail.comment || t("grid.noComment") }} + + + + + + {{ t("grid.cellValue") }} + + {{ t("grid.compactJson") }} + {{ t("grid.formattedJson") }} + + + {{ t(`grid.binaryDownload.${mode}`) }} + + + + + {{ t("grid.imagePreview") }} + + + + + + + + {{ t("dangerDialog.confirm") }}{{ t("dangerDialog.cancel") }} + + + {{ detail.rawValuePreview }} + {{ t("grid.largeValuePreviewHint", { count: detail.rawValuePreview.length }) }} + + + + + {{ t("dangerDialog.confirm") }}{{ t("dangerDialog.cancel") }} + + + {{ t("grid.setNull") }}{{ t("grid.copyColumnName") }}{{ t("grid.copySqlCondition") }} + + + + diff --git a/apps/desktop/src/components/grid/DataGridColumnHeader.vue b/apps/desktop/src/components/grid/DataGridColumnHeader.vue new file mode 100644 index 0000000000..5dbf51c9c7 --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridColumnHeader.vue @@ -0,0 +1,104 @@ + + + + + + + + {{ name }} + {{ columnType }} + {{ columnComment }} + + + + + + + + {{ columnNameLabel }} + + {{ name }} + + + + + + {{ columnTypeLabel }} + {{ tooltipColumnType ?? columnType }} + + + {{ columnCommentLabel }} + {{ tooltipColumnComment ?? columnComment }} + + + + + + + diff --git a/apps/desktop/src/components/grid/DataGridConditionEditor.vue b/apps/desktop/src/components/grid/DataGridConditionEditor.vue new file mode 100644 index 0000000000..bd42ccd045 --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridConditionEditor.vue @@ -0,0 +1,540 @@ + + + + + + + {{ props.kind === "where" ? "WHERE" : "ORDER BY" }} + + + + + + + + + + + + + + + + + + {{ props.kind === "where" ? "WHERE" : "ORDER BY" }} + + + + + + + + + + + + + + + + {{ suggestion.value }} + + + + + {{ emptyHistoryText }} + + + + + + + {{ historyPreview.value }} + + + + + + diff --git a/apps/desktop/src/components/grid/DataGridDetailDialogs.vue b/apps/desktop/src/components/grid/DataGridDetailDialogs.vue new file mode 100644 index 0000000000..946fceccb8 --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridDetailDialogs.vue @@ -0,0 +1,162 @@ + + + + + + + {{ t("grid.rowDetailsFor", { row: rowDetail.rowNumber }) }} + + + {{ t("grid.columnsCount", { count: rowDetail.fields.length }) }} + + + + + + + {{ t("grid.fieldIndex") }} + {{ t("grid.columnName") }} + {{ t("grid.cellValue") }} + + + + + + {{ index + 1 }} + + {{ field.column }} + {{ field.type || "-" }} + {{ field.comment }} + + + {{ field.value === null ? t("grid.nullValue") : t("grid.valueLength") }}: {{ field.value === null ? "true" : field.length }} + + {{ field.rawValuePreview }} + {{ t("grid.largeValuePreviewHint", { count: field.rawValuePreview.length }) }} + + {{ t("grid.formattedJson") }} + {{ field.formattedJson }} + + + + + + + + + {{ t("grid.detailSearchNoMatch") }} + + + {{ t("grid.copyRow") }}{{ t("grid.copyRowTsv") }} + + + + + + + {{ t("grid.columnDetailsFor", { column: columnDetail.column }) }} + + + {{ t("grid.columnName") }} + {{ columnDetail.column }} + + + {{ t("grid.columnType") }} + {{ columnDetail.type || "-" }} + + + {{ t("grid.rowCount") }} + {{ columnDetail.fields.length }} + + + {{ t("grid.columnComment") }} + {{ columnDetail.comment || t("grid.noComment") }} + + + + {{ t("grid.rowCount") }}: {{ columnDetail.fields.length }} + + + + + + + {{ t("grid.rowNumber") }} + {{ t("grid.cellValue") }} + + + + + + {{ field.rowNumber }} + + {{ field.value === null ? t("grid.nullValue") : t("grid.valueLength") }}: {{ field.value === null ? "true" : field.length }} + + {{ field.rawValuePreview }} + {{ t("grid.largeValuePreviewHint", { count: field.rawValuePreview.length }) }} + + {{ t("grid.formattedJson") }} + {{ field.formattedJson }} + + + + + + + + + {{ t("grid.detailSearchNoMatch") }} + + + {{ t("grid.copyColumnValues") }}{{ t("grid.copyColumnTsv") }} + + {{ t("grid.copyColumnName") }} + + + diff --git a/apps/desktop/src/components/grid/DataGridExportMenu.vue b/apps/desktop/src/components/grid/DataGridExportMenu.vue new file mode 100644 index 0000000000..61b7f14d41 --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridExportMenu.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/apps/desktop/src/components/grid/DataGridFilterBuilder.vue b/apps/desktop/src/components/grid/DataGridFilterBuilder.vue new file mode 100644 index 0000000000..2a5c5fce90 --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridFilterBuilder.vue @@ -0,0 +1,130 @@ + + + + + + {{ t("grid.filter") }} + {{ t("grid.filterBuilderAddRule") }} + + + + + + {{ rule.conjunction }} + + + updateRuleColumn(rule.id, value)"> + + + {{ column }} + {{ t("grid.filterBuilderNoMatchingColumns") }} + + + + + + + emit('updateRule', rule.id, { mode: value as DataGridContextFilterMode })"> + + {{ t(option.labelKey) }} + + + emit('updateRule', rule.id, { rawValue: String(value ?? '') })" @keydown.enter.prevent="emit('apply')" /> + emit('updateRule', rule.id, { rawEndValue: String(value ?? '') })" @keydown.enter.prevent="emit('apply')" /> + + + emit('updateRule', rule.id, { rawValue: String(value ?? '') })" + @keydown.enter.prevent="emit('apply')" + /> + {{ t("grid.filterBuilderNoValue") }} + + + + + + + + {{ t("grid.filterBuilderEmpty") }} + + {{ t("grid.clearFilter") }} + + {{ t("grid.resetFilterBuilder") }}{{ t("grid.applyFilter") }} + + + + diff --git a/apps/desktop/src/components/grid/DataGridMongoJsonPreview.vue b/apps/desktop/src/components/grid/DataGridMongoJsonPreview.vue new file mode 100644 index 0000000000..94a9afbbd8 --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridMongoJsonPreview.vue @@ -0,0 +1,74 @@ + + + + + + + + {{ t("grid.mongoJsonPreview") }} + + + + + + + {{ text }} + {{ t("grid.largeValuePreviewHint", { count: text.length }) }} + + + {{ t("grid.mongoJsonPreviewEmpty") }} + + diff --git a/apps/desktop/src/components/grid/DataGridPagination.vue b/apps/desktop/src/components/grid/DataGridPagination.vue new file mode 100644 index 0000000000..d480b4f861 --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridPagination.vue @@ -0,0 +1,93 @@ + + + + + + {{ t("grid.selectionSum", { value: selectionSummarySumText }) }} + + {{ t("grid.selectionCells", { count: selectionSummary.cellCount }) }} + {{ t("grid.rows", { count: selectionSummary.rowCount }) }} + + + + + {{ t("grid.allLoaded") }} + + + + + {{ t("grid.customRowsPerPage") }} + + + + + {{ t("grid.applyPageSize") }} + + + + + + {{ currentPage }} + + + + + + diff --git a/apps/desktop/src/components/grid/DataGridQueryControls.vue b/apps/desktop/src/components/grid/DataGridQueryControls.vue new file mode 100644 index 0000000000..df85213893 --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridQueryControls.vue @@ -0,0 +1,214 @@ + + + + + + + + + + {{ filterButtonCount }} + + + + + {{ t("grid.filter") }} + {{ t("grid.filterBuilderAddRule") }} + + + + + + {{ t("grid.localFiltersActive", { count: localFilterCount }) }} + + {{ t("grid.clearLocalFiltersShort") }} + + + + {{ summary.columnName }} + + , {{ value }} + {{ t("grid.localFilterMoreValues", { count: summary.hiddenValueCount }) }} + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/src/components/grid/DataGridSearchBar.vue b/apps/desktop/src/components/grid/DataGridSearchBar.vue new file mode 100644 index 0000000000..9e430a1005 --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridSearchBar.vue @@ -0,0 +1,74 @@ + + + + + + + + + + + {{ suggestion }} + + + {{ props.currentMatchIndex + 1 }}/{{ props.matchCount }} + 0 + + + + diff --git a/apps/desktop/src/components/grid/DataGridToolbar.vue b/apps/desktop/src/components/grid/DataGridToolbar.vue new file mode 100644 index 0000000000..28328aab82 --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridToolbar.vue @@ -0,0 +1,255 @@ + + + + + + + + + + + + {{ refresh.label }} + + + {{ refresh.tooltip ?? refresh.label }} + + + + + + + {{ autoRefresh?.enabled ? `${autoRefresh.intervalSeconds}s` : autoRefresh?.shortLabel }} + + + + + + + {{ autoRefresh?.enabled ? autoRefresh.stopLabel : autoRefresh?.startLabel }} + + + + + {{ autoRefresh?.intervalLabel(seconds) }} + + + + + + + + + + + {{ addRow?.label }} + + + {{ addRow?.tooltip ?? addRow?.label }} + + + + + + + + + {{ exportData?.label }} + + + + {{ exportData?.label }} + + + + + + {{ item.label }} + + + + + + + + + + {{ transpose?.label }} + + + {{ transpose?.tooltip ?? transpose?.label }} + + + + + + + {{ tableInfo?.label }} + + + {{ tableInfo?.tooltip ?? tableInfo?.label }} + + + + + + + + {{ preview?.label }} + + + {{ preview?.tooltip ?? preview?.label }} + + + + + + + + + {{ save.pendingCount }} + + {{ save?.label }} + + + {{ save?.tooltip ?? save?.label }} ({{ save.shortcutLabel }}) + + + + + + + {{ rollback?.label }} + + + {{ rollback?.tooltip ?? rollback?.label }} + + + + + + + diff --git a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts new file mode 100644 index 0000000000..b8f83fe147 --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts @@ -0,0 +1,299 @@ +// @vitest-environment happy-dom + +import { nextTick } from "vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { dispatch, findAll, findOne, hostText, mountComponent } from "./vueHostHarness"; +import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail"; + +const mocks = vi.hoisted(() => ({ + editor: { create: vi.fn(), destroy: vi.fn(), setValue: vi.fn(), openSearch: vi.fn() }, + updateSettings: vi.fn(), + renderWkt: vi.fn(), + panelCancel: vi.fn(), + panelOpenSearch: vi.fn(), +})); + +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock("@lucide/vue", async () => { + const { createPassthroughStub } = await import("./vueHostHarness"); + const icon = createPassthroughStub("Icon", "i"); + return { Check: icon, ChevronLeft: icon, ChevronRight: icon, ChevronsLeft: icon, ChevronsRight: icon, Loader2: icon, Upload: icon, Search: icon, X: icon, Code2: icon, Copy: icon, Eye: icon, EyeOff: icon, Info: icon, Pencil: icon, Plus: icon, Trash2: icon }; +}); + +vi.mock("@/components/ui/button", async () => ({ Button: (await import("./vueHostHarness")).createPassthroughStub("Button", "button") })); +vi.mock("@/components/ui/input", async () => ({ Input: (await import("./vueHostHarness")).createPassthroughStub("Input", "input") })); +vi.mock("@/components/ui/dialog", async () => { + const { createPassthroughStub } = await import("./vueHostHarness"); + return { Dialog: createPassthroughStub("Dialog"), DialogContent: createPassthroughStub("DialogContent"), DialogFooter: createPassthroughStub("DialogFooter"), DialogHeader: createPassthroughStub("DialogHeader"), DialogTitle: createPassthroughStub("DialogTitle") }; +}); +vi.mock("@/components/ui/dropdown-menu", async () => { + const { createPassthroughStub } = await import("./vueHostHarness"); + return { DropdownMenu: createPassthroughStub("DropdownMenu"), DropdownMenuContent: createPassthroughStub("DropdownMenuContent"), DropdownMenuItem: createPassthroughStub("DropdownMenuItem", "button"), DropdownMenuTrigger: createPassthroughStub("DropdownMenuTrigger") }; +}); +vi.mock("@/components/ui/popover", async () => { + const { createPassthroughStub } = await import("./vueHostHarness"); + return { Popover: createPassthroughStub("Popover"), PopoverContent: createPassthroughStub("PopoverContent"), PopoverTrigger: createPassthroughStub("PopoverTrigger") }; +}); +vi.mock("@/components/ui/tooltip", async () => { + const { createPassthroughStub } = await import("./vueHostHarness"); + return { Tooltip: createPassthroughStub("Tooltip"), TooltipContent: createPassthroughStub("TooltipContent"), TooltipTrigger: createPassthroughStub("TooltipTrigger") }; +}); +vi.mock("@/components/ui/select", async () => { + const { createPassthroughStub } = await import("./vueHostHarness"); + return { Select: createPassthroughStub("Select"), SelectContent: createPassthroughStub("SelectContent"), SelectItem: createPassthroughStub("SelectItem"), SelectTrigger: createPassthroughStub("SelectTrigger"), SelectValue: createPassthroughStub("SelectValue") }; +}); +vi.mock("@/components/ui/tabs", async () => ({ TabsContent: (await import("./vueHostHarness")).createPassthroughStub("TabsContent") })); +vi.mock("@/components/ui/LightDropdown.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("LightDropdown") })); +vi.mock("@/components/ui/LightTooltip.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("LightTooltip") })); +vi.mock("@/components/grid/TemporalCellEditor.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("TemporalCellEditor") })); +vi.mock("@/composables/useCellDetailEditor", () => ({ useCellDetailEditor: () => mocks.editor })); +vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false }, themePalette: { value: {} } }) })); +vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { cellDetailJsonFormatted: true, theme: "default", fontSize: 13, fontFamily: "monospace" }, updateEditorSettings: mocks.updateSettings }) })); +vi.mock("@/lib/dataGrid/geometryPreview", () => ({ isHexGeometry: () => false, renderWktOnCanvas: mocks.renderWkt })); +vi.mock("@/composables/useDataGridCellDetail", async () => { + const { ref } = await import("vue"); + return { + useDataGridCellDetail: ({ onCancel }: { onCancel: () => void }) => { + mocks.panelCancel.mockImplementation(onCancel); + return { geometryPreviewOpen: ref(false), geometryCanvas: ref(), detailsEditorContainer: ref(), sideJsonPreviewContainer: ref(), openSearch: mocks.panelOpenSearch }; + }, + }; +}); + +import DataGridCellDetailDialog from "@/components/grid/DataGridCellDetailDialog.vue"; +import DataGridCellDetailPanel from "@/components/grid/DataGridCellDetailPanel.vue"; +import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue"; +import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue"; +import DataGridPagination from "@/components/grid/DataGridPagination.vue"; +import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue"; + +function detail(patch: Partial = {}): DataGridCellDetail { + return { + rowNumber: 1, + rowId: 0, + colIndex: 0, + column: "payload", + type: "JSON", + comment: "", + value: '{"a":1}', + rawValue: '{"a":1}', + rawValuePreview: '{"a":1}', + displayValue: '{"a":1}', + displayValuePreview: '{"a":1}', + isValuePreviewTruncated: false, + imagePreviewUrl: null, + length: 7, + formattedJson: '{\n "a": 1\n}', + isEditable: true, + ...patch, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("DataGridSearchBar", () => { + it("focuses/selects the input and forwards keyboard and suggestion interactions", () => { + const keydown = vi.fn(); + const acceptSuggestion = vi.fn(); + const hoverSuggestion = vi.fn(); + const close = vi.fn(); + const mounted = mountComponent(DataGridSearchBar, { + open: true, + text: "pay", + suggestions: ["payload"], + suggestionIndex: 0, + matchCount: 2, + currentMatchIndex: 0, + hasDeferredSearchText: false, + onKeydown: keydown, + onAcceptSuggestion: acceptSuggestion, + onHoverSuggestion: hoverSuggestion, + onClose: close, + }); + const input = findOne(mounted.root, (node) => node.type === "input"); + + mounted.exposed.value.focus(true); + expect(input.focused).toBe(true); + expect(input.selected).toBe(true); + dispatch(input, "keydown", { key: "Enter" }); + expect(keydown).toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" })); + + const suggestion = findOne(mounted.root, (node) => hostText(node) === "payload" && !!node.props.onMousedown); + const mouseDown = dispatch(suggestion, "mousedown"); + expect(mouseDown.defaultPrevented).toBe(true); + expect(acceptSuggestion).toHaveBeenCalledWith(0); + dispatch(suggestion, "mouseenter"); + expect(hoverSuggestion).toHaveBeenCalledWith(0); + + const closeButton = findOne(mounted.root, (node) => node.type === "button"); + dispatch(closeButton, "click"); + expect(close).toHaveBeenCalledOnce(); + }); +}); + +describe("DataGridPagination", () => { + it("enforces first/previous/next/last disabled boundaries", async () => { + const firstPage = vi.fn(); + const previousPage = vi.fn(); + const nextPage = vi.fn(); + const lastPage = vi.fn(); + const mounted = mountComponent(DataGridPagination, { + selectionSummary: null, + selectionSummarySumText: "", + loading: false, + infiniteScrollEnabled: false, + infiniteScrollAllLoaded: false, + pageSize: 100, + customPageSizeInput: "", + pageSizeMenuItems: [], + exportMenuItems: [], + currentPage: 1, + canGoNextPage: false, + canJumpLastPage: false, + onFirstPage: firstPage, + onPreviousPage: previousPage, + onNextPage: nextPage, + onLastPage: lastPage, + }); + const navigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0"); + + expect(navigation.map((node) => node.props.disabled)).toEqual([true, true, true, true]); + navigation.forEach((node) => dispatch(node, "click")); + expect(firstPage).not.toHaveBeenCalled(); + expect(previousPage).not.toHaveBeenCalled(); + expect(nextPage).not.toHaveBeenCalled(); + expect(lastPage).not.toHaveBeenCalled(); + + await mounted.setProps({ currentPage: 2, canGoNextPage: true, canJumpLastPage: true }); + const enabledNavigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0"); + expect(enabledNavigation.map((node) => node.props.disabled)).toEqual([false, false, false, false]); + enabledNavigation.forEach((node) => dispatch(node, "click")); + expect(firstPage).toHaveBeenCalledOnce(); + expect(previousPage).toHaveBeenCalledOnce(); + expect(nextPage).toHaveBeenCalledOnce(); + expect(lastPage).toHaveBeenCalledOnce(); + }); +}); + +describe("DataGridColumnHeader", () => { + it("cancels resize-handle clicks without leaking header click events", () => { + const click = vi.fn(); + const clickCapture = vi.fn(); + const resizeStart = vi.fn(); + const autoFit = vi.fn(); + const mounted = mountComponent(DataGridColumnHeader, { + name: "id", + actualColumnIndex: 0, + visibleColumnIndex: 0, + dark: true, + copyColumnNameLabel: "copy", + columnNameLabel: "name", + columnTypeLabel: "type", + columnCommentLabel: "comment", + onClick: click, + onClickCapture: clickCapture, + onResizeStart: resizeStart, + onAutoFit: autoFit, + }); + const handle = findOne(mounted.root, (node) => node.props["data-column-resize-handle"] === ""); + const header = findOne(mounted.root, (node) => node.props["data-grid-column-index"] === 0); + expect(String(header.props.class)).toContain("data-grid-header-cell--dark"); + + const down = dispatch(handle, "mousedown"); + expect(down.propagationStopped).toBe(true); + expect(resizeStart).toHaveBeenCalledOnce(); + const handleClick = dispatch(handle, "click"); + expect(handleClick.propagationStopped).toBe(true); + expect(handleClick.defaultPrevented).toBe(true); + expect(click).not.toHaveBeenCalled(); + expect(clickCapture).not.toHaveBeenCalled(); + dispatch(handle, "dblclick"); + expect(autoFit).toHaveBeenCalledOnce(); + }); +}); + +describe("DataGridFilterBuilder", () => { + it("isolates text-entry keys while preserving popup navigation keys", () => { + const mounted = mountComponent(DataGridFilterBuilder, { rules: [{ id: "r1", columnName: "id", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }], columns: ["id"], filteredColumns: ["id"], modeOptions: [{ value: "equals", labelKey: "equals" }], columnSearch: "" }); + const searchInput = findOne(mounted.root, (node) => node.type === "input" && node.props.placeholder === "grid.filterBuilderSearchColumns"); + + expect(dispatch(searchInput, "keydown", { key: "a" }).propagationStopped).toBe(true); + expect(dispatch(searchInput, "keydown", { key: "Backspace" }).propagationStopped).toBe(true); + expect(dispatch(searchInput, "keydown", { key: "ArrowDown" }).propagationStopped).toBe(false); + expect(dispatch(searchInput, "keydown", { key: "Process", isComposing: true }).propagationStopped).toBe(true); + }); +}); + +describe("cell detail surfaces", () => { + it("copies the presented value, emits edit, closes, and replaces the JSON result", async () => { + const copyText = vi.fn(); + const edit = vi.fn(); + const updateOpen = vi.fn(); + const mounted = mountComponent(DataGridCellDetailDialog, { open: true, detail: detail(), typeColorClass: () => "", openImagePreview: vi.fn(), copyText, canDownloadBinaryValue: () => false, downloadBinaryValue: vi.fn(), onEdit: edit, "onUpdate:open": updateOpen }); + await nextTick(); + await nextTick(); + + const copyValue = findOne(mounted.root, (node) => node.props.title === "grid.copyValue"); + dispatch(copyValue, "click"); + expect(copyText).toHaveBeenCalledWith('{\n "a": 1\n}'); + dispatch( + findOne(mounted.root, (node) => node.props.title === "grid.editValue"), + "click", + ); + expect(edit).toHaveBeenCalledOnce(); + + await mounted.setProps({ detail: detail({ rawValue: '{"b":2}', formattedJson: '{\n "b": 2\n}' }) }); + expect(mocks.editor.setValue).toHaveBeenCalledWith('{\n "b": 2\n}', "json"); + await mounted.setProps({ detail: detail({ value: null, rawValue: "", formattedJson: "" }) }); + expect(mocks.editor.destroy).toHaveBeenCalledOnce(); + + const dialog = findOne(mounted.root, (node) => node.props["data-stub"] === "Dialog"); + dialog.props["onUpdate:open"](false); + expect(updateOpen).toHaveBeenCalledWith(false); + }); + + it("forwards panel edit/copy/cancel actions and exposes search", () => { + const startEdit = vi.fn(); + const copyValue = vi.fn(); + const cancel = vi.fn(); + const mounted = mountComponent(DataGridCellDetailPanel, { + detail: detail({ formattedJson: "" }), + panelIsBottom: false, + metadataCollapsed: false, + valueFillsHeight: false, + editing: false, + sideJsonView: false, + showCompactJson: false, + canCompactJson: false, + typeColorClass: () => "", + canDownloadBinaryValue: () => false, + downloadBinaryValue: vi.fn(), + openImagePreview: vi.fn(), + canCopySqlCondition: () => true, + onStartEdit: startEdit, + onCopyValue: copyValue, + onCancel: cancel, + }); + + dispatch( + findOne(mounted.root, (node) => node.props.title === "grid.editValue"), + "click", + ); + dispatch( + findOne(mounted.root, (node) => node.props.title === "grid.copyValue"), + "click", + ); + dispatch( + findOne(mounted.root, (node) => node.type === "pre"), + "dblclick", + ); + expect(startEdit).toHaveBeenCalledTimes(2); + expect(copyValue).toHaveBeenCalledOnce(); + mocks.panelCancel(); + expect(cancel).toHaveBeenCalledOnce(); + mounted.exposed.value.openSearch(); + expect(mocks.panelOpenSearch).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/desktop/src/components/grid/__tests__/vueHostHarness.ts b/apps/desktop/src/components/grid/__tests__/vueHostHarness.ts new file mode 100644 index 0000000000..31f83424ae --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/vueHostHarness.ts @@ -0,0 +1,185 @@ +import { createRenderer, defineComponent, h, nextTick, reactive, ref, type Component } from "vue"; + +export interface HostNode { + type: string; + props: Record; + children: Array; + parent: HostNode | null; + focused: boolean; + selected: boolean; + value?: unknown; + listeners: Map void>>; + focus(): void; + select(): void; + addEventListener(name: string, listener: (event: any) => void): void; + removeEventListener(name: string, listener: (event: any) => void): void; +} + +function createHostNode(type: string): HostNode { + const node: HostNode = { + type, + props: {}, + children: [], + parent: null, + focused: false, + selected: false, + listeners: new Map(), + focus() { + node.focused = true; + }, + select() { + node.selected = true; + }, + addEventListener(name, listener) { + const listeners = node.listeners.get(name) ?? []; + listeners.push(listener); + node.listeners.set(name, listeners); + }, + removeEventListener(name, listener) { + node.listeners.set( + name, + (node.listeners.get(name) ?? []).filter((candidate) => candidate !== listener), + ); + }, + }; + return node; +} + +function insert(child: HostNode, parent: HostNode, anchor?: HostNode | null) { + child.parent = parent; + if (!anchor) { + parent.children.push(child); + return; + } + const index = parent.children.indexOf(anchor); + parent.children.splice(index < 0 ? parent.children.length : index, 0, child); +} + +const renderer = createRenderer({ + patchProp(node, key, _previous, next) { + if (next === null || next === undefined) delete node.props[key]; + else node.props[key] = next; + if (key === "value") node.value = next; + }, + insert, + remove(node) { + if (!node.parent) return; + const index = node.parent.children.indexOf(node); + if (index >= 0) node.parent.children.splice(index, 1); + node.parent = null; + }, + createElement: createHostNode, + createText(text) { + const node = createHostNode("#text"); + node.children = [text]; + return node; + }, + createComment(text) { + const node = createHostNode("#comment"); + node.children = [text]; + return node; + }, + setText(node, text) { + node.children = [text]; + }, + setElementText(node, text) { + node.children = [text]; + }, + parentNode: (node) => node.parent, + nextSibling(node) { + if (!node.parent) return null; + const index = node.parent.children.indexOf(node); + return (node.parent.children[index + 1] as HostNode | undefined) ?? null; + }, + setScopeId(node, id) { + node.props[id] = ""; + }, + cloneNode(node) { + return { ...node, props: { ...node.props }, children: [...node.children], parent: null, listeners: new Map(node.listeners) }; + }, + insertStaticContent(content, parent, anchor) { + const node = createHostNode("#static"); + node.children = [content]; + insert(node, parent, anchor); + return [node, node]; + }, +}); + +export function createPassthroughStub(name: string, tag = "div") { + return defineComponent({ + name, + inheritAttrs: false, + setup(_props, { attrs, slots }) { + return () => h(tag, { ...attrs, "data-stub": name }, [slots.default?.(), slots.content?.()]); + }, + }); +} + +export function mountComponent(component: Component, initialProps: Record) { + const props = reactive({ ...initialProps }); + const componentRef = ref(); + const root = createHostNode("root"); + const wrapper = defineComponent({ + setup() { + return () => h(component, { ...props, ref: componentRef }); + }, + }); + const app = renderer.createApp(wrapper); + app.mount(root); + return { + root, + exposed: componentRef, + async setProps(patch: Record) { + Object.assign(props, patch); + await nextTick(); + }, + unmount() { + app.unmount(); + }, + }; +} + +export function findAll(node: HostNode, predicate: (node: HostNode) => boolean): HostNode[] { + const matches: HostNode[] = []; + if (predicate(node)) matches.push(node); + for (const child of node.children) { + if (typeof child !== "string") matches.push(...findAll(child, predicate)); + } + return matches; +} + +export function findOne(node: HostNode, predicate: (node: HostNode) => boolean): HostNode { + const match = findAll(node, predicate)[0]; + if (!match) throw new Error("Expected host node was not rendered"); + return match; +} + +export function hostText(node: HostNode): string { + return node.children.map((child) => (typeof child === "string" ? child : hostText(child))).join(""); +} + +function eventPropName(name: string) { + return `on${name[0].toUpperCase()}${name.slice(1)}`; +} + +export function dispatch(node: HostNode, name: string, overrides: Record = {}) { + const event = { + type: name, + target: node, + currentTarget: node, + defaultPrevented: false, + propagationStopped: false, + preventDefault() { + event.defaultPrevented = true; + }, + stopPropagation() { + event.propagationStopped = true; + }, + ...overrides, + }; + if (node.props.disabled) return event; + for (const listener of node.listeners.get(name) ?? []) listener(event); + const handler = node.props[eventPropName(name)]; + for (const callback of Array.isArray(handler) ? handler : handler ? [handler] : []) callback(event); + return event; +} diff --git a/apps/desktop/src/composables/__tests__/useDataGridAsyncSurface.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridAsyncSurface.spec.ts new file mode 100644 index 0000000000..8f1a7c5369 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useDataGridAsyncSurface.spec.ts @@ -0,0 +1,31 @@ +import { effectScope, nextTick, ref } from "vue"; +import { describe, expect, it } from "vitest"; +import { useDataGridAsyncSurface } from "@/composables/useDataGridAsyncSurface"; + +describe("useDataGridAsyncSurface", () => { + it("defers mounting until first open and keeps the surface mounted afterward", async () => { + const open = ref(false); + const scope = effectScope(); + const mounted = scope.run(() => useDataGridAsyncSurface(open))!; + + expect(mounted.value).toBe(false); + + open.value = true; + await nextTick(); + expect(mounted.value).toBe(true); + + open.value = false; + await nextTick(); + expect(mounted.value).toBe(true); + + scope.stop(); + }); + + it("mounts immediately when restored with an already-open surface", () => { + const scope = effectScope(); + const mounted = scope.run(() => useDataGridAsyncSurface(ref(true)))!; + + expect(mounted.value).toBe(true); + scope.stop(); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridAutoRefresh.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridAutoRefresh.spec.ts new file mode 100644 index 0000000000..947136f6fd --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useDataGridAutoRefresh.spec.ts @@ -0,0 +1,32 @@ +import { computed, nextTick } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useDataGridAutoRefresh } from "@/composables/useDataGridAutoRefresh"; + +describe("useDataGridAutoRefresh", () => { + afterEach(() => vi.useRealTimers()); + + it("refreshes on the configured interval when enabled", async () => { + vi.useFakeTimers(); + const refresh = vi.fn(); + const autoRefresh = useDataGridAutoRefresh({ canRefresh: computed(() => true), refresh, initialIntervalSeconds: 5 }); + + autoRefresh.toggle(); + vi.advanceTimersByTime(5000); + await nextTick(); + + expect(refresh).toHaveBeenCalledOnce(); + autoRefresh.stop(); + }); + + it("does not refresh while the operation is unavailable", () => { + vi.useFakeTimers(); + const refresh = vi.fn(); + const autoRefresh = useDataGridAutoRefresh({ canRefresh: computed(() => false), refresh, initialIntervalSeconds: 1 }); + + autoRefresh.toggle(); + vi.advanceTimersByTime(1000); + + expect(refresh).not.toHaveBeenCalled(); + autoRefresh.stop(); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridCanvasRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridCanvasRuntime.spec.ts new file mode 100644 index 0000000000..bc74615fc2 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useDataGridCanvasRuntime.spec.ts @@ -0,0 +1,160 @@ +import { effectScope } from "vue"; +import { describe, expect, it, vi } from "vitest"; +import { useDataGridCanvasRuntime, type DataGridAnimationFrameDriver } from "@/composables/useDataGridCanvasRuntime"; +import { useDataGridScrollbars } from "@/composables/useDataGridScrollbars"; + +function createFrameDriver() { + let nextId = 0; + const callbacks = new Map(); + const driver: DataGridAnimationFrameDriver = { + request(callback) { + const id = nextId++; + callbacks.set(id, callback); + return id; + }, + cancel(id) { + callbacks.delete(id); + }, + }; + return { driver, callbacks, run: (id: number) => callbacks.get(id)?.(0) }; +} + +function createObserver() { + const observe = vi.fn(); + const disconnect = vi.fn(); + return { observer: { observe, disconnect } as unknown as ResizeObserver, observe, disconnect }; +} + +describe("data grid visual runtimes", () => { + it("coalesces canvas frames and cancels all resources on dispose", () => { + const frames = createFrameDriver(); + const resize = createObserver(); + const draw = vi.fn(); + const viewport = { children: [] } as unknown as Element; + const runtime = useDataGridCanvasRuntime({ + draw, + syncViewport: vi.fn(), + getViewport: () => viewport, + refreshPixelRatio: vi.fn(), + frameDriver: frames.driver, + createResizeObserver: () => resize.observer, + }); + + runtime.observeViewport(); + runtime.scheduleDraw(); + runtime.scheduleDraw(); + runtime.schedulePixelRatioRefresh(); + expect(frames.callbacks.size).toBe(2); + + runtime.dispose(); + expect(frames.callbacks.size).toBe(0); + expect(resize.disconnect).toHaveBeenCalledOnce(); + expect(draw).not.toHaveBeenCalled(); + }); + + it("keeps a large scroll burst within one scheduled draw frame", () => { + const frames = createFrameDriver(); + const draw = vi.fn(); + const runtime = useDataGridCanvasRuntime({ + draw, + syncViewport: vi.fn(), + getViewport: () => ({ children: [] }) as unknown as Element, + frameDriver: frames.driver, + }); + + for (let index = 0; index < 10_000; index++) runtime.scheduleDraw(); + + expect(frames.callbacks.size).toBe(1); + const frameId = [...frames.callbacks.keys()][0]; + frames.run(frameId); + expect(draw).toHaveBeenCalledOnce(); + runtime.dispose(); + }); + + it("keeps the latest drag coordinate and disconnects observed scroll content", () => { + const frames = createFrameDriver(); + const resize = createObserver(); + const horizontalDrag = vi.fn(); + const child = { children: [] } as unknown as Element; + const scroller = { children: [child] } as unknown as Element; + const runtime = useDataGridScrollbars({ + update: vi.fn(), + getScroller: () => scroller, + applyHorizontalDrag: horizontalDrag, + applyVerticalDrag: vi.fn(), + frameDriver: frames.driver, + createResizeObserver: () => resize.observer, + }); + + runtime.observeScroller(); + runtime.scheduleHorizontalDrag(10); + runtime.scheduleHorizontalDrag(25); + runtime.flushHorizontalDrag(); + + expect(horizontalDrag).toHaveBeenCalledWith(25); + expect(resize.observe).toHaveBeenCalledTimes(2); + runtime.dispose(); + expect(resize.disconnect).toHaveBeenCalledOnce(); + expect(frames.callbacks.size).toBe(0); + }); + + it("tears down pending canvas work when its Vue scope stops", () => { + const frames = createFrameDriver(); + const resize = createObserver(); + const draw = vi.fn(); + const syncViewport = vi.fn(); + const scope = effectScope(); + const runtime = scope.run(() => + useDataGridCanvasRuntime({ + draw, + syncViewport, + getViewport: () => ({ children: [] }) as unknown as Element, + frameDriver: frames.driver, + createResizeObserver: () => resize.observer, + }), + )!; + + runtime.observeViewport(); + runtime.scheduleDraw(); + const lateDraw = [...frames.callbacks.values()][0]; + scope.stop(); + + expect(runtime.disposed).toBe(true); + expect(runtime.active).toBe(false); + expect(frames.callbacks.size).toBe(0); + expect(resize.disconnect).toHaveBeenCalledOnce(); + lateDraw(0); + expect(draw).not.toHaveBeenCalled(); + expect(syncViewport).toHaveBeenCalledOnce(); + }); + + it("does not revive scroll work after disposal", () => { + const frames = createFrameDriver(); + const resize = createObserver(); + const update = vi.fn(); + const verticalDrag = vi.fn(); + const runtime = useDataGridScrollbars({ + update, + getScroller: () => ({ children: [] }) as unknown as Element, + applyHorizontalDrag: vi.fn(), + applyVerticalDrag: verticalDrag, + frameDriver: frames.driver, + createResizeObserver: () => resize.observer, + }); + + runtime.observeScroller(); + runtime.scheduleVerticalDrag(42); + const lateCallbacks = [...frames.callbacks.values()]; + runtime.dispose(); + runtime.resume(); + runtime.scheduleUpdate(); + runtime.flushVerticalDrag(); + lateCallbacks.forEach((callback) => callback(0)); + + expect(runtime.disposed).toBe(true); + expect(runtime.active).toBe(false); + expect(frames.callbacks.size).toBe(0); + expect(update).not.toHaveBeenCalled(); + expect(verticalDrag).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts new file mode 100644 index 0000000000..fca3e9a34b --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts @@ -0,0 +1,141 @@ +// @vitest-environment happy-dom + +import { effectScope, ref } from "vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { dataGridColumnOffsets, dataGridHorizontalColumnWindow, useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout"; + +describe("useDataGridColumnLayout", () => { + beforeEach(() => localStorage.clear()); + it("builds cumulative offsets", () => { + expect(dataGridColumnOffsets([80, 120, 60])).toEqual([0, 80, 200, 260]); + }); + + it("windows columns while preserving spacer widths", () => { + const widths = [100, 100, 100, 100, 100]; + const offsets = dataGridColumnOffsets(widths); + expect(dataGridHorizontalColumnWindow({ widths, offsets, columnCount: 5, scrollLeft: 250, viewportWidth: 100, rowNumberWidth: 40, bufferPx: 0 })).toEqual({ start: 2, end: 4, beforeWidth: 200, afterWidth: 100 }); + }); + + it("returns an empty window without columns", () => { + expect(dataGridHorizontalColumnWindow({ widths: [], offsets: [0], columnCount: 0, scrollLeft: 0, viewportWidth: 0, rowNumberWidth: 40, bufferPx: 900 })).toEqual({ + start: 0, + end: 0, + beforeWidth: 0, + afterWidth: 0, + }); + }); + + it("owns visibility, null-column toggles, and persisted ordering", () => { + const scope = effectScope(); + const state = scope.run(() => + useDataGridColumnLayoutState({ + columns: ref(["id", "name", "empty"]), + sourceColumns: ref(undefined), + commentByColumn: ref(new Map()), + displayableColumnIndexes: ref([0, 1, 2]), + allNullColumnIndexes: ref([2]), + columnOrderKeys: ref(["id\0\0", "name\0\0", "empty\0\0"]), + layoutScopeKey: ref("test-layout"), + tableScopeKey: ref(""), + }), + )!; + + state.toggleColumnVisibility(1); + expect(state.visibleColumnIndexes.value).toEqual([0, 2]); + state.toggleAllNullColumns(); + expect(state.visibleColumnIndexes.value).toEqual([0]); + state.showAllColumns(); + expect(state.visibleColumnIndexes.value).toEqual([0, 1, 2]); + state.persistColumnOrder([1, 0, 2]); + expect(state.orderedDisplayableColumnIndexes.value).toEqual([1, 0, 2]); + scope.stop(); + }); + + it("keeps a new resize active when the previous resize completion frame is pending", () => { + const frames = new Map(); + let nextFrame = 1; + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => { + const frame = nextFrame++; + frames.set(frame, callback); + return frame; + }), + ); + vi.stubGlobal( + "cancelAnimationFrame", + vi.fn((frame: number) => frames.delete(frame)), + ); + + const scope = effectScope(); + const layout = scope.run(() => + useDataGridColumnLayout({ + columnNames: ref(["id"]), + visibleColumnIndexes: ref([0]), + renderedColumnWidths: ref([100]), + scrollLeft: ref(0), + viewportWidth: ref(400), + rowNumberWidth: 40, + }), + )!; + + layout.startColumnHeaderResize(0, new MouseEvent("mousedown")); + window.dispatchEvent(new MouseEvent("mouseup")); + expect(frames.size).toBe(1); + + layout.startColumnHeaderResize(0, new MouseEvent("mousedown")); + expect(frames.size).toBe(0); + expect(layout.columnHeaderResizeActive.value).toBe(true); + + scope.stop(); + }); + + it("commits column drag order and removes global interaction state on disposal", () => { + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + + const first = document.createElement("div"); + first.dataset.visibleColIndex = "0"; + first.getBoundingClientRect = () => ({ left: 0, width: 100, right: 100, top: 0, bottom: 20, height: 20, x: 0, y: 0, toJSON: () => ({}) }); + const second = document.createElement("div"); + second.dataset.visibleColIndex = "1"; + second.getBoundingClientRect = () => ({ left: 100, width: 100, right: 200, top: 0, bottom: 20, height: 20, x: 100, y: 0, toJSON: () => ({}) }); + const header = document.createElement("div"); + header.append(first, second); + const persist = vi.fn(); + + const scope = effectScope(); + const layout = scope.run(() => + useDataGridColumnLayout({ + columnNames: ref(["id", "name"]), + visibleColumnIndexes: ref([0, 1]), + renderedColumnWidths: ref([100, 100]), + scrollLeft: ref(0), + viewportWidth: ref(400), + rowNumberWidth: 40, + headerRef: ref(header), + onPersistColumnOrder: persist, + }), + )!; + + layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 20, clientY: 10 })); + window.dispatchEvent(new PointerEvent("pointermove", { clientX: 180, clientY: 10 })); + expect(document.body.style.userSelect).toBe("none"); + window.dispatchEvent(new PointerEvent("pointerup", { clientX: 180, clientY: 10 })); + expect(persist).toHaveBeenCalledWith([1, 0]); + expect(document.body.style.userSelect).toBe(""); + + layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 20, clientY: 10 })); + window.dispatchEvent(new PointerEvent("pointermove", { clientX: 180, clientY: 10 })); + scope.stop(); + expect(document.body.style.userSelect).toBe(""); + window.dispatchEvent(new PointerEvent("pointerup", { clientX: 180, clientY: 10 })); + expect(persist).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts index a87f11a4e1..8e274b46ee 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts @@ -1,6 +1,6 @@ import { computed, nextTick, ref } from "vue"; -import { describe, expect, it, vi } from "vitest"; -import { DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH } from "@/lib/dataGrid/dataGridColumnWidth"; +import { describe, expect, it } from "vitest"; +import { DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH } from "@/lib/dataGrid/dataGridColumnWidth"; import { DATA_GRID_ROW_NUM_WIDTH, resizeDataGridColumnWidth, useDataGridColumnResize } from "@/composables/useDataGridColumnResize"; function createResizeState(options: { columns: string[]; rows: Array>; columnIndexes?: number[]; density?: "compact" | "standard" | "comfortable"; compactColumnHeaderActions?: boolean }) { diff --git a/apps/desktop/src/composables/__tests__/useDataGridConditionEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridConditionEditor.spec.ts new file mode 100644 index 0000000000..699f8f91fc --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useDataGridConditionEditor.spec.ts @@ -0,0 +1,137 @@ +import { effectScope, nextTick, ref } from "vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useDataGridConditionEditor } from "@/composables/useDataGridConditionEditor"; +import { rememberDataGridConditionHistory } from "@/lib/dataGrid/dataGridConditionHistory"; + +const storage = new Map(); +vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + clear: () => storage.clear(), +}); + +function keyboardEvent(key: string) { + return { key, shiftKey: false, preventDefault: vi.fn() } as unknown as KeyboardEvent; +} + +describe("useDataGridConditionEditor", () => { + beforeEach(() => { + localStorage.clear(); + vi.useRealTimers(); + }); + + it("replaces the active token and supports clamped keyboard navigation", async () => { + const value = ref("status = cus"); + const editor = useDataGridConditionEditor({ + kind: "where", + value, + columns: ["customer_id", "customer_name"], + historyScope: {}, + }); + + value.value = "status = cust"; + await nextTick(); + await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["customer_id", "customer_name"])); + expect(editor.navigate(1)).toBe(true); + expect(editor.highlightedIndex.value).toBe(1); + expect(editor.navigate(1)).toBe(true); + expect(editor.highlightedIndex.value).toBe(1); + expect(editor.accept()).toBe(true); + expect(value.value).toBe("status = customer_name"); + }); + + it("ignores stale asynchronous suggestion responses", async () => { + vi.useFakeTimers(); + const value = ref(""); + const resolvers = new Map void>(); + const editor = useDataGridConditionEditor({ + kind: "where", + value, + historyScope: {}, + suggestionProvider: ({ token }) => new Promise((resolve) => resolvers.set(token, resolve)), + suggestionDebounceMs: 10, + }); + + value.value = "cus"; + await nextTick(); + vi.advanceTimersByTime(10); + await nextTick(); + value.value = "ord"; + await nextTick(); + vi.advanceTimersByTime(10); + await nextTick(); + + resolvers.get("ord")?.(["order_id"]); + await Promise.resolve(); + expect(editor.suggestions.value.map((item) => item.value)).toEqual(["order_id"]); + resolvers.get("cus")?.(["customer_id"]); + await Promise.resolve(); + expect(editor.suggestions.value.map((item) => item.value)).toEqual(["order_id"]); + }); + + it("loads, filters, accepts, and deletes scoped history", () => { + const scope = { connectionId: "connection", database: "db", tableName: "users" }; + rememberDataGridConditionHistory("where", scope, "status = 'active'"); + rememberDataGridConditionHistory("where", scope, "customer_id > 10"); + const value = ref("status"); + const editor = useDataGridConditionEditor({ kind: "where", value, historyScope: scope }); + + editor.openHistory(); + expect(editor.suggestions.value.map((item) => item.value)).toEqual(["status = 'active'"]); + expect(editor.accept(0)).toBe(true); + expect(value.value).toBe("status = 'active'"); + + editor.openHistory(); + editor.deleteHistory("status = 'active'"); + expect(editor.suggestions.value).toEqual([]); + expect(editor.historyOpen.value).toBe(true); + }); + + it("aborts pending suggestion work when its scope is disposed", async () => { + vi.useFakeTimers(); + const value = ref(""); + const aborted = vi.fn(); + const scope = effectScope(); + scope.run(() => { + useDataGridConditionEditor({ + kind: "orderBy", + value, + historyScope: {}, + suggestionDebounceMs: 10, + suggestionProvider: ({ signal }) => { + signal.addEventListener("abort", aborted); + return new Promise(() => {}); + }, + }); + }); + + value.value = "created"; + await nextTick(); + vi.advanceTimersByTime(10); + scope.stop(); + expect(aborted).toHaveBeenCalledOnce(); + }); + + it("maps Enter, Tab, arrows, and Escape without applying stale selections", async () => { + const value = ref(""); + const editor = useDataGridConditionEditor({ kind: "orderBy", value, columns: ["name"], historyScope: {} }); + value.value = "na"; + await nextTick(); + await vi.waitFor(() => expect(editor.suggestions.value).toHaveLength(1)); + + const down = keyboardEvent("ArrowDown"); + expect(editor.handleKeydown(down)).toBe("navigate"); + expect(down.preventDefault).toHaveBeenCalledOnce(); + const tab = keyboardEvent("Tab"); + expect(editor.handleKeydown(tab)).toBe("accept"); + expect(value.value).toBe("name"); + + const enter = keyboardEvent("Enter"); + expect(editor.handleKeydown(enter)).toBe("apply"); + editor.openHistory(); + const escape = keyboardEvent("Escape"); + expect(editor.handleKeydown(escape)).toBe("dismiss"); + expect(editor.dropdownOpen.value).toBe(false); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridFilterBuilder.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridFilterBuilder.spec.ts new file mode 100644 index 0000000000..730e3bc019 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useDataGridFilterBuilder.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { buildDataGridStructuredWhere, useDataGridFilterBuilder, type DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder"; + +describe("useDataGridFilterBuilder", () => { + it("normalizes values when modes change", () => { + const builder = useDataGridFilterBuilder({ columns: ["id"], createId: () => "rule-1", isComplete: () => true, buildCondition: async () => "id = 1" }); + builder.ensureRule(); + builder.updateRule("rule-1", { rawValue: "1", rawEndValue: "2", mode: "is-null" }); + expect(builder.rules.value[0]).toMatchObject({ rawValue: "", rawEndValue: "" }); + }); + + it("skips disabled rules and applies conjunctions", async () => { + let nextId = 0; + const builder = useDataGridFilterBuilder({ + columns: ["id", "name"], + createId: () => `rule-${++nextId}`, + isComplete: (rule) => !!rule.rawValue, + buildCondition: async (rule) => `${rule.columnName} = '${rule.rawValue}'`, + }); + builder.ensureRule(); + builder.updateRule("rule-1", { rawValue: "1" }); + builder.addRule(); + builder.updateRule("rule-2", { columnName: "name", rawValue: "Alice", conjunction: "OR" }); + expect(await builder.apply()).toBe("(id = '1') OR (name = 'Alice')"); + }); + + it("groups conditions in rule order", () => { + const rule = (id: string, conjunction: "AND" | "OR"): DataGridStructuredFilterRule => ({ id, columnName: id, mode: "equals", rawValue: id, rawEndValue: "", conjunction }); + expect( + buildDataGridStructuredWhere([ + { rule: rule("a", "AND"), condition: "a" }, + { rule: rule("b", "AND"), condition: "b" }, + { rule: rule("c", "OR"), condition: "c" }, + ]), + ).toBe("((a) AND (b)) OR (c)"); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridResultLifecycle.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridResultLifecycle.spec.ts new file mode 100644 index 0000000000..896f8cfe51 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useDataGridResultLifecycle.spec.ts @@ -0,0 +1,28 @@ +import { nextTick, ref } from "vue"; +import { describe, expect, it } from "vitest"; +import { useDataGridResultLifecycle } from "@/composables/useDataGridResultLifecycle"; + +describe("useDataGridResultLifecycle", () => { + it("invalidates operations when the result identity changes", async () => { + const result = ref({ id: 1 }); + const lifecycle = useDataGridResultLifecycle({ resultKey: result }); + const operation = lifecycle.beginOperation(); + + result.value = { id: 2 }; + await nextTick(); + + expect(lifecycle.isCurrent(operation)).toBe(false); + lifecycle.cleanup(); + }); + + it("invalidates operations explicitly and cleanup is idempotent", () => { + const lifecycle = useDataGridResultLifecycle({ resultKey: ref({ id: 1 }) }); + const operation = lifecycle.beginOperation(); + + lifecycle.invalidate(); + lifecycle.cleanup(); + lifecycle.cleanup(); + + expect(lifecycle.isCurrent(operation)).toBe(false); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts new file mode 100644 index 0000000000..4340ca5f0a --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts @@ -0,0 +1,27 @@ +import { nextTick, ref } from "vue"; +import { describe, expect, it, vi } from "vitest"; +import { useDataGridSearch } from "@/composables/useDataGridSearch"; + +describe("useDataGridSearch", () => { + it("debounces matching across columns and cells", async () => { + vi.useFakeTimers(); + const search = useDataGridSearch({ columns: ["id", "name"], rows: [[1, "Alice"]], getCellText: (row, column) => String(row[column] ?? "") }); + search.searchText.value = "ali"; + await nextTick(); + expect(search.matches.value).toEqual([]); + vi.advanceTimersByTime(150); + await nextTick(); + expect(search.matches.value).toEqual([{ kind: "cell", displayRow: 0, col: 1 }]); + vi.useRealTimers(); + }); + + it("suggests columns and replaces only the active token", async () => { + const columns = ref(["customer_id", "created_at"]); + const search = useDataGridSearch({ columns, rows: [], getCellText: () => "" }); + search.searchText.value = "status = cus"; + await nextTick(); + expect(search.suggestions.value).toEqual(["customer_id"]); + expect(search.acceptSuggestion()).toBe(true); + expect(search.searchText.value).toBe("status = customer_id"); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridSort.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridSort.spec.ts new file mode 100644 index 0000000000..9d734b8ae4 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useDataGridSort.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { useDataGridSort } from "@/composables/useDataGridSort"; + +describe("useDataGridSort", () => { + it("stores the selected database or local sort state", () => { + const sort = useDataGridSort(); + + sort.setSort("name", 2, "desc", "local"); + + expect(sort.sortColumn.value).toBe("name"); + expect(sort.sortColumnIndex.value).toBe(2); + expect(sort.sortDirection.value).toBe("desc"); + expect(sort.sortMode.value).toBe("local"); + }); + + it("resets to the default unsorted state", () => { + const sort = useDataGridSort(); + sort.setSort("name", 2, "desc", "local"); + + sort.clearSort(); + + expect(sort.sortColumn.value).toBeNull(); + expect(sort.sortColumnIndex.value).toBeNull(); + expect(sort.sortDirection.value).toBe("asc"); + expect(sort.sortMode.value).toBe("database"); + }); +}); diff --git a/apps/desktop/src/composables/useDataGridAsyncSurface.ts b/apps/desktop/src/composables/useDataGridAsyncSurface.ts new file mode 100644 index 0000000000..1c392857b7 --- /dev/null +++ b/apps/desktop/src/composables/useDataGridAsyncSurface.ts @@ -0,0 +1,16 @@ +import { ref, toValue, watch, type MaybeRefOrGetter } from "vue"; + +export function useDataGridAsyncSurface(open: MaybeRefOrGetter) { + const mounted = ref(false); + + watch( + () => toValue(open), + (value) => { + // Preserve the existing dialog instance after its chunk loads so closing it does not reset local UI state. + if (value) mounted.value = true; + }, + { immediate: true }, + ); + + return mounted; +} diff --git a/apps/desktop/src/composables/useDataGridAutoRefresh.ts b/apps/desktop/src/composables/useDataGridAutoRefresh.ts new file mode 100644 index 0000000000..1e37b86653 --- /dev/null +++ b/apps/desktop/src/composables/useDataGridAutoRefresh.ts @@ -0,0 +1,51 @@ +import { onBeforeUnmount, ref, type ComputedRef } from "vue"; + +export interface UseDataGridAutoRefreshOptions { + canRefresh: ComputedRef; + refresh: () => void | Promise; + initialIntervalSeconds?: number; +} + +export function useDataGridAutoRefresh(options: UseDataGridAutoRefreshOptions) { + const intervalSeconds = ref(options.initialIntervalSeconds ?? 10); + const enabled = ref(false); + let timer: ReturnType | undefined; + + function stop() { + if (timer !== undefined) clearInterval(timer); + timer = undefined; + } + + function tick() { + if (!enabled.value || !options.canRefresh.value) return; + void options.refresh(); + } + + function start() { + stop(); + if (!enabled.value) return; + timer = setInterval(tick, intervalSeconds.value * 1000); + } + + function setIntervalSeconds(seconds: number) { + intervalSeconds.value = seconds; + if (enabled.value) start(); + } + + function toggle() { + enabled.value = !enabled.value; + if (enabled.value) start(); + else stop(); + } + + onBeforeUnmount(stop); + + return { + intervalSeconds, + enabled, + start, + stop, + setIntervalSeconds, + toggle, + }; +} diff --git a/apps/desktop/src/composables/useDataGridCanvasRuntime.ts b/apps/desktop/src/composables/useDataGridCanvasRuntime.ts new file mode 100644 index 0000000000..56619a311c --- /dev/null +++ b/apps/desktop/src/composables/useDataGridCanvasRuntime.ts @@ -0,0 +1,116 @@ +import { getCurrentScope, onScopeDispose } from "vue"; + +export interface DataGridAnimationFrameDriver { + request(callback: FrameRequestCallback): number; + cancel(frameId: number): void; +} + +export interface DataGridCanvasRuntimeOptions { + draw(): void; + syncViewport(): void; + getViewport(): Element | null; + refreshPixelRatio?: () => void; + frameDriver?: DataGridAnimationFrameDriver; + createResizeObserver?: (callback: ResizeObserverCallback) => ResizeObserver | null; + startPaused?: boolean; +} + +export interface DataGridCanvasRuntime { + readonly active: boolean; + readonly disposed: boolean; + scheduleDraw(): void; + drawNow(): void; + schedulePixelRatioRefresh(): void; + observeViewport(): void; + pause(): void; + resume(): void; + dispose(): void; +} + +function defaultFrameDriver(): DataGridAnimationFrameDriver { + return { + request: (callback) => requestAnimationFrame(callback), + cancel: (frameId) => cancelAnimationFrame(frameId), + }; +} + +function defaultResizeObserverFactory(callback: ResizeObserverCallback): ResizeObserver | null { + return typeof ResizeObserver === "undefined" ? null : new ResizeObserver(callback); +} + +export function useDataGridCanvasRuntime(options: DataGridCanvasRuntimeOptions): DataGridCanvasRuntime { + const frameDriver = options.frameDriver ?? defaultFrameDriver(); + const createResizeObserver = options.createResizeObserver ?? defaultResizeObserverFactory; + let active = !options.startPaused; + let disposed = false; + let drawFrame: number | null = null; + let pixelRatioFrame: number | null = null; + let resizeObserver: ResizeObserver | null = null; + + const cancelFrames = () => { + if (drawFrame !== null) frameDriver.cancel(drawFrame); + if (pixelRatioFrame !== null) frameDriver.cancel(pixelRatioFrame); + drawFrame = null; + pixelRatioFrame = null; + }; + + const runtime: DataGridCanvasRuntime = { + get active() { + return active; + }, + get disposed() { + return disposed; + }, + scheduleDraw() { + if (!active || disposed || drawFrame !== null) return; + drawFrame = frameDriver.request(() => { + drawFrame = null; + if (active && !disposed) options.draw(); + }); + }, + drawNow() { + if (!active || disposed) return; + if (drawFrame !== null) frameDriver.cancel(drawFrame); + drawFrame = null; + options.draw(); + }, + schedulePixelRatioRefresh() { + if (!active || disposed || pixelRatioFrame !== null || !options.refreshPixelRatio) return; + pixelRatioFrame = frameDriver.request(() => { + pixelRatioFrame = null; + if (active && !disposed) options.refreshPixelRatio?.(); + }); + }, + observeViewport() { + resizeObserver?.disconnect(); + resizeObserver = null; + if (!active || disposed) return; + options.syncViewport(); + const viewport = options.getViewport(); + if (!viewport) return; + resizeObserver = createResizeObserver(() => { + if (active && !disposed) options.syncViewport(); + }); + resizeObserver?.observe(viewport); + }, + pause() { + active = false; + resizeObserver?.disconnect(); + resizeObserver = null; + cancelFrames(); + }, + resume() { + if (disposed) return; + active = true; + runtime.observeViewport(); + }, + dispose() { + if (disposed) return; + runtime.pause(); + disposed = true; + }, + }; + + if (getCurrentScope()) onScopeDispose(runtime.dispose); + return runtime; +} diff --git a/apps/desktop/src/composables/useDataGridCellDetail.ts b/apps/desktop/src/composables/useDataGridCellDetail.ts new file mode 100644 index 0000000000..9638f8b4a8 --- /dev/null +++ b/apps/desktop/src/composables/useDataGridCellDetail.ts @@ -0,0 +1,67 @@ +import { nextTick, ref, watch, type Ref } from "vue"; +import { useCellDetailEditor, type UseCellDetailEditorReturn } from "@/composables/useCellDetailEditor"; +import { useTheme } from "@/composables/useTheme"; +import { useSettingsStore } from "@/stores/settingsStore"; +import { renderWktOnCanvas } from "@/lib/dataGrid/geometryPreview"; +import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail"; + +export function useDataGridCellDetail(options: { detail: Ref; editValue: Ref; onCancel: () => void }) { + const settingsStore = useSettingsStore(); + const { isDark, themePalette } = useTheme(); + const geometryPreviewOpen = ref(false); + const geometryCanvas = ref(null); + const detailsEditorContainer = ref(); + const sideJsonPreviewContainer = ref(); + let detailsEditor: UseCellDetailEditorReturn | null = null; + let sideJsonEditor: UseCellDetailEditorReturn | null = null; + + const editorOptions = () => ({ + editorTheme: () => settingsStore.editorSettings.theme, + appAppearance: () => (isDark.value ? "dark" : "light") as import("@/lib/app/appTheme").AppThemeAppearance, + appPalette: () => themePalette.value, + fontSize: () => settingsStore.editorSettings.fontSize, + fontFamily: () => settingsStore.editorSettings.fontFamily, + }); + + watch(geometryPreviewOpen, async (open) => { + if (!open) return; + await nextTick(); + const canvas = geometryCanvas.value; + const detail = options.detail.value; + if (canvas && detail.value !== null && detail.value !== undefined) renderWktOnCanvas(canvas, String(detail.value)); + }); + + watch(detailsEditorContainer, async (element) => { + if (element && !detailsEditor) { + detailsEditor = useCellDetailEditor({ onChange: (value) => (options.editValue.value = value), onEscape: options.onCancel, ...editorOptions() }); + await detailsEditor.create(element, options.editValue.value, options.detail.value.type); + } else if (!element && detailsEditor) { + detailsEditor.destroy(); + detailsEditor = null; + } + }); + + watch(sideJsonPreviewContainer, async (element) => { + if (element && !sideJsonEditor) { + sideJsonEditor = useCellDetailEditor({ language: "json", readOnly: true, ...editorOptions() }); + await sideJsonEditor.create(element, options.detail.value.formattedJson ?? "", "json"); + } else if (!element && sideJsonEditor) { + sideJsonEditor.destroy(); + sideJsonEditor = null; + } + }); + + watch( + () => options.detail.value.formattedJson ?? "", + (value) => sideJsonEditor?.setValue(value, "json"), + ); + watch(options.editValue, (value) => detailsEditor?.setValue(value, options.detail.value.type)); + + return { + geometryPreviewOpen, + geometryCanvas, + detailsEditorContainer, + sideJsonPreviewContainer, + openSearch: () => detailsEditor?.openSearch() ?? sideJsonEditor?.openSearch() ?? false, + }; +} diff --git a/apps/desktop/src/composables/useDataGridColumnLayout.ts b/apps/desktop/src/composables/useDataGridColumnLayout.ts new file mode 100644 index 0000000000..3dbcdc4a7e --- /dev/null +++ b/apps/desktop/src/composables/useDataGridColumnLayout.ts @@ -0,0 +1,512 @@ +import { computed, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue"; +import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex, orderedColumnIndexes } from "@/lib/dataGrid/dataGridColumnOrder"; +import { columnHeaderCanvasPointerDisabled, columnHeaderClickShouldBeSuppressed, columnHeaderPreviewOffsetForColumn, columnHeaderTooltipDisabled } from "@/lib/dataGrid/dataGridColumnHeaderInteraction"; +import { + loadDataGridColumnOrder, + loadTableDataGridColumnOrder, + notifyTableDataGridColumnOrderChanged, + removeDataGridColumnOrder, + removeTableDataGridColumnOrder, + saveDataGridColumnOrder, + saveTableDataGridColumnOrder, + type TableDataGridColumnOrderChangedDetail, +} from "@/lib/dataGrid/dataGridColumnLayoutStorage"; +import { filterColumnVisibilityOptions, hiddenColumnIndexesWithAllNullColumns, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGrid/dataGridColumnVisibility"; + +export type RenderedDataGridColumn = { + visibleColIdx: number; + actualColIdx: number; + name: string; +}; + +export type DataGridHorizontalColumnWindow = { + start: number; + end: number; + beforeWidth: number; + afterWidth: number; +}; + +type ColumnHeaderDragState = { + sourceVisibleIndex: number; + targetVisibleIndex: number; + startX: number; + startY: number; + currentX: number; + columnRects: { visibleIndex: number; left: number; width: number }[]; + dragging: boolean; +}; + +export function dataGridColumnOffsets(widths: readonly number[]): number[] { + const offsets = Array.from({ length: widths.length + 1 }, () => 0); + for (let index = 0; index < widths.length; index++) offsets[index + 1] = offsets[index] + (widths[index] ?? 0); + return offsets; +} + +export function dataGridHorizontalColumnWindow(options: { widths: readonly number[]; offsets: readonly number[]; columnCount: number; scrollLeft: number; viewportWidth: number; rowNumberWidth: number; bufferPx: number }): DataGridHorizontalColumnWindow { + const { widths, offsets, columnCount } = options; + if (columnCount === 0 || widths.length === 0) return { start: 0, end: 0, beforeWidth: 0, afterWidth: 0 }; + + const viewportStart = Math.max(0, options.scrollLeft - options.rowNumberWidth - options.bufferPx); + const viewportEnd = Math.max(options.viewportWidth, 1) + Math.max(0, options.scrollLeft - options.rowNumberWidth) + options.bufferPx; + let low = 0; + let high = columnCount - 1; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if ((offsets[mid + 1] ?? 0) < viewportStart) low = mid + 1; + else high = mid; + } + const start = low; + let end = start; + while (end < columnCount && (offsets[end] ?? 0) < viewportEnd) end++; + + const columnsWidth = offsets[columnCount] ?? 0; + const visibleWidth = offsets[end] ?? offsets[start] ?? 0; + return { start, end, beforeWidth: offsets[start] ?? 0, afterWidth: Math.max(0, columnsWidth - visibleWidth) }; +} + +export function useDataGridColumnLayoutState(options: { + columns: MaybeRefOrGetter; + sourceColumns?: MaybeRefOrGetter; + commentByColumn?: MaybeRefOrGetter>; + displayableColumnIndexes: MaybeRefOrGetter; + allNullColumnIndexes: MaybeRefOrGetter; + columnOrderKeys: MaybeRefOrGetter; + layoutScopeKey: MaybeRefOrGetter; + tableScopeKey: MaybeRefOrGetter; + onRefreshMetrics?: () => void; +}) { + const hiddenColumnIndexes = ref>(new Set()); + const nullColumnsHidden = ref(false); + const autoHiddenNullColumnIndexes = ref>(new Set()); + const persistedColumnOrderKeys = ref([]); + const orderedDisplayableColumnIndexes = computed(() => orderedColumnIndexes({ availableIndexes: toValue(options.displayableColumnIndexes), columnKeys: toValue(options.columnOrderKeys), orderedKeys: persistedColumnOrderKeys.value })); + const visibleColumnIndexes = computed(() => visibleColumnIndexesForFilter(orderedDisplayableColumnIndexes.value, hiddenColumnIndexes.value)); + const displayableColumnCount = computed(() => toValue(options.displayableColumnIndexes).length); + const hiddenColumnCount = computed(() => displayableColumnCount.value - visibleColumnIndexes.value.length); + const allNullColumnCount = computed(() => toValue(options.allNullColumnIndexes).length); + const hasCustomColumnOrder = computed(() => !isDefaultColumnOrder(toValue(options.displayableColumnIndexes), orderedDisplayableColumnIndexes.value)); + const canToggleAllNullColumns = computed(() => nullColumnsHidden.value || (toValue(options.allNullColumnIndexes).length > 0 && displayableColumnCount.value > 1)); + + function filteredColumnVisibilityOptions(query: string) { + const displayable = new Set(toValue(options.displayableColumnIndexes)); + return filterColumnVisibilityOptions(toValue(options.columns), query, { sourceColumns: toValue(options.sourceColumns), commentByColumn: toValue(options.commentByColumn) }).filter((option) => displayable.has(option.index)); + } + function isColumnVisible(columnIndex: number) { + return !hiddenColumnIndexes.value.has(columnIndex); + } + function toggleColumnVisibility(columnIndex: number) { + hiddenColumnIndexes.value = nextHiddenColumnIndexes({ columnIndex, hiddenIndexes: hiddenColumnIndexes.value, totalColumns: displayableColumnCount.value }); + } + function showAllColumns() { + hiddenColumnIndexes.value = new Set(); + } + function invertColumnVisibility() { + hiddenColumnIndexes.value = invertedHiddenColumnIndexes([...toValue(options.displayableColumnIndexes)], hiddenColumnIndexes.value); + } + function loadColumnOrder() { + const tableScopeKey = toValue(options.tableScopeKey); + const tableOrder = tableScopeKey ? loadTableDataGridColumnOrder(tableScopeKey) : []; + persistedColumnOrderKeys.value = tableOrder.length ? tableOrder : loadDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys)); + } + function persistColumnOrder(indexes: number[]) { + const tableScopeKey = toValue(options.tableScopeKey); + if (isDefaultColumnOrder(toValue(options.displayableColumnIndexes), indexes)) { + removeDataGridColumnOrder(toValue(options.layoutScopeKey)); + if (tableScopeKey) { + removeTableDataGridColumnOrder(tableScopeKey); + notifyTableDataGridColumnOrderChanged(tableScopeKey); + } + persistedColumnOrderKeys.value = []; + return; + } + const keys = columnOrderKeysForIndexes(indexes, toValue(options.columnOrderKeys)); + persistedColumnOrderKeys.value = keys; + saveDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys), keys); + if (tableScopeKey) { + saveTableDataGridColumnOrder(tableScopeKey, keys); + notifyTableDataGridColumnOrderChanged(tableScopeKey); + } + } + function resetColumnOrder() { + removeDataGridColumnOrder(toValue(options.layoutScopeKey)); + const tableScopeKey = toValue(options.tableScopeKey); + if (tableScopeKey) { + removeTableDataGridColumnOrder(tableScopeKey); + notifyTableDataGridColumnOrderChanged(tableScopeKey); + } + persistedColumnOrderKeys.value = []; + if (options.onRefreshMetrics) nextTick(options.onRefreshMetrics); + } + function showAllNullColumns() { + hiddenColumnIndexes.value = removeAutoHiddenColumnIndexes(hiddenColumnIndexes.value, autoHiddenNullColumnIndexes.value); + autoHiddenNullColumnIndexes.value = new Set(); + nullColumnsHidden.value = false; + } + function hideAllNullColumns() { + const next = hiddenColumnIndexesWithAllNullColumns({ availableIndexes: [...toValue(options.displayableColumnIndexes)], hiddenIndexes: hiddenColumnIndexes.value, allNullIndexes: new Set(toValue(options.allNullColumnIndexes)) }); + hiddenColumnIndexes.value = next.hiddenIndexes; + autoHiddenNullColumnIndexes.value = next.autoHiddenIndexes; + nullColumnsHidden.value = next.autoHiddenIndexes.size > 0; + } + function toggleAllNullColumns() { + if (nullColumnsHidden.value) showAllNullColumns(); + else hideAllNullColumns(); + } + function onTableDataGridColumnOrderChanged(event: Event) { + if (!(event instanceof CustomEvent)) return; + const detail = event.detail as TableDataGridColumnOrderChangedDetail | undefined; + if (!detail || detail.scopeKey !== toValue(options.tableScopeKey)) return; + loadColumnOrder(); + if (options.onRefreshMetrics) nextTick(options.onRefreshMetrics); + } + + function resetColumnVisibility() { + hiddenColumnIndexes.value = new Set(); + autoHiddenNullColumnIndexes.value = new Set(); + nullColumnsHidden.value = false; + } + + watch( + () => [...toValue(options.allNullColumnIndexes)], + () => { + if (!nullColumnsHidden.value) return; + hiddenColumnIndexes.value = removeAutoHiddenColumnIndexes(hiddenColumnIndexes.value, autoHiddenNullColumnIndexes.value); + autoHiddenNullColumnIndexes.value = new Set(); + hideAllNullColumns(); + }, + ); + watch([() => toValue(options.layoutScopeKey), () => toValue(options.tableScopeKey)], loadColumnOrder, { immediate: true }); + + return { + hiddenColumnIndexes, + nullColumnsHidden, + orderedDisplayableColumnIndexes, + visibleColumnIndexes, + displayableColumnCount, + hiddenColumnCount, + allNullColumnCount, + hasCustomColumnOrder, + canToggleAllNullColumns, + filteredColumnVisibilityOptions, + isColumnVisible, + toggleColumnVisibility, + showAllColumns, + invertColumnVisibility, + persistColumnOrder, + resetColumnOrder, + toggleAllNullColumns, + resetColumnVisibility, + onTableDataGridColumnOrderChanged, + }; +} + +export function useDataGridColumnLayout(options: { + columnNames: MaybeRefOrGetter; + visibleColumnIndexes: MaybeRefOrGetter; + renderedColumnWidths: MaybeRefOrGetter; + scrollLeft: MaybeRefOrGetter; + viewportWidth: MaybeRefOrGetter; + rowNumberWidth: number; + bufferPx?: number; + headerRef?: MaybeRefOrGetter; + orderedColumnIndexes?: MaybeRefOrGetter; + hiddenColumnIndexes?: MaybeRefOrGetter>; + getIsResizing?: () => boolean; + onResizeStart?: (visibleColIdx: number, event: MouseEvent) => void; + onCanvasMouseLeave?: () => void; + onCanvasDrawSchedule?: () => void; + onRefreshMetrics?: () => void; + onPersistColumnOrder?: (indexes: number[]) => void; +}) { + const renderedColumnOffsets = computed(() => dataGridColumnOffsets(toValue(options.renderedColumnWidths))); + const horizontalColumnWindow = computed(() => + dataGridHorizontalColumnWindow({ + widths: toValue(options.renderedColumnWidths), + offsets: renderedColumnOffsets.value, + columnCount: toValue(options.visibleColumnIndexes).length, + scrollLeft: toValue(options.scrollLeft), + viewportWidth: toValue(options.viewportWidth), + rowNumberWidth: options.rowNumberWidth, + bufferPx: options.bufferPx ?? 900, + }), + ); + const renderedGridColumns = computed(() => { + const columnNames = toValue(options.columnNames); + const visibleIndexes = toValue(options.visibleColumnIndexes); + const window = horizontalColumnWindow.value; + return visibleIndexes.slice(window.start, window.end).map((actualColIdx, offset) => ({ + visibleColIdx: window.start + offset, + actualColIdx, + name: columnNames[actualColIdx] ?? "", + })); + }); + + function renderedColumnStyle(visibleColIdx: number) { + return { width: `var(--col-w-${visibleColIdx})` }; + } + + function columnContentOffsetLeft(visibleColIdx: number): number { + return options.rowNumberWidth + (renderedColumnOffsets.value[visibleColIdx] ?? 0); + } + + const columnHeaderDragState = ref(null); + const columnHeaderResizeActive = ref(false); + let columnHeaderDragClickGuardUntil = 0; + let columnHeaderSuppressNextClick = false; + let columnHeaderSuppressClickTimer = 0; + let columnHeaderDragFrame = 0; + let columnHeaderResizeFrame = 0; + let columnHeaderPendingClientX = 0; + let columnHeaderResizeListenersCleanup: (() => void) | null = null; + + const columnHeaderTooltipsDisabled = computed(() => + columnHeaderTooltipDisabled({ + columnDragActive: columnHeaderDragState.value !== null, + columnResizeActive: columnHeaderResizeActive.value, + }), + ); + + function columnHeaderPointerInteractionActive(): boolean { + return columnHeaderCanvasPointerDisabled({ + columnDragActive: columnHeaderDragState.value !== null, + columnResizeActive: columnHeaderResizeActive.value, + }); + } + + function clearColumnHeaderResizeListeners() { + columnHeaderResizeListenersCleanup?.(); + columnHeaderResizeListenersCleanup = null; + } + + function clearColumnHeaderClickGuard() { + columnHeaderSuppressNextClick = false; + columnHeaderDragClickGuardUntil = 0; + if (columnHeaderSuppressClickTimer) { + window.clearTimeout(columnHeaderSuppressClickTimer); + columnHeaderSuppressClickTimer = 0; + } + } + + function armColumnHeaderClickGuard() { + clearColumnHeaderClickGuard(); + columnHeaderSuppressNextClick = true; + columnHeaderDragClickGuardUntil = Date.now() + 800; + columnHeaderSuppressClickTimer = window.setTimeout(clearColumnHeaderClickGuard, 800); + } + + function finishColumnHeaderResizeInteraction() { + clearColumnHeaderResizeListeners(); + if (columnHeaderResizeFrame) cancelAnimationFrame(columnHeaderResizeFrame); + columnHeaderResizeFrame = requestAnimationFrame(() => { + columnHeaderResizeFrame = 0; + columnHeaderResizeActive.value = false; + }); + } + + function startColumnHeaderResize(visibleColIdx: number, event: MouseEvent) { + clearColumnHeaderResizeListeners(); + if (columnHeaderResizeFrame) { + cancelAnimationFrame(columnHeaderResizeFrame); + columnHeaderResizeFrame = 0; + } + columnHeaderResizeActive.value = true; + armColumnHeaderClickGuard(); + options.onCanvasMouseLeave?.(); + const finishResize = () => { + armColumnHeaderClickGuard(); + finishColumnHeaderResizeInteraction(); + }; + columnHeaderResizeListenersCleanup = () => { + window.removeEventListener("mouseup", finishResize, true); + window.removeEventListener("blur", finishResize, true); + }; + window.addEventListener("mouseup", finishResize, true); + window.addEventListener("blur", finishResize, true); + options.onResizeStart?.(visibleColIdx, event); + } + + function columnHeaderInteractiveTarget(target: EventTarget | null): boolean { + return target instanceof HTMLElement && !!target.closest("button, input, textarea, select, [contenteditable='true'], [role='button'], [data-column-resize-handle]"); + } + + function columnHeaderDropTargetVisibleIndex(clientX: number): number { + const state = columnHeaderDragState.value; + if (!state || state.columnRects.length === 0) return state?.sourceVisibleIndex ?? 0; + for (const rect of state.columnRects) { + if (clientX < rect.left + rect.width / 2) return rect.visibleIndex; + } + return toValue(options.visibleColumnIndexes).length; + } + + function applyColumnHeaderDragPreview() { + columnHeaderDragFrame = 0; + const state = columnHeaderDragState.value; + if (!state?.dragging) return; + state.currentX = columnHeaderPendingClientX; + state.targetVisibleIndex = columnHeaderDropTargetVisibleIndex(columnHeaderPendingClientX); + options.onCanvasDrawSchedule?.(); + } + + function scheduleColumnHeaderDragPreview(clientX: number) { + columnHeaderPendingClientX = clientX; + if (columnHeaderDragFrame) return; + columnHeaderDragFrame = requestAnimationFrame(applyColumnHeaderDragPreview); + } + + function flushColumnHeaderDragPreview() { + if (columnHeaderDragFrame) cancelAnimationFrame(columnHeaderDragFrame); + applyColumnHeaderDragPreview(); + } + + function cancelColumnHeaderDragPreview() { + if (!columnHeaderDragFrame) return; + cancelAnimationFrame(columnHeaderDragFrame); + columnHeaderDragFrame = 0; + } + + function columnHeaderLayoutRects() { + const header = toValue(options.headerRef); + return Array.from(header?.querySelectorAll("[data-visible-col-index]") ?? []) + .map((element) => { + const rect = element.getBoundingClientRect(); + return { visibleIndex: Number(element.dataset.visibleColIndex), left: rect.left, width: rect.width }; + }) + .filter((rect) => Number.isFinite(rect.visibleIndex)); + } + + function stopColumnHeaderDrag(commit: boolean) { + const state = columnHeaderDragState.value; + if (!state) return; + const hadCanvasPreview = state.dragging; + window.removeEventListener("pointermove", onColumnHeaderPointerMove, true); + window.removeEventListener("pointerup", onColumnHeaderPointerUp, true); + window.removeEventListener("pointercancel", onColumnHeaderPointerCancel, true); + cancelColumnHeaderDragPreview(); + document.body.style.userSelect = ""; + columnHeaderDragState.value = null; + if (hadCanvasPreview) options.onCanvasDrawSchedule?.(); + if (state.dragging) armColumnHeaderClickGuard(); + if (!commit || !state.dragging || state.sourceVisibleIndex === state.targetVisibleIndex) return; + const next = moveVisibleColumnIndex({ + orderedIndexes: toValue(options.orderedColumnIndexes ?? options.visibleColumnIndexes), + hiddenIndexes: toValue(options.hiddenColumnIndexes ?? (() => new Set())), + fromVisibleIndex: state.sourceVisibleIndex, + toVisibleIndex: state.targetVisibleIndex, + }); + options.onPersistColumnOrder?.(next); + options.onRefreshMetrics?.(); + } + + function onColumnHeaderPointerMove(event: PointerEvent) { + const state = columnHeaderDragState.value; + if (!state) return; + const moved = Math.abs(event.clientX - state.startX) > 5 || Math.abs(event.clientY - state.startY) > 5; + if (!state.dragging && moved) { + state.dragging = true; + document.body.style.userSelect = "none"; + options.onCanvasMouseLeave?.(); + } + if (!state.dragging) return; + event.preventDefault(); + scheduleColumnHeaderDragPreview(event.clientX); + } + + function onColumnHeaderPointerUp(event: PointerEvent) { + columnHeaderPendingClientX = event.clientX; + flushColumnHeaderDragPreview(); + stopColumnHeaderDrag(true); + } + + function onColumnHeaderPointerCancel() { + stopColumnHeaderDrag(false); + } + + function startColumnHeaderDrag(visibleColIdx: number, event: PointerEvent) { + if (event.button !== 0 || options.getIsResizing?.() || columnHeaderInteractiveTarget(event.target)) return; + columnHeaderDragState.value = { + sourceVisibleIndex: visibleColIdx, + targetVisibleIndex: visibleColIdx, + startX: event.clientX, + startY: event.clientY, + currentX: event.clientX, + columnRects: columnHeaderLayoutRects(), + dragging: false, + }; + columnHeaderPendingClientX = event.clientX; + window.addEventListener("pointermove", onColumnHeaderPointerMove, true); + window.addEventListener("pointerup", onColumnHeaderPointerUp, true); + window.addEventListener("pointercancel", onColumnHeaderPointerCancel, true); + } + + function suppressHeaderClickIfNeeded(event: MouseEvent): boolean { + if (!columnHeaderClickShouldBeSuppressed({ now: Date.now(), guardUntil: columnHeaderDragClickGuardUntil, suppressNextClick: columnHeaderSuppressNextClick })) return false; + clearColumnHeaderClickGuard(); + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + return true; + } + + function columnHeaderDragClass(visibleColIdx: number) { + const state = columnHeaderDragState.value; + return { "z-30 shadow-lg ring-1 ring-primary/40 bg-background dark:bg-muted pointer-events-none": state?.dragging && state.sourceVisibleIndex === visibleColIdx }; + } + + function columnHeaderPreviewOffset(visibleColIdx: number): number { + const state = columnHeaderDragState.value; + if (!state) return 0; + return columnHeaderPreviewOffsetForColumn({ + columnDragActive: state.dragging, + visibleColIdx, + sourceVisibleIndex: state.sourceVisibleIndex, + targetVisibleIndex: state.targetVisibleIndex, + startX: state.startX, + currentX: state.currentX, + sourceWidth: toValue(options.renderedColumnWidths)[state.sourceVisibleIndex] ?? 0, + }); + } + + function columnHeaderStyle(visibleColIdx: number) { + const style = renderedColumnStyle(visibleColIdx); + const offset = columnHeaderPreviewOffset(visibleColIdx); + if (!offset) return style; + return { ...style, transform: `translateX(${offset}px)`, transition: columnHeaderDragState.value?.sourceVisibleIndex === visibleColIdx ? undefined : "transform 120ms ease-out" }; + } + + const columnHeaderPreviewOffsets = computed(() => toValue(options.renderedColumnWidths).map((_, visibleColIdx) => columnHeaderPreviewOffset(visibleColIdx))); + const columnHeaderPreviewSourceVisibleIndex = computed(() => { + const state = columnHeaderDragState.value; + return state?.dragging ? state.sourceVisibleIndex : null; + }); + + function disposeColumnHeaderInteractions() { + stopColumnHeaderDrag(false); + clearColumnHeaderResizeListeners(); + clearColumnHeaderClickGuard(); + if (columnHeaderDragFrame) cancelAnimationFrame(columnHeaderDragFrame); + if (columnHeaderResizeFrame) cancelAnimationFrame(columnHeaderResizeFrame); + columnHeaderResizeFrame = 0; + document.body.style.userSelect = ""; + } + onScopeDispose(disposeColumnHeaderInteractions); + + return { + renderedColumnOffsets, + horizontalColumnWindow, + renderedGridColumns, + renderedColumnStyle, + columnContentOffsetLeft, + columnHeaderDragState, + columnHeaderResizeActive, + columnHeaderTooltipsDisabled, + columnHeaderPreviewOffsets, + columnHeaderPreviewSourceVisibleIndex, + columnHeaderPointerInteractionActive, + startColumnHeaderResize, + startColumnHeaderDrag, + suppressHeaderClickIfNeeded, + columnHeaderDragClass, + columnHeaderStyle, + disposeColumnHeaderInteractions, + }; +} diff --git a/apps/desktop/src/composables/useDataGridConditionEditor.ts b/apps/desktop/src/composables/useDataGridConditionEditor.ts new file mode 100644 index 0000000000..837e5ddb14 --- /dev/null +++ b/apps/desktop/src/composables/useDataGridConditionEditor.ts @@ -0,0 +1,213 @@ +import { computed, getCurrentScope, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from "vue"; +import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberDataGridConditionHistory, type DataGridConditionHistoryKind, type DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory"; + +export type DataGridConditionSuggestionKind = "column" | "history"; + +export interface DataGridConditionSuggestion { + value: string; + kind: DataGridConditionSuggestionKind; +} + +export interface DataGridConditionSuggestionContext { + kind: DataGridConditionHistoryKind; + value: string; + token: string; + signal: AbortSignal; +} + +export type DataGridConditionSuggestionProvider = (context: DataGridConditionSuggestionContext) => readonly string[] | Promise; + +export interface UseDataGridConditionEditorOptions { + kind: DataGridConditionHistoryKind; + value: Ref; + columns?: MaybeRefOrGetter; + historyScope: MaybeRefOrGetter; + suggestionProvider?: DataGridConditionSuggestionProvider; + suggestionDebounceMs?: number; + suggestionLimit?: number; +} + +const WHERE_TOKEN_PATTERN = /([^\s,()><=!&|]+)$/; +const ORDER_BY_TOKEN_PATTERN = /([^\s,()]+)$/; + +function activeToken(kind: DataGridConditionHistoryKind, value: string): string { + return ( + value + .trim() + .split(kind === "where" ? /[\s,()><=!&|]+/ : /[\s,()]+/) + .pop() ?? "" + ); +} + +function replaceActiveToken(kind: DataGridConditionHistoryKind, value: string, replacement: string): string { + const match = value.match(kind === "where" ? WHERE_TOKEN_PATTERN : ORDER_BY_TOKEN_PATTERN); + if (!match) return value; + return `${value.slice(0, -match[1].length)}${replacement}`; +} + +export function useDataGridConditionEditor(options: UseDataGridConditionEditorOptions) { + const suggestions = ref([]); + const highlightedIndex = ref(-1); + const historyOpen = ref(false); + const suggestionsLoading = ref(false); + let suggestionTimer: ReturnType | undefined; + let suggestionRequestId = 0; + let suggestionAbortController: AbortController | undefined; + let suppressNextValueSuggestion = false; + + const dropdownOpen = computed(() => suggestions.value.length > 0 || historyOpen.value); + + function cancelSuggestionRequest() { + if (suggestionTimer) clearTimeout(suggestionTimer); + suggestionTimer = undefined; + suggestionRequestId += 1; + suggestionAbortController?.abort(); + suggestionAbortController = undefined; + suggestionsLoading.value = false; + } + + function dismiss() { + cancelSuggestionRequest(); + suggestions.value = []; + highlightedIndex.value = -1; + historyOpen.value = false; + } + + function defaultSuggestions(token: string): readonly string[] { + const normalizedToken = token.toLowerCase(); + if (!normalizedToken) return []; + return (toValue(options.columns) ?? []).filter((column) => column.toLowerCase().startsWith(normalizedToken) && column.toLowerCase() !== normalizedToken); + } + + async function loadSuggestions(value: string, requestId: number, controller: AbortController) { + const token = activeToken(options.kind, value); + if (!token) return; + suggestionsLoading.value = true; + try { + const values = options.suggestionProvider ? await options.suggestionProvider({ kind: options.kind, value, token, signal: controller.signal }) : defaultSuggestions(token); + // A slower request must never replace suggestions for a newer editor value. + if (controller.signal.aborted || requestId !== suggestionRequestId || options.value.value !== value || historyOpen.value) return; + const limit = options.suggestionLimit ?? 8; + suggestions.value = [...new Set(values)].slice(0, limit).map((suggestion) => ({ value: suggestion, kind: "column" })); + highlightedIndex.value = suggestions.value.length > 0 ? 0 : -1; + } catch (error) { + if (!controller.signal.aborted && requestId === suggestionRequestId) { + suggestions.value = []; + highlightedIndex.value = -1; + console.warn("[DBX][condition-editor] Failed to load suggestions", error); + } + } finally { + if (requestId === suggestionRequestId) suggestionsLoading.value = false; + } + } + + function scheduleSuggestions(value: string) { + cancelSuggestionRequest(); + suggestions.value = []; + highlightedIndex.value = -1; + historyOpen.value = false; + if (!value.trim()) return; + + const requestId = suggestionRequestId; + const controller = new AbortController(); + suggestionAbortController = controller; + suggestionTimer = setTimeout(() => { + suggestionTimer = undefined; + void loadSuggestions(value, requestId, controller); + }, options.suggestionDebounceMs ?? 0); + } + + function openHistory() { + cancelSuggestionRequest(); + if (dropdownOpen.value) { + dismiss(); + return; + } + historyOpen.value = true; + suggestions.value = loadDataGridConditionHistory(options.kind, toValue(options.historyScope), options.value.value).map((value) => ({ value, kind: "history" })); + highlightedIndex.value = -1; + } + + function deleteHistory(value: string) { + const history = forgetDataGridConditionHistory(options.kind, toValue(options.historyScope), value); + const query = options.value.value.trim().toLowerCase(); + suggestions.value = history.filter((item) => !query || item.toLowerCase().includes(query)).map((item) => ({ value: item, kind: "history" })); + highlightedIndex.value = suggestions.value.length > 0 ? Math.min(Math.max(highlightedIndex.value, 0), suggestions.value.length - 1) : -1; + historyOpen.value = true; + } + + function rememberHistory(value = options.value.value) { + return rememberDataGridConditionHistory(options.kind, toValue(options.historyScope), value); + } + + function navigate(delta: number) { + if (suggestions.value.length === 0) return false; + if (highlightedIndex.value < 0) { + highlightedIndex.value = delta > 0 ? 0 : suggestions.value.length - 1; + } else { + highlightedIndex.value = Math.min(Math.max(highlightedIndex.value + delta, 0), suggestions.value.length - 1); + } + return true; + } + + function accept(index = highlightedIndex.value) { + const suggestion = suggestions.value[index]; + if (!suggestion) return false; + suppressNextValueSuggestion = true; + options.value.value = suggestion.kind === "history" ? suggestion.value : replaceActiveToken(options.kind, options.value.value, suggestion.value); + dismiss(); + return true; + } + + function handleKeydown(event: KeyboardEvent): "accept" | "apply" | "dismiss" | "navigate" | undefined { + if (dropdownOpen.value && event.key === "Escape") { + event.preventDefault(); + dismiss(); + return "dismiss"; + } + if (suggestions.value.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) { + event.preventDefault(); + navigate(event.key === "ArrowDown" ? 1 : -1); + return "navigate"; + } + if (suggestions.value.length > 0 && event.key === "Tab") { + event.preventDefault(); + accept(); + return "accept"; + } + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + if (suggestions.value.length > 0 && highlightedIndex.value >= 0) { + accept(); + return "accept"; + } + return "apply"; + } + return undefined; + } + + watch(options.value, (value) => { + if (suppressNextValueSuggestion) { + suppressNextValueSuggestion = false; + return; + } + scheduleSuggestions(value); + }); + if (getCurrentScope()) onScopeDispose(cancelSuggestionRequest); + + return { + suggestions, + highlightedIndex, + historyOpen, + suggestionsLoading, + dropdownOpen, + scheduleSuggestions, + openHistory, + deleteHistory, + rememberHistory, + dismiss, + navigate, + accept, + handleKeydown, + }; +} diff --git a/apps/desktop/src/composables/useDataGridFilterBuilder.ts b/apps/desktop/src/composables/useDataGridFilterBuilder.ts new file mode 100644 index 0000000000..5f42782264 --- /dev/null +++ b/apps/desktop/src/composables/useDataGridFilterBuilder.ts @@ -0,0 +1,93 @@ +import { computed, ref, toValue, watch, type MaybeRefOrGetter } from "vue"; +import { filterModeNeedsValue, filterModeUsesRange } from "@/lib/dataGrid/dataGridColumnFilter"; +import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql"; + +export type DataGridStructuredFilterRule = { + id: string; + columnName: string; + mode: DataGridContextFilterMode; + rawValue: string; + rawEndValue: string; + conjunction: "AND" | "OR"; + disabled?: boolean; +}; + +export type UseDataGridFilterBuilderOptions = { + columns: MaybeRefOrGetter; + createId?: () => string; + isComplete: (rule: DataGridStructuredFilterRule) => boolean; + buildCondition: (rule: DataGridStructuredFilterRule) => Promise; +}; + +export function buildDataGridStructuredWhere(items: Array<{ rule: DataGridStructuredFilterRule; condition: string }>): string { + if (!items.length) return ""; + let result = items[0].condition; + for (let index = 1; index < items.length; index++) { + result = `(${result}) ${items[index].rule.conjunction} (${items[index].condition})`; + } + return result; +} + +export function useDataGridFilterBuilder(options: UseDataGridFilterBuilderOptions) { + const rules = ref([]); + const open = ref(false); + const columnSearch = ref(""); + const appliedWhereInput = ref(""); + const filteredColumns = computed(() => { + const query = columnSearch.value.trim().toLowerCase(); + return query ? toValue(options.columns).filter((column) => column.toLowerCase().includes(query)) : [...toValue(options.columns)]; + }); + const activeCount = computed(() => rules.value.filter((rule) => !rule.disabled && rule.columnName && options.isComplete(rule)).length); + + function defaultRule(): DataGridStructuredFilterRule { + return { id: options.createId?.() ?? crypto.randomUUID(), columnName: toValue(options.columns)[0] ?? "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }; + } + function ensureRule() { + if (!rules.value.length && toValue(options.columns).length) rules.value = [defaultRule()]; + } + function addRule() { + ensureRule(); + rules.value = [...rules.value, defaultRule()]; + } + function removeRule(id: string) { + rules.value = rules.value.filter((rule) => rule.id !== id); + if (!rules.value.length) appliedWhereInput.value = ""; + } + function updateRule(id: string, patch: Partial) { + rules.value = rules.value.map((rule) => { + if (rule.id !== id) return rule; + const next = { ...rule, ...patch }; + if (!filterModeNeedsValue(next.mode)) next.rawValue = next.rawEndValue = ""; + else if (!filterModeUsesRange(next.mode)) next.rawEndValue = ""; + return next; + }); + } + function reset() { + appliedWhereInput.value = ""; + rules.value = toValue(options.columns).length ? [defaultRule()] : []; + } + async function buildWhere() { + const items = (await Promise.all(rules.value.map(async (rule) => ({ rule, condition: !rule.disabled && rule.columnName && options.isComplete(rule) ? await options.buildCondition(rule) : undefined })))).filter( + (item): item is { rule: DataGridStructuredFilterRule; condition: string } => !!item.condition, + ); + return buildDataGridStructuredWhere(items); + } + async function apply() { + appliedWhereInput.value = await buildWhere(); + open.value = false; + return appliedWhereInput.value; + } + + watch(open, (value) => { + if (!value) columnSearch.value = ""; + }); + watch( + () => [...toValue(options.columns)], + (columns) => { + if (!columns.length) rules.value = []; + else rules.value = rules.value.map((rule) => (columns.includes(rule.columnName) ? rule : { ...rule, columnName: columns[0] })); + }, + ); + + return { rules, open, columnSearch, appliedWhereInput, filteredColumns, activeCount, defaultRule, ensureRule, addRule, removeRule, updateRule, reset, buildWhere, apply }; +} diff --git a/apps/desktop/src/composables/useDataGridResultLifecycle.ts b/apps/desktop/src/composables/useDataGridResultLifecycle.ts new file mode 100644 index 0000000000..f577a5bf10 --- /dev/null +++ b/apps/desktop/src/composables/useDataGridResultLifecycle.ts @@ -0,0 +1,38 @@ +import { onUnmounted, watch, type Ref } from "vue"; +import type { DataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime"; + +export interface UseDataGridResultLifecycleOptions { + resultKey: Ref; + runtimeScope?: DataGridRuntimeScope; +} + +export function useDataGridResultLifecycle(options: UseDataGridResultLifecycleOptions) { + let version = 0; + let disposed = false; + + const invalidate = () => { + version += 1; + }; + + const stopWatchingResult = watch(options.resultKey, invalidate); + const cleanup = () => { + if (disposed) return; + disposed = true; + invalidate(); + stopWatchingResult(); + }; + + if (options.runtimeScope) options.runtimeScope.addCleanup(cleanup); + else onUnmounted(cleanup); + + return { + beginOperation() { + return version; + }, + isCurrent(operationVersion: number) { + return !disposed && operationVersion === version; + }, + invalidate, + cleanup, + }; +} diff --git a/apps/desktop/src/composables/useDataGridScrollbars.ts b/apps/desktop/src/composables/useDataGridScrollbars.ts new file mode 100644 index 0000000000..10e945697b --- /dev/null +++ b/apps/desktop/src/composables/useDataGridScrollbars.ts @@ -0,0 +1,126 @@ +import { getCurrentScope, onScopeDispose } from "vue"; +import type { DataGridAnimationFrameDriver } from "./useDataGridCanvasRuntime"; + +export interface DataGridScrollbarsOptions { + update(): void; + getScroller(): Element | null; + applyHorizontalDrag(clientX: number): void; + applyVerticalDrag(clientY: number): void; + frameDriver: DataGridAnimationFrameDriver; + createResizeObserver?: (callback: ResizeObserverCallback) => ResizeObserver | null; + startPaused?: boolean; +} + +export interface DataGridScrollbarsRuntime { + readonly active: boolean; + readonly disposed: boolean; + scheduleUpdate(): void; + scheduleHorizontalDrag(clientX: number): void; + scheduleVerticalDrag(clientY: number): void; + flushHorizontalDrag(): void; + flushVerticalDrag(): void; + observeScroller(): void; + pause(): void; + resume(): void; + dispose(): void; +} + +function defaultResizeObserverFactory(callback: ResizeObserverCallback): ResizeObserver | null { + return typeof ResizeObserver === "undefined" ? null : new ResizeObserver(callback); +} + +export function useDataGridScrollbars(options: DataGridScrollbarsOptions): DataGridScrollbarsRuntime { + const createResizeObserver = options.createResizeObserver ?? defaultResizeObserverFactory; + let active = !options.startPaused; + let disposed = false; + let updateFrame: number | null = null; + let horizontalFrame: number | null = null; + let verticalFrame: number | null = null; + let pendingClientX = 0; + let pendingClientY = 0; + let resizeObserver: ResizeObserver | null = null; + + const cancelFrame = (frame: number | null) => { + if (frame !== null) options.frameDriver.cancel(frame); + }; + const runtime: DataGridScrollbarsRuntime = { + get active() { + return active; + }, + get disposed() { + return disposed; + }, + scheduleUpdate() { + if (!active || disposed || updateFrame !== null) return; + updateFrame = options.frameDriver.request(() => { + updateFrame = null; + if (active && !disposed) options.update(); + }); + }, + scheduleHorizontalDrag(clientX) { + pendingClientX = clientX; + if (!active || disposed || horizontalFrame !== null) return; + horizontalFrame = options.frameDriver.request(() => { + horizontalFrame = null; + if (active && !disposed) options.applyHorizontalDrag(pendingClientX); + }); + }, + scheduleVerticalDrag(clientY) { + pendingClientY = clientY; + if (!active || disposed || verticalFrame !== null) return; + verticalFrame = options.frameDriver.request(() => { + verticalFrame = null; + if (active && !disposed) options.applyVerticalDrag(pendingClientY); + }); + }, + flushHorizontalDrag() { + if (!active || disposed || horizontalFrame === null) return; + options.frameDriver.cancel(horizontalFrame); + horizontalFrame = null; + options.applyHorizontalDrag(pendingClientX); + }, + flushVerticalDrag() { + if (!active || disposed || verticalFrame === null) return; + options.frameDriver.cancel(verticalFrame); + verticalFrame = null; + options.applyVerticalDrag(pendingClientY); + }, + observeScroller() { + resizeObserver?.disconnect(); + resizeObserver = null; + if (!active || disposed) return; + const scroller = options.getScroller(); + if (scroller) { + resizeObserver = createResizeObserver(runtime.scheduleUpdate); + resizeObserver?.observe(scroller); + // Child size changes can alter scrollWidth without resizing the scroller itself. + for (const child of Array.from(scroller.children)) resizeObserver?.observe(child); + } + runtime.scheduleUpdate(); + }, + pause() { + active = false; + resizeObserver?.disconnect(); + resizeObserver = null; + cancelFrame(updateFrame); + cancelFrame(horizontalFrame); + cancelFrame(verticalFrame); + updateFrame = null; + horizontalFrame = null; + verticalFrame = null; + }, + resume() { + if (disposed) return; + active = true; + runtime.observeScroller(); + }, + dispose() { + if (disposed) return; + runtime.pause(); + disposed = true; + }, + }; + + if (getCurrentScope()) onScopeDispose(runtime.dispose); + return runtime; +} diff --git a/apps/desktop/src/composables/useDataGridSearch.ts b/apps/desktop/src/composables/useDataGridSearch.ts new file mode 100644 index 0000000000..9fc8480fa7 --- /dev/null +++ b/apps/desktop/src/composables/useDataGridSearch.ts @@ -0,0 +1,131 @@ +import { computed, getCurrentScope, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue"; + +export type DataGridSearchMatch = { + kind: "cell" | "column"; + displayRow: number; + col: number; +}; + +export type UseDataGridSearchOptions = { + columns: MaybeRefOrGetter; + suggestionColumns?: MaybeRefOrGetter; + rows: MaybeRefOrGetter; + getCellText: (row: Row, columnIndex: number) => string; + debounceMs?: number; + onNavigate?: (match: DataGridSearchMatch) => void; +}; + +const SEARCH_TOKEN_SEPARATOR = /[\s,()><=!&|]+/; +const SEARCH_TOKEN_SUFFIX = /([^\s,()><=!&|]+)$/; +const SEARCH_PAIRS: Record = { "'": "'", '"': '"', "(": ")" }; + +export function useDataGridSearch(options: UseDataGridSearchOptions) { + const searchText = ref(""); + const deferredSearchText = ref(""); + const overlayVisible = ref(false); + const currentMatchIndex = ref(-1); + const suggestions = ref([]); + const suggestionIndex = ref(-1); + let searchTimer: ReturnType | undefined; + + const matches = computed(() => { + const query = deferredSearchText.value; + if (!query) return []; + const result: DataGridSearchMatch[] = []; + const columns = toValue(options.columns); + columns.forEach((column, col) => { + if (column.toLowerCase().includes(query)) result.push({ kind: "column", displayRow: -1, col }); + }); + toValue(options.rows).forEach((row, displayRow) => { + columns.forEach((_, col) => { + if (options.getCellText(row, col).toLowerCase().includes(query)) result.push({ kind: "cell", displayRow, col }); + }); + }); + return result; + }); + const matchSet = computed(() => new Set(matches.value.map((match) => `${match.kind}:${match.displayRow}:${match.col}`))); + const currentMatch = computed(() => matches.value[currentMatchIndex.value] ?? null); + + function clearTimer() { + if (searchTimer !== undefined) clearTimeout(searchTimer); + searchTimer = undefined; + } + + watch(searchText, (value) => { + clearTimer(); + const query = value.trim().toLowerCase(); + if (!query) deferredSearchText.value = ""; + else searchTimer = setTimeout(() => (deferredSearchText.value = query), options.debounceMs ?? 150); + + const lastToken = value.trim().split(SEARCH_TOKEN_SEPARATOR).pop()?.toLowerCase() ?? ""; + suggestions.value = lastToken + ? toValue(options.suggestionColumns ?? options.columns) + .filter((column) => column.toLowerCase().startsWith(lastToken) && column.toLowerCase() !== lastToken) + .slice(0, 8) + : []; + suggestionIndex.value = suggestions.value.length ? 0 : -1; + }); + + watch(matches, (value) => { + currentMatchIndex.value = value.length ? 0 : -1; + if (value[0]) nextTick(() => options.onNavigate?.(value[0])); + }); + + function acceptSuggestion(index = suggestionIndex.value) { + const suggestion = suggestions.value[index]; + if (!suggestion) return false; + const token = searchText.value.match(SEARCH_TOKEN_SUFFIX)?.[1]; + if (token) searchText.value = searchText.value.slice(0, -token.length) + suggestion; + suggestions.value = []; + suggestionIndex.value = -1; + return true; + } + + function navigateSuggestion(delta: number) { + if (!suggestions.value.length) return; + suggestionIndex.value = Math.min(Math.max(suggestionIndex.value + delta, 0), suggestions.value.length - 1); + } + + function navigateMatch(delta: number) { + if (!matches.value.length) return; + currentMatchIndex.value = (currentMatchIndex.value + delta + matches.value.length) % matches.value.length; + const match = currentMatch.value; + if (match) options.onNavigate?.(match); + } + + function close() { + clearTimer(); + overlayVisible.value = false; + searchText.value = ""; + deferredSearchText.value = ""; + suggestions.value = []; + } + + function onKeydown(event: KeyboardEvent) { + const pair = SEARCH_PAIRS[event.key]; + const input = event.target as HTMLInputElement; + if (pair && !event.ctrlKey && !event.metaKey && input?.setSelectionRange) { + const start = input.selectionStart ?? 0; + const end = input.selectionEnd ?? start; + event.preventDefault(); + const selected = searchText.value.slice(start, end); + searchText.value = `${searchText.value.slice(0, start)}${event.key}${selected}${pair}${searchText.value.slice(end)}`; + nextTick(() => input.setSelectionRange(start + 1 + selected.length, start + 1 + selected.length)); + return; + } + if (suggestions.value.length && event.key === "Tab") { + event.preventDefault(); + acceptSuggestion(); + } else if (suggestions.value.length && (event.key === "ArrowDown" || event.key === "ArrowUp")) { + event.preventDefault(); + navigateSuggestion(event.key === "ArrowDown" ? 1 : -1); + } else if (event.key === "Enter") { + event.preventDefault(); + navigateMatch(event.shiftKey ? -1 : 1); + } + } + + if (getCurrentScope()) onScopeDispose(clearTimer); + + return { searchText, deferredSearchText, overlayVisible, currentMatchIndex, suggestions, suggestionIndex, matches, matchSet, currentMatch, acceptSuggestion, navigateSuggestion, navigateMatch, close, onKeydown }; +} diff --git a/apps/desktop/src/composables/useDataGridSelection.ts b/apps/desktop/src/composables/useDataGridSelection.ts index af12e3afb8..5d53710535 100644 --- a/apps/desktop/src/composables/useDataGridSelection.ts +++ b/apps/desktop/src/composables/useDataGridSelection.ts @@ -1,5 +1,6 @@ import { ref, computed, getCurrentScope, onScopeDispose, type ComputedRef, type Ref } from "vue"; import { allCellsSelectionRange, extractColumnsSelection, extractSelection, isCellInSelection, normalizeSelectionRange, normalizeSelectedColumnIndexes, rowSelectionRange, type CellPosition, type CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection"; +import type { DataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime"; type CellValue = string | number | boolean | null; @@ -24,6 +25,7 @@ export interface UseDataGridSelectionOptions { gridRef: Ref; getScrollElement?: () => HTMLElement | null; cellFromClientPoint?: (clientX: number, clientY: number) => CellPosition | null; + runtimeScope?: DataGridRuntimeScope; } const AUTO_SCROLL_EDGE_SIZE = 40; @@ -341,7 +343,8 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { document.addEventListener("mousemove", handleSelectionPointerMove); } - if (getCurrentScope()) onScopeDispose(finishCellSelection); + if (options.runtimeScope) options.runtimeScope.addCleanup(finishCellSelection); + else if (getCurrentScope()) onScopeDispose(finishCellSelection); function extendCellSelection(rowIndex: number, colIndex: number) { if (!isSelectingCells.value || !selectionAnchor.value) return; diff --git a/apps/desktop/src/composables/useDataGridSort.ts b/apps/desktop/src/composables/useDataGridSort.ts new file mode 100644 index 0000000000..b937377d48 --- /dev/null +++ b/apps/desktop/src/composables/useDataGridSort.ts @@ -0,0 +1,29 @@ +import { ref } from "vue"; +import type { DataGridSortDirection, DataGridSortMode } from "@/lib/dataGrid/dataGridSort"; + +export function useDataGridSort() { + const sortColumn = ref(null); + const sortColumnIndex = ref(null); + const sortDirection = ref("asc"); + const sortMode = ref("database"); + + function setSort(column: string | null, columnIndex: number | null, direction: DataGridSortDirection, mode: DataGridSortMode) { + sortColumn.value = column; + sortColumnIndex.value = columnIndex; + sortDirection.value = direction; + sortMode.value = mode; + } + + function clearSort() { + setSort(null, null, "asc", "database"); + } + + return { + sortColumn, + sortColumnIndex, + sortDirection, + sortMode, + setSort, + clearSort, + }; +} diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridClipboard.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridClipboard.spec.ts index 5746ad07dd..a0f4fe01df 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridClipboard.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridClipboard.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { claimDataGridPaste } from "@/lib/dataGrid/dataGridClipboard"; +import { claimDataGridPaste, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard"; function target(nativeClipboard: boolean): EventTarget { return { @@ -49,3 +49,26 @@ describe("claimDataGridPaste", () => { expect(event.stopPropagation).toHaveBeenCalledOnce(); }); }); + +describe("planDataGridPaste", () => { + it("flattens a clipboard matrix within grid bounds", () => { + expect( + planDataGridPaste( + [ + ["a", "b"], + ["c", "d"], + ], + 2, + 1, + ), + ).toEqual([ + { rowOffset: 0, columnOffset: 0, value: "a" }, + { rowOffset: 1, columnOffset: 0, value: "c" }, + ]); + }); + + it("returns no cells for empty bounds", () => { + expect(planDataGridPaste([["a"]], 0, 1)).toEqual([]); + expect(planDataGridPaste([["a"]], 1, 0)).toEqual([]); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenu.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenu.spec.ts new file mode 100644 index 0000000000..d04dfa453b --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenu.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; +import { createDataGridCellContextMenuItems, createDataGridColumnContextMenuItems, createDataGridCompactColumnActionItems, createDataGridFilterSubmenu, createDataGridRowContextMenuItems, createDataGridSortMenuItems, dataGridSelectedSortMenuValue } from "@/lib/dataGrid/dataGridContextMenu"; + +const icon = {}; + +describe("dataGridContextMenu", () => { + it("marks only the active sort action and enables clear", () => { + const state = { column: "id", columnIndex: 0, direction: "desc" as const, mode: "local" as const }; + const items = createDataGridSortMenuItems({ + column: "id", + columnIndex: 0, + state, + labels: { databaseAscending: "db asc", databaseDescending: "db desc", currentPageAscending: "page asc", currentPageDescending: "page desc", clear: "clear" }, + icons: { database: icon, ascending: icon, descending: icon, clear: icon }, + }); + expect(items.map((item) => item.checked)).toEqual([false, false, false, true, undefined]); + expect(items.at(-1)?.disabled).toBe(false); + expect(dataGridSelectedSortMenuValue(state, "id", 0)).toBe("local-desc"); + }); + + it("omits unavailable server actions and disables unavailable formatter", () => { + const items = createDataGridCompactColumnActionItems({ + labels: { formatter: "formatter", localFilter: "local", serverFilter: "server" }, + icons: { formatter: icon, filter: icon, database: icon }, + formatterAvailable: false, + serverFilterAvailable: false, + }); + expect(items.map((item) => item.value)).toEqual(["formatter", "localFilter"]); + expect(items[0]?.disabled).toBe(true); + }); + + it("builds typed column, cell, and row capability groups", () => { + const action = vi.fn(); + const filter = createDataGridFilterSubmenu({ + label: "filter", + icon, + labels: { equals: "equals", notEquals: "not equals", like: "like", notLike: "not like", lessThan: "less", greaterThan: "greater", isNull: "null", isNotNull: "not null", clear: "clear" }, + apply: action, + clear: action, + }); + const columnItems = createDataGridColumnContextMenuItems({ + headerColumn: true, + contextColumn: true, + canCopyAlterSql: true, + canFilter: true, + hasSort: true, + sortMode: "database", + labels: { copyName: "copy name", copyNames: "copy names", details: "details", copyAlterSql: "alter", databaseAscending: "db asc", databaseDescending: "db desc", localAscending: "local asc", localDescending: "local desc", clearSort: "clear sort" }, + icons: { copy: icon, columnDetails: icon, database: icon, ascending: icon, descending: icon, clearSort: icon }, + actions: { copyName: action, copyNames: action, details: action, copyAlterSql: action, sort: action }, + filterSubmenu: filter, + }); + expect(columnItems.map((item) => item.label)).toContain("filter"); + + const cellItems = createDataGridCellContextMenuItems({ + hasCell: true, + hasColumn: true, + headerColumn: false, + editable: true, + hasCellSelection: true, + hasEditableSelection: false, + hasSelection: true, + labels: { cellDetails: "cell", columnDetails: "column", rowDetails: "row", setNull: "null", bulkEdit: "bulk", transpose: "transpose" }, + icons: { cellDetails: icon, columnDetails: icon, rowDetails: icon, setNull: icon, bulkEdit: icon, transpose: icon }, + actions: { cellDetails: action, columnDetails: action, rowDetails: action, setNull: action, bulkEdit: action, transpose: action }, + copySubmenu: { label: "copy" }, + selectionSubmenu: { label: "selection" }, + }); + expect(cellItems.find((item) => item.label === "bulk")?.disabled).toBe(true); + + const rowItems = createDataGridRowContextMenuItems({ + editable: true, + hasRow: true, + canClone: true, + deleted: false, + canDelete: true, + labels: { clone: "clone", restore: "restore", delete: "delete" }, + icons: { clone: icon, restore: icon, delete: icon }, + actions: { clone: action, restore: action, delete: action }, + }); + expect(rowItems.find((item) => item.label === "delete")?.variant).toBe("destructive"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridEditAdapter.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridEditAdapter.spec.ts new file mode 100644 index 0000000000..ff8ed992a5 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridEditAdapter.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { dataGridEditAdapterKind } from "@/lib/dataGrid/dataGridEditAdapter"; + +describe("dataGridEditAdapter", () => { + it("prioritizes custom save handlers", () => { + expect(dataGridEditAdapterKind({ databaseType: "mongodb", custom: true, editable: true })).toBe("custom"); + }); + + it("distinguishes document and relational editing", () => { + expect(dataGridEditAdapterKind({ databaseType: "mongodb", custom: false, editable: true })).toBe("document"); + expect(dataGridEditAdapterKind({ databaseType: "postgresql", custom: false, editable: true })).toBe("relational"); + }); + + it("keeps read-only and unknown contexts unsupported", () => { + expect(dataGridEditAdapterKind({ databaseType: "postgresql", custom: false, editable: false })).toBe("unsupported"); + expect(dataGridEditAdapterKind({ custom: false, editable: true })).toBe("unsupported"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridFilterBuilderPersistence.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridFilterBuilderPersistence.spec.ts new file mode 100644 index 0000000000..e5619211b0 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridFilterBuilderPersistence.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { loadDataGridStructuredFilterState, saveDataGridStructuredFilterState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence"; + +describe("data grid filter builder persistence", () => { + it("isolates scopes and returns cloned state", () => { + saveDataGridStructuredFilterState("cache", { + scopeKey: "scope-a", + manualWhereInput: "status = 1", + rules: [{ id: "rule-1", columnName: "status", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" }], + appliedWhereInput: '"status" = 1', + serverColumnFilters: { 0: { condition: '"status" = 1', keys: ["1"], labels: ["1"] } }, + }); + + expect(loadDataGridStructuredFilterState("cache", "scope-b")).toBeUndefined(); + const restored = loadDataGridStructuredFilterState("cache", "scope-a")!; + restored.rules[0].rawValue = "2"; + restored.serverColumnFilters[0].keys.push("2"); + + expect(loadDataGridStructuredFilterState("cache", "scope-a")).toMatchObject({ + rules: [{ rawValue: "1" }], + serverColumnFilters: { 0: { keys: ["1"] } }, + }); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridNavigation.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridNavigation.spec.ts new file mode 100644 index 0000000000..ca18a427fb --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridNavigation.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { moveDataGridCell, navigateDataGridCell } from "@/lib/dataGrid/dataGridNavigation"; + +const bounds = { rowCount: 3, visibleColumnCount: 4 }; + +describe("dataGridNavigation", () => { + it("clamps directional movement to grid boundaries", () => { + expect(moveDataGridCell({ rowIndex: 0, colIndex: 0 }, -1, -1, bounds)).toEqual({ rowIndex: 0, colIndex: 0 }); + expect(moveDataGridCell({ rowIndex: 2, colIndex: 3 }, 1, 1, bounds)).toEqual({ rowIndex: 2, colIndex: 3 }); + expect(moveDataGridCell({ rowIndex: 1, colIndex: 2 }, -1, 1, bounds)).toEqual({ rowIndex: 0, colIndex: 3 }); + }); + + it("supports home and end navigation without changing the row", () => { + expect(navigateDataGridCell({ rowIndex: 2, colIndex: 2 }, "home", bounds)).toEqual({ rowIndex: 2, colIndex: 0 }); + expect(navigateDataGridCell({ rowIndex: 2, colIndex: 0 }, "end", bounds)).toEqual({ rowIndex: 2, colIndex: 3 }); + }); + + it("returns no target for an empty grid", () => { + expect(navigateDataGridCell({ rowIndex: 0, colIndex: 0 }, "down", { rowCount: 0, visibleColumnCount: 4 })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridRuntime.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridRuntime.spec.ts new file mode 100644 index 0000000000..639d1d39ce --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridRuntime.spec.ts @@ -0,0 +1,46 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createDataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime"; + +describe("dataGridRuntime", () => { + it("disposes registered resources in reverse order and only once", () => { + const scope = createDataGridRuntimeScope(); + const calls: string[] = []; + const removeFirst = scope.addCleanup(() => calls.push("first")); + scope.addCleanup(() => calls.push("second")); + + removeFirst(); + scope.dispose(); + scope.dispose(); + + expect(calls).toEqual(["second"]); + expect(scope.disposed).toBe(true); + }); + + it("runs cleanup immediately when registered after disposal", () => { + const scope = createDataGridRuntimeScope(); + const cleanup = vi.fn(); + + scope.dispose(); + const unregister = scope.addCleanup(cleanup); + + unregister(); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("keeps runtime modules independent from the DataGrid component", () => { + const sourceRoots = [join(process.cwd(), "apps/desktop/src/lib/dataGrid"), join(process.cwd(), "apps/desktop/src/composables")]; + const sourceFiles: string[] = []; + const visit = (directory: string) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) visit(path); + else if (entry.name.endsWith(".ts") && (!directory.endsWith("/composables") || entry.name.startsWith("useDataGrid"))) sourceFiles.push(path); + } + }; + sourceRoots.forEach(visit); + + expect(sourceFiles.filter((path) => readFileSync(path, "utf8").includes("DataGrid.vue"))).toEqual([]); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridToolbar.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridToolbar.spec.ts new file mode 100644 index 0000000000..2c8ca617eb --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridToolbar.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import { dataGridToolbarIntervalOptions, selectDataGridToolbarAutoRefreshInterval, selectDataGridToolbarExportItem, toggleDataGridToolbarAutoRefresh, triggerDataGridToolbarAction, type DataGridToolbarAutoRefreshCapability } from "@/lib/dataGrid/dataGridToolbar"; + +function autoRefreshCapability(overrides: Partial = {}): DataGridToolbarAutoRefreshCapability { + return { + label: "Auto-refresh", + shortLabel: "Auto", + startLabel: "Start auto-refresh", + stopLabel: "Stop auto-refresh", + enabled: false, + intervalSeconds: 10, + intervalOptions: [5, 10, 30], + intervalLabel: (seconds) => `${seconds}s`, + onToggle: vi.fn(), + onSelectInterval: vi.fn(), + ...overrides, + }; +} + +describe("data grid toolbar capabilities", () => { + it("does not invoke hidden or disabled actions", async () => { + const onTrigger = vi.fn(); + + await expect(triggerDataGridToolbarAction({ label: "Save", visible: false, onTrigger })).resolves.toBe(false); + await expect(triggerDataGridToolbarAction({ label: "Save", disabled: true, onTrigger })).resolves.toBe(false); + expect(onTrigger).not.toHaveBeenCalled(); + }); + + it("invokes enabled save and rollback callbacks independently", async () => { + const save = vi.fn(); + const rollback = vi.fn(); + + await expect(triggerDataGridToolbarAction({ label: "Save", onTrigger: save })).resolves.toBe(true); + await expect(triggerDataGridToolbarAction({ label: "Rollback", onTrigger: rollback })).resolves.toBe(true); + expect(save).toHaveBeenCalledOnce(); + expect(rollback).toHaveBeenCalledOnce(); + }); + + it("keeps preset intervals ordered and preserves a persisted custom interval", () => { + expect(dataGridToolbarIntervalOptions([30, 5, 10, 10, 0, -1, 2.5], 15)).toEqual([5, 10, 15, 30]); + }); + + it("toggles auto-refresh and selects valid intervals", async () => { + const onToggle = vi.fn(); + const onSelectInterval = vi.fn(); + const capability = autoRefreshCapability({ onToggle, onSelectInterval }); + + await expect(toggleDataGridToolbarAutoRefresh(capability)).resolves.toBe(true); + await expect(selectDataGridToolbarAutoRefreshInterval(capability, 30)).resolves.toBe(true); + await expect(selectDataGridToolbarAutoRefreshInterval(capability, 99)).resolves.toBe(false); + expect(onToggle).toHaveBeenCalledOnce(); + expect(onSelectInterval).toHaveBeenCalledOnce(); + expect(onSelectInterval).toHaveBeenCalledWith(30); + }); + + it("blocks auto-refresh changes while the capability is disabled", async () => { + const onToggle = vi.fn(); + const onSelectInterval = vi.fn(); + const capability = autoRefreshCapability({ disabled: true, onToggle, onSelectInterval }); + + await expect(toggleDataGridToolbarAutoRefresh(capability)).resolves.toBe(false); + await expect(selectDataGridToolbarAutoRefreshInterval(capability, 10)).resolves.toBe(false); + expect(onToggle).not.toHaveBeenCalled(); + expect(onSelectInterval).not.toHaveBeenCalled(); + }); + + it("rejects disabled or unknown export items", async () => { + const onSelect = vi.fn(); + const capability = { + label: "Export", + items: [ + { value: "csv", label: "CSV" }, + { value: "sql", label: "SQL", disabled: true }, + ], + onSelect, + }; + + await expect(selectDataGridToolbarExportItem(capability, "csv")).resolves.toBe(true); + await expect(selectDataGridToolbarExportItem(capability, "sql")).resolves.toBe(false); + await expect(selectDataGridToolbarExportItem(capability, "xlsx")).resolves.toBe(false); + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith("csv"); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridClipboard.ts b/apps/desktop/src/lib/dataGrid/dataGridClipboard.ts index 9e2aa1874c..15d2867e7b 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridClipboard.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridClipboard.ts @@ -2,6 +2,12 @@ import { eventTargetUsesNativeClipboard } from "@/lib/common/clipboard"; export type DataGridPasteIntent = "native" | "block" | "paste"; +export interface DataGridPasteCell { + rowOffset: number; + columnOffset: number; + value: string; +} + interface DataGridPasteEvent { target?: EventTarget | null; preventDefault(): void; @@ -14,3 +20,14 @@ export function claimDataGridPaste(event: DataGridPasteEvent, editable: boolean, event.stopPropagation(); return editable && hasSelection ? "paste" : "block"; } + +export function planDataGridPaste(rows: readonly (readonly string[])[], maxRows: number, maxColumns: number): DataGridPasteCell[] { + if (maxRows <= 0 || maxColumns <= 0) return []; + const cells: DataGridPasteCell[] = []; + rows.slice(0, maxRows).forEach((row, rowOffset) => { + row.slice(0, maxColumns).forEach((value, columnOffset) => { + cells.push({ rowOffset, columnOffset, value }); + }); + }); + return cells; +} diff --git a/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts b/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts new file mode 100644 index 0000000000..da666cc03e --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts @@ -0,0 +1,216 @@ +import type { Component } from "vue"; + +export type DataGridColumnMenuItem = { + label: string; + value: string; + icon?: Component; + disabled?: boolean; + separatorBefore?: boolean; + checked?: boolean; +}; + +export type DataGridContextMenuItem = { + label: string; + action?: () => void; + disabled?: boolean; + separator?: boolean; + icon?: Component; + iconClass?: string; + shortcut?: string; + variant?: "default" | "destructive"; + visible?: boolean; + children?: DataGridContextMenuItem[]; +}; + +export type DataGridContextMenuIcons = { + copy: Component; + filter: Component; + selection: Component; + export: Component; + database: Component; + ascending: Component; + descending: Component; + clearSort: Component; + cellDetails: Component; + rowDetails: Component; + columnDetails: Component; + setNull: Component; + bulkEdit: Component; + transpose: Component; + clone: Component; + restore: Component; + delete: Component; +}; + +export function createDataGridFilterSubmenu(options: { + label: string; + icon: Component; + labels: Record<"equals" | "notEquals" | "like" | "notLike" | "lessThan" | "greaterThan" | "isNull" | "isNotNull" | "clear", string>; + apply: (mode: "equals" | "not-equals" | "like" | "not-like" | "less-than" | "greater-than" | "is-null" | "is-not-null") => void; + clear: () => void; +}): DataGridContextMenuItem { + const { labels } = options; + return { + label: options.label, + icon: options.icon, + children: [ + { label: labels.equals, action: () => options.apply("equals") }, + { label: labels.notEquals, action: () => options.apply("not-equals") }, + { label: labels.like, action: () => options.apply("like") }, + { label: labels.notLike, action: () => options.apply("not-like") }, + { label: labels.lessThan, action: () => options.apply("less-than") }, + { label: labels.greaterThan, action: () => options.apply("greater-than") }, + { label: "", separator: true }, + { label: labels.isNull, action: () => options.apply("is-null") }, + { label: labels.isNotNull, action: () => options.apply("is-not-null") }, + { label: "", separator: true }, + { label: labels.clear, action: options.clear }, + ], + }; +} + +export function createDataGridColumnContextMenuItems(options: { + headerColumn: boolean; + contextColumn: boolean; + canCopyAlterSql: boolean; + canFilter: boolean; + hasSort: boolean; + sortMode: "database" | "local"; + labels: Record<"copyName" | "copyNames" | "details" | "copyAlterSql" | "databaseAscending" | "databaseDescending" | "localAscending" | "localDescending" | "clearSort", string>; + icons: Pick; + actions: { copyName: () => void; copyNames: () => void; details: () => void; copyAlterSql: () => void; sort: (direction: "asc" | "desc" | null, mode: "database" | "local") => void }; + filterSubmenu: DataGridContextMenuItem; +}): DataGridContextMenuItem[] { + const items: DataGridContextMenuItem[] = []; + if (options.headerColumn) { + items.push({ label: options.labels.copyName, action: options.actions.copyName, icon: options.icons.copy }); + items.push({ label: options.labels.copyNames, action: options.actions.copyNames, icon: options.icons.copy }); + items.push({ label: options.labels.details, action: options.actions.details, icon: options.icons.columnDetails }); + if (options.canCopyAlterSql) items.push({ label: options.labels.copyAlterSql, action: options.actions.copyAlterSql, icon: options.icons.copy }); + } + if (!options.contextColumn) return items; + items.push( + { label: options.labels.databaseAscending, action: () => options.actions.sort("asc", "database"), icon: options.icons.database }, + { label: options.labels.databaseDescending, action: () => options.actions.sort("desc", "database"), icon: options.icons.database }, + { label: "", separator: true }, + { label: options.labels.localAscending, action: () => options.actions.sort("asc", "local"), icon: options.icons.ascending }, + { label: options.labels.localDescending, action: () => options.actions.sort("desc", "local"), icon: options.icons.descending }, + ); + if (options.hasSort) items.push({ label: options.labels.clearSort, action: () => options.actions.sort(null, options.sortMode), icon: options.icons.clearSort }); + if (options.canFilter) items.push({ label: "", separator: true }, options.filterSubmenu); + items.push({ label: "", separator: true }); + return items; +} + +export function createDataGridCellContextMenuItems(options: { + hasCell: boolean; + hasColumn: boolean; + headerColumn: boolean; + editable: boolean; + hasCellSelection: boolean; + hasEditableSelection: boolean; + hasSelection: boolean; + labels: Record<"cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose", string>; + icons: Pick; + actions: Record<"cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose", () => void>; + downloadItem?: DataGridContextMenuItem | null; + copySubmenu: DataGridContextMenuItem; + selectionSubmenu: DataGridContextMenuItem; +}): DataGridContextMenuItem[] { + const items: DataGridContextMenuItem[] = []; + if (options.hasCell) { + if (options.hasColumn) { + items.push({ label: options.labels.cellDetails, action: options.actions.cellDetails, icon: options.icons.cellDetails }); + if (options.downloadItem) items.push(options.downloadItem); + items.push({ label: options.labels.columnDetails, action: options.actions.columnDetails, icon: options.icons.columnDetails }); + } + items.push({ label: options.labels.rowDetails, action: options.actions.rowDetails, icon: options.icons.rowDetails }, { label: "", separator: true }); + } + if (!options.headerColumn) items.push(options.copySubmenu); + if (options.editable && options.hasCellSelection) { + if (!options.headerColumn) items.push({ label: options.labels.setNull, action: options.actions.setNull, disabled: !options.hasEditableSelection, icon: options.icons.setNull }); + items.push({ label: options.labels.bulkEdit, action: options.actions.bulkEdit, disabled: !options.hasEditableSelection, icon: options.icons.bulkEdit }); + } + if (options.hasCell) items.push({ label: options.labels.transpose, action: options.actions.transpose, icon: options.icons.transpose }); + if (options.hasSelection) items.push(options.selectionSubmenu); + return items; +} + +export function createDataGridRowContextMenuItems(options: { + editable: boolean; + hasRow: boolean; + canClone: boolean; + deleted: boolean; + canDelete: boolean; + labels: Record<"clone" | "restore" | "delete", string>; + icons: Pick; + actions: Record<"clone" | "restore" | "delete", () => void>; +}): DataGridContextMenuItem[] { + if (!options.editable || !options.hasRow) return []; + const items: DataGridContextMenuItem[] = [{ label: "", separator: true }]; + if (options.canClone) items.push({ label: options.labels.clone, action: options.actions.clone, icon: options.icons.clone }); + if (options.deleted) items.push({ label: options.labels.restore, action: options.actions.restore, icon: options.icons.restore }); + else if (options.canDelete) items.push({ label: options.labels.delete, action: options.actions.delete, icon: options.icons.delete, variant: "destructive" }); + items.push({ label: "", separator: true }); + return items; +} + +export function createDataGridContextMenuItems(...groups: Array): DataGridContextMenuItem[] { + return groups.flat(); +} + +export type DataGridColumnSortState = { + column: string | null; + columnIndex: number | null; + direction: "asc" | "desc"; + mode: "database" | "local"; +}; + +type SortMenuLabels = { + databaseAscending: string; + databaseDescending: string; + currentPageAscending: string; + currentPageDescending: string; + clear: string; +}; + +type SortMenuIcons = { + database: Component; + ascending: Component; + descending: Component; + clear: Component; +}; + +export function dataGridColumnIsSorted(state: DataGridColumnSortState, column: string, columnIndex: number): boolean { + return state.column === column && state.columnIndex === columnIndex; +} + +export function dataGridSelectedSortMenuValue(state: DataGridColumnSortState, column: string, columnIndex: number): string | undefined { + return dataGridColumnIsSorted(state, column, columnIndex) ? `${state.mode}-${state.direction}` : undefined; +} + +export function createDataGridSortMenuItems(options: { column: string; columnIndex: number; state: DataGridColumnSortState; labels: SortMenuLabels; icons: SortMenuIcons }): DataGridColumnMenuItem[] { + const { column, columnIndex, state, labels, icons } = options; + const sorted = dataGridColumnIsSorted(state, column, columnIndex); + return [ + { label: labels.databaseAscending, value: "database-asc", icon: icons.database, checked: sorted && state.direction === "asc" && state.mode === "database" }, + { label: labels.databaseDescending, value: "database-desc", icon: icons.database, checked: sorted && state.direction === "desc" && state.mode === "database" }, + { label: labels.currentPageAscending, value: "local-asc", icon: icons.ascending, checked: sorted && state.direction === "asc" && state.mode === "local", separatorBefore: true }, + { label: labels.currentPageDescending, value: "local-desc", icon: icons.descending, checked: sorted && state.direction === "desc" && state.mode === "local" }, + { label: labels.clear, value: "clear", icon: icons.clear, disabled: !sorted, separatorBefore: true }, + ]; +} + +export function createDataGridCompactColumnActionItems(options: { + labels: { formatter: string; localFilter: string; serverFilter: string }; + icons: { formatter: Component; filter: Component; database: Component }; + formatterAvailable: boolean; + serverFilterAvailable: boolean; +}): DataGridColumnMenuItem[] { + const { labels, icons } = options; + return [ + { label: labels.formatter, value: "formatter", icon: icons.formatter, disabled: !options.formatterAvailable }, + { label: labels.localFilter, value: "localFilter", icon: icons.filter }, + ...(options.serverFilterAvailable ? [{ label: labels.serverFilter, value: "serverFilter", icon: icons.database }] : []), + ]; +} diff --git a/apps/desktop/src/lib/dataGrid/dataGridEditAdapter.ts b/apps/desktop/src/lib/dataGrid/dataGridEditAdapter.ts new file mode 100644 index 0000000000..0cadb96a7d --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridEditAdapter.ts @@ -0,0 +1,20 @@ +import type { DatabaseType } from "@/types/database"; +import type { DataGridEditAdapter } from "@/lib/dataGrid/dataGridRuntime"; + +export type DataGridEditAdapterKind = "relational" | "document" | "custom" | "unsupported"; + +export interface DataGridEditAdapterContext { + databaseType?: DatabaseType; + custom: boolean; + editable: boolean; +} + +export function dataGridEditAdapterKind(context: DataGridEditAdapterContext): DataGridEditAdapterKind { + if (context.custom) return "custom"; + if (!context.editable) return "unsupported"; + if (context.databaseType === "mongodb") return "document"; + if (context.databaseType) return "relational"; + return "unsupported"; +} + +export type { DataGridEditAdapter }; diff --git a/apps/desktop/src/lib/dataGrid/dataGridFilterBuilderPersistence.ts b/apps/desktop/src/lib/dataGrid/dataGridFilterBuilderPersistence.ts new file mode 100644 index 0000000000..547bbae278 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridFilterBuilderPersistence.ts @@ -0,0 +1,39 @@ +import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder"; + +export type DataGridCachedServerColumnFilter = { + condition: string; + keys: string[]; + labels: string[]; +}; + +export type DataGridStructuredFilterCacheState = { + scopeKey: string; + manualWhereInput: string; + rules: DataGridStructuredFilterRule[]; + appliedWhereInput: string; + serverColumnFilters: Record; +}; + +const structuredFilterStateCache = new Map(); + +export function cloneDataGridStructuredFilterRules(rules: readonly DataGridStructuredFilterRule[]): DataGridStructuredFilterRule[] { + return rules.map((rule) => ({ ...rule })); +} + +export function loadDataGridStructuredFilterState(cacheKey: string, scopeKey: string): DataGridStructuredFilterCacheState | undefined { + const cached = structuredFilterStateCache.get(cacheKey); + if (!cached || cached.scopeKey !== scopeKey) return undefined; + return { + ...cached, + rules: cloneDataGridStructuredFilterRules(cached.rules), + serverColumnFilters: structuredClone(cached.serverColumnFilters), + }; +} + +export function saveDataGridStructuredFilterState(cacheKey: string, state: DataGridStructuredFilterCacheState) { + structuredFilterStateCache.set(cacheKey, { + ...state, + rules: cloneDataGridStructuredFilterRules(state.rules), + serverColumnFilters: structuredClone(state.serverColumnFilters), + }); +} diff --git a/apps/desktop/src/lib/dataGrid/dataGridNavigation.ts b/apps/desktop/src/lib/dataGrid/dataGridNavigation.ts new file mode 100644 index 0000000000..a9f35e4862 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridNavigation.ts @@ -0,0 +1,38 @@ +import type { CellPosition } from "@/lib/dataGrid/gridSelection"; + +export type DataGridNavigationDirection = "up" | "down" | "left" | "right" | "home" | "end"; + +export interface DataGridNavigationBounds { + rowCount: number; + visibleColumnCount: number; +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} + +export function moveDataGridCell(position: CellPosition, rowDelta: number, columnDelta: number, bounds: DataGridNavigationBounds): CellPosition | null { + if (bounds.rowCount <= 0 || bounds.visibleColumnCount <= 0) return null; + return { + rowIndex: clamp(position.rowIndex + rowDelta, 0, bounds.rowCount - 1), + colIndex: clamp(position.colIndex + columnDelta, 0, bounds.visibleColumnCount - 1), + }; +} + +export function navigateDataGridCell(position: CellPosition, direction: DataGridNavigationDirection, bounds: DataGridNavigationBounds): CellPosition | null { + if (bounds.rowCount <= 0 || bounds.visibleColumnCount <= 0) return null; + switch (direction) { + case "up": + return moveDataGridCell(position, -1, 0, bounds); + case "down": + return moveDataGridCell(position, 1, 0, bounds); + case "left": + return moveDataGridCell(position, 0, -1, bounds); + case "right": + return moveDataGridCell(position, 0, 1, bounds); + case "home": + return { rowIndex: position.rowIndex, colIndex: 0 }; + case "end": + return { rowIndex: position.rowIndex, colIndex: bounds.visibleColumnCount - 1 }; + } +} diff --git a/apps/desktop/src/lib/dataGrid/dataGridRuntime.ts b/apps/desktop/src/lib/dataGrid/dataGridRuntime.ts new file mode 100644 index 0000000000..0e4abae5e5 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridRuntime.ts @@ -0,0 +1,84 @@ +import type { CellPosition, CellSelectionRange } from "@/lib/dataGrid/gridSelection"; + +export type GridCoordinate = CellPosition; +export type GridRange = CellSelectionRange; + +export interface GridEditPatch { + rowIndex: number; + columnIndex: number; + previousValue: Value; + nextValue: Value; + row: Row; +} + +export interface GridColumnLayout { + order: readonly string[]; + hidden: ReadonlySet; + pinned: ReadonlySet; + widths: Readonly>; +} + +export interface DataGridRuntimeScope { + readonly disposed: boolean; + addCleanup(cleanup: () => void): () => void; + dispose(): void; +} + +export interface DataGridCapability { + readonly name: string; + dispose?(): void; +} + +export interface DataGridSelectionCapability extends DataGridCapability { + getRange(): GridRange | null; + setRange(range: GridRange | null): void; + isSelected(position: GridCoordinate): boolean; +} + +export interface DataGridNavigationCapability extends DataGridCapability { + moveFocus(position: GridCoordinate, direction: "up" | "down" | "left" | "right" | "home" | "end"): GridCoordinate; +} + +export interface DataGridEditAdapter> { + canEditCell(row: Row, columnIndex: number): boolean; + createPatch(input: GridEditPatch): Patch | null; + applyPatch(patch: Patch): Promise | void; +} + +export interface DataGridRuntimeDependencies { + readonly result: Result; + readonly rowCount: number; + readonly columnCount: number; + readonly registerCleanup: DataGridRuntimeScope["addCleanup"]; +} + +export function createDataGridRuntimeScope(): DataGridRuntimeScope { + const cleanups: Array<() => void> = []; + let disposed = false; + + return { + get disposed() { + return disposed; + }, + addCleanup(cleanup) { + if (disposed) { + cleanup(); + return () => undefined; + } + + cleanups.push(cleanup); + let registered = true; + return () => { + if (!registered) return; + registered = false; + const index = cleanups.indexOf(cleanup); + if (index >= 0) cleanups.splice(index, 1); + }; + }, + dispose() { + if (disposed) return; + disposed = true; + for (const cleanup of cleanups.splice(0).reverse()) cleanup(); + }, + }; +} diff --git a/apps/desktop/src/lib/dataGrid/dataGridToolbar.ts b/apps/desktop/src/lib/dataGrid/dataGridToolbar.ts index 3dd1173193..41ebf166b4 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridToolbar.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridToolbar.ts @@ -1,3 +1,88 @@ export const DATA_GRID_COMPACT_TOPBAR_WIDTH = 900; export type DataGridReloadIntent = "refresh"; + +export interface DataGridToolbarActionCapability { + label: string; + tooltip?: string; + visible?: boolean; + disabled?: boolean; + active?: boolean; + loading?: boolean; + onTrigger: () => void | Promise; +} + +export interface DataGridToolbarSaveCapability extends DataGridToolbarActionCapability { + pendingCount: number; + shortcutLabel?: string; +} + +export interface DataGridToolbarMenuItem { + value: string; + label: string; + disabled?: boolean; + separatorBefore?: boolean; +} + +export interface DataGridToolbarExportCapability { + label: string; + visible?: boolean; + disabled?: boolean; + items: readonly DataGridToolbarMenuItem[]; + onSelect: (value: string) => void | Promise; +} + +export interface DataGridToolbarAutoRefreshCapability { + label: string; + shortLabel: string; + startLabel: string; + stopLabel: string; + visible?: boolean; + disabled?: boolean; + enabled: boolean; + intervalSeconds: number; + intervalOptions: readonly number[]; + intervalLabel: (seconds: number) => string; + onToggle: () => void | Promise; + onSelectInterval: (seconds: number) => void | Promise; +} + +export function isDataGridToolbarCapabilityVisible(capability: { visible?: boolean } | undefined): boolean { + return !!capability && capability.visible !== false; +} + +export function isDataGridToolbarCapabilityDisabled(capability: { disabled?: boolean; loading?: boolean } | undefined): boolean { + return !capability || capability.disabled === true; +} + +export async function triggerDataGridToolbarAction(capability: DataGridToolbarActionCapability | undefined): Promise { + if (!capability || !isDataGridToolbarCapabilityVisible(capability) || isDataGridToolbarCapabilityDisabled(capability)) return false; + await capability.onTrigger(); + return true; +} + +export function dataGridToolbarIntervalOptions(intervalOptions: readonly number[], currentIntervalSeconds: number): number[] { + // Keep a persisted custom interval selectable even when it is not in today's preset list. + return [...new Set([...intervalOptions, currentIntervalSeconds].filter((seconds) => Number.isInteger(seconds) && seconds > 0))].sort((left, right) => left - right); +} + +export async function toggleDataGridToolbarAutoRefresh(capability: DataGridToolbarAutoRefreshCapability | undefined): Promise { + if (!capability || !isDataGridToolbarCapabilityVisible(capability) || isDataGridToolbarCapabilityDisabled(capability)) return false; + await capability.onToggle(); + return true; +} + +export async function selectDataGridToolbarAutoRefreshInterval(capability: DataGridToolbarAutoRefreshCapability | undefined, seconds: number): Promise { + if (!capability || !isDataGridToolbarCapabilityVisible(capability) || isDataGridToolbarCapabilityDisabled(capability)) return false; + if (!dataGridToolbarIntervalOptions(capability.intervalOptions, capability.intervalSeconds).includes(seconds)) return false; + await capability.onSelectInterval(seconds); + return true; +} + +export async function selectDataGridToolbarExportItem(capability: DataGridToolbarExportCapability | undefined, value: string): Promise { + if (!capability || !isDataGridToolbarCapabilityVisible(capability) || capability.disabled) return false; + const item = capability.items.find((candidate) => candidate.value === value); + if (!item || item.disabled) return false; + await capability.onSelect(value); + return true; +} diff --git a/package.json b/package.json index f7c78bcfb6..6789e5e880 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "@types/node": "^25.9.1", "@types/splitpanes": "^2.2.6", "@vitejs/plugin-vue": "^6.0.7", + "happy-dom": "^20.10.6", "husky": "^9.1.7", "lint-staged": "^16.4.0", "oxfmt": "0.53.0", diff --git a/packages/app-tests/dataGridContextMenu.test.ts b/packages/app-tests/dataGridContextMenu.test.ts index 1033044a56..49827e2cfa 100644 --- a/packages/app-tests/dataGridContextMenu.test.ts +++ b/packages/app-tests/dataGridContextMenu.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "vitest"; import { compileScript, parse } from "vue/compiler-sfc"; +import { createDataGridCellContextMenuItems } from "../../apps/desktop/src/lib/dataGrid/dataGridContextMenu"; const dataGridPath = "apps/desktop/src/components/grid/DataGrid.vue"; const dataGridSource = readFileSync(dataGridPath, "utf8"); @@ -23,11 +24,29 @@ test("set NULL applies a real null value only to editable selections", () => { }); test("editable cell selections expose set NULL before bulk edit", () => { - const menuBlock = dataGridSource.match(/if \(props\.editable && hasCellSelection\.value\) \{[^]*?\n \}/)?.[0] ?? ""; + const icon = {}; + const action = () => {}; + const items = createDataGridCellContextMenuItems({ + hasCell: false, + hasColumn: false, + headerColumn: false, + editable: true, + hasCellSelection: true, + hasEditableSelection: false, + hasSelection: false, + labels: { cellDetails: "cell", columnDetails: "column", rowDetails: "row", setNull: "set null", bulkEdit: "bulk edit", transpose: "transpose" }, + icons: { cellDetails: icon, columnDetails: icon, rowDetails: icon, setNull: icon, bulkEdit: icon, transpose: icon }, + actions: { cellDetails: action, columnDetails: action, rowDetails: action, setNull: action, bulkEdit: action, transpose: action }, + copySubmenu: { label: "copy" }, + selectionSubmenu: { label: "selection" }, + }); - assert.match(menuBlock, /const hasEditableSelection = selectionHasEditableCells\(\);/); - assert.match(menuBlock, /if \(!contextHeaderColumn\.value\) \{/); - assert.match(menuBlock, /label: t\("grid\.setNull"\),\s+action: setSelectionNull,\s+disabled: !hasEditableSelection,\s+icon: X,/); - assert.match(menuBlock, /label: t\("grid\.bulkEditSelection"\),\s+action: openBulkEditDialog,\s+disabled: !hasEditableSelection,/); - assert.ok(menuBlock.indexOf('t("grid.setNull")') < menuBlock.indexOf('t("grid.bulkEditSelection")')); + assert.deepEqual( + items.map((item) => ({ label: item.label, disabled: item.disabled })), + [ + { label: "copy", disabled: undefined }, + { label: "set null", disabled: true }, + { label: "bulk edit", disabled: true }, + ], + ); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ea3f57cc1..d32420e3a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,6 +192,9 @@ importers: '@vitejs/plugin-vue': specifier: ^6.0.7 version: 6.0.7(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4))(vue@3.5.35(typescript@6.0.3)) + happy-dom: + specifier: ^20.10.6 + version: 20.10.6 husky: specifier: ^9.1.7 version: 9.1.7 @@ -218,7 +221,7 @@ importers: version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4) vitest: specifier: ^4.1.8 - version: 4.1.8(@types/node@25.9.1)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.1)(happy-dom@20.10.6)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)) vue-tsc: specifier: ^3.3.3 version: 3.3.3(typescript@6.0.3) @@ -1635,6 +1638,12 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@uiw/codemirror-theme-duotone@4.25.10': resolution: {integrity: sha512-OQzg73rlUnDQ1NDT2KR4Nt+67oMg5yRWXz60WoSATiLGhNoz8Af8hAKnY1TC+v4P8OmLoFnufFL7DQARX1wqQQ==} @@ -1942,6 +1951,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -2592,6 +2605,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + happy-dom@20.10.6: + resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} + engines: {node: '>=20.0.0'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -4058,6 +4075,10 @@ packages: web-worker@1.5.0: resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -4084,6 +4105,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -5276,6 +5309,12 @@ snapshots: '@types/web-bluetooth@0.0.21': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.9.1 + '@uiw/codemirror-theme-duotone@4.25.10(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)': dependencies: '@uiw/codemirror-themes': 4.25.10(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0) @@ -5682,6 +5721,10 @@ snapshots: node-releases: 2.0.50 update-browserslist-db: 1.2.3(browserslist@4.28.4) + buffer-image-size@0.6.4: + dependencies: + '@types/node': 25.9.1 + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -6362,6 +6405,19 @@ snapshots: graceful-fs@4.2.11: {} + happy-dom@20.10.6: + dependencies: + '@types/node': 25.9.1 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-symbols@1.1.0: {} hasown@2.0.3: @@ -7716,7 +7772,7 @@ snapshots: tsx: 4.22.4 yaml: 2.8.4 - vitest@4.1.8(@types/node@25.9.1)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)): + vitest@4.1.8(@types/node@25.9.1)(happy-dom@20.10.6)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)) @@ -7740,6 +7796,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.1 + happy-dom: 20.10.6 transitivePeerDependencies: - msw @@ -7829,6 +7886,8 @@ snapshots: web-worker@1.5.0: {} + whatwg-mimetype@3.0.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -7852,6 +7911,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.1: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 diff --git a/vitest.config.ts b/vitest.config.ts index d8687fa9fb..9f878571b9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,9 @@ import path from "node:path"; +import vue from "@vitejs/plugin-vue"; import { defineConfig } from "vitest/config"; export default defineConfig({ + plugins: [vue()], resolve: { alias: { "@": path.resolve(__dirname, "apps/desktop/src"),
{{ activeCellDetail.rawValuePreview }}
{{ mongoJsonPreviewText }}
{{ dialogCellDetail.rawValuePreview }}
{{ field.rawValuePreview }}
{{ field.formattedJson }}
- {{ t("grid.bulkEditDescription", { count: selectedCellCount }) }} -
{{ t("grid.bulkEditDescription", { count: selectedCellCount }) }}
{{ detail.rawValuePreview }}
{{ text }}