@itrocks/list 0.8.0 → 0.13.3

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.
@@ -0,0 +1,245 @@
1
+ import { PropertySelectView } from '@itrocks/property-select/front/property-select.js';
2
+ import { normalisePath } from '@itrocks/property-select/property-select.js';
3
+ import { HttpStateStore } from './selector-state.js';
4
+ export class ListPropertySelect {
5
+ article;
6
+ options;
7
+ abort = new AbortController();
8
+ button;
9
+ disposed = false;
10
+ drag;
11
+ observer;
12
+ pane;
13
+ pending = new Map();
14
+ view;
15
+ constructor(article, options) {
16
+ this.article = article;
17
+ this.options = options;
18
+ const content = document.createElement('div');
19
+ this.button = article.querySelector('.add-column');
20
+ this.pane = options.mountSelector(content, 'Add columns');
21
+ this.view = new PropertySelectView(content, {
22
+ consumer: this, metadata: options.metadata, root: article.dataset.propertyRoot,
23
+ advice: options.advice, stateKey: article.dataset.selectorKey, stateMode: 'session',
24
+ stateStore: new HttpStateStore(), usedPaths: this.paths()
25
+ }, { templateUrl: options.templateUrl });
26
+ this.observer = new MutationObserver(records => {
27
+ if (this.drag && records.some(record => record.target.tagName === 'TBODY'))
28
+ this.view.selector.cancel(this.drag.intent.id, 'row view changed');
29
+ this.view.selector.setConfiguration({ usedPaths: this.paths() });
30
+ });
31
+ this.observer.observe(article.querySelector('thead'), { childList: true, subtree: true });
32
+ this.observer.observe(article.querySelector('tbody'), { childList: true });
33
+ this.button.addEventListener('click', () => this.pane.open(), { signal: this.abort.signal });
34
+ }
35
+ async loadColumn(path, signal) {
36
+ const form = this.article.querySelector('form');
37
+ const table = this.article.querySelector('table.objects');
38
+ const rows = [...table.tBodies[0].rows];
39
+ const ids = rows.map(row => row.querySelector('input[name=id]').value);
40
+ const query = new URLSearchParams({ column: path, rowIds: ids.join(',') });
41
+ const response = await fetch(`${form.action}?${query}`, {
42
+ signal: AbortSignal.any([signal, this.abort.signal])
43
+ });
44
+ const result = await response.json();
45
+ if (!response.ok)
46
+ throw new Error(result.error ?? 'Column unavailable');
47
+ signal.throwIfAborted();
48
+ if (this.disposed || !this.article.isConnected || rows.some((row, index) => table.tBodies[0].rows[index] !== row)
49
+ || rows.length !== table.tBodies[0].rows.length)
50
+ throw new Error('The row view changed; try again');
51
+ if (result.path !== path)
52
+ throw new Error('Mismatched column response');
53
+ const values = new Map(result.rows.map(row => [String(row.id), row.html]));
54
+ if (ids.some(id => !values.has(id)))
55
+ throw new Error('The row scope changed; reload the list');
56
+ const header = document.createElement('th');
57
+ header.dataset.property = path;
58
+ header.dataset.sortable = String(result.sortable);
59
+ const input = document.createElement('input');
60
+ input.name = `search.${encodeURIComponent(path).replaceAll('.', '%2E')}`;
61
+ input.dataset.propertyFilter = path;
62
+ input.setAttribute('aria-label', `Filter ${result.label}`);
63
+ header.append(result.label, input);
64
+ const cells = ids.map(id => {
65
+ const cell = document.createElement('td');
66
+ cell.innerHTML = values.get(id);
67
+ return cell;
68
+ });
69
+ return { cells, header };
70
+ }
71
+ async add(path, signal) {
72
+ const content = await this.loadColumn(path, signal);
73
+ const outcome = await this.options.addition.add({ create: () => content, id: path });
74
+ await this.options.saveColumns();
75
+ this.view.selector.setConfiguration({ usedPaths: this.paths() });
76
+ return outcome;
77
+ }
78
+ begin(intent, sample) {
79
+ if (this.disposed || this.drag || this.pending.size)
80
+ throw new Error('A column operation is pending');
81
+ const path = normalisePath(intent.path);
82
+ const paths = this.paths();
83
+ const suspension = this.pane.suspend({ mobileOnly: true });
84
+ try {
85
+ const operation = this.options.addition.beginExternal({
86
+ id: path,
87
+ accept: async (signal) => {
88
+ const form = this.article.querySelector('form');
89
+ const query = new URLSearchParams({ column: path, validate: '1' });
90
+ const response = await fetch(`${form.action}?${query}`, {
91
+ signal: AbortSignal.any([signal, this.abort.signal])
92
+ });
93
+ const result = await response.json();
94
+ if (!response.ok || result.path !== path)
95
+ throw new Error(result.error ?? 'Column unavailable');
96
+ },
97
+ create: () => {
98
+ const header = document.createElement('th');
99
+ header.dataset.property = path;
100
+ header.dataset.sortable = 'false';
101
+ header.textContent = path;
102
+ const cells = [...this.article.querySelectorAll('table.objects tbody tr')].map(() => {
103
+ const cell = document.createElement('td');
104
+ cell.textContent = 'Loading…';
105
+ return cell;
106
+ });
107
+ return { cells, header };
108
+ },
109
+ load: async (signal) => {
110
+ try {
111
+ return await this.loadColumn(path, signal);
112
+ }
113
+ catch (error) {
114
+ if (!this.disposed && !signal.aborted)
115
+ this.previewError(path);
116
+ throw error;
117
+ }
118
+ }
119
+ }, sample);
120
+ this.drag = { intent, operation, paths, restore: () => suspension.restore() };
121
+ }
122
+ catch (error) {
123
+ suspension.restore();
124
+ throw error;
125
+ }
126
+ }
127
+ cancel(id) {
128
+ if (this.drag?.intent.id !== id)
129
+ return;
130
+ const drag = this.drag;
131
+ drag.operation.cancel();
132
+ this.drag = undefined;
133
+ drag.restore();
134
+ this.view.selector.setConfiguration({ usedPaths: this.paths() });
135
+ }
136
+ previewError(path) {
137
+ const header = [...this.article.querySelectorAll('thead [data-property]')]
138
+ .find(header => header.dataset.property === path);
139
+ if (!header)
140
+ return;
141
+ for (const row of this.article.querySelectorAll('tbody tr'))
142
+ row.cells[header.cellIndex].textContent = 'Unable to load values';
143
+ if (header.querySelector('.column-retry'))
144
+ return;
145
+ const retry = document.createElement('button');
146
+ retry.type = 'button';
147
+ retry.className = 'column-retry';
148
+ retry.textContent = 'Retry values';
149
+ retry.addEventListener('click', event => {
150
+ event.stopPropagation();
151
+ void this.options.addition.retry(path);
152
+ }, { signal: this.abort.signal });
153
+ header.append(retry);
154
+ }
155
+ dispose() {
156
+ if (this.disposed)
157
+ return;
158
+ if (this.drag)
159
+ this.cancel(this.drag.intent.id);
160
+ this.disposed = true;
161
+ this.abort.abort();
162
+ this.observer.disconnect();
163
+ this.view.dispose();
164
+ this.pane.remove();
165
+ this.options.addition.dispose();
166
+ this.button.hidden = true;
167
+ this.pending.clear();
168
+ }
169
+ async end(id, sample) {
170
+ const drag = this.drag;
171
+ if (!drag || drag.intent.id !== id)
172
+ throw new Error('Unknown column transport');
173
+ try {
174
+ const status = await drag.operation.finish(sample);
175
+ if (status !== 'cancelled')
176
+ await this.options.saveColumns();
177
+ if (this.disposed)
178
+ return { ...drag.intent, status: 'cancelled' };
179
+ if (this.drag === drag)
180
+ this.drag = undefined;
181
+ this.view.selector.setConfiguration({ usedPaths: this.paths() });
182
+ return { ...drag.intent, status };
183
+ }
184
+ catch (error) {
185
+ drag.operation.cancel();
186
+ return { ...drag.intent, status: 'rejected', message: String(error) };
187
+ }
188
+ finally {
189
+ if (this.drag === drag)
190
+ this.drag = undefined;
191
+ drag.restore();
192
+ }
193
+ }
194
+ async mount() {
195
+ const mounted = await this.view.mount();
196
+ if (mounted && !this.disposed)
197
+ this.button.hidden = false;
198
+ return mounted;
199
+ }
200
+ move(id, sample) {
201
+ if (this.drag?.intent.id === id)
202
+ this.drag.operation.move(sample);
203
+ }
204
+ paths() {
205
+ if (this.drag)
206
+ return [...this.drag.paths];
207
+ return [...this.article.querySelectorAll('thead [data-property]:not(.column-add-provisional)')]
208
+ .map(header => header.dataset.property);
209
+ }
210
+ async select(intent, signal) {
211
+ const path = normalisePath(intent.path);
212
+ try {
213
+ if (this.drag)
214
+ throw new Error('A column transport is pending');
215
+ let status;
216
+ const pending = this.pending.get(path);
217
+ if (pending) {
218
+ await pending;
219
+ status = 'existing';
220
+ }
221
+ else if (this.paths().includes(path))
222
+ status = 'existing';
223
+ else {
224
+ const operation = this.add(path, signal);
225
+ this.pending.set(path, operation);
226
+ try {
227
+ status = await operation;
228
+ }
229
+ finally {
230
+ if (this.pending.get(path) === operation)
231
+ this.pending.delete(path);
232
+ }
233
+ }
234
+ signal.throwIfAborted();
235
+ if (this.disposed)
236
+ throw new Error('List disposed');
237
+ this.options.addition.reveal(path);
238
+ this.options.addition.highlight(path);
239
+ return { ...intent, status };
240
+ }
241
+ catch (error) {
242
+ return { ...intent, message: error instanceof Error ? error.message : 'Column unavailable', status: 'rejected' };
243
+ }
244
+ }
245
+ }
@@ -0,0 +1,14 @@
1
+ import type { SelectorState } from '@itrocks/property-select/property-select.js';
2
+ import type { StateRecord } from '@itrocks/property-select/property-select.js';
3
+ import type { StateStore } from '@itrocks/property-select/property-select.js';
4
+ /** The key is supplied by the host router; this transport does not derive it from location. */
5
+ export declare class HttpStateStore implements StateStore {
6
+ private readonly endpoint;
7
+ private readonly fetcher;
8
+ private generation?;
9
+ constructor(endpoint?: string, fetcher?: typeof fetch);
10
+ read(key: string, signal?: AbortSignal): Promise<StateRecord>;
11
+ remove(key: string, revision: number): Promise<StateRecord>;
12
+ write(key: string, state: SelectorState, revision: number): Promise<StateRecord>;
13
+ private request;
14
+ }
@@ -0,0 +1,38 @@
1
+ import { StateConflictError } from '@itrocks/property-select/property-select.js';
2
+ /** The key is supplied by the host router; this transport does not derive it from location. */
3
+ export class HttpStateStore {
4
+ endpoint;
5
+ fetcher;
6
+ generation;
7
+ constructor(endpoint = '/property-select/state/json', fetcher = globalThis.fetch.bind(globalThis)) {
8
+ this.endpoint = endpoint;
9
+ this.fetcher = fetcher;
10
+ }
11
+ read(key, signal) {
12
+ return this.request('GET', key, undefined, undefined, signal);
13
+ }
14
+ remove(key, revision) {
15
+ return this.request('DELETE', key, undefined, revision);
16
+ }
17
+ write(key, state, revision) {
18
+ return this.request('PUT', key, state, revision);
19
+ }
20
+ async request(method, key, state, revision, signal) {
21
+ const response = await this.fetcher(`${this.endpoint}?${new URLSearchParams({ key })}`, {
22
+ body: method === 'GET' ? undefined : JSON.stringify({ revision, state }), cache: 'no-store',
23
+ credentials: 'same-origin', method, signal,
24
+ headers: { 'Content-Type': 'application/json', 'X-Property-Select-Session': this.generation ?? '' }
25
+ });
26
+ if (response.status === 410)
27
+ throw new Error('The selector session has ended. Reload the page.');
28
+ if (response.status === 409)
29
+ throw new StateConflictError();
30
+ if (!response.ok)
31
+ throw new Error(`Unable to save selector state (${response.status}). Try again.`);
32
+ const generation = response.headers.get('X-Property-Select-Session') ?? undefined;
33
+ if (this.generation && generation !== this.generation)
34
+ throw new Error('The selector session has ended. Reload the page.');
35
+ this.generation = generation;
36
+ return response.json();
37
+ }
38
+ }