@zerotal/flow 1.0.0
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/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +84 -0
- package/src/Component.ts +1498 -0
- package/src/Form.ts +189 -0
- package/src/Layout.ts +47 -0
- package/src/augment.ts +94 -0
- package/src/client/FlowClientError.ts +12 -0
- package/src/client/FlowComponent.ts +96 -0
- package/src/client/alpinejs-morph.d.ts +5 -0
- package/src/client/bridge.ts +3463 -0
- package/src/client/confirm.ts +338 -0
- package/src/client/cspAdapter.ts +79 -0
- package/src/client/cspEvaluator.ts +494 -0
- package/src/client/errorOverlay.ts +124 -0
- package/src/client/headless.ts +948 -0
- package/src/client/index.csp.ts +47 -0
- package/src/client/index.ts +56 -0
- package/src/client/optimistic.ts +52 -0
- package/src/client/timeline.ts +160 -0
- package/src/client/url.ts +51 -0
- package/src/commands/MakeFlowCommand.ts +290 -0
- package/src/compiler/cache.ts +101 -0
- package/src/compiler/directives.ts +107 -0
- package/src/compiler/helpers.ts +26 -0
- package/src/compiler/index.ts +172 -0
- package/src/compiler/transform.ts +2070 -0
- package/src/components.ts +1465 -0
- package/src/config.ts +52 -0
- package/src/decorators.ts +978 -0
- package/src/dehydrate.ts +333 -0
- package/src/durable.ts +243 -0
- package/src/events.ts +118 -0
- package/src/frameworkEvents.ts +40 -0
- package/src/headless.ts +1147 -0
- package/src/index.ts +267 -0
- package/src/jsx-dev-runtime.ts +14 -0
- package/src/jsx-runtime.ts +1272 -0
- package/src/mixins.ts +199 -0
- package/src/observability.ts +167 -0
- package/src/pagination.ts +300 -0
- package/src/presence.ts +81 -0
- package/src/provider/FlowProvider.ts +1401 -0
- package/src/registry.ts +118 -0
- package/src/renderContext.ts +55 -0
- package/src/router.ts +401 -0
- package/src/shared.ts +226 -0
- package/src/store.ts +116 -0
- package/src/synths/CarbonIntervalSynth.ts +22 -0
- package/src/synths/CarbonSynth.ts +20 -0
- package/src/synths/CollectionSynth.ts +62 -0
- package/src/synths/DateSynth.ts +24 -0
- package/src/synths/ModelSynth.ts +78 -0
- package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
- package/src/synths/index.ts +148 -0
- package/src/testing.ts +361 -0
- package/src/types.ts +315 -0
- package/src/uploads/FileUploads.ts +94 -0
- package/src/uploads/TemporaryUploadedFile.ts +274 -0
- package/src/uploads/sign.ts +18 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +216 -0
- package/src/validation.ts +55 -0
package/src/mixins.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// ── Component mixins ────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// `ComponentWith(...)` composes any number of feature mixins on top of `Component`,
|
|
4
|
+
// so a page can be broken into focused, reusable behaviours (pagination, file uploads,
|
|
5
|
+
// sortable tables, …) that stack left-to-right instead of bloating one class — the
|
|
6
|
+
// Flow counterpart of the ORM's `BaseModelWith(...)`:
|
|
7
|
+
//
|
|
8
|
+
// // before — one class carrying every feature inline, base repeated per mixin
|
|
9
|
+
// class UsersPage extends FileUploads(Pagination(Component)) { … }
|
|
10
|
+
//
|
|
11
|
+
// // after — flat, left-to-right, base baked in
|
|
12
|
+
// class UsersPage extends ComponentWith(Pagination, FileUploads) {
|
|
13
|
+
// async render() { … } // the page only owns its own markup + state
|
|
14
|
+
// }
|
|
15
|
+
//
|
|
16
|
+
// A Component mixin is the canonical generic form below — note it extends an ABSTRACT
|
|
17
|
+
// base and returns an `abstract class`, so it doesn't have to implement Component's
|
|
18
|
+
// abstract `render()` (the final page supplies it):
|
|
19
|
+
//
|
|
20
|
+
// // app/flow/mixins/file-upload.ts
|
|
21
|
+
// import { Component, expose, type Constructor } from "@zerotal/flow";
|
|
22
|
+
//
|
|
23
|
+
// export function FileUpload<T extends Constructor<Component>>(Base: T) {
|
|
24
|
+
// abstract class WithUpload extends Base {
|
|
25
|
+
// @expose uploading = false;
|
|
26
|
+
// @expose async upload() { … }
|
|
27
|
+
// }
|
|
28
|
+
// return WithUpload;
|
|
29
|
+
// }
|
|
30
|
+
//
|
|
31
|
+
// Because each mixin returns `class extends Base`, Component's full surface (`flash()`,
|
|
32
|
+
// `redirect()`, `validate()`, the `this.set`/`refresh`/… client magics, …) and every other
|
|
33
|
+
// mixin's members flow through to the final page — fully type-checked. The shipped
|
|
34
|
+
// `Pagination` mixin (see ./pagination.ts) is written exactly this way and composes here:
|
|
35
|
+
// `ComponentWith(Pagination)`.
|
|
36
|
+
//
|
|
37
|
+
// Mixin `@expose`/`@locked` props register on the mixin's prototype, which sits in the page's
|
|
38
|
+
// prototype chain — so they're discovered at runtime exactly like props declared on the page
|
|
39
|
+
// itself (snapshot, reactivity, client writes, `@url` sync all work). Note: pages composed
|
|
40
|
+
// with `ComponentWith(...)` render via the runtime path rather than the AOT compiler (which
|
|
41
|
+
// only statically sees a page's own `extends Component` + locally-declared members) — the same
|
|
42
|
+
// fallback complex pages already use; behaviour is identical, you just don't get the
|
|
43
|
+
// ahead-of-time compile step for that page.
|
|
44
|
+
|
|
45
|
+
import { Component } from "./Component.ts";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A class constructor. Abstract-aware (`abstract new …`) so the abstract `Component` base —
|
|
49
|
+
* and mixin classes that don't yet implement `render()` — satisfy it. Use `Constructor<Component>`
|
|
50
|
+
* as a mixin's base bound to require a Component lineage.
|
|
51
|
+
*/
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic mixin base bound
|
|
53
|
+
export type Constructor<T = object> = abstract new (...args: any[]) => T;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A Component mixin: receives a base constructor and returns an extended one. Authors write
|
|
57
|
+
* the generic form so the mixin composes onto whatever base it is given:
|
|
58
|
+
*
|
|
59
|
+
* export function FileUpload<T extends Constructor<Component>>(Base: T) {
|
|
60
|
+
* abstract class WithUpload extends Base { … }
|
|
61
|
+
* return WithUpload;
|
|
62
|
+
* }
|
|
63
|
+
*/
|
|
64
|
+
export type Mixin<TIn extends Constructor = Constructor, TOut extends Constructor = Constructor> = (
|
|
65
|
+
Base: TIn,
|
|
66
|
+
) => TOut;
|
|
67
|
+
|
|
68
|
+
type Base = typeof Component;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Compose one or more feature {@link Mixin}s onto {@link Component}, folding them left-to-right
|
|
72
|
+
* so a page can be built from focused, reusable behaviours instead of one bloated class.
|
|
73
|
+
*
|
|
74
|
+
* @remarks
|
|
75
|
+
* Each mixin receives the accumulated base and returns an extended class, so `Component`'s full
|
|
76
|
+
* surface (`flash()`, `redirect()`, `validate()`, the client magics, …) and every mixin's
|
|
77
|
+
* `@expose`/`@locked` members flow through to the final page — fully type-checked. Mixin props
|
|
78
|
+
* register on the mixin prototype, which sits in the page's prototype chain, so snapshot,
|
|
79
|
+
* reactivity, and client writes all work. Overloads cover 1–8 mixins; nest another
|
|
80
|
+
* `ComponentWith(...)` call for more. Pages composed this way render via the runtime path rather
|
|
81
|
+
* than the AOT compiler.
|
|
82
|
+
*
|
|
83
|
+
* @param mixins mixin factories applied left-to-right (each takes the prior result as its base).
|
|
84
|
+
* @returns a class extending `Component` with every mixin's members mixed in.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```tsx
|
|
88
|
+
* // app/flow/mixins/pagination.ts
|
|
89
|
+
* import { Component, expose, type Constructor } from "@zerotal/flow";
|
|
90
|
+
*
|
|
91
|
+
* export function Pagination<T extends Constructor<Component>>(Base: T) {
|
|
92
|
+
* abstract class WithPagination extends Base {
|
|
93
|
+
* @expose page = 1;
|
|
94
|
+
* @expose next() { this.page++; }
|
|
95
|
+
* }
|
|
96
|
+
* return WithPagination;
|
|
97
|
+
* }
|
|
98
|
+
*
|
|
99
|
+
* // app/flow/UsersPage.tsx
|
|
100
|
+
* class UsersPage extends ComponentWith(Pagination) {
|
|
101
|
+
* override async render() {
|
|
102
|
+
* return <div data-flow-root>Page {this.page}</div>;
|
|
103
|
+
* }
|
|
104
|
+
* }
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export function ComponentWith<A extends Constructor>(a: (base: Base) => A): A;
|
|
108
|
+
export function ComponentWith<A extends Constructor, B extends Constructor>(
|
|
109
|
+
a: (base: Base) => A,
|
|
110
|
+
b: (base: A) => B,
|
|
111
|
+
): B;
|
|
112
|
+
export function ComponentWith<A extends Constructor, B extends Constructor, C extends Constructor>(
|
|
113
|
+
a: (base: Base) => A,
|
|
114
|
+
b: (base: A) => B,
|
|
115
|
+
c: (base: B) => C,
|
|
116
|
+
): C;
|
|
117
|
+
export function ComponentWith<
|
|
118
|
+
A extends Constructor,
|
|
119
|
+
B extends Constructor,
|
|
120
|
+
C extends Constructor,
|
|
121
|
+
D extends Constructor,
|
|
122
|
+
>(a: (base: Base) => A, b: (base: A) => B, c: (base: B) => C, d: (base: C) => D): D;
|
|
123
|
+
export function ComponentWith<
|
|
124
|
+
A extends Constructor,
|
|
125
|
+
B extends Constructor,
|
|
126
|
+
C extends Constructor,
|
|
127
|
+
D extends Constructor,
|
|
128
|
+
E extends Constructor,
|
|
129
|
+
>(
|
|
130
|
+
a: (base: Base) => A,
|
|
131
|
+
b: (base: A) => B,
|
|
132
|
+
c: (base: B) => C,
|
|
133
|
+
d: (base: C) => D,
|
|
134
|
+
e: (base: D) => E,
|
|
135
|
+
): E;
|
|
136
|
+
export function ComponentWith<
|
|
137
|
+
A extends Constructor,
|
|
138
|
+
B extends Constructor,
|
|
139
|
+
C extends Constructor,
|
|
140
|
+
D extends Constructor,
|
|
141
|
+
E extends Constructor,
|
|
142
|
+
F extends Constructor,
|
|
143
|
+
>(
|
|
144
|
+
a: (base: Base) => A,
|
|
145
|
+
b: (base: A) => B,
|
|
146
|
+
c: (base: B) => C,
|
|
147
|
+
d: (base: C) => D,
|
|
148
|
+
e: (base: D) => E,
|
|
149
|
+
f: (base: E) => F,
|
|
150
|
+
): F;
|
|
151
|
+
export function ComponentWith<
|
|
152
|
+
A extends Constructor,
|
|
153
|
+
B extends Constructor,
|
|
154
|
+
C extends Constructor,
|
|
155
|
+
D extends Constructor,
|
|
156
|
+
E extends Constructor,
|
|
157
|
+
F extends Constructor,
|
|
158
|
+
G extends Constructor,
|
|
159
|
+
>(
|
|
160
|
+
a: (base: Base) => A,
|
|
161
|
+
b: (base: A) => B,
|
|
162
|
+
c: (base: B) => C,
|
|
163
|
+
d: (base: C) => D,
|
|
164
|
+
e: (base: D) => E,
|
|
165
|
+
f: (base: E) => F,
|
|
166
|
+
g: (base: F) => G,
|
|
167
|
+
): G;
|
|
168
|
+
export function ComponentWith<
|
|
169
|
+
A extends Constructor,
|
|
170
|
+
B extends Constructor,
|
|
171
|
+
C extends Constructor,
|
|
172
|
+
D extends Constructor,
|
|
173
|
+
E extends Constructor,
|
|
174
|
+
F extends Constructor,
|
|
175
|
+
G extends Constructor,
|
|
176
|
+
H extends Constructor,
|
|
177
|
+
>(
|
|
178
|
+
a: (base: Base) => A,
|
|
179
|
+
b: (base: A) => B,
|
|
180
|
+
c: (base: B) => C,
|
|
181
|
+
d: (base: C) => D,
|
|
182
|
+
e: (base: D) => E,
|
|
183
|
+
f: (base: E) => F,
|
|
184
|
+
g: (base: F) => G,
|
|
185
|
+
h: (base: G) => H,
|
|
186
|
+
): H;
|
|
187
|
+
|
|
188
|
+
// Implementation: fold the mixins over Component, left to right. The base param is `any` so
|
|
189
|
+
// each typed overload above (whose base is the narrower `typeof Component` or a prior mixin's
|
|
190
|
+
// result) stays assignable to this implementation signature.
|
|
191
|
+
export function ComponentWith(
|
|
192
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- variadic mixin folder
|
|
193
|
+
...mixins: Array<(base: any) => Constructor>
|
|
194
|
+
): Constructor {
|
|
195
|
+
return mixins.reduce<Constructor>(
|
|
196
|
+
(acc, mixin) => mixin(acc),
|
|
197
|
+
Component as unknown as Constructor,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow → observer bridges. Flow emits its own realtime framework events
|
|
3
|
+
* (`WebSocketConnected` / `WebSocketDisconnected` / `FlowActionHandled`) on the
|
|
4
|
+
* core `FrameworkEvents` bus; this module forwards them to whichever observer
|
|
5
|
+
* packages are installed.
|
|
6
|
+
*
|
|
7
|
+
* A Flow action round-trips like a request, so its monitor record carries the
|
|
8
|
+
* same correlated per-request state (queries, N+1 flag, custom context) that other
|
|
9
|
+
* packages buffered against the action's HttpContext — drained here through the
|
|
10
|
+
* store's `collectRequestState`.
|
|
11
|
+
*
|
|
12
|
+
* Each observer's write surface is resolved from the container by binding key and
|
|
13
|
+
* typed through a local structural interface, so Flow depends on none of the
|
|
14
|
+
* observer packages — installing or removing an observer requires no change here.
|
|
15
|
+
*/
|
|
16
|
+
import { FrameworkEvents } from "@zerotal/core";
|
|
17
|
+
import type { Application } from "@zerotal/core";
|
|
18
|
+
import { WebSocketConnected, WebSocketDisconnected, FlowActionHandled } from "./frameworkEvents.ts";
|
|
19
|
+
|
|
20
|
+
/** The subset of the monitor store this bridge calls (bound as `monitor.store`). */
|
|
21
|
+
interface MonitorSink {
|
|
22
|
+
recordEvent(e: {
|
|
23
|
+
kind: string;
|
|
24
|
+
label: string;
|
|
25
|
+
status?: "ok" | "warn" | "bad" | "info";
|
|
26
|
+
route?: string | null;
|
|
27
|
+
data?: Record<string, unknown>;
|
|
28
|
+
}): void;
|
|
29
|
+
collectRequestState(ctx: object): {
|
|
30
|
+
queries: unknown[];
|
|
31
|
+
nplus: boolean;
|
|
32
|
+
context: Record<string, unknown>;
|
|
33
|
+
payload: unknown;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The subset of the devtools trace sink this bridge calls (bound as `devtools.trace`). */
|
|
38
|
+
interface DevtoolsSink {
|
|
39
|
+
channel(descriptor: {
|
|
40
|
+
id: string;
|
|
41
|
+
label: string;
|
|
42
|
+
badge?: string;
|
|
43
|
+
title?: string;
|
|
44
|
+
meta?: string[];
|
|
45
|
+
warn?: string;
|
|
46
|
+
order?: number;
|
|
47
|
+
}): void;
|
|
48
|
+
record(ctx: object, channel: string, entry: Record<string, unknown>): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Framework/asset noise the panel shouldn't chart as realtime traffic.
|
|
52
|
+
const IGNORE_PREFIXES = ["/monitor", "/metrics", "/__flow", "/__zerotal", "/__dev", "/health"];
|
|
53
|
+
|
|
54
|
+
/** Raw request path from an HttpContext (best-effort). */
|
|
55
|
+
function _rawPath(ctx: object): string {
|
|
56
|
+
return (ctx as { url?: { pathname?: string } }).url?.pathname ?? "/";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Authenticated user identity (email → id → name), or null when unauthenticated. */
|
|
60
|
+
function _ctxUser(ctx: object): string | null {
|
|
61
|
+
const u = (ctx as { user?: unknown }).user;
|
|
62
|
+
if (u == null) return null;
|
|
63
|
+
if (typeof u === "string" || typeof u === "number") return String(u);
|
|
64
|
+
if (typeof u === "object") {
|
|
65
|
+
const o = u as Record<string, unknown>;
|
|
66
|
+
const v = o.email ?? o.id ?? o.name;
|
|
67
|
+
return v != null ? String(v) : null;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve an observer's write surface, or `undefined` when that package is not
|
|
74
|
+
* installed. The cast is confined here: each binding is declared by the package
|
|
75
|
+
* that owns it, and Flow deliberately depends on none of them.
|
|
76
|
+
*/
|
|
77
|
+
function _observer<T>(app: Application, binding: string): T | undefined {
|
|
78
|
+
return app.container.tryMake(binding as never) as T | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Subscribe Flow's realtime events to every installed observer. Returns a disposer
|
|
83
|
+
* that removes every subscription; call it from the Flow provider's `onStopping()`.
|
|
84
|
+
*/
|
|
85
|
+
export function installFlowObservability(app: Application): () => void {
|
|
86
|
+
const unsubs: Array<() => void> = [];
|
|
87
|
+
|
|
88
|
+
const store = _observer<MonitorSink>(app, "monitor.store");
|
|
89
|
+
if (store) {
|
|
90
|
+
unsubs.push(
|
|
91
|
+
FrameworkEvents.on(WebSocketConnected, () =>
|
|
92
|
+
store.recordEvent({ kind: "ws", label: "connect", status: "info" }),
|
|
93
|
+
),
|
|
94
|
+
FrameworkEvents.on(WebSocketDisconnected, () =>
|
|
95
|
+
store.recordEvent({ kind: "ws", label: "disconnect", status: "info" }),
|
|
96
|
+
),
|
|
97
|
+
FrameworkEvents.on(FlowActionHandled, (e) => {
|
|
98
|
+
const ctx = e.ctx;
|
|
99
|
+
// Skip the monitor panel's own components — its poll/refresh round-trips would
|
|
100
|
+
// otherwise dominate the realtime view. Draining discards their buffered state.
|
|
101
|
+
if (ctx && IGNORE_PREFIXES.some((p) => _rawPath(ctx).startsWith(p))) {
|
|
102
|
+
store.collectRequestState(ctx);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// Pull the same correlated context an HTTP request gets: the user (set by
|
|
106
|
+
// re-run middleware), the client IP, and the SQL queries tied to this action.
|
|
107
|
+
const state = ctx
|
|
108
|
+
? store.collectRequestState(ctx)
|
|
109
|
+
: { queries: [] as unknown[], nplus: false, context: {} as Record<string, unknown> };
|
|
110
|
+
store.recordEvent({
|
|
111
|
+
kind: "ws",
|
|
112
|
+
label: "action",
|
|
113
|
+
status: e.ok ? "ok" : "bad",
|
|
114
|
+
route: e.component,
|
|
115
|
+
data: {
|
|
116
|
+
component: e.component,
|
|
117
|
+
action: e.action,
|
|
118
|
+
ms: Math.round(e.durationMs),
|
|
119
|
+
ok: e.ok,
|
|
120
|
+
user: ctx ? _ctxUser(ctx) : null,
|
|
121
|
+
ip: e.ip,
|
|
122
|
+
nplus: state.nplus,
|
|
123
|
+
// Process heap at action completion — the same per-request memory proxy
|
|
124
|
+
// HTTP requests record, captured synchronously in the emit call stack.
|
|
125
|
+
memKb: Math.round(process.memoryUsage().heapUsed / 1024),
|
|
126
|
+
nQueries: state.queries.length,
|
|
127
|
+
queries: state.queries.slice(0, 25),
|
|
128
|
+
context: state.context,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const trace = _observer<DevtoolsSink>(app, "devtools.trace");
|
|
136
|
+
if (trace) {
|
|
137
|
+
trace.channel({
|
|
138
|
+
id: "flow",
|
|
139
|
+
label: "Flow",
|
|
140
|
+
badge: "component",
|
|
141
|
+
title: "action",
|
|
142
|
+
meta: ["durationMs", "ip"],
|
|
143
|
+
warn: "failed",
|
|
144
|
+
order: 20,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
unsubs.push(
|
|
148
|
+
FrameworkEvents.on(FlowActionHandled, (e) => {
|
|
149
|
+
// An action round-trips over the WebSocket against its own HttpContext,
|
|
150
|
+
// so it buffers against that context and lands on that action's trace.
|
|
151
|
+
if (!e.ctx) return;
|
|
152
|
+
if (IGNORE_PREFIXES.some((p) => _rawPath(e.ctx as object).startsWith(p))) return;
|
|
153
|
+
trace.record(e.ctx as object, "flow", {
|
|
154
|
+
component: e.component,
|
|
155
|
+
action: e.action,
|
|
156
|
+
durationMs: Math.round(e.durationMs),
|
|
157
|
+
ip: e.ip,
|
|
158
|
+
...(e.ok ? {} : { failed: true }),
|
|
159
|
+
});
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return () => {
|
|
165
|
+
for (const unsub of unsubs) unsub();
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// ── In-memory pagination ──────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Pairs with `@expose @url page` to paginate an in-memory array (filtered/derived
|
|
4
|
+
// data) inside render() or onMount(). For database queries, prefer the ORM's
|
|
5
|
+
// query builder `.paginate(perPage, page)` — this helper is for arrays you already
|
|
6
|
+
// hold in memory. The result shape mirrors the ORM paginator for consistency.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The result of paginating an in-memory array — one page of `data` plus the
|
|
10
|
+
* surrounding metadata a view needs to render a pager.
|
|
11
|
+
*
|
|
12
|
+
* The shape intentionally mirrors the ORM query builder's paginator, so a
|
|
13
|
+
* template can consume either interchangeably.
|
|
14
|
+
*
|
|
15
|
+
* @typeParam T - Element type of the paginated array.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const page = paginate(posts, 2, 10);
|
|
20
|
+
* page.data; // the 10 items on page 2
|
|
21
|
+
* page.from; // 11
|
|
22
|
+
* page.hasMorePages; // true if a page 3 exists
|
|
23
|
+
* page.elements(); // [1, 2, 3, '...', 20]
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export interface Paginator<T> {
|
|
27
|
+
/**
|
|
28
|
+
* The items on the current page (a slice of the input array).
|
|
29
|
+
* @category Access
|
|
30
|
+
*/
|
|
31
|
+
data: T[];
|
|
32
|
+
/**
|
|
33
|
+
* Total item count across all pages.
|
|
34
|
+
* @category Metadata
|
|
35
|
+
*/
|
|
36
|
+
total: number;
|
|
37
|
+
/**
|
|
38
|
+
* Items per page (the effective, floored value; at least 1).
|
|
39
|
+
* @category Metadata
|
|
40
|
+
*/
|
|
41
|
+
perPage: number;
|
|
42
|
+
/**
|
|
43
|
+
* Current page (1-based, clamped to a valid range).
|
|
44
|
+
* @category Metadata
|
|
45
|
+
*/
|
|
46
|
+
page: number;
|
|
47
|
+
/**
|
|
48
|
+
* Last page number (>= 1; 1 even when there are no items).
|
|
49
|
+
* @category Metadata
|
|
50
|
+
*/
|
|
51
|
+
lastPage: number;
|
|
52
|
+
/**
|
|
53
|
+
* 1-based index of the first item on this page (0 when empty).
|
|
54
|
+
* @category Metadata
|
|
55
|
+
*/
|
|
56
|
+
from: number;
|
|
57
|
+
/**
|
|
58
|
+
* 1-based index of the last item on this page (0 when empty).
|
|
59
|
+
* @category Metadata
|
|
60
|
+
*/
|
|
61
|
+
to: number;
|
|
62
|
+
/**
|
|
63
|
+
* True when on page 1.
|
|
64
|
+
* @category Metadata
|
|
65
|
+
*/
|
|
66
|
+
onFirstPage: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* True when a further page exists.
|
|
69
|
+
* @category Metadata
|
|
70
|
+
*/
|
|
71
|
+
hasMorePages: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Page-number window for rendering a numbered pager, with `'...'` gaps —
|
|
74
|
+
* e.g. `[1, '...', 4, 5, 6, '...', 20]`. The first and last pages are always
|
|
75
|
+
* included.
|
|
76
|
+
*
|
|
77
|
+
* @param each - How many page links to show on each side of the current page (default `1`).
|
|
78
|
+
* @returns A list of page numbers interleaved with `'...'` for elided ranges.
|
|
79
|
+
* @category Navigation
|
|
80
|
+
*/
|
|
81
|
+
elements(each?: number): (number | "...")[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Paginate an in-memory array — the array counterpart to the ORM's
|
|
86
|
+
* `.paginate(perPage, page)`. Use this for filtered/derived data you already
|
|
87
|
+
* hold in memory (e.g. inside `render()` or `onMount()`); for database queries
|
|
88
|
+
* prefer the query builder's paginator.
|
|
89
|
+
*
|
|
90
|
+
* `page` is clamped into `[1, lastPage]` and `perPage` is floored to at least 1,
|
|
91
|
+
* so out-of-range input never throws.
|
|
92
|
+
*
|
|
93
|
+
* @typeParam T - Element type of `items`.
|
|
94
|
+
* @param items - The full, already-in-memory collection to page over.
|
|
95
|
+
* @param page - 1-based page to return (clamped; default `1`).
|
|
96
|
+
* @param perPage - Items per page (floored to >= 1; default `15`).
|
|
97
|
+
* @returns A {@link Paginator} for the requested page.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* const results = posts.filter((p) => p.published);
|
|
102
|
+
* const page = paginate(results, this.page, 10);
|
|
103
|
+
* for (const post of page.data) { ... }
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export function paginate<T>(items: T[], page = 1, perPage = 15): Paginator<T> {
|
|
107
|
+
const total = items.length;
|
|
108
|
+
perPage = Math.max(1, Math.floor(perPage));
|
|
109
|
+
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
|
110
|
+
const currentPage = Math.min(Math.max(1, Math.floor(page) || 1), lastPage);
|
|
111
|
+
const offset = (currentPage - 1) * perPage;
|
|
112
|
+
const data = items.slice(offset, offset + perPage);
|
|
113
|
+
return {
|
|
114
|
+
data,
|
|
115
|
+
total,
|
|
116
|
+
perPage,
|
|
117
|
+
page: currentPage,
|
|
118
|
+
lastPage,
|
|
119
|
+
from: total === 0 ? 0 : offset + 1,
|
|
120
|
+
to: offset + data.length,
|
|
121
|
+
onFirstPage: currentPage <= 1,
|
|
122
|
+
hasMorePages: currentPage < lastPage,
|
|
123
|
+
elements(each = 1) {
|
|
124
|
+
return pageElements(currentPage, lastPage, each);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Pagination mixin ──────────────────────────────────────────────────────────
|
|
130
|
+
//
|
|
131
|
+
// The TypeScript equivalent of Livewire's `WithPagination` trait: compose it onto a
|
|
132
|
+
// component to add a URL-synced `page` plus page-navigation methods (`nextPage`,
|
|
133
|
+
// `previousPage`, `gotoPage`, `resetPage`), WITHOUT putting any of that on
|
|
134
|
+
// the base Component.
|
|
135
|
+
//
|
|
136
|
+
// class PostsPage extends ComponentWith(Pagination) {
|
|
137
|
+
// @locked posts: Post[] = [];
|
|
138
|
+
// async onMount() { this.posts = await Post.all(); }
|
|
139
|
+
// async render() {
|
|
140
|
+
// const posts = await Post.paginate(10); // uses this.page
|
|
141
|
+
// return <div>…{p.data.map(…)}… pager with this.nextPage / ?page= links</div>;
|
|
142
|
+
// }
|
|
143
|
+
// }
|
|
144
|
+
//
|
|
145
|
+
// `page` is @expose + @url (in the snapshot, synced to ?page=). The nav methods are
|
|
146
|
+
// @expose, so they're callable from the browser (onClick={this.nextPage}). Decorators
|
|
147
|
+
// applied here register on the mixin's prototype, which the component's prototype-chain
|
|
148
|
+
// walk already traverses — so it "just works".
|
|
149
|
+
//
|
|
150
|
+
// Named paginators (Livewire's `pageName`): every nav method accepts an optional page
|
|
151
|
+
// name so a single page can drive more than one independent paginator —
|
|
152
|
+
// `this.nextPage("invoices")`, `Invoice.paginate(10, undefined, "invoices")`. The default
|
|
153
|
+
// paginator ("page") is the URL-synced one; named paginators live in the snapshot.
|
|
154
|
+
//
|
|
155
|
+
// Optional update hooks fire around a page change, mirroring Livewire — define any of
|
|
156
|
+
// `updatingPage(page, name)` / `updatedPage(page, name)` (default paginator) or the
|
|
157
|
+
// generic `updatingPaginators(page, name)` / `updatedPaginators(page, name)`.
|
|
158
|
+
|
|
159
|
+
import { Component } from "./Component.ts";
|
|
160
|
+
import { expose, url } from "./decorators.ts";
|
|
161
|
+
import { setCurrentPageResolver, pageElements, type HttpContext } from "@zerotal/core";
|
|
162
|
+
|
|
163
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
164
|
+
type AbstractComponentCtor = abstract new (...args: any[]) => Component;
|
|
165
|
+
|
|
166
|
+
/** Optional, user-defined pagination lifecycle hooks (all optional). */
|
|
167
|
+
interface PaginationHooks {
|
|
168
|
+
updatingPage?(page: number, pageName: string): void;
|
|
169
|
+
updatedPage?(page: number, pageName: string): void;
|
|
170
|
+
updatingPaginators?(page: number, pageName: string): void;
|
|
171
|
+
updatedPaginators?(page: number, pageName: string): void;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const DEFAULT_PAGE = "page";
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Component mixin that adds URL-synced pagination state and navigation actions —
|
|
178
|
+
* flow's equivalent of Livewire's `WithPagination` trait.
|
|
179
|
+
*
|
|
180
|
+
* @remarks
|
|
181
|
+
* Composing this onto a component adds:
|
|
182
|
+
* - a `page` field, `@expose`d and `@url`-synced to the `?page=` query string;
|
|
183
|
+
* - a `paginators` map holding the page of each NAMED paginator (snapshot-only),
|
|
184
|
+
* so a single component can drive several independent pagers by passing a
|
|
185
|
+
* `pageName` (e.g. `nextPage("invoices")`, `paginate(rows, 10, "invoices")`);
|
|
186
|
+
* - `@expose`d navigation actions callable straight from the browser:
|
|
187
|
+
* {@link Paginated.gotoPage | gotoPage}, `resetPage`, `nextPage`,
|
|
188
|
+
* `previousPage`;
|
|
189
|
+
* - a `paginate(items, perPage, pageName?)` helper that slices an in-memory array
|
|
190
|
+
* at the current page.
|
|
191
|
+
*
|
|
192
|
+
* All page writes clamp to `>= 1`. Optional lifecycle hooks fire around a page
|
|
193
|
+
* change if you define them: `updatingPage`/`updatedPage` (default paginator) and
|
|
194
|
+
* the generic `updatingPaginators`/`updatedPaginators`. Call `resetPage()` when a
|
|
195
|
+
* filter or search term changes so the user isn't stranded on an empty page.
|
|
196
|
+
*
|
|
197
|
+
* @typeParam TBase - The (abstract) Component constructor being extended.
|
|
198
|
+
* @param Base - The component class to mix into.
|
|
199
|
+
* @returns A subclass of `Base` with pagination state and navigation.
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* ```tsx
|
|
203
|
+
* class PostsPage extends ComponentWith(Pagination) {
|
|
204
|
+
* @locked posts: Post[] = [];
|
|
205
|
+
* async onMount() { this.posts = await Post.all(); }
|
|
206
|
+
* async render() {
|
|
207
|
+
* const posts = await Post.paginate(10); // uses this.page
|
|
208
|
+
* return (
|
|
209
|
+
* <div>
|
|
210
|
+
* {p.data.map((post) => <PostCard post={post} />)}
|
|
211
|
+
* <button onClick={this.previousPage} disabled={p.onFirstPage}>Prev</button>
|
|
212
|
+
* <button onClick={this.nextPage} disabled={!p.hasMorePages}>Next</button>
|
|
213
|
+
* </div>
|
|
214
|
+
* );
|
|
215
|
+
* }
|
|
216
|
+
* }
|
|
217
|
+
* ```
|
|
218
|
+
*/
|
|
219
|
+
export function Pagination<TBase extends AbstractComponentCtor>(Base: TBase) {
|
|
220
|
+
abstract class Paginated extends Base {
|
|
221
|
+
/** Current page of the default paginator, synced to the `?page=` query string. */
|
|
222
|
+
@expose @url page = 1;
|
|
223
|
+
|
|
224
|
+
/** Current page of each NAMED paginator (Livewire `pageName`), keyed by name. Snapshot-only. */
|
|
225
|
+
@expose paginators: Record<string, number> = {};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Read the current page for a paginator (default or named).
|
|
229
|
+
* @param pageName - Named paginator; omit for the default URL-synced one.
|
|
230
|
+
* @returns The current 1-based page (>= 1).
|
|
231
|
+
* @category Access
|
|
232
|
+
*/
|
|
233
|
+
pageFor(pageName: string = DEFAULT_PAGE): number {
|
|
234
|
+
if (pageName === DEFAULT_PAGE) return this.page;
|
|
235
|
+
return this.paginators[pageName] ?? 1;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Point database pagination at this component's page, for the rest of the request.
|
|
240
|
+
*
|
|
241
|
+
* With it, `Post.paginate(10)` inside the component returns the page the user is on —
|
|
242
|
+
* no page argument to thread through. It runs on every request because a WebSocket
|
|
243
|
+
* action carries no `?page=` for the query string to fall back on; the page lives here.
|
|
244
|
+
*/
|
|
245
|
+
override async onBoot(ctx?: HttpContext): Promise<void> {
|
|
246
|
+
await super.onBoot(ctx);
|
|
247
|
+
setCurrentPageResolver((pageName) => this.pageFor(pageName));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Write a paginator's page (clamped to >= 1), firing the update hooks around it.
|
|
251
|
+
* Internal — not `@expose`, so it's never callable from the client. */
|
|
252
|
+
_setPageValue(pageName: string, value: number): void {
|
|
253
|
+
const next = Math.max(1, Math.floor(Number(value)) || 1);
|
|
254
|
+
if (next === this.pageFor(pageName)) return;
|
|
255
|
+
|
|
256
|
+
const hooks = this as unknown as PaginationHooks;
|
|
257
|
+
hooks.updatingPaginators?.(next, pageName);
|
|
258
|
+
if (pageName === DEFAULT_PAGE) hooks.updatingPage?.(next, pageName);
|
|
259
|
+
|
|
260
|
+
if (pageName === DEFAULT_PAGE) this.page = next;
|
|
261
|
+
else this.paginators = { ...this.paginators, [pageName]: next };
|
|
262
|
+
|
|
263
|
+
if (pageName === DEFAULT_PAGE) hooks.updatedPage?.(next, pageName);
|
|
264
|
+
hooks.updatedPaginators?.(next, pageName);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Jump to a specific page (clamped to >= 1).
|
|
269
|
+
* @param p - Target 1-based page.
|
|
270
|
+
* @param pageName - Named paginator; omit for the default.
|
|
271
|
+
* @category Navigation
|
|
272
|
+
*/
|
|
273
|
+
@expose gotoPage(p: number, pageName: string = DEFAULT_PAGE): void {
|
|
274
|
+
this._setPageValue(pageName, p);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Reset to page 1 — call this when filters/search change so the user isn't
|
|
278
|
+
* left on a now-empty page.
|
|
279
|
+
* @category Navigation
|
|
280
|
+
*/
|
|
281
|
+
@expose resetPage(pageName: string = DEFAULT_PAGE): void {
|
|
282
|
+
this._setPageValue(pageName, 1);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Advance to the next page.
|
|
286
|
+
* @category Navigation
|
|
287
|
+
*/
|
|
288
|
+
@expose nextPage(pageName: string = DEFAULT_PAGE): void {
|
|
289
|
+
this._setPageValue(pageName, this.pageFor(pageName) + 1);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Go back one page (clamped to >= 1).
|
|
293
|
+
* @category Navigation
|
|
294
|
+
*/
|
|
295
|
+
@expose previousPage(pageName: string = DEFAULT_PAGE): void {
|
|
296
|
+
this._setPageValue(pageName, this.pageFor(pageName) - 1);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return Paginated;
|
|
300
|
+
}
|