diff --git a/mathesar_ui/src/App.d.ts b/mathesar_ui/src/App.d.ts index bc466325b8..c2ecc90cbe 100644 --- a/mathesar_ui/src/App.d.ts +++ b/mathesar_ui/src/App.d.ts @@ -33,12 +33,6 @@ export interface SchemaResponse extends SchemaEntry, TreeItem { tables: DBObjectEntry[]; } -// TODO: Come up with a better name for representing both tables and views -export enum TabularType { - Table = 1, - View = 2, -} - export type DbType = string; export interface FilterConfiguration { diff --git a/mathesar_ui/src/App.svelte b/mathesar_ui/src/App.svelte index 7214fd2697..cf23f44695 100644 --- a/mathesar_ui/src/App.svelte +++ b/mathesar_ui/src/App.svelte @@ -6,6 +6,15 @@ import Header from '@mathesar/header/Header.svelte'; import { toast } from '@mathesar/stores/toast'; import { confirmationController } from '@mathesar/stores/confirmation'; + import { currentSchemaId } from '@mathesar/stores/schemas'; + import { beginUpdatingUrlWhenSchemaChanges } from './utils/routing'; + + // This is a bit of a hack to deal with our routing still being a patchwork of + // declarative and imperative logic. Without this call, the URL will not + // reliably set the query params when the schema changes. It actually _will_ + // set the query params _sometimes_, but we weren't able to figure out why the + // behavior is inconsistent. + beginUpdatingUrlWhenSchemaChanges(currentSchemaId); diff --git a/mathesar_ui/src/api/tables/records.ts b/mathesar_ui/src/api/tables/records.ts index 1d7bc94003..19451aae99 100644 --- a/mathesar_ui/src/api/tables/records.ts +++ b/mathesar_ui/src/api/tables/records.ts @@ -1,6 +1,51 @@ +export interface Grouping { + /** Each string is a column name */ + columns: string[]; + mode: GroupingMode; + /** + * When `mode` === 'distinct', `num_groups` will always be `null`. + * + * When `mode` === 'percentile', `num_groups` will give the number of groups, + * as specified in the request params. + */ + num_groups: number | null; + ranged: boolean; + groups: Group[]; +} + +export type SortDirection = 'asc' | 'desc'; +export interface SortingEntry { + /** column name */ + field: string; + direction: SortDirection; +} +export type FilterCombination = 'and' | 'or'; +export type FilterOperation = 'eq' | 'ne' | 'get_duplicates'; +export interface FilterCondition { + /** column name */ + field: string; + op: FilterOperation; + value: unknown; +} +type MakeFilteringOption = U extends string + ? { [k in U]: FilterCondition[] } + : never; +export type Filtering = + | FilterCondition[] + | MakeFilteringOption; + +export interface GetRequestParams { + limit?: number; + offset?: number; + order_by?: SortingEntry[]; + grouping?: Pick; + filters?: Filtering; +} + export type ResultValue = string | number | boolean | null; export interface Result { + /** keys are column names */ [k: string]: ResultValue; } @@ -29,21 +74,6 @@ export interface Group { result_indices: number[]; } -export interface Grouping { - /** Each string is a column name */ - columns: string[]; - mode: GroupingMode; - /** - * When `mode` === 'distinct', `num_groups` will always be `null`. - * - * When `mode` === 'percentile', `num_groups` will give the number of groups, - * as specified in the request params. - */ - num_groups: number | null; - ranged: boolean; - groups: Group[]; -} - export interface Response { count: number; grouping: Grouping | null; diff --git a/mathesar_ui/src/component-library/common/utils/ImmutableMap.ts b/mathesar_ui/src/component-library/common/utils/ImmutableMap.ts new file mode 100644 index 0000000000..bc264093d4 --- /dev/null +++ b/mathesar_ui/src/component-library/common/utils/ImmutableMap.ts @@ -0,0 +1,105 @@ +export default class ImmutableMap< + Key extends string | number | boolean | null, + Value, +> { + private map: Map; + + constructor(i: Iterable<[Key, Value]> = []) { + this.map = new Map(i); + } + + /** + * This method exists to allow us to subclass this class and call the + * constructor of the subclass from within this base class. + * + * If there's a way we can use generics to avoid `any` here, we'd love to + * know. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private getNewInstance(...args: any[]): this { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any + return new (this.constructor as any)(...args) as this; + } + + /** + * The value supplied here will overwrite any value that is already associated + * with `key`. + */ + with(key: Key, value: Value): this { + const map = new Map(this.map); + map.set(key, value); + return this.getNewInstance(map); + } + + /** + * When the same keys exist in within the entries of this instance and the + * entries supplied, the values from the entries supplied will be used instead + * of the values in this instance. This behavior is consistent with the `with` + * method. + */ + withEntries(entries: Iterable<[Key, Value]>): this { + const map = new Map(this.map); + [...entries].forEach(([key, value]) => { + map.set(key, value); + }); + return this.getNewInstance(map); + } + + /** + * If `key` already exists, its corresponding value will remain. If `key` does + * not exist, then the value supplied here will be used. + */ + coalesce(key: Key, value: Value): this { + return this.has(key) ? this : this.with(key, value); + } + + /** + * When the same keys exist in within the entries of this instance and the + * entries supplied, the values from this instance will be used instead of the + * values from the supplied entries. This behavior is consistent with the + * `coalesce` method. + */ + coalesceEntries(other: Iterable<[Key, Value]>): this { + const map = new Map(this.map); + [...other].forEach(([key, value]) => { + if (!this.has(key)) { + map.set(key, value); + } + }); + return this.getNewInstance(map); + } + + without(key: Key): this { + const map = new Map(this.map); + map.delete(key); + return this.getNewInstance(map); + } + + has(key: Key): boolean { + return this.map.has(key); + } + + get(key: Key): Value | undefined { + return this.map.get(key); + } + + get size(): number { + return this.map.size; + } + + keys(): IterableIterator { + return this.map.keys(); + } + + values(): IterableIterator { + return this.map.values(); + } + + entries(): IterableIterator<[Key, Value]> { + return this.map.entries(); + } + + [Symbol.iterator](): IterableIterator<[Key, Value]> { + return this.entries(); + } +} diff --git a/mathesar_ui/src/component-library/common/utils/ImmutableSet.ts b/mathesar_ui/src/component-library/common/utils/ImmutableSet.ts index f11cefd7cf..e3ecde4b22 100644 --- a/mathesar_ui/src/component-library/common/utils/ImmutableSet.ts +++ b/mathesar_ui/src/component-library/common/utils/ImmutableSet.ts @@ -5,24 +5,38 @@ export default class ImmutableSet { this.set = new Set(i); } - with(item: T): ImmutableSet { + /** + * This method exists to allow us to subclass this class and call the + * constructor of the subclass from within this base class. + * + * If there's a way we can use generics to avoid `any` here, we'd love to + * know. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private getNewInstance(...args: any[]): this { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any + return new (this.constructor as any)(...args) as this; + } + + with(item: T): this { const set = new Set(this.set); set.add(item); - return new ImmutableSet(set); + return this.getNewInstance(set); } - union(other: ImmutableSet): ImmutableSet { + union(other: ImmutableSet): this { const set = new Set(this.set); [...other.values()].forEach((value) => { set.add(value); }); - return new ImmutableSet(set); + return this.getNewInstance(set); } - without(item: T): ImmutableSet { + without(item: T): this { const set = new Set(this.set); set.delete(item); - return new ImmutableSet(set); + // + return this.getNewInstance(set); } has(item: T): boolean { @@ -40,4 +54,8 @@ export default class ImmutableSet { valuesArray(): T[] { return [...this.set.values()]; } + + [Symbol.iterator](): IterableIterator { + return this.values(); + } } diff --git a/mathesar_ui/src/component-library/common/utils/formatUtils.ts b/mathesar_ui/src/component-library/common/utils/formatUtils.ts index a36a511674..8c7a163edf 100644 --- a/mathesar_ui/src/component-library/common/utils/formatUtils.ts +++ b/mathesar_ui/src/component-library/common/utils/formatUtils.ts @@ -1,3 +1,5 @@ +import { hasStringLabelProperty } from './typeUtils'; + export function formatSize(sizeInBytes: number): string { if (sizeInBytes === 0) { return '0 B'; @@ -13,3 +15,14 @@ export function formatSize(sizeInBytes: number): string { return `${repValue.toFixed(2)} ${repUnit}B`; } + +/** + * If the given value has a label property and it is a string, return it. + * Otherwise, return the value itself, converted to a string. + */ +export function getLabel(v: unknown): string { + if (hasStringLabelProperty(v)) { + return v.label; + } + return String(v); +} diff --git a/mathesar_ui/src/component-library/common/utils/index.ts b/mathesar_ui/src/component-library/common/utils/index.ts index 6053f86655..9f4857aca3 100644 --- a/mathesar_ui/src/component-library/common/utils/index.ts +++ b/mathesar_ui/src/component-library/common/utils/index.ts @@ -2,6 +2,7 @@ export { default as CancellablePromise } from './CancellablePromise'; export { default as EventHandler } from './EventHandler'; export { default as ImmutableSet } from './ImmutableSet'; +export { default as ImmutableMap } from './ImmutableMap'; // Utility Functions export * from './domUtils'; diff --git a/mathesar_ui/src/component-library/common/utils/typeUtils.ts b/mathesar_ui/src/component-library/common/utils/typeUtils.ts new file mode 100644 index 0000000000..f85f293ec0 --- /dev/null +++ b/mathesar_ui/src/component-library/common/utils/typeUtils.ts @@ -0,0 +1,7 @@ +function hasLabelProperty(v: unknown): v is { label: unknown } { + return typeof v === 'object' && v !== null && 'label' in v; +} + +export function hasStringLabelProperty(v: unknown): v is { label: string } { + return hasLabelProperty(v) && typeof v.label === 'string'; +} diff --git a/mathesar_ui/src/component-library/index.ts b/mathesar_ui/src/component-library/index.ts index 88b9349e94..9f3e1a7a9e 100644 --- a/mathesar_ui/src/component-library/index.ts +++ b/mathesar_ui/src/component-library/index.ts @@ -33,6 +33,7 @@ export { default as Dropdown } from './dropdown/Dropdown.svelte'; export { default as FileUpload } from './file-upload/FileUpload.svelte'; export { default as Notification } from './notification/Notification.svelte'; export { default as Pagination } from './pagination/Pagination.svelte'; +export { default as SimpleSelect } from './simple-select/SimpleSelect.svelte'; export { default as Select } from './select/Select.svelte'; export { default as TabContainer } from './tabs/TabContainer.svelte'; export { default as Tree } from './tree/Tree.svelte'; diff --git a/mathesar_ui/src/component-library/pagination/Pagination.svelte b/mathesar_ui/src/component-library/pagination/Pagination.svelte index 114ae1c892..c0eb4576a8 100644 --- a/mathesar_ui/src/component-library/pagination/Pagination.svelte +++ b/mathesar_ui/src/component-library/pagination/Pagination.svelte @@ -29,6 +29,12 @@ undefined; // Total number of pages. + // + // TODO: @seancolsen says: + // > Refactor `pageCount` to no longer be an exported prop. We're exporting it + // > just so the parent component can access the calculation done within this + // > component. That's an unconventional flow of data. I'd rather do the + // > calculation in the parent and pass it down to this component. export let pageCount = 0; // ARIA Label for component diff --git a/mathesar_ui/src/component-library/select/Select.svelte b/mathesar_ui/src/component-library/select/Select.svelte index bcdc5eb0da..a46a0ec0ae 100644 --- a/mathesar_ui/src/component-library/select/Select.svelte +++ b/mathesar_ui/src/component-library/select/Select.svelte @@ -1,3 +1,11 @@ + + + +