@ashley-shrok/viewmodel-shell 0.3.5 → 0.3.7
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/package.json +1 -1
- package/src/index.ts +55 -28
- package/styles/default.css +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "A server-driven UI framework where the wire format is structured enough that agents can build full-stack apps without ever opening a browser and all UI tests are pure unit tests with no browser runtime. Server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. Backend-agnostic — a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/index.ts
CHANGED
|
@@ -192,6 +192,10 @@ export interface ShellOptions {
|
|
|
192
192
|
getRequestHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
193
193
|
/** Called when the server responds with a redirect URL. Defaults to window.location.href = url. */
|
|
194
194
|
onRedirect?: (url: string) => void;
|
|
195
|
+
/** When set, the shell dispatches a "poll" action at this interval (ms) after every load/dispatch.
|
|
196
|
+
* The server can override the next interval via ShellResponse.nextPollIn, or stop polling by
|
|
197
|
+
* omitting nextPollIn when no pollInterval is configured. */
|
|
198
|
+
pollInterval?: number;
|
|
195
199
|
}
|
|
196
200
|
|
|
197
201
|
export interface ShellSideEffect {
|
|
@@ -208,17 +212,21 @@ export interface ShellResponse {
|
|
|
208
212
|
redirect?: string;
|
|
209
213
|
/** Applied in order before redirect or re-render. */
|
|
210
214
|
sideEffects?: ShellSideEffect[];
|
|
215
|
+
/** When set, schedules the next poll at this delay (ms). Overrides pollInterval for one tick. */
|
|
216
|
+
nextPollIn?: number;
|
|
211
217
|
}
|
|
212
218
|
|
|
213
219
|
export class ViewModelShell {
|
|
214
220
|
private currentVm: ViewNode | null = null;
|
|
215
221
|
private currentState: unknown = null;
|
|
216
222
|
private dispatching = false;
|
|
223
|
+
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
217
224
|
|
|
218
225
|
constructor(private options: ShellOptions) {}
|
|
219
226
|
|
|
220
227
|
async load(params?: Record<string, string>): Promise<void> {
|
|
221
228
|
const { endpoint, adapter, onError, onLoading } = this.options;
|
|
229
|
+
this.stopPolling();
|
|
222
230
|
try {
|
|
223
231
|
onLoading?.(true);
|
|
224
232
|
const url = params ? `${endpoint}?${new URLSearchParams(params)}` : endpoint;
|
|
@@ -229,6 +237,7 @@ export class ViewModelShell {
|
|
|
229
237
|
this.currentVm = body.vm;
|
|
230
238
|
this.currentState = body.state;
|
|
231
239
|
adapter.render(body.vm, (action) => this.dispatch(action));
|
|
240
|
+
this.schedulePoll(body.nextPollIn);
|
|
232
241
|
} catch (err) {
|
|
233
242
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
234
243
|
onError ? onError(error) : console.error("[ViewModelShell]", error);
|
|
@@ -237,9 +246,9 @@ export class ViewModelShell {
|
|
|
237
246
|
}
|
|
238
247
|
}
|
|
239
248
|
|
|
240
|
-
async dispatch(action: ActionEvent): Promise<void> {
|
|
249
|
+
async dispatch(action: ActionEvent, silent = false): Promise<void> {
|
|
241
250
|
if (this.dispatching) return;
|
|
242
|
-
const { actionEndpoint,
|
|
251
|
+
const { actionEndpoint, onError, onLoading } = this.options;
|
|
243
252
|
if (this.currentState === null) {
|
|
244
253
|
const err = new Error(
|
|
245
254
|
`Cannot dispatch '${action.name}' before initial load completes. ` +
|
|
@@ -250,7 +259,7 @@ export class ViewModelShell {
|
|
|
250
259
|
}
|
|
251
260
|
try {
|
|
252
261
|
this.dispatching = true;
|
|
253
|
-
onLoading?.(true);
|
|
262
|
+
if (!silent) onLoading?.(true);
|
|
254
263
|
const form = new FormData();
|
|
255
264
|
form.append("_action", JSON.stringify({ name: action.name, context: action.context ?? {} }));
|
|
256
265
|
form.append("_state", JSON.stringify(this.currentState));
|
|
@@ -266,40 +275,58 @@ export class ViewModelShell {
|
|
|
266
275
|
body: form,
|
|
267
276
|
});
|
|
268
277
|
if (!res.ok) throw new Error(`Action '${action.name}' failed: ${res.status}`);
|
|
269
|
-
|
|
270
|
-
for (const effect of body.sideEffects ?? []) {
|
|
271
|
-
if (effect.type === "set-local-storage" && effect.key != null) {
|
|
272
|
-
localStorage.setItem(effect.key, effect.value ?? "");
|
|
273
|
-
} else if (effect.type === "set-session-storage" && effect.key != null) {
|
|
274
|
-
sessionStorage.setItem(effect.key, effect.value ?? "");
|
|
275
|
-
}
|
|
276
|
-
// unknown types silently ignored — forward-compatible
|
|
277
|
-
}
|
|
278
|
-
if (body.redirect) {
|
|
279
|
-
if (this.options.onRedirect) {
|
|
280
|
-
this.options.onRedirect(body.redirect);
|
|
281
|
-
} else {
|
|
282
|
-
window.location.href = body.redirect;
|
|
283
|
-
}
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
this.currentVm = body.vm;
|
|
287
|
-
this.currentState = body.state;
|
|
288
|
-
adapter.render(body.vm, (a) => this.dispatch(a));
|
|
278
|
+
this.processResponse((await res.json()) as ShellResponse);
|
|
289
279
|
} catch (err) {
|
|
290
280
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
291
281
|
onError ? onError(error) : console.error("[ViewModelShell]", error);
|
|
292
282
|
} finally {
|
|
293
283
|
this.dispatching = false;
|
|
294
|
-
onLoading?.(false);
|
|
284
|
+
if (!silent) onLoading?.(false);
|
|
295
285
|
}
|
|
296
286
|
}
|
|
297
287
|
|
|
298
|
-
|
|
299
|
-
|
|
288
|
+
/** Feed a pre-parsed ShellResponse into the shell — for SSE/WebSocket integrations. */
|
|
289
|
+
push(response: ShellResponse): void {
|
|
290
|
+
if (this.dispatching) return;
|
|
291
|
+
this.processResponse(response);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
stopPolling(): void {
|
|
295
|
+
if (this.pollTimer) { clearTimeout(this.pollTimer); this.pollTimer = null; }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
getCurrentVm(): ViewNode | null { return this.currentVm; }
|
|
299
|
+
getCurrentState(): unknown { return this.currentState; }
|
|
300
|
+
|
|
301
|
+
private processResponse(body: ShellResponse): void {
|
|
302
|
+
for (const effect of body.sideEffects ?? []) {
|
|
303
|
+
if (effect.type === "set-local-storage" && effect.key != null) {
|
|
304
|
+
localStorage.setItem(effect.key, effect.value ?? "");
|
|
305
|
+
} else if (effect.type === "set-session-storage" && effect.key != null) {
|
|
306
|
+
sessionStorage.setItem(effect.key, effect.value ?? "");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (body.redirect) {
|
|
310
|
+
if (this.options.onRedirect) {
|
|
311
|
+
this.options.onRedirect(body.redirect);
|
|
312
|
+
} else {
|
|
313
|
+
window.location.href = body.redirect;
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
this.currentVm = body.vm!;
|
|
318
|
+
this.currentState = body.state;
|
|
319
|
+
this.options.adapter.render(body.vm!, (a) => this.dispatch(a));
|
|
320
|
+
this.schedulePoll(body.nextPollIn);
|
|
300
321
|
}
|
|
301
322
|
|
|
302
|
-
|
|
303
|
-
|
|
323
|
+
private schedulePoll(nextPollIn?: number): void {
|
|
324
|
+
const delay = nextPollIn ?? this.options.pollInterval;
|
|
325
|
+
if (delay == null) return;
|
|
326
|
+
if (this.pollTimer) clearTimeout(this.pollTimer);
|
|
327
|
+
this.pollTimer = setTimeout(() => {
|
|
328
|
+
this.pollTimer = null;
|
|
329
|
+
this.dispatch({ name: "poll" }, true);
|
|
330
|
+
}, delay);
|
|
304
331
|
}
|
|
305
332
|
}
|
package/styles/default.css
CHANGED
|
@@ -345,6 +345,11 @@ select.vms-field__input { cursor: pointer; padding-right: 2.25rem; }
|
|
|
345
345
|
flex-direction: column;
|
|
346
346
|
gap: 0.75rem;
|
|
347
347
|
}
|
|
348
|
+
/* Children of the modal body must not be flex-shrunk below their natural
|
|
349
|
+
height — otherwise a table-wrapper (overflow:hidden) inside the modal
|
|
350
|
+
gets squeezed and clips rows. The modal body's overflow-y:auto handles
|
|
351
|
+
the scroll. */
|
|
352
|
+
.vms-modal__body > * { flex-shrink: 0; }
|
|
348
353
|
.vms-modal__footer {
|
|
349
354
|
display: flex;
|
|
350
355
|
justify-content: flex-end;
|