Refactor table state, start adding history support, update packages
All checks were successful
build / build (push) Successful in 5m0s
All checks were successful
build / build (push) Successful in 5m0s
This commit is contained in:
10520
package-lock.json
generated
Normal file
10520
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,7 @@
|
||||
"@angular/compiler-cli": "^21.2.4",
|
||||
"@tauri-apps/cli": "^2.10.1",
|
||||
"@types/jasmine": "~5.1.15",
|
||||
"baseline-browser-mapping": "^2.10.24",
|
||||
"jasmine-core": "~5.12.1",
|
||||
"karma": "~6.4.4",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
>
|
||||
<ng-template #panel>
|
||||
<div class="h-full w-full overflow-auto">
|
||||
<app-file-tree (selectFile)="selectedFile.set($event)"></app-file-tree>
|
||||
<app-file-tree
|
||||
(selectFile)="selectedSheet.set({ file: $event, history: [] })"
|
||||
></app-file-tree>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template #panel>
|
||||
@@ -31,12 +33,12 @@
|
||||
[class.border-dashed]="dragging()"
|
||||
[class.border-2]="dragging()"
|
||||
>
|
||||
@if (tabs().length > 0) {
|
||||
@if (sheets().length > 0) {
|
||||
<p-tabs [(value)]="selectedTab" class="w-full" scrollable>
|
||||
<p-tablist>
|
||||
@for (tab of tabs(); track $index) {
|
||||
@for (tab of sheets(); track $index) {
|
||||
<p-tab [value]="$index" (auxclick)="removeTab($index)">
|
||||
<span>{{ tab.name }}</span>
|
||||
<span>{{ tab.file.name }}</span>
|
||||
<span
|
||||
(click)="removeTab($index)"
|
||||
class="material-symbols-outlined"
|
||||
@@ -48,10 +50,10 @@
|
||||
</p-tablist>
|
||||
</p-tabs>
|
||||
}
|
||||
@if (selectedFile()) {
|
||||
@if (selectedSheet()) {
|
||||
<app-file-viewer
|
||||
class="flex w-full flex-1"
|
||||
[file]="selectedFile()"
|
||||
[sheet]="selectedSheet()"
|
||||
[(columns)]="selectedFileColumns"
|
||||
></app-file-viewer>
|
||||
} @else {
|
||||
@@ -67,13 +69,21 @@
|
||||
</ng-template>
|
||||
<ng-template #panel>
|
||||
<div class="col flex items-center justify-center">
|
||||
Panel 3
|
||||
</div></ng-template
|
||||
>
|
||||
@let selectedSheet = this.selectedSheet();
|
||||
@if (selectedSheet) {
|
||||
@for (state of selectedSheet.history; track $index) {
|
||||
<!-- TODO: Make a diff compared to the initial state. -->
|
||||
<!-- Alternative is to make state a list of patches, but probably more complicated -->
|
||||
<div>
|
||||
|
||||
</div>
|
||||
}
|
||||
}</div
|
||||
></ng-template>
|
||||
</p-splitter>
|
||||
</ng-template>
|
||||
<ng-template #panel>
|
||||
@if (selectedFile()) {
|
||||
@if (selectedSheet()) {
|
||||
<app-column-editor
|
||||
class="w-full overflow-auto"
|
||||
[(columns)]="selectedFileColumns"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
effect,
|
||||
signal,
|
||||
viewChild,
|
||||
@@ -16,7 +17,7 @@ import { InputTextModule } from 'primeng/inputtext';
|
||||
import { FileTreeComponent } from './file-tree/file-tree.component';
|
||||
import { FileViewerComponent } from './file-viewer/file-viewer.component';
|
||||
import { ColumnEditorComponent } from './column-editor/column-editor.component';
|
||||
import { Column } from './duckdb.service';
|
||||
import { Column, Sheet } from './duckdb.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -37,9 +38,9 @@ import { Column } from './duckdb.service';
|
||||
styleUrl: './app.component.scss',
|
||||
})
|
||||
export class AppComponent {
|
||||
protected selectedFile = signal<File | undefined>(undefined);
|
||||
protected selectedSheet = signal<Sheet | undefined>(undefined);
|
||||
protected selectedFileColumns = signal<Column[]>([]);
|
||||
protected tabs = signal<File[]>([]);
|
||||
protected sheets = signal<Sheet[]>([]);
|
||||
protected selectedTab = signal(0);
|
||||
protected dragging = signal(false);
|
||||
|
||||
@@ -47,25 +48,25 @@ export class AppComponent {
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const selectedFile = this.selectedFile();
|
||||
const selectedFile = this.selectedSheet();
|
||||
if (selectedFile) {
|
||||
this.addFile(selectedFile);
|
||||
this.addSheet(selectedFile);
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.selectedFile() !== this.tabs()[this.selectedTab()]) {
|
||||
if (this.tabs().length > 0) {
|
||||
this.selectedFile.set(this.tabs()[Number(this.selectedTab())]);
|
||||
if (this.selectedSheet() !== this.sheets()[this.selectedTab()]) {
|
||||
if (this.sheets().length > 0) {
|
||||
this.selectedSheet.set(this.sheets()[Number(this.selectedTab())]);
|
||||
} else {
|
||||
this.selectedFile.set(undefined);
|
||||
this.selectedSheet.set(undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected removeTab(index: number) {
|
||||
this.tabs.update((tabs) => {
|
||||
this.sheets.update((tabs) => {
|
||||
const copy = tabs.slice();
|
||||
copy.splice(index, 1);
|
||||
return copy;
|
||||
@@ -73,12 +74,12 @@ export class AppComponent {
|
||||
if (this.selectedTab() === index) {
|
||||
if (this.selectedTab() > 0) {
|
||||
this.selectedTab.update((tab) => tab - 1);
|
||||
this.selectedFile.set(this.tabs()[this.selectedTab()]);
|
||||
} else if (this.tabs().length > 1) {
|
||||
this.selectedSheet.set(this.sheets()[this.selectedTab()]);
|
||||
} else if (this.sheets().length > 1) {
|
||||
this.selectedTab.update((tab) => tab + 1);
|
||||
this.selectedFile.set(this.tabs()[this.selectedTab()]);
|
||||
this.selectedSheet.set(this.sheets()[this.selectedTab()]);
|
||||
} else {
|
||||
this.selectedFile.set(undefined);
|
||||
this.selectedSheet.set(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,7 +90,7 @@ export class AppComponent {
|
||||
if (files) {
|
||||
for (const file of files) {
|
||||
if (file.type === 'text/csv') {
|
||||
this.addFile(file);
|
||||
this.addSheet({ file, history: [] });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,31 +120,43 @@ export class AppComponent {
|
||||
if (files) {
|
||||
for (const file of files) {
|
||||
if (file.type === 'text/csv') {
|
||||
this.addFile(file);
|
||||
this.addSheet({ file, history: [] });
|
||||
}
|
||||
}
|
||||
this.selectedTab.set(this.tabs().length - 1);
|
||||
this.selectedTab.set(this.sheets().length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private addFile(file: File) {
|
||||
private addSheet(newSheet: Sheet) {
|
||||
if (
|
||||
this.tabs().find(
|
||||
(tab) =>
|
||||
tab.webkitRelativePath === file.webkitRelativePath &&
|
||||
tab.name === file.name,
|
||||
this.sheets().find(
|
||||
(sheet) =>
|
||||
sheet.file.webkitRelativePath === newSheet.file.webkitRelativePath &&
|
||||
sheet.file.name === newSheet.file.name,
|
||||
)
|
||||
) {
|
||||
this.selectedTab.set(
|
||||
this.tabs().findIndex(
|
||||
(tab) =>
|
||||
tab.webkitRelativePath === file.webkitRelativePath &&
|
||||
tab.name === file.name,
|
||||
this.sheets().findIndex(
|
||||
(sheet) =>
|
||||
sheet.file.webkitRelativePath ===
|
||||
newSheet.file.webkitRelativePath &&
|
||||
sheet.file.name === newSheet.file.name,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
this.tabs.update((tabs) => [...tabs, file]);
|
||||
this.selectedTab.set(this.tabs().length - 1);
|
||||
this.sheets.update((tabs) => [...tabs, newSheet]);
|
||||
this.selectedTab.set(this.sheets().length - 1);
|
||||
}
|
||||
}
|
||||
@HostListener('window:keydown', [
|
||||
'$event.key',
|
||||
'$event.ctrlKey',
|
||||
'$event.metaKey',
|
||||
])
|
||||
keydown(key: string, hasCtrl: boolean, hasMeta: boolean) {
|
||||
const sheets = this.sheets();
|
||||
if (sheets.length > 1 && key === 'z' && (hasCtrl || hasMeta)) {
|
||||
this.sheets()[0].history.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,18 @@ export const RowsResponse = z.object({
|
||||
aggregateValues: AggregateValue.array(),
|
||||
});
|
||||
|
||||
export const TableState = z.object({
|
||||
columns: z.array(Column),
|
||||
sorts: z.array(SortColumn),
|
||||
filters: z.array(Filter),
|
||||
aggregates: z.array(Aggregate)
|
||||
});
|
||||
|
||||
export const Sheet = z.object({
|
||||
file: z.file(),
|
||||
history: z.array(TableState),
|
||||
})
|
||||
|
||||
export type Column = z.infer<typeof Column>;
|
||||
export type SortColumn = z.infer<typeof SortColumn>;
|
||||
export type FilterValue = z.infer<typeof FilterValue>;
|
||||
@@ -57,6 +69,8 @@ export type AggregateType = z.infer<typeof AggregateType>;
|
||||
export type Aggregate = z.infer<typeof Aggregate>;
|
||||
export type AggregateValue = z.infer<typeof AggregateValue>;
|
||||
export type RowsResponse = z.infer<typeof RowsResponse>;
|
||||
export type TableState = z.infer<typeof TableState>;
|
||||
export type Sheet = z.infer<typeof Sheet>;
|
||||
|
||||
const sanitisedFileName = (file: File) =>
|
||||
file.name.toLowerCase().replaceAll("'", '').replaceAll(/\s*/g, '');
|
||||
@@ -176,15 +190,12 @@ export class DuckdbService {
|
||||
file: File,
|
||||
start: number,
|
||||
numRows: number,
|
||||
columns: Column[],
|
||||
sorts: SortColumn[],
|
||||
filters: Filter[],
|
||||
aggregates: Aggregate[],
|
||||
state: TableState,
|
||||
): Promise<RowsResponse> {
|
||||
const conn = await this.db.connect();
|
||||
try {
|
||||
const whereClause = this.getWhereClause(filters);
|
||||
const mappedFilterValues = filters.flatMap((filter) =>
|
||||
const whereClause = this.getWhereClause(state.filters);
|
||||
const mappedFilterValues = state.filters.flatMap((filter) =>
|
||||
filter.value
|
||||
.filter((value) => value?.value)
|
||||
.map(
|
||||
@@ -193,8 +204,8 @@ export class DuckdbService {
|
||||
),
|
||||
);
|
||||
let aggregatesQuery = 'SELECT COUNT(1) totalRows';
|
||||
if (aggregates.length > 0) {
|
||||
for (const aggregate of aggregates) {
|
||||
if (state.aggregates.length > 0) {
|
||||
for (const aggregate of state.aggregates) {
|
||||
aggregatesQuery += `, ${aggregate.type}("${aggregate.column}") "${aggregate.column}"`;
|
||||
}
|
||||
}
|
||||
@@ -206,9 +217,9 @@ export class DuckdbService {
|
||||
const aggregateValues: AggregateValue[] = Object.entries(aggregatesJson)
|
||||
.filter(([key]) => key !== 'totalRows')
|
||||
.map(([key, value]) => AggregateValue.parse({ column: key, value }));
|
||||
let query = `SELECT ${columns.map((column) => `"${column.name}"`).join(', ')} FROM ${sanitisedFileName(file)} ${whereClause}`;
|
||||
if (sorts.length > 0) {
|
||||
query += ` ORDER BY ${sorts.map((sort) => `"${sort.name}" ${sort.sortType}`).join(', ')}`;
|
||||
let query = `SELECT ${state.columns.map((column) => `"${column.name}"`).join(', ')} FROM ${sanitisedFileName(file)} ${whereClause}`;
|
||||
if (state.sorts.length > 0) {
|
||||
query += ` ORDER BY ${state.sorts.map((sort) => `"${sort.name}" ${sort.sortType}`).join(', ')}`;
|
||||
}
|
||||
query += ` LIMIT ${numRows} OFFSET ${start}`;
|
||||
const stmt = await conn.prepare(query);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DuckdbService,
|
||||
Filter,
|
||||
FilterValue,
|
||||
Sheet,
|
||||
aggregateTypes,
|
||||
} from '../duckdb.service';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
@@ -44,7 +45,7 @@ import { PaginatorModule, PaginatorState } from 'primeng/paginator';
|
||||
PaginatorModule,
|
||||
],
|
||||
template: `
|
||||
@if (file() && enabledColumns().length > 0) {
|
||||
@if (sheet() && enabledColumns().length > 0) {
|
||||
<div class="h-full">
|
||||
<p-table
|
||||
#table
|
||||
@@ -170,37 +171,37 @@ import { PaginatorModule, PaginatorState } from 'primeng/paginator';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FileViewerComponent {
|
||||
file = input<File | undefined>();
|
||||
sheet = input<Sheet | undefined>();
|
||||
|
||||
private duckdbService = inject(DuckdbService);
|
||||
|
||||
// Can't be computed since effect has to run first so file exists in duckdb
|
||||
columns = model<Column[]>([]);
|
||||
protected aggregates = signal<(Aggregate | undefined)[]>([]);
|
||||
protected currentAggregateColumn = signal<number | undefined>(undefined);
|
||||
protected aggregateValues = signal<(number | undefined)[]>([]);
|
||||
protected readonly aggregates = signal<(Aggregate | undefined)[]>([]);
|
||||
protected readonly currentAggregateColumn = signal<number | undefined>(undefined);
|
||||
protected readonly aggregateValues = signal<(number | undefined)[]>([]);
|
||||
protected readonly NUM_ROWS = 200;
|
||||
protected currentPage = signal(0);
|
||||
protected readonly currentPage = signal(0);
|
||||
|
||||
// This is required since once the number of rows exceeds ~300000, the footer gets blocked
|
||||
// and the user can't even scroll to those bottom rows.
|
||||
protected readonly PAGE_SIZE = 300_000;
|
||||
|
||||
protected currentValue = signal<any[]>([]);
|
||||
protected appliedFilters = signal<Filter[]>([]);
|
||||
protected currentRowCount = signal(0n);
|
||||
protected readonly currentValue = signal<any[]>([]);
|
||||
protected readonly appliedFilters = signal<Filter[]>([]);
|
||||
protected readonly currentRowCount = signal(0n);
|
||||
|
||||
private table = viewChild(Table);
|
||||
private readonly table = viewChild(Table);
|
||||
|
||||
protected hasAggregates = computed(
|
||||
protected readonly hasAggregates = computed(
|
||||
() => !!this.columns().find((col) => this.isAggregateColumn(col)),
|
||||
);
|
||||
|
||||
protected enabledColumns = computed(() =>
|
||||
protected readonly enabledColumns = computed(() =>
|
||||
this.columns().filter((col) => col.enabled),
|
||||
);
|
||||
|
||||
protected aggregateItems: MenuItem[] = aggregateTypes.map((type) => ({
|
||||
protected readonly aggregateItems: MenuItem[] = aggregateTypes.map((type) => ({
|
||||
label: type,
|
||||
command: () => this.updateAggregateValue(type),
|
||||
}));
|
||||
@@ -218,8 +219,8 @@ export class FileViewerComponent {
|
||||
}
|
||||
|
||||
protected async onLazyLoad(event: TableLazyLoadEvent) {
|
||||
const file = this.file();
|
||||
if (file) {
|
||||
const sheet = this.sheet();
|
||||
if (sheet) {
|
||||
this.appliedFilters.set(
|
||||
event.filters
|
||||
? Object.entries(event.filters).flatMap(([column, filter]) => {
|
||||
@@ -254,16 +255,20 @@ export class FileViewerComponent {
|
||||
: [],
|
||||
);
|
||||
const rows = await this.duckdbService.getRows(
|
||||
file,
|
||||
sheet.file,
|
||||
(event.first ?? 0) + this.currentPage() * this.PAGE_SIZE,
|
||||
event.rows ?? 0,
|
||||
this.columns(),
|
||||
event.multiSortMeta?.map((meta) => ({
|
||||
name: meta.field,
|
||||
sortType: meta.order < 0 ? 'desc' : 'asc',
|
||||
})) ?? [],
|
||||
this.appliedFilters(),
|
||||
this.aggregates().filter((agg) => !!agg) as Aggregate[],
|
||||
{
|
||||
columns: this.columns(),
|
||||
// TODO: We should be maintaining sorts separately so we can store them
|
||||
sorts:
|
||||
event.multiSortMeta?.map((meta) => ({
|
||||
name: meta.field,
|
||||
sortType: meta.order < 0 ? 'desc' : 'asc',
|
||||
})) ?? [],
|
||||
filters: this.appliedFilters(),
|
||||
aggregates: this.aggregates().filter((agg) => !!agg) as Aggregate[],
|
||||
},
|
||||
);
|
||||
// First clear out existing data, don't want to risk loading entire file into memory
|
||||
// Keep data in previous/next page so when user scrolls between 2 pages they don't see missing data
|
||||
@@ -285,20 +290,18 @@ export class FileViewerComponent {
|
||||
}
|
||||
|
||||
private async loadEmpty() {
|
||||
const file = this.file();
|
||||
if (file) {
|
||||
await this.duckdbService.addFile(file);
|
||||
this.columns.set(await this.duckdbService.getColumns(file));
|
||||
// TODO: Add to history, if the history is empty. If not empty, then load from that history
|
||||
const sheet = this.sheet();
|
||||
if (sheet) {
|
||||
await this.duckdbService.addFile(sheet.file);
|
||||
this.columns.set(await this.duckdbService.getColumns(sheet.file));
|
||||
this.aggregates.set(new Array(this.columns().length));
|
||||
const rows = await this.duckdbService.getRows(
|
||||
file,
|
||||
0,
|
||||
this.NUM_ROWS,
|
||||
this.columns(),
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
const rows = await this.duckdbService.getRows(sheet.file, 0, this.NUM_ROWS, {
|
||||
columns: this.columns(),
|
||||
sorts: [],
|
||||
filters: [],
|
||||
aggregates: [],
|
||||
});
|
||||
this.currentRowCount.set(rows.totalRows);
|
||||
this.currentPage.set(0);
|
||||
const newValue = Array.from({
|
||||
@@ -318,6 +321,7 @@ export class FileViewerComponent {
|
||||
}
|
||||
|
||||
private async updateAggregateValue(type: AggregateType) {
|
||||
// Update history
|
||||
this.aggregates.update((aggregates) => {
|
||||
const copy = aggregates.slice();
|
||||
copy[this.currentAggregateColumn()!] = {
|
||||
@@ -327,8 +331,9 @@ export class FileViewerComponent {
|
||||
return copy;
|
||||
});
|
||||
|
||||
|
||||
const aggregateValue = await this.duckdbService.getAggregateValue(
|
||||
this.file()!,
|
||||
this.sheet()!.file,
|
||||
this.appliedFilters(),
|
||||
this.aggregates()[this.currentAggregateColumn()!]!,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user