runeforge 0.0.54 → 0.0.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/components/crud/Field.svelte +108 -15
- package/dist/components/crud/GenericCRUD.svelte +1 -0
- package/dist/components/table/PaginatedTable.svelte +8 -32
- package/dist/components/table/utils.d.ts +9 -0
- package/dist/components/table/utils.js +35 -0
- package/dist/i18n/en.js +1 -0
- package/dist/i18n/es.js +1 -0
- package/dist/i18n/types.d.ts +1 -0
- package/dist/types/attribute.d.ts +9 -0
- package/dist/types/crud.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -379,6 +379,7 @@ Every entry in an `InterfaceMetadata<T>` object is an `AttributeMetadata` — a
|
|
|
379
379
|
| `formatter` | `(data) => (value, row) => string` | all | Custom cell text — see [Formatters](#formatters) |
|
|
380
380
|
| `excludedFromList/Create/Read/Update` | `boolean` | all | Hides the field from that specific view |
|
|
381
381
|
| `sortable` / `filterable` | `boolean` | all | Table column controls |
|
|
382
|
+
| `filterOptions` | `SelectOption[]` | all | Static, exhaustive column filter choices, replacing the sampled-from-loaded-rows checkbox list — see [Server-side pagination, sorting & filtering](#server-side-pagination-sorting--filtering) |
|
|
382
383
|
|
|
383
384
|
### Validation
|
|
384
385
|
|
|
@@ -963,6 +964,23 @@ export const load: PageServerLoad = ({ url }) => {
|
|
|
963
964
|
|
|
964
965
|
No other prop changes are needed — column sorting/filtering UI, the paginator, and (with `config.export.callback`) export all keep working the same way, just backed by the server instead of the in-memory array. Boolean-column filters send comma-separated values (`?active=true,false`); date-range filters send `<attribute>_from`/`<attribute>_to`.
|
|
965
966
|
|
|
967
|
+
A text column's filter checkbox list is populated from values seen on the currently loaded page — a cosmetic hint in server mode, not an exhaustive list, since the full set of values lives server-side. For a column whose possible values are a known, bounded set (an enum-like field, a small lookup table), give it `filterOptions` instead so every choice always shows up, regardless of what the current page contains:
|
|
968
|
+
|
|
969
|
+
```ts
|
|
970
|
+
status: {
|
|
971
|
+
label: 'Status',
|
|
972
|
+
type: AttributeType.text,
|
|
973
|
+
filterable: true,
|
|
974
|
+
filterOptions: [
|
|
975
|
+
{ value: 'DRAFT', label: 'Draft' },
|
|
976
|
+
{ value: 'PUBLISHED', label: 'Published' },
|
|
977
|
+
{ value: 'ARCHIVED', label: 'Archived' },
|
|
978
|
+
],
|
|
979
|
+
},
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
`value` is matched against the column's rendered cell text and sent server-side as-is (same as any other checkbox filter value); `label` is only what's displayed, falling back to `value`.
|
|
983
|
+
|
|
966
984
|
### PaginatedTable
|
|
967
985
|
|
|
968
986
|
A standalone table component with built-in sort, filter, and pagination — the same engine `GenericCRUD` uses internally.
|
|
@@ -93,6 +93,43 @@
|
|
|
93
93
|
return String(n).padStart(2, '0');
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// ISO (yyyy-mm-dd) <-> display (dd/mm/yyyy) conversion and typing mask.
|
|
97
|
+
function isoDateToDisplay(iso: string): string {
|
|
98
|
+
const [year, month, day] = iso.split('-');
|
|
99
|
+
return `${day}/${month}/${year}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function displayToIsoDate(display: string): string | null {
|
|
103
|
+
const match = display.trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
|
104
|
+
if (!match) return null;
|
|
105
|
+
const [, dd, mm, yyyy] = match;
|
|
106
|
+
const day = Number(dd);
|
|
107
|
+
const month = Number(mm);
|
|
108
|
+
const year = Number(yyyy);
|
|
109
|
+
// Rejects invalid dates (e.g. 31/02).
|
|
110
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
111
|
+
if (
|
|
112
|
+
date.getUTCFullYear() !== year ||
|
|
113
|
+
date.getUTCMonth() !== month - 1 ||
|
|
114
|
+
date.getUTCDate() !== day
|
|
115
|
+
) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return `${yyyy}-${mm.padStart(2, '0')}-${dd.padStart(2, '0')}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Inserts "/" as digits are typed: "21" -> "21/", "211" -> "21/1".
|
|
122
|
+
function maskDateDigits(digits: string): string {
|
|
123
|
+
const day = digits.slice(0, 2);
|
|
124
|
+
const month = digits.slice(2, 4);
|
|
125
|
+
const year = digits.slice(4, 8);
|
|
126
|
+
let out = day;
|
|
127
|
+
if (digits.length >= 2) out += '/';
|
|
128
|
+
out += month;
|
|
129
|
+
if (digits.length >= 4) out += '/';
|
|
130
|
+
return out + year;
|
|
131
|
+
}
|
|
132
|
+
|
|
96
133
|
// `record[field.attribute]` may be empty, a bare "YYYY-MM-DD" (freshly
|
|
97
134
|
// picked, before any time is set) or a full ISO datetime (loaded from an
|
|
98
135
|
// existing instance) — parsed once here so the date button, the time
|
|
@@ -112,16 +149,47 @@
|
|
|
112
149
|
const timePart = $derived(
|
|
113
150
|
parsedDateTime ? `${pad(parsedDateTime.getHours())}:${pad(parsedDateTime.getMinutes())}` : ''
|
|
114
151
|
);
|
|
115
|
-
const formattedDatePart = $derived(
|
|
116
|
-
parsedDateTime
|
|
117
|
-
? `${pad(parsedDateTime.getDate())}/${pad(parsedDateTime.getMonth() + 1)}/${parsedDateTime.getFullYear()}`
|
|
118
|
-
: ''
|
|
119
|
-
);
|
|
120
152
|
|
|
121
153
|
function setDateTime(nextDatePart: string, nextTimePart: string) {
|
|
122
154
|
record[field.attribute] = nextDatePart ? `${nextDatePart}T${nextTimePart || '00:00'}` : '';
|
|
123
155
|
}
|
|
124
156
|
|
|
157
|
+
// Typed display text for the date part, synced from `datePart`.
|
|
158
|
+
// eslint-disable-next-line svelte/prefer-writable-derived
|
|
159
|
+
let dateTextValue = $state('');
|
|
160
|
+
$effect(() => {
|
|
161
|
+
dateTextValue = datePart ? isoDateToDisplay(datePart) : '';
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
function onDateTextInput(event: Event) {
|
|
165
|
+
const raw = (event.currentTarget as HTMLInputElement).value;
|
|
166
|
+
const prevDigits = dateTextValue.replace(/\D/g, '');
|
|
167
|
+
let digits = raw.replace(/\D/g, '');
|
|
168
|
+
// Drops one extra digit when backspacing over an inserted "/".
|
|
169
|
+
if (raw.length < dateTextValue.length && digits.length === prevDigits.length) {
|
|
170
|
+
digits = digits.slice(0, -1);
|
|
171
|
+
}
|
|
172
|
+
digits = digits.slice(0, 8);
|
|
173
|
+
dateTextValue = maskDateDigits(digits);
|
|
174
|
+
if (!digits) {
|
|
175
|
+
setDateTime('', timePart);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const parsed = displayToIsoDate(dateTextValue);
|
|
179
|
+
if (parsed) setDateTime(parsed, timePart);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function onDateTextBlur() {
|
|
183
|
+
if (!dateTextValue.trim()) {
|
|
184
|
+
setDateTime('', timePart);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (!displayToIsoDate(dateTextValue)) {
|
|
188
|
+
// Discards invalid input.
|
|
189
|
+
dateTextValue = datePart ? isoDateToDisplay(datePart) : '';
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
125
193
|
$effect(() => {
|
|
126
194
|
if (!field.dependentOptions || isMultiValued) return;
|
|
127
195
|
const current = record[field.attribute];
|
|
@@ -226,16 +294,41 @@
|
|
|
226
294
|
disabled={fieldDisabled}
|
|
227
295
|
/>
|
|
228
296
|
<div class="flex gap-2">
|
|
229
|
-
<
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
297
|
+
<div class="relative flex-1" style="anchor-name:{dateAnchorName}">
|
|
298
|
+
<input
|
|
299
|
+
type="text"
|
|
300
|
+
id={field.attribute}
|
|
301
|
+
placeholder={field.placeholder ?? 'dd/mm/aaaa'}
|
|
302
|
+
value={dateTextValue}
|
|
303
|
+
oninput={onDateTextInput}
|
|
304
|
+
onblur={onDateTextBlur}
|
|
305
|
+
autocomplete="off"
|
|
306
|
+
disabled={fieldDisabled}
|
|
307
|
+
class="input input-bordered w-full pr-9"
|
|
308
|
+
class:input-error={!!error}
|
|
309
|
+
/>
|
|
310
|
+
<button
|
|
311
|
+
type="button"
|
|
312
|
+
popovertarget={datePopId}
|
|
313
|
+
aria-label={strings.chooseDate}
|
|
314
|
+
disabled={fieldDisabled}
|
|
315
|
+
class="absolute inset-y-0 right-0 flex items-center px-2.5 text-base-content/50 hover:text-base-content disabled:pointer-events-none disabled:opacity-40"
|
|
316
|
+
>
|
|
317
|
+
<svg
|
|
318
|
+
class="size-4"
|
|
319
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
320
|
+
viewBox="0 0 24 24"
|
|
321
|
+
fill="none"
|
|
322
|
+
stroke="currentColor"
|
|
323
|
+
stroke-width="2"
|
|
324
|
+
stroke-linecap="round"
|
|
325
|
+
stroke-linejoin="round"
|
|
326
|
+
>
|
|
327
|
+
<rect x="3" y="4" width="18" height="18" rx="2" />
|
|
328
|
+
<path d="M16 2v4M8 2v4M3 10h18" />
|
|
329
|
+
</svg>
|
|
330
|
+
</button>
|
|
331
|
+
</div>
|
|
239
332
|
<input
|
|
240
333
|
type="time"
|
|
241
334
|
class="input input-bordered w-32 shrink-0"
|
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
import TableHeader from './TableHeader.svelte';
|
|
6
6
|
import { SortState, FilterState, snapshotFilter } from './state.svelte.js';
|
|
7
7
|
import {
|
|
8
|
-
|
|
9
|
-
isFilterable,
|
|
8
|
+
resolveDistinctValues,
|
|
10
9
|
resolveReorderComparator
|
|
11
10
|
} from './utils.js';
|
|
12
11
|
import type {
|
|
@@ -83,37 +82,14 @@
|
|
|
83
82
|
const serverFilterSampleSize = 5;
|
|
84
83
|
|
|
85
84
|
// Server mode: there's no way to know every value a column can take without
|
|
86
|
-
// querying the whole (server-owned) dataset, so
|
|
87
|
-
// cosmetic hint — up to
|
|
88
|
-
// the current page, not
|
|
89
|
-
//
|
|
90
|
-
//
|
|
85
|
+
// querying the whole (server-owned) dataset, so a plain text column's
|
|
86
|
+
// entries are deliberately just a cosmetic hint — up to
|
|
87
|
+
// `serverFilterSampleSize` distinct values found on the current page, not
|
|
88
|
+
// an exhaustive list. `boolean` columns and columns with `filterOptions`
|
|
89
|
+
// are the exceptions: their full set of choices is always known upfront,
|
|
90
|
+
// so it shows up regardless of what the current page happens to contain.
|
|
91
91
|
const distinctValues = $derived(
|
|
92
|
-
pagination
|
|
93
|
-
? {
|
|
94
|
-
...Object.fromEntries(
|
|
95
|
-
Object.entries(
|
|
96
|
-
distinctEntries(
|
|
97
|
-
data,
|
|
98
|
-
columns.filter((c) => isFilterable(c) && c.type !== 'boolean')
|
|
99
|
-
)
|
|
100
|
-
).map(([attribute, entries]) => [attribute, entries.slice(0, serverFilterSampleSize)])
|
|
101
|
-
),
|
|
102
|
-
...Object.fromEntries(
|
|
103
|
-
columns
|
|
104
|
-
.filter((c) => isFilterable(c) && c.type === 'boolean')
|
|
105
|
-
.map((c) => [
|
|
106
|
-
c.attribute,
|
|
107
|
-
[
|
|
108
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
109
|
-
{ key: 'true', label: c.formatter?.(true as any, {} as T), row: {} as T },
|
|
110
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
111
|
-
{ key: 'false', label: c.formatter?.(false as any, {} as T), row: {} as T }
|
|
112
|
-
]
|
|
113
|
-
])
|
|
114
|
-
)
|
|
115
|
-
}
|
|
116
|
-
: distinctEntries(data, columns)
|
|
92
|
+
resolveDistinctValues(data, columns, pagination ? serverFilterSampleSize : undefined)
|
|
117
93
|
);
|
|
118
94
|
|
|
119
95
|
// Server-pagination mode owns its own full row set server-side, where
|
|
@@ -5,6 +5,15 @@ export declare function isSortable<T extends object>(col: ColumnDefinition<T>):
|
|
|
5
5
|
export declare function isFilterable<T extends object>(col: ColumnDefinition<T>): boolean;
|
|
6
6
|
export declare function compare(a: unknown, b: unknown): number;
|
|
7
7
|
export declare function distinctEntries<T extends object>(data: T[], columns: ColumnDefinition<T>[]): Record<string, DistinctEntry<T>[]>;
|
|
8
|
+
/** Resolves the checkbox-list entries for every filterable column: a
|
|
9
|
+
* column's own `filterOptions` wins when present (a static, exhaustive
|
|
10
|
+
* list — see its doc comment); a `boolean` column always gets its two known
|
|
11
|
+
* states; everything else falls back to values actually seen in `data`,
|
|
12
|
+
* truncated to `sampleSize` when given. Sampling off `data` is only ever a
|
|
13
|
+
* hint under server-side pagination, where `data` is just the current
|
|
14
|
+
* page — pass `sampleSize` there; omit it in client mode, where `data` is
|
|
15
|
+
* the complete, already-filtered row set. */
|
|
16
|
+
export declare function resolveDistinctValues<T extends object>(data: T[], columns: ColumnDefinition<T>[], sampleSize?: number): Record<string, DistinctEntry<T>[]>;
|
|
8
17
|
/** Returns a copy of `rows` with the item at `from` moved to `to` — pure
|
|
9
18
|
* array surgery, handy for building custom reorder UIs on `PaginatedTable`.
|
|
10
19
|
* Out-of-range indices are a no-op. */
|
|
@@ -42,6 +42,41 @@ export function distinctEntries(data, columns) {
|
|
|
42
42
|
}
|
|
43
43
|
return result;
|
|
44
44
|
}
|
|
45
|
+
/** Resolves the checkbox-list entries for every filterable column: a
|
|
46
|
+
* column's own `filterOptions` wins when present (a static, exhaustive
|
|
47
|
+
* list — see its doc comment); a `boolean` column always gets its two known
|
|
48
|
+
* states; everything else falls back to values actually seen in `data`,
|
|
49
|
+
* truncated to `sampleSize` when given. Sampling off `data` is only ever a
|
|
50
|
+
* hint under server-side pagination, where `data` is just the current
|
|
51
|
+
* page — pass `sampleSize` there; omit it in client mode, where `data` is
|
|
52
|
+
* the complete, already-filtered row set. */
|
|
53
|
+
export function resolveDistinctValues(data, columns, sampleSize) {
|
|
54
|
+
const result = {};
|
|
55
|
+
for (const col of columns) {
|
|
56
|
+
if (!isFilterable(col))
|
|
57
|
+
continue;
|
|
58
|
+
if (col.filterOptions) {
|
|
59
|
+
result[col.attribute] = col.filterOptions.map((o) => ({
|
|
60
|
+
key: o.value,
|
|
61
|
+
label: o.label,
|
|
62
|
+
row: {},
|
|
63
|
+
}));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (col.type === 'boolean') {
|
|
67
|
+
result[col.attribute] = [
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
69
|
+
{ key: 'true', label: col.formatter?.(true, {}), row: {} },
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
71
|
+
{ key: 'false', label: col.formatter?.(false, {}), row: {} },
|
|
72
|
+
];
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const entries = distinctEntries(data, [col])[col.attribute] ?? [];
|
|
76
|
+
result[col.attribute] = sampleSize != null ? entries.slice(0, sampleSize) : entries;
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
45
80
|
/** Returns a copy of `rows` with the item at `from` moved to `to` — pure
|
|
46
81
|
* array surgery, handy for building custom reorder UIs on `PaginatedTable`.
|
|
47
82
|
* Out-of-range indices are a no-op. */
|
package/dist/i18n/en.js
CHANGED
package/dist/i18n/es.js
CHANGED
package/dist/i18n/types.d.ts
CHANGED
|
@@ -75,6 +75,15 @@ export type AttributeMetadata = {
|
|
|
75
75
|
excludedFromRead?: boolean;
|
|
76
76
|
sortable?: boolean;
|
|
77
77
|
filterable?: boolean;
|
|
78
|
+
/** List column filter only: a static, exhaustive set of choices shown in
|
|
79
|
+
* the column filter's checkbox list, instead of the values sampled off
|
|
80
|
+
* currently loaded rows. For a bounded, known set (e.g. an enum-like text
|
|
81
|
+
* column) that the loaded page might not fully represent — especially
|
|
82
|
+
* under [server-side pagination](#server-side-pagination-sorting--filtering),
|
|
83
|
+
* where the sample is only a handful of values off the current page. `value`
|
|
84
|
+
* is matched against the rendered cell text (and sent server-side as-is);
|
|
85
|
+
* `label` is only what's displayed, falling back to `value`. */
|
|
86
|
+
filterOptions?: SelectOption[];
|
|
78
87
|
groupedAs?: string;
|
|
79
88
|
min?: number;
|
|
80
89
|
max?: number;
|
package/dist/types/crud.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export type ColumnDefinition<T extends object = Record<string, unknown>> = {
|
|
|
12
12
|
formatter?: CellFormatter<T, T[K]>;
|
|
13
13
|
sortable?: boolean;
|
|
14
14
|
filterable?: boolean;
|
|
15
|
+
/** Static, exhaustive filter choices — see `AttributeMetadata.filterOptions`. */
|
|
16
|
+
filterOptions?: SelectOption[];
|
|
15
17
|
/** Embedded columns only: sub-field definitions for each item, used to
|
|
16
18
|
* render a default cell summary and to expand the column into one
|
|
17
19
|
* sub-column per field on CSV/XLSX export. */
|