@supersoniks/concorde 4.5.2 → 4.6.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/build-infos.json +1 -1
- package/concorde-core.bundle.js +131 -131
- package/concorde-core.es.js +1110 -1075
- package/dist/concorde-core.bundle.js +131 -131
- package/dist/concorde-core.es.js +1110 -1075
- package/package.json +4 -1
- package/src/core/decorators/Subscriber.ts +2 -0
- package/src/core/decorators/subscriber/handle.disambig.spec.ts +20 -0
- package/src/core/decorators/subscriber/handle.skip.spec.ts +37 -0
- package/src/core/decorators/subscriber/handle.ts +128 -0
- package/src/core/decorators/subscriber/onAssign.ts +94 -4
- package/src/decorators.ts +6 -0
- package/src/docs/_decorators/bind.md +1 -1
- package/src/docs/_decorators/handle.md +169 -0
- package/src/docs/_decorators/on-assign.md +52 -0
- package/src/docs/_misc/dataProviderKey.md +4 -4
- package/src/docs/example/decorators-demo-subscribe-publish-get-demos.ts +91 -1
- package/src/docs/navigation/navigation.ts +4 -0
- package/src/docs/search/docs-search.json +272 -22
- package/src/tsconfig.json +9 -0
- package/src/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersoniks/concorde",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.6.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "",
|
|
@@ -288,6 +288,9 @@
|
|
|
288
288
|
"./decorators/subscriber/dynamicPath": "./src/core/decorators/subscriber/dynamicPath.ts",
|
|
289
289
|
"./decorators/subscriber/dynamicPropertyWatch.spec": "./src/core/decorators/subscriber/dynamicPropertyWatch.spec.ts",
|
|
290
290
|
"./decorators/subscriber/dynamicPropertyWatch": "./src/core/decorators/subscriber/dynamicPropertyWatch.ts",
|
|
291
|
+
"./decorators/subscriber/handle.disambig.spec": "./src/core/decorators/subscriber/handle.disambig.spec.ts",
|
|
292
|
+
"./decorators/subscriber/handle.skip.spec": "./src/core/decorators/subscriber/handle.skip.spec.ts",
|
|
293
|
+
"./decorators/subscriber/handle": "./src/core/decorators/subscriber/handle.ts",
|
|
291
294
|
"./decorators/subscriber/onAssign": "./src/core/decorators/subscriber/onAssign.ts",
|
|
292
295
|
"./decorators/subscriber/publish.spec": "./src/core/decorators/subscriber/publish.spec.ts",
|
|
293
296
|
"./decorators/subscriber/publish": "./src/core/decorators/subscriber/publish.ts",
|
|
@@ -2,6 +2,8 @@ export { bind } from "./subscriber/bind";
|
|
|
2
2
|
export { publish } from "./subscriber/publish";
|
|
3
3
|
export { subscribe } from "./subscriber/subscribe";
|
|
4
4
|
export { onAssign } from "./subscriber/onAssign";
|
|
5
|
+
export { handle, Skip } from "./subscriber/handle";
|
|
6
|
+
export type { HandleOptions } from "./subscriber/handle";
|
|
5
7
|
export { autoSubscribe } from "./subscriber/autoSubscribe";
|
|
6
8
|
export { autoFill } from "./subscriber/autoFill";
|
|
7
9
|
export { ancestorAttribute } from "./subscriber/ancestorAttribute";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { DataProviderKey } from "../../utils/dataProviderKey";
|
|
3
|
+
|
|
4
|
+
function isDataProviderKey(value: unknown): boolean {
|
|
5
|
+
return Object.prototype.toString.call(value) === "[object DataProviderKey]";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe("handle: désambiguïsation clé vs options", () => {
|
|
9
|
+
it("reconnaît une DataProviderKey (y compris après navigation)", () => {
|
|
10
|
+
const key = new DataProviderKey<{ total: number }>("cart");
|
|
11
|
+
expect(isDataProviderKey(key)).toBe(true);
|
|
12
|
+
expect(isDataProviderKey(key.total)).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("ne confond pas un objet d'options avec une clé", () => {
|
|
16
|
+
expect(isDataProviderKey({ skip: ["emptyObject"] })).toBe(false);
|
|
17
|
+
expect(isDataProviderKey({ waitForAllDefined: true })).toBe(false);
|
|
18
|
+
expect(isDataProviderKey(undefined)).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Skip, isSkipped } from "./onAssign";
|
|
3
|
+
|
|
4
|
+
describe("isSkipped (catégories Skip de @handle)", () => {
|
|
5
|
+
it("Nullish : null et undefined uniquement", () => {
|
|
6
|
+
expect(isSkipped(null, [Skip.Nullish])).toBe(true);
|
|
7
|
+
expect(isSkipped(undefined, [Skip.Nullish])).toBe(true);
|
|
8
|
+
expect(isSkipped(0, [Skip.Nullish])).toBe(false);
|
|
9
|
+
expect(isSkipped("", [Skip.Nullish])).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("EmptyString : pas de coercition", () => {
|
|
13
|
+
expect(isSkipped("", [Skip.EmptyString])).toBe(true);
|
|
14
|
+
expect(isSkipped("a", [Skip.EmptyString])).toBe(false);
|
|
15
|
+
expect(isSkipped(0, [Skip.EmptyString])).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("EmptyObject : objet sans clé, tableau exclu", () => {
|
|
19
|
+
expect(isSkipped({}, [Skip.EmptyObject])).toBe(true);
|
|
20
|
+
expect(isSkipped({ a: 1 }, [Skip.EmptyObject])).toBe(false);
|
|
21
|
+
expect(isSkipped([], [Skip.EmptyObject])).toBe(false);
|
|
22
|
+
expect(isSkipped(null, [Skip.EmptyObject])).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("EmptyArray : tableau vide, objet exclu", () => {
|
|
26
|
+
expect(isSkipped([], [Skip.EmptyArray])).toBe(true);
|
|
27
|
+
expect(isSkipped([1], [Skip.EmptyArray])).toBe(false);
|
|
28
|
+
expect(isSkipped({}, [Skip.EmptyArray])).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("combinaison de catégories", () => {
|
|
32
|
+
const kinds = [Skip.Nullish, Skip.EmptyObject];
|
|
33
|
+
expect(isSkipped(null, kinds)).toBe(true);
|
|
34
|
+
expect(isSkipped({}, kinds)).toBe(true);
|
|
35
|
+
expect(isSkipped({ a: 1 }, kinds)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DataProviderKey,
|
|
3
|
+
DataProviderKeyHost,
|
|
4
|
+
} from "../../utils/dataProviderKey";
|
|
5
|
+
import { createOnAssign, Skip } from "./onAssign";
|
|
6
|
+
|
|
7
|
+
export { Skip } from "./onAssign";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Options de `@handle`, nommées d'après les cas d'usage réels rencontrés dans
|
|
11
|
+
* les projets (panier, file d'attente, formulaires multi-publishers, etc.).
|
|
12
|
+
*
|
|
13
|
+
* Par défaut (aucune option), `@handle` appelle la méthode **à chaque
|
|
14
|
+
* assignation**, même quand la valeur reçue est `null`/`undefined` (c'est la
|
|
15
|
+
* différence de comportement voulue par rapport à `@onAssign`).
|
|
16
|
+
*/
|
|
17
|
+
export type HandleOptions = {
|
|
18
|
+
/**
|
|
19
|
+
* Attendre que **toutes** les clés surveillées soient définies (non
|
|
20
|
+
* `null`/`undefined`) avant d'appeler la méthode. Reproduit la sémantique
|
|
21
|
+
* historique de `@onAssign`.
|
|
22
|
+
*
|
|
23
|
+
* À utiliser quand la logique combine plusieurs sources et n'a de sens que
|
|
24
|
+
* lorsque toutes sont prêtes (ex. calcul de date à partir de
|
|
25
|
+
* `date` + `timeZone` + `direction`).
|
|
26
|
+
*/
|
|
27
|
+
waitForAllDefined?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Ne pas appeler la méthode si la valeur reçue appartient à l'une de ces
|
|
30
|
+
* catégories. Chaque entrée est une catégorie nommée (pas une valeur), donc
|
|
31
|
+
* aucune ambiguïté valeur/motif.
|
|
32
|
+
*
|
|
33
|
+
* - `Skip.Nullish` ignore `null`/`undefined` ;
|
|
34
|
+
* - `Skip.EmptyObject` / `Skip.EmptyArray` ignorent `{}` / `[]` ;
|
|
35
|
+
* - `Skip.EmptyString` ignore `""`.
|
|
36
|
+
*
|
|
37
|
+
* Pratique quand un publisher non initialisé émet `{}` comme état « pas encore
|
|
38
|
+
* chargé » : `skip: [Skip.EmptyObject]`. Pour une validation arbitraire sur une
|
|
39
|
+
* valeur précise, faire le test directement dans la méthode.
|
|
40
|
+
*/
|
|
41
|
+
skip?: Skip[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type HandleDecorator<Host, Fn> = (
|
|
45
|
+
target: Host,
|
|
46
|
+
propertyKey: string,
|
|
47
|
+
descriptor: TypedPropertyDescriptor<Fn>,
|
|
48
|
+
) => void;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Détecte une `DataProviderKey` (Proxy marqué `Symbol.toStringTag`) afin de la
|
|
52
|
+
* distinguer d'un objet d'options passé en dernier argument.
|
|
53
|
+
*/
|
|
54
|
+
function isDataProviderKey(value: unknown): value is DataProviderKey<unknown> {
|
|
55
|
+
return Object.prototype.toString.call(value) === "[object DataProviderKey]";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Callback typé déclenché lorsqu'un (ou plusieurs) chemin(s) publisher sont
|
|
60
|
+
* assignés via `DataProviderKey<T>`. Invoque la méthode décorée avec la/les
|
|
61
|
+
* valeur(s) assignée(s) (calculs, effets de bord, mise à jour d'autres `@state`…).
|
|
62
|
+
*
|
|
63
|
+
* Supporte les chemins dynamiques : placeholders type `"users.${userIndex}"`.
|
|
64
|
+
*
|
|
65
|
+
* Contrairement à `@onAssign`, la méthode est appelée à chaque assignation,
|
|
66
|
+
* même quand la valeur reçue est `null`/`undefined` — sauf si une option
|
|
67
|
+
* (`waitForAllDefined`, `skip`) restreint le déclenchement.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* // Mono-clé
|
|
71
|
+
* const cart = new DataProviderKey<Cart>("cart");
|
|
72
|
+
* @handle(cart.total)
|
|
73
|
+
* updateSummary(total: number) {
|
|
74
|
+
* this.summary = total * this.taxRate;
|
|
75
|
+
* }
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* // Multi-clés (coordination de plusieurs publishers)
|
|
79
|
+
* @handle(config.show, idle.isIdle, { waitForAllDefined: true })
|
|
80
|
+
* onModal(show: boolean, isIdle: boolean) {
|
|
81
|
+
* if (show && isIdle) this.open(); else this.close();
|
|
82
|
+
* }
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* // Ignorer un objet vide émis par un publisher non initialisé
|
|
86
|
+
* @handle(user.profile, { skip: [Skip.EmptyObject] })
|
|
87
|
+
* onProfile(profile: Profile) { this.name = profile.name; }
|
|
88
|
+
*/
|
|
89
|
+
export function handle<A, U = any>(
|
|
90
|
+
key: DataProviderKey<A, U>,
|
|
91
|
+
options?: HandleOptions,
|
|
92
|
+
): HandleDecorator<DataProviderKeyHost<U>, (a: A) => void>;
|
|
93
|
+
export function handle<A, B, UA = any, UB = any>(
|
|
94
|
+
keyA: DataProviderKey<A, UA>,
|
|
95
|
+
keyB: DataProviderKey<B, UB>,
|
|
96
|
+
options?: HandleOptions,
|
|
97
|
+
): HandleDecorator<
|
|
98
|
+
DataProviderKeyHost<UA> & DataProviderKeyHost<UB>,
|
|
99
|
+
(a: A, b: B) => void
|
|
100
|
+
>;
|
|
101
|
+
export function handle<A, B, C, UA = any, UB = any, UC = any>(
|
|
102
|
+
keyA: DataProviderKey<A, UA>,
|
|
103
|
+
keyB: DataProviderKey<B, UB>,
|
|
104
|
+
keyC: DataProviderKey<C, UC>,
|
|
105
|
+
options?: HandleOptions,
|
|
106
|
+
): HandleDecorator<
|
|
107
|
+
DataProviderKeyHost<UA> & DataProviderKeyHost<UB> & DataProviderKeyHost<UC>,
|
|
108
|
+
(a: A, b: B, c: C) => void
|
|
109
|
+
>;
|
|
110
|
+
export function handle(
|
|
111
|
+
...args: Array<DataProviderKey<unknown> | HandleOptions | undefined>
|
|
112
|
+
) {
|
|
113
|
+
const last = args[args.length - 1];
|
|
114
|
+
const hasOptions = last !== undefined && !isDataProviderKey(last);
|
|
115
|
+
const options = (hasOptions ? last : {}) as HandleOptions;
|
|
116
|
+
const keys = (hasOptions ? args.slice(0, -1) : args) as Array<
|
|
117
|
+
DataProviderKey<unknown>
|
|
118
|
+
>;
|
|
119
|
+
const paths = keys.map((key) => key.path);
|
|
120
|
+
|
|
121
|
+
return createOnAssign(
|
|
122
|
+
{
|
|
123
|
+
dispatchWhenUndefined: !options.waitForAllDefined,
|
|
124
|
+
skip: options.skip,
|
|
125
|
+
},
|
|
126
|
+
paths,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -23,7 +23,100 @@ type Configuration = {
|
|
|
23
23
|
index: number;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Catégories de valeurs « ignorables » par le garde `skip`. Chaque catégorie est
|
|
28
|
+
* un prédicat nommé (et non une égalité de valeur), ce qui lève toute ambiguïté
|
|
29
|
+
* entre « valeur » et « motif » (notamment pour l'objet vide).
|
|
30
|
+
*/
|
|
31
|
+
export enum Skip {
|
|
32
|
+
/**
|
|
33
|
+
* `null` ou `undefined`. En pratique un publisher renvoie toujours `null`
|
|
34
|
+
* (jamais `undefined`) car `get()` coerce `undefined → null`.
|
|
35
|
+
*/
|
|
36
|
+
Nullish = "nullish",
|
|
37
|
+
/** Chaîne vide `""`. */
|
|
38
|
+
EmptyString = "emptyString",
|
|
39
|
+
/** Objet sans clé (`{}`), tableau exclu. */
|
|
40
|
+
EmptyObject = "emptyObject",
|
|
41
|
+
/** Tableau vide (`[]`). */
|
|
42
|
+
EmptyArray = "emptyArray",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SKIP_PREDICATES: Record<Skip, (value: unknown) => boolean> = {
|
|
46
|
+
[Skip.Nullish]: (v) => v === null || v === undefined,
|
|
47
|
+
[Skip.EmptyString]: (v) => v === "",
|
|
48
|
+
[Skip.EmptyObject]: (v) =>
|
|
49
|
+
typeof v === "object" &&
|
|
50
|
+
v !== null &&
|
|
51
|
+
!Array.isArray(v) &&
|
|
52
|
+
Object.keys(v).length === 0,
|
|
53
|
+
[Skip.EmptyArray]: (v) => Array.isArray(v) && v.length === 0,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `true` si `value` appartient à au moins une des catégories `kinds`.
|
|
58
|
+
*/
|
|
59
|
+
export function isSkipped(value: unknown, kinds: Skip[]): boolean {
|
|
60
|
+
return kinds.some((kind) => SKIP_PREDICATES[kind](value));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type OnAssignOptions = {
|
|
64
|
+
/**
|
|
65
|
+
* Quand `true`, le callback est invoqué à chaque assignation, même si la
|
|
66
|
+
* valeur reçue est `null`/`undefined`.
|
|
67
|
+
* Quand `false` (défaut), le callback n'est invoqué que lorsque toutes les
|
|
68
|
+
* valeurs surveillées sont définies (non `null` et non `undefined`).
|
|
69
|
+
*/
|
|
70
|
+
dispatchWhenUndefined?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Ne pas invoquer le callback si une valeur reçue appartient à l'une de ces
|
|
73
|
+
* catégories (ex. `[Skip.Nullish, Skip.EmptyObject]`).
|
|
74
|
+
*/
|
|
75
|
+
skip?: Skip[];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function shouldDispatch(
|
|
79
|
+
currentValues: unknown[],
|
|
80
|
+
expectedCount: number,
|
|
81
|
+
options: OnAssignOptions,
|
|
82
|
+
): boolean {
|
|
83
|
+
// Garde structurelle : toutes les valeurs doivent être définies.
|
|
84
|
+
if (!options.dispatchWhenUndefined) {
|
|
85
|
+
const definedCount = currentValues
|
|
86
|
+
.slice(0, expectedCount)
|
|
87
|
+
.filter((v) => v !== null && v !== undefined).length;
|
|
88
|
+
if (definedCount !== expectedCount) return false;
|
|
89
|
+
}
|
|
90
|
+
// Garde de contenu : catégories skip.
|
|
91
|
+
if (options.skip && options.skip.length > 0) {
|
|
92
|
+
for (let i = 0; i < expectedCount; i++) {
|
|
93
|
+
if (isSkipped(currentValues[i], options.skip)) return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @deprecated Utiliser `@handle` à la place.
|
|
101
|
+
*
|
|
102
|
+
* `@onAssign` prend des chemins sous forme de **chaînes** non typées et n'appelle
|
|
103
|
+
* la méthode que lorsque **toutes** les valeurs sont définies. `@handle` offre la
|
|
104
|
+
* même chose en **typé** (via `DataProviderKey`), avec des options explicites :
|
|
105
|
+
*
|
|
106
|
+
* - équivalent direct : `@onAssign("a", "b")` → `@handle(keyA, keyB, { waitForAllDefined: true })`
|
|
107
|
+
* - comportement par défaut de `@handle` : appel à chaque assignation (même `null`/`undefined`)
|
|
108
|
+
* - `skip` pour ignorer des catégories de valeurs (ex. `[Skip.Nullish, Skip.EmptyObject]`)
|
|
109
|
+
*
|
|
110
|
+
* `@onAssign` reste fonctionnel et inchangé le temps de la migration.
|
|
111
|
+
*/
|
|
26
112
|
export function onAssign(...values: Array<string>) {
|
|
113
|
+
return createOnAssign({}, values);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createOnAssign(
|
|
117
|
+
options: OnAssignOptions,
|
|
118
|
+
values: Array<string>,
|
|
119
|
+
) {
|
|
27
120
|
const pathConfigs: PathConfiguration[] = values.map((path) => {
|
|
28
121
|
const dynamicDependencies = extractDynamicDependencies(path);
|
|
29
122
|
return {
|
|
@@ -69,10 +162,7 @@ export function onAssign(...values: Array<string>) {
|
|
|
69
162
|
const callbacks: Set<Callback> = new Set();
|
|
70
163
|
const onAssign = (assignedValue: unknown) => {
|
|
71
164
|
onAssignValues[i] = assignedValue;
|
|
72
|
-
if (
|
|
73
|
-
onAssignValues.filter((v) => v !== null && v !== undefined)
|
|
74
|
-
.length === values.length
|
|
75
|
-
) {
|
|
165
|
+
if (shouldDispatch(onAssignValues, values.length, options)) {
|
|
76
166
|
callbacks.forEach((callback) => callback(...onAssignValues));
|
|
77
167
|
}
|
|
78
168
|
};
|
package/src/decorators.ts
CHANGED
|
@@ -7,6 +7,11 @@ export const bind = mySubscriber.bind;
|
|
|
7
7
|
export const publish = mySubscriber.publish;
|
|
8
8
|
export const subscribe = mySubscriber.subscribe;
|
|
9
9
|
export const onAssign = mySubscriber.onAssign;
|
|
10
|
+
export const handle = mySubscriber.handle;
|
|
11
|
+
export {
|
|
12
|
+
Skip,
|
|
13
|
+
type HandleOptions,
|
|
14
|
+
} from "@supersoniks/concorde/core/decorators/Subscriber";
|
|
10
15
|
export const ancestorAttribute = mySubscriber.ancestorAttribute;
|
|
11
16
|
export const autoSubscribe = mySubscriber.autoSubscribe;
|
|
12
17
|
export const autoFill = mySubscriber.autoFill;
|
|
@@ -31,6 +36,7 @@ window["concorde-decorator-subscriber"] = {
|
|
|
31
36
|
publish: mySubscriber.publish,
|
|
32
37
|
subscribe: mySubscriber.subscribe,
|
|
33
38
|
onAssing: mySubscriber.onAssign,
|
|
39
|
+
handle: mySubscriber.handle,
|
|
34
40
|
ancestorAttribute: mySubscriber.ancestorAttribute,
|
|
35
41
|
autoSubscribe: mySubscriber.autoSubscribe,
|
|
36
42
|
autoFill: mySubscriber.autoFill,
|
|
@@ -4,7 +4,7 @@ Binds a class property to a path in a publisher. The property updates when publi
|
|
|
4
4
|
|
|
5
5
|
For Lit re-renders, also add `@state()` on the same property.
|
|
6
6
|
|
|
7
|
-
**See also:** [@subscribe](#docs/_decorators/subscribe.md/subscribe), [@publish](#docs/_decorators/publish.md/publish), [@get](#docs/_decorators/get.md/get).
|
|
7
|
+
**See also:** [@subscribe](#docs/_decorators/subscribe.md/subscribe), [@handle](#docs/_decorators/handle.md/handle), [@publish](#docs/_decorators/publish.md/publish), [@get](#docs/_decorators/get.md/get).
|
|
8
8
|
|
|
9
9
|
## Principle
|
|
10
10
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# @handle
|
|
2
|
+
|
|
3
|
+
Typed callback on one or more `DataProviderKey<T>` paths: invokes the decorated **method** when a publisher assigns a value (calculations, side effects, updating other `@state` properties, etc.).
|
|
4
|
+
|
|
5
|
+
Unlike [@subscribe](#docs/_decorators/subscribe.md/subscribe), nothing is bound to the decorated member — only your method runs. `@handle` is typed and accepts up to **3 keys** plus an optional trailing `HandleOptions` object. It supersedes the string-based [@onAssign](#docs/_decorators/on-assign.md/on-assign).
|
|
6
|
+
|
|
7
|
+
By default the method is called on **every** assignment, even when the value is `null` / `undefined`. Use the options below to restrict that.
|
|
8
|
+
|
|
9
|
+
## Import
|
|
10
|
+
|
|
11
|
+
<sonic-code language="typescript">
|
|
12
|
+
<template>
|
|
13
|
+
import { handle, Skip } from "@supersoniks/concorde/decorators";
|
|
14
|
+
import { DataProviderKey } from "@supersoniks/concorde/dataProviderKey";
|
|
15
|
+
</template>
|
|
16
|
+
</sonic-code>
|
|
17
|
+
|
|
18
|
+
## Basic example
|
|
19
|
+
|
|
20
|
+
<sonic-code language="typescript">
|
|
21
|
+
<template>
|
|
22
|
+
type DemoCounterData = { count: number };
|
|
23
|
+
const demoDataKey = new DataProviderKey<DemoCounterData>("demoData");
|
|
24
|
+
//
|
|
25
|
+
@customElement("demo-handle")
|
|
26
|
+
export class DemoHandle extends LitElement {
|
|
27
|
+
@state() doubled = 0;
|
|
28
|
+
@state() lastUpdate = "";
|
|
29
|
+
//
|
|
30
|
+
@handle(demoDataKey.count)
|
|
31
|
+
onCountChange(count: number) {
|
|
32
|
+
this.doubled = count * 2;
|
|
33
|
+
this.lastUpdate = new Date().toLocaleTimeString();
|
|
34
|
+
}
|
|
35
|
+
//
|
|
36
|
+
incrementCount() {
|
|
37
|
+
const publisher = PublisherManager.get("demoData");
|
|
38
|
+
const data = publisher.get() as DemoCounterData;
|
|
39
|
+
publisher.set({ ...data, count: data.count + 1 });
|
|
40
|
+
}
|
|
41
|
+
//
|
|
42
|
+
render() {
|
|
43
|
+
return html`
|
|
44
|
+
<p>Doubled count: ${this.doubled}</p>
|
|
45
|
+
<p><small>Last update: ${this.lastUpdate}</small></p>
|
|
46
|
+
<sonic-button @click=${this.incrementCount}>Increment</sonic-button>
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
</template>
|
|
51
|
+
</sonic-code>
|
|
52
|
+
|
|
53
|
+
<sonic-code>
|
|
54
|
+
<template>
|
|
55
|
+
<demo-handle></demo-handle>
|
|
56
|
+
</template>
|
|
57
|
+
</sonic-code>
|
|
58
|
+
|
|
59
|
+
## Dynamic path
|
|
60
|
+
|
|
61
|
+
Placeholders in `DataProviderKey` resolve from the host component’s properties (same rules as `@bind` / `@subscribe`).
|
|
62
|
+
|
|
63
|
+
<sonic-code language="typescript">
|
|
64
|
+
<template>
|
|
65
|
+
type User = { firstName: string; lastName: string; email: string };
|
|
66
|
+
//
|
|
67
|
+
@customElement("demo-handle-dynamic")
|
|
68
|
+
export class DemoHandleDynamic extends LitElement {
|
|
69
|
+
@property({ type: Number })
|
|
70
|
+
userIndex = 0;
|
|
71
|
+
//
|
|
72
|
+
@state() displayName = "";
|
|
73
|
+
@state() lastUpdate = "";
|
|
74
|
+
//
|
|
75
|
+
@handle(new DataProviderKey<User, { userIndex: number }>("demoUsers.${userIndex}"))
|
|
76
|
+
onUserAssigned(user: User) {
|
|
77
|
+
this.displayName = `${user.firstName} ${user.lastName}`;
|
|
78
|
+
this.lastUpdate = new Date().toLocaleTimeString();
|
|
79
|
+
}
|
|
80
|
+
//
|
|
81
|
+
render() {
|
|
82
|
+
return html`...`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
</template>
|
|
86
|
+
</sonic-code>
|
|
87
|
+
|
|
88
|
+
<sonic-code>
|
|
89
|
+
<template>
|
|
90
|
+
<demo-handle-dynamic></demo-handle-dynamic>
|
|
91
|
+
</template>
|
|
92
|
+
</sonic-code>
|
|
93
|
+
|
|
94
|
+
## Multiple paths
|
|
95
|
+
|
|
96
|
+
`@handle` accepts up to **3 keys**; the method receives one strongly-typed argument per key, in order. Each assignment triggers the method, so make your method safe against partial values (or use `waitForAllDefined`, see below).
|
|
97
|
+
|
|
98
|
+
<sonic-code language="typescript">
|
|
99
|
+
<template>
|
|
100
|
+
type QueueConfig = { onInactivity: { stillHere: { show: boolean } } };
|
|
101
|
+
const config = new DataProviderKey<QueueConfig>("sessionQueueConfig");
|
|
102
|
+
const idle = new DataProviderKey<{ isIdle: boolean }>("idleStatus");
|
|
103
|
+
//
|
|
104
|
+
@customElement("demo-handle-multi")
|
|
105
|
+
export class DemoHandleMulti extends LitElement {
|
|
106
|
+
//
|
|
107
|
+
// show: boolean, isIdle: boolean — fully typed from the keys
|
|
108
|
+
@handle(config.onInactivity.stillHere.show, idle.isIdle)
|
|
109
|
+
onInactivity(show: boolean, isIdle: boolean) {
|
|
110
|
+
if (show === true && isIdle === true) this.openModal();
|
|
111
|
+
else this.closeModal();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
</template>
|
|
115
|
+
</sonic-code>
|
|
116
|
+
|
|
117
|
+
## Options (`HandleOptions`)
|
|
118
|
+
|
|
119
|
+
Pass an options object as the **last** argument. The names map to real situations seen in the apps.
|
|
120
|
+
|
|
121
|
+
### `waitForAllDefined`
|
|
122
|
+
|
|
123
|
+
Only call the method once **all** watched keys are defined (non `null` / `undefined`). This reproduces the historical `@onAssign` semantics — use it when the logic only makes sense with every source ready (e.g. building a date from `date` + `timeZone` + `direction`).
|
|
124
|
+
|
|
125
|
+
<sonic-code language="typescript">
|
|
126
|
+
<template>
|
|
127
|
+
@handle(trip.departureDate, trip.event.timeZone, form.direction, {
|
|
128
|
+
waitForAllDefined: true,
|
|
129
|
+
})
|
|
130
|
+
updateDepartureDate(date: number, timeZone: string, direction: string) {
|
|
131
|
+
// called only when the three values are all available
|
|
132
|
+
this.formDate = formatDate(date, timeZone, direction);
|
|
133
|
+
}
|
|
134
|
+
</template>
|
|
135
|
+
</sonic-code>
|
|
136
|
+
|
|
137
|
+
### `skip`
|
|
138
|
+
|
|
139
|
+
Do **not** call the method when a received value belongs to one of the listed **categories** (the `Skip` enum). Each entry is a named category — not a value — so there is no value/pattern ambiguity (e.g. `{}` is `Skip.EmptyObject`, an explicit "empty object" category, never a value comparison).
|
|
140
|
+
|
|
141
|
+
| Category | Matches |
|
|
142
|
+
| --- | --- |
|
|
143
|
+
| `Skip.Nullish` | `null` or `undefined` (a publisher always emits `null`, never `undefined`) |
|
|
144
|
+
| `Skip.EmptyString` | `""` |
|
|
145
|
+
| `Skip.EmptyObject` | object with no keys (`{}`), arrays excluded |
|
|
146
|
+
| `Skip.EmptyArray` | empty array (`[]`) |
|
|
147
|
+
|
|
148
|
+
Useful when a not-yet-initialized publisher emits `{}` as a "loading" state. For a **specific value** (e.g. a particular string), guard inside the method instead.
|
|
149
|
+
|
|
150
|
+
<sonic-code language="typescript">
|
|
151
|
+
<template>
|
|
152
|
+
@handle(user.profile, { skip: [Skip.Nullish, Skip.EmptyObject] })
|
|
153
|
+
onProfile(profile: Profile) {
|
|
154
|
+
// not called while the publisher still holds {} (not loaded yet)
|
|
155
|
+
this.displayName = profile.firstName;
|
|
156
|
+
}
|
|
157
|
+
</template>
|
|
158
|
+
</sonic-code>
|
|
159
|
+
|
|
160
|
+
> Options can be combined, e.g. `@handle(a, b, { waitForAllDefined: true, skip: [Skip.Nullish] })`. For any **arbitrary** validation on a specific value, just guard inside the method (`if (!isValid(v)) return;`) — that is exactly what an `accept`-style predicate would do, since `@handle` only runs your method.
|
|
161
|
+
|
|
162
|
+
## Highlights
|
|
163
|
+
|
|
164
|
+
- Strict typing: the method receives one argument per key, in order.
|
|
165
|
+
- Up to 3 keys; for 4+ keys (rare), keep [@onAssign](#docs/_decorators/on-assign.md/on-assign) for now.
|
|
166
|
+
- By default the method runs on **every** assignment, even with `null` / `undefined` (unlike `@onAssign`, which waits for all values). Opt back into that behavior with `waitForAllDefined`.
|
|
167
|
+
- `skip` filters out values by **named category** (e.g. `[Skip.Nullish, Skip.EmptyObject]`); for arbitrary checks on a specific value, guard inside the method.
|
|
168
|
+
|
|
169
|
+
See also [DataProviderKey](#docs/_misc/dataProviderKey.md/dataProviderKey).
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
# @onAssign
|
|
2
2
|
|
|
3
|
+
> **⚠️ Deprecated — use [@handle](#docs/_decorators/handle.md/handle) instead.**
|
|
4
|
+
> `@onAssign` takes untyped **string** paths; `@handle` does the same with full typing via `DataProviderKey`, supports up to 3 keys, and exposes explicit options. `@onAssign` stays functional and unchanged during the migration. See the **Migrating to @handle** section below.
|
|
5
|
+
|
|
3
6
|
The `@onAssign` decorator allows you to execute a method when one or more publishers are updated. The method is called only when all specified publishers have been assigned values.
|
|
4
7
|
|
|
8
|
+
For a **typed** equivalent (recommended), use [@handle](#docs/_decorators/handle.md/handle).
|
|
9
|
+
|
|
5
10
|
## Principle
|
|
6
11
|
|
|
7
12
|
This decorator subscribes to one or more publishers via the `PublisherManager`. When all specified publishers have been assigned values (via `set`), the decorated method is called with all the values as arguments.
|
|
@@ -327,6 +332,53 @@ shippingPub.set({ address: "123 Main St" });
|
|
|
327
332
|
</template>
|
|
328
333
|
</sonic-code>
|
|
329
334
|
|
|
335
|
+
## Migrating to @handle
|
|
336
|
+
|
|
337
|
+
`@handle` is the typed successor of `@onAssign`. The key behavioral difference: `@onAssign` waits for **all** values to be defined before calling the method, whereas `@handle` calls it on **every** assignment by default. Use the `waitForAllDefined` option to keep the old semantics.
|
|
338
|
+
|
|
339
|
+
### Why migrate
|
|
340
|
+
|
|
341
|
+
- **Typed paths**: keys are `DataProviderKey<T>`, so the method arguments are strongly typed (no more `any`).
|
|
342
|
+
- **Explicit intent**: `waitForAllDefined` and `skip` replace implicit behavior.
|
|
343
|
+
- **Single API**: `@handle` covers the mono- and multi-path cases (up to 3 keys).
|
|
344
|
+
|
|
345
|
+
### Equivalent semantics (`waitForAllDefined`)
|
|
346
|
+
|
|
347
|
+
<sonic-code language="typescript">
|
|
348
|
+
<template>
|
|
349
|
+
// Before
|
|
350
|
+
@onAssign("demoUser", "demoUserSettings")
|
|
351
|
+
handleDataReady(user: any, settings: any) { /* ... */ }
|
|
352
|
+
//
|
|
353
|
+
// After — same "wait for everything" behavior, but typed
|
|
354
|
+
const user = new DataProviderKey<User>("demoUser");
|
|
355
|
+
const settings = new DataProviderKey<Settings>("demoUserSettings");
|
|
356
|
+
//
|
|
357
|
+
@handle(user, settings, { waitForAllDefined: true })
|
|
358
|
+
handleDataReady(user: User, settings: Settings) { /* ... */ }
|
|
359
|
+
</template>
|
|
360
|
+
</sonic-code>
|
|
361
|
+
|
|
362
|
+
### Single path
|
|
363
|
+
|
|
364
|
+
<sonic-code language="typescript">
|
|
365
|
+
<template>
|
|
366
|
+
// Before
|
|
367
|
+
@onAssign("settings.modules.logs_route.enabled")
|
|
368
|
+
onLogRoute(value: boolean) { /* ... */ }
|
|
369
|
+
//
|
|
370
|
+
// After
|
|
371
|
+
const settings = new DataProviderKey<AppSettings>("settings");
|
|
372
|
+
//
|
|
373
|
+
@handle(settings.modules.logs_route.enabled)
|
|
374
|
+
onLogRoute(value: boolean) { /* ... */ }
|
|
375
|
+
</template>
|
|
376
|
+
</sonic-code>
|
|
377
|
+
|
|
378
|
+
### 4+ paths
|
|
379
|
+
|
|
380
|
+
`@handle` is capped at 3 keys. For the rare case of 4 or more publishers, keep `@onAssign` for now, or split the logic into several `@handle` methods that each store their value and call a shared method (guarding against partial values).
|
|
381
|
+
|
|
330
382
|
## Notes
|
|
331
383
|
|
|
332
384
|
- This decorator works with any component that has `connectedCallback` and `disconnectedCallback` methods (such as `LitElement` or components extending `Subscriber`)
|
|
@@ -97,13 +97,13 @@ const pathProp = key.path; // "data.count"
|
|
|
97
97
|
|
|
98
98
|
## Use cases
|
|
99
99
|
|
|
100
|
-
- **Type-safe bindings**: paths for `@bind`, `@subscribe`, `@publish`
|
|
100
|
+
- **Type-safe bindings**: paths for `@bind`, `@subscribe`, `@publish`, `@handle`
|
|
101
101
|
- **Dynamic paths**: reusable keys with `${...}` placeholders
|
|
102
102
|
- **Form fields**: form data paths with compile-time checking
|
|
103
103
|
|
|
104
|
-
## Integration with @subscribe and @
|
|
104
|
+
## Integration with @subscribe, @publish and @handle
|
|
105
105
|
|
|
106
|
-
Use `DataProviderKey` with `@subscribe` (read-only)
|
|
106
|
+
Use `DataProviderKey` with `@subscribe` (read-only), `@publish` (write-only), or `@handle` (method callback on assign). With `@subscribe` / `@publish`, the decorated property **must** match the key’s value type. With `@handle`, the method receives `(value: T)`.
|
|
107
107
|
|
|
108
108
|
<sonic-code language="typescript">
|
|
109
109
|
<template>
|
|
@@ -126,7 +126,7 @@ export class UserForm extends LitElement {
|
|
|
126
126
|
</template>
|
|
127
127
|
</sonic-code>
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
These decorators support dynamic paths: `"base.${prop}"` in the constructor. A wrong property type (e.g. `number` for `DataProviderKey<string>`) is a TypeScript error. See [@handle](#docs/_decorators/handle.md/handle) for method callbacks.
|
|
130
130
|
|
|
131
131
|
## Notes
|
|
132
132
|
|