@platforma-sdk/ui-vue 1.57.3 → 1.58.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.
@@ -8,12 +8,19 @@ import type {
8
8
  ValueWithUTag,
9
9
  AuthorMarker,
10
10
  PlatformaExtended,
11
+ InferPluginHandles,
12
+ PluginHandle,
13
+ InferFactoryData,
14
+ InferFactoryOutputs,
15
+ PluginFactoryLike,
11
16
  } from "@platforma-sdk/model";
12
17
  import {
13
18
  hasAbortError,
14
19
  unwrapResult,
15
20
  deriveDataFromStorage,
16
21
  getPluginData,
22
+ isPluginOutputKey,
23
+ pluginOutputPrefix,
17
24
  } from "@platforma-sdk/model";
18
25
  import type { Ref } from "vue";
19
26
  import { reactive, computed, ref } from "vue";
@@ -23,14 +30,16 @@ import { ensureOutputHasStableFlag, MultiError } from "../utils";
23
30
  import { applyPatch } from "fast-json-patch";
24
31
  import { UpdateSerializer } from "./UpdateSerializer";
25
32
  import { watchIgnorable } from "@vueuse/core";
33
+ import type { PluginState, PluginAccess } from "../usePlugin";
26
34
 
27
35
  export const patchPoolingDelay = 150;
28
36
 
29
- /** Internal interface for plugin data access injected separately from the app. */
30
- export interface PluginDataAccess {
31
- readonly pluginDataMap: Record<string, unknown>;
32
- setPluginData(pluginId: string, value: unknown): Promise<boolean>;
33
- initPluginDataSlot(pluginId: string): void;
37
+ /** Internal per-plugin state with reconciliation support. */
38
+ interface InternalPluginState<Data = unknown, Outputs = unknown> extends PluginState<
39
+ Data,
40
+ Outputs
41
+ > {
42
+ readonly ignoreUpdates: (fn: () => void) => void;
34
43
  }
35
44
 
36
45
  export const createNextAuthorMarker = (marker: AuthorMarker | undefined): AuthorMarker => ({
@@ -65,9 +74,10 @@ export function createAppV3<
65
74
  Args = unknown,
66
75
  Outputs extends BlockOutputsBase = BlockOutputsBase,
67
76
  Href extends `/${string}` = `/${string}`,
77
+ Plugins extends Record<string, unknown> = Record<string, unknown>,
68
78
  >(
69
79
  state: ValueWithUTag<BlockStateV3<Data, Outputs, Href>>,
70
- platforma: PlatformaExtended<PlatformaV3<Data, Args, Outputs, Href>>,
80
+ platforma: PlatformaExtended<PlatformaV3<Data, Args, Outputs, Href, Plugins>>,
71
81
  settings: AppSettings,
72
82
  ) {
73
83
  const debug = (msg: string, ...rest: unknown[]) => {
@@ -111,19 +121,19 @@ export function createAppV3<
111
121
  const debounceSpan = settings.debounceSpan ?? 200;
112
122
 
113
123
  const setDataQueue = new UpdateSerializer({ debounceSpan });
114
- const pluginDataQueues = new Map<string, UpdateSerializer>();
115
- const getPluginDataQueue = (pluginId: string): UpdateSerializer => {
116
- let queue = pluginDataQueues.get(pluginId);
124
+ const pluginDataQueues = new Map<PluginHandle, UpdateSerializer>();
125
+ const getPluginDataQueue = (handle: PluginHandle): UpdateSerializer => {
126
+ let queue = pluginDataQueues.get(handle);
117
127
  if (!queue) {
118
128
  queue = new UpdateSerializer({ debounceSpan });
119
- pluginDataQueues.set(pluginId, queue);
129
+ pluginDataQueues.set(handle, queue);
120
130
  }
121
131
  return queue;
122
132
  };
123
133
  const setNavigationStateQueue = new UpdateSerializer({ debounceSpan });
124
134
 
125
- /** Reactive map of plugin data keyed by pluginId. Optimistic state for plugin components. */
126
- const pluginDataMap = reactive<Record<string, unknown>>({});
135
+ /** Lazily-created per-plugin reactive states. */
136
+ const pluginStates = new Map<PluginHandle, InternalPluginState>();
127
137
  /**
128
138
  * Reactive snapshot of the application state, including args, outputs, UI state, and navigation state.
129
139
  */
@@ -141,25 +151,21 @@ export function createAppV3<
141
151
  return platforma.mutateStorage({ operation: "update-block-data", value }, nextAuthorMarker());
142
152
  };
143
153
 
144
- const updatePluginData = async (pluginId: string, value: unknown) => {
154
+ const updatePluginData = async (handle: PluginHandle, value: unknown) => {
145
155
  return platforma.mutateStorage(
146
- { operation: "update-plugin-data", pluginId, value },
156
+ { operation: "update-plugin-data", pluginId: handle, value },
147
157
  nextAuthorMarker(),
148
158
  );
149
159
  };
150
160
 
151
- /** Derives plugin data for a given pluginId from the current snapshot. */
152
- const derivePluginDataFromSnapshot = (pluginId: string): unknown => {
153
- return getPluginData(snapshot.value.blockStorage, pluginId);
154
- };
155
-
156
161
  const setNavigationState = async (state: NavigationState<Href>) => {
157
162
  return platforma.setNavigationState(state);
158
163
  };
159
164
 
160
165
  const outputs = computed<OutputValues<Outputs>>(() => {
161
- const entries = Object.entries(snapshot.value.outputs as Partial<Readonly<Outputs>>).map(
162
- ([k, outputWithStatus]) =>
166
+ const entries = Object.entries(snapshot.value.outputs as Partial<Readonly<Outputs>>)
167
+ .filter(([k]) => !isPluginOutputKey(k))
168
+ .map(([k, outputWithStatus]) =>
163
169
  platforma.blockModelInfo.outputs[k]?.withStatus
164
170
  ? [k, ensureOutputHasStableFlag(outputWithStatus)]
165
171
  : [
@@ -168,17 +174,17 @@ export function createAppV3<
168
174
  ? outputWithStatus.value
169
175
  : undefined,
170
176
  ],
171
- );
177
+ );
172
178
  return Object.fromEntries(entries);
173
179
  });
174
180
 
175
181
  const outputErrors = computed<OutputErrors<Outputs>>(() => {
176
- const entries = Object.entries(snapshot.value.outputs as Partial<Readonly<Outputs>>).map(
177
- ([k, vOrErr]) => [
182
+ const entries = Object.entries(snapshot.value.outputs as Partial<Readonly<Outputs>>)
183
+ .filter(([k]) => !isPluginOutputKey(k))
184
+ .map(([k, vOrErr]) => [
178
185
  k,
179
186
  vOrErr && vOrErr.ok === false ? new MultiError(vOrErr.errors) : undefined,
180
- ],
181
- );
187
+ ]);
182
188
  return Object.fromEntries(entries);
183
189
  });
184
190
 
@@ -251,8 +257,12 @@ export function createAppV3<
251
257
  snapshot.value = applyPatch(snapshot.value, patches.value, false, false).newDocument;
252
258
  updateAppModel({ data: deriveDataFromStorage<Data>(snapshot.value.blockStorage) });
253
259
  // Reconcile plugin data from external source
254
- for (const pluginId of Object.keys(pluginDataMap)) {
255
- pluginDataMap[pluginId] = derivePluginDataFromSnapshot(pluginId);
260
+ for (const [handle, pluginState] of pluginStates) {
261
+ pluginState.ignoreUpdates(() => {
262
+ pluginState.model.data = deepClone(
263
+ getPluginData(snapshot.value.blockStorage, handle),
264
+ );
265
+ });
256
266
  }
257
267
  data.isExternalSnapshot = isAuthorChanged;
258
268
  });
@@ -318,26 +328,83 @@ export function createAppV3<
318
328
  },
319
329
  };
320
330
 
331
+ /** Creates a lazily-cached per-plugin reactive state. */
332
+ const createPluginState = <F extends PluginFactoryLike>(
333
+ handle: PluginHandle<F>,
334
+ ): InternalPluginState<InferFactoryData<F>, InferFactoryOutputs<F>> => {
335
+ const prefix = pluginOutputPrefix(handle);
336
+
337
+ const pluginOutputs = computed(() => {
338
+ const result: Record<string, unknown> = {};
339
+ for (const [key, outputWithStatus] of Object.entries(
340
+ snapshot.value.outputs as Partial<Readonly<Outputs>>,
341
+ )) {
342
+ if (!key.startsWith(prefix)) continue;
343
+ result[key.slice(prefix.length)] =
344
+ outputWithStatus.ok && outputWithStatus.value !== undefined
345
+ ? outputWithStatus.value
346
+ : undefined;
347
+ }
348
+ return result;
349
+ });
350
+
351
+ const pluginOutputErrors = computed(() => {
352
+ const result: Record<string, Error | undefined> = {};
353
+ for (const [key, vOrErr] of Object.entries(
354
+ snapshot.value.outputs as Partial<Readonly<Outputs>>,
355
+ )) {
356
+ if (!key.startsWith(prefix)) continue;
357
+ result[key.slice(prefix.length)] =
358
+ vOrErr && vOrErr.ok === false ? new MultiError(vOrErr.errors) : undefined;
359
+ }
360
+ return result;
361
+ });
362
+
363
+ const pluginModel = reactive({
364
+ data: deepClone(getPluginData(snapshot.value.blockStorage, handle)),
365
+ outputs: pluginOutputs,
366
+ outputErrors: pluginOutputErrors,
367
+ }) as InternalPluginState<InferFactoryData<F>, InferFactoryOutputs<F>>["model"];
368
+
369
+ const { ignoreUpdates } = watchIgnorable(
370
+ () => pluginModel.data,
371
+ (newData) => {
372
+ if (newData === undefined) return;
373
+ debug("plugin setData", handle, newData);
374
+ getPluginDataQueue(handle).run(() =>
375
+ updatePluginData(handle, deepClone(newData)).then(unwrapResult),
376
+ );
377
+ },
378
+ { deep: true },
379
+ );
380
+
381
+ return {
382
+ model: pluginModel,
383
+ ignoreUpdates,
384
+ };
385
+ };
386
+
321
387
  /** Plugin internals — provided via separate injection key, not exposed on useApp(). */
322
- const pluginAccess: PluginDataAccess = {
323
- pluginDataMap,
324
- setPluginData(pluginId: string, value: unknown): Promise<boolean> {
325
- pluginDataMap[pluginId] = value;
326
- debug("setPluginData", pluginId, value);
327
- return getPluginDataQueue(pluginId).run(() =>
328
- updatePluginData(pluginId, value).then(unwrapResult),
329
- );
330
- },
331
- initPluginDataSlot(pluginId: string): void {
332
- if (!(pluginId in pluginDataMap)) {
333
- pluginDataMap[pluginId] = derivePluginDataFromSnapshot(pluginId);
388
+ const pluginAccess: PluginAccess = {
389
+ getOrCreatePluginState<F extends PluginFactoryLike>(handle: PluginHandle<F>) {
390
+ const existing = pluginStates.get(handle);
391
+ if (existing) {
392
+ return existing as unknown as PluginState<InferFactoryData<F>, InferFactoryOutputs<F>>;
334
393
  }
394
+ const state = createPluginState(handle);
395
+ pluginStates.set(handle, state);
396
+ return state;
335
397
  },
336
398
  };
337
399
 
400
+ const plugins = Object.fromEntries(
401
+ platforma.blockModelInfo.pluginIds.map((id) => [id, id]),
402
+ ) as InferPluginHandles<Plugins>;
403
+
338
404
  const getters = {
339
405
  closedRef,
340
406
  snapshot,
407
+ plugins,
341
408
  queryParams: computed(() => parseQuery<Href>(snapshot.value.navigationState.href as Href)),
342
409
  href: computed(() => snapshot.value.navigationState.href),
343
410
  hasErrors: computed(() =>
@@ -360,4 +427,5 @@ export type BaseAppV3<
360
427
  Args = unknown,
361
428
  Outputs extends BlockOutputsBase = BlockOutputsBase,
362
429
  Href extends `/${string}` = `/${string}`,
363
- > = ReturnType<typeof createAppV3<Data, Args, Outputs, Href>>["app"];
430
+ Plugins extends Record<string, unknown> = Record<string, unknown>,
431
+ > = ReturnType<typeof createAppV3<Data, Args, Outputs, Href, Plugins>>["app"];
package/src/lib.ts CHANGED
@@ -35,7 +35,15 @@ export * from "./components/PlAdvancedFilter";
35
35
 
36
36
  export * from "./defineApp";
37
37
 
38
- export { usePluginData } from "./usePluginData";
38
+ export { usePlugin } from "./usePlugin";
39
+ export type { PluginState } from "./usePlugin";
40
+ export type {
41
+ PluginHandle,
42
+ PluginFactoryLike,
43
+ InferPluginHandle,
44
+ InferFactoryData,
45
+ InferFactoryOutputs,
46
+ } from "@platforma-sdk/model";
39
47
 
40
48
  export * from "./createModel";
41
49
 
@@ -0,0 +1,65 @@
1
+ import { inject, type Reactive } from "vue";
2
+ import { pluginDataKey } from "./defineApp";
3
+ import type {
4
+ PluginHandle,
5
+ InferFactoryData,
6
+ InferFactoryOutputs,
7
+ PluginFactoryLike,
8
+ } from "@platforma-sdk/model";
9
+
10
+ /** Per-plugin reactive model exposed to consumers via usePlugin(). */
11
+ export interface PluginState<Data = unknown, Outputs = unknown> {
12
+ readonly model: Reactive<{
13
+ data: Data;
14
+ outputs: Outputs extends Record<string, unknown>
15
+ ? { [K in keyof Outputs]: Outputs[K] | undefined }
16
+ : Record<string, unknown>;
17
+ outputErrors: Outputs extends Record<string, unknown>
18
+ ? { [K in keyof Outputs]?: Error }
19
+ : Record<string, Error | undefined>;
20
+ }>;
21
+ }
22
+
23
+ /** Internal interface for plugin access — provided via Vue injection to usePlugin(). */
24
+ export interface PluginAccess {
25
+ getOrCreatePluginState<F extends PluginFactoryLike>(
26
+ handle: PluginHandle<F>,
27
+ ): PluginState<InferFactoryData<F>, InferFactoryOutputs<F>>;
28
+ }
29
+
30
+ /**
31
+ * Composable for accessing a plugin's reactive model: data, outputs, and outputErrors.
32
+ *
33
+ * Mirrors the `app.model` access pattern — `plugin.model.data` is reactive and deep-watched,
34
+ * mutations are automatically queued and sent to storage.
35
+ *
36
+ * @param handle - Opaque plugin handle obtained from `app.plugins`.
37
+ * @typeParam F - The plugin factory type (inferred from the handle)
38
+ *
39
+ * @example
40
+ * ```vue
41
+ * <script setup lang="ts">
42
+ * import { usePlugin, type InferPluginHandle } from '@platforma-sdk/ui-vue';
43
+ * import type { CounterPlugin } from './plugins/counter';
44
+ *
45
+ * const props = defineProps<{ instance: InferPluginHandle<CounterPlugin> }>();
46
+ * const plugin = usePlugin(props.instance);
47
+ *
48
+ * plugin.model.data.count += 1; // reactive, triggers storage update
49
+ * plugin.model.outputs.displayText // computed, plugin's own outputs only
50
+ * plugin.model.outputErrors.displayText // Error | undefined
51
+ * </script>
52
+ * ```
53
+ */
54
+ export function usePlugin<F extends PluginFactoryLike>(handle: PluginHandle<F>) {
55
+ const access = inject<PluginAccess>(pluginDataKey);
56
+
57
+ if (!access) {
58
+ throw new Error(
59
+ "usePlugin requires a V3 block (BlockModelV3). " +
60
+ "Make sure the block uses apiVersion 3 and the plugin is installed.",
61
+ );
62
+ }
63
+
64
+ return access.getOrCreatePluginState<F>(handle);
65
+ }
@@ -1,30 +0,0 @@
1
- /**
2
- * Composable for accessing and updating plugin-specific data.
3
- *
4
- * Plugin components are self-contained: they use this composable to read/write
5
- * their own data slice from BlockStorage without knowing about the parent block's data.
6
- *
7
- * Requires a V3 block (BlockModelV3). Throws if used in a V1/V2 block.
8
- *
9
- * @param pluginId - The plugin instance ID (must match the ID used in BlockModelV3.plugin()).
10
- * Must be a static value; changing it after mount will not re-bind the composable.
11
- * @returns `{ data, updateData }` where `data` is a reactive ref to the plugin's data,
12
- * and `updateData` returns a promise resolving to `true` if the mutation was sent,
13
- * or `false` if data is not yet available.
14
- *
15
- * @example
16
- * ```vue
17
- * <script setup lang="ts">
18
- * const { data, updateData } = usePluginData<CounterData>('counter');
19
- *
20
- * function increment() {
21
- * updateData((d) => ({ ...d, count: d.count + 1 }));
22
- * }
23
- * </script>
24
- * ```
25
- */
26
- export declare function usePluginData<Data>(pluginId: string): {
27
- data: import('vue').ComputedRef<Data | undefined>;
28
- updateData: (cb: (current: Data) => Data) => Promise<boolean>;
29
- };
30
- //# sourceMappingURL=usePluginData.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"usePluginData.d.ts","sourceRoot":"","sources":["../src/usePluginData.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM;;qBAwB1B,CAAC,OAAO,EAAE,IAAI,KAAK,IAAI,KAAG,OAAO,CAAC,OAAO,CAAC;EAQnE"}
@@ -1,22 +0,0 @@
1
- import { inject as i, computed as u } from "vue";
2
- import { deepClone as s } from "./lib/util/helpers/dist/objects.js";
3
- import { pluginDataKey as l } from "./defineApp.js";
4
- function d(t) {
5
- const e = i(l);
6
- if (!e)
7
- throw new Error(
8
- "usePluginData requires a V3 block (BlockModelV3). Make sure the block uses apiVersion 3 and the plugin is installed."
9
- );
10
- e.initPluginDataSlot(t);
11
- const a = u(() => e.pluginDataMap[t]);
12
- return { data: a, updateData: (o) => {
13
- const r = a.value;
14
- if (r === void 0) return Promise.resolve(!1);
15
- const n = o(s(r));
16
- return e.setPluginData(t, n);
17
- } };
18
- }
19
- export {
20
- d as usePluginData
21
- };
22
- //# sourceMappingURL=usePluginData.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"usePluginData.js","sources":["../src/usePluginData.ts"],"sourcesContent":["import { computed, inject } from \"vue\";\nimport { deepClone } from \"@milaboratories/helpers\";\nimport { pluginDataKey } from \"./defineApp\";\nimport type { PluginDataAccess } from \"./internal/createAppV3\";\n\n/**\n * Composable for accessing and updating plugin-specific data.\n *\n * Plugin components are self-contained: they use this composable to read/write\n * their own data slice from BlockStorage without knowing about the parent block's data.\n *\n * Requires a V3 block (BlockModelV3). Throws if used in a V1/V2 block.\n *\n * @param pluginId - The plugin instance ID (must match the ID used in BlockModelV3.plugin()).\n * Must be a static value; changing it after mount will not re-bind the composable.\n * @returns `{ data, updateData }` where `data` is a reactive ref to the plugin's data,\n * and `updateData` returns a promise resolving to `true` if the mutation was sent,\n * or `false` if data is not yet available.\n *\n * @example\n * ```vue\n * <script setup lang=\"ts\">\n * const { data, updateData } = usePluginData<CounterData>('counter');\n *\n * function increment() {\n * updateData((d) => ({ ...d, count: d.count + 1 }));\n * }\n * </script>\n * ```\n */\nexport function usePluginData<Data>(pluginId: string) {\n const access = inject<PluginDataAccess>(pluginDataKey);\n\n if (!access) {\n throw new Error(\n \"usePluginData requires a V3 block (BlockModelV3). \" +\n \"Make sure the block uses apiVersion 3 and the plugin is installed.\",\n );\n }\n\n // Initialize the plugin data slot from snapshot if not already done\n access.initPluginDataSlot(pluginId);\n\n // Reactive reference to the plugin's data in the shared optimistic map\n const data = computed<Data | undefined>(() => {\n return access.pluginDataMap[pluginId] as Data | undefined;\n });\n\n /**\n * Update plugin data with optimistic feedback.\n *\n * @param cb - Callback that receives a deep clone of current data and returns new data.\n * @returns Promise that resolves to true when the mutation is sent\n */\n const updateData = (cb: (current: Data) => Data): Promise<boolean> => {\n const current = data.value;\n if (current === undefined) return Promise.resolve(false);\n const newValue = cb(deepClone(current));\n return access.setPluginData(pluginId, newValue);\n };\n\n return { data, updateData };\n}\n"],"names":["usePluginData","pluginId","access","inject","pluginDataKey","data","computed","cb","current","newValue","deepClone"],"mappings":";;;AA8BO,SAASA,EAAoBC,GAAkB;AACpD,QAAMC,IAASC,EAAyBC,CAAa;AAErD,MAAI,CAACF;AACH,UAAM,IAAI;AAAA,MACR;AAAA,IAAA;AAMJ,EAAAA,EAAO,mBAAmBD,CAAQ;AAGlC,QAAMI,IAAOC,EAA2B,MAC/BJ,EAAO,cAAcD,CAAQ,CACrC;AAeD,SAAO,EAAE,MAAAI,GAAM,YAPI,CAACE,MAAkD;AACpE,UAAMC,IAAUH,EAAK;AACrB,QAAIG,MAAY,OAAW,QAAO,QAAQ,QAAQ,EAAK;AACvD,UAAMC,IAAWF,EAAGG,EAAUF,CAAO,CAAC;AACtC,WAAON,EAAO,cAAcD,GAAUQ,CAAQ;AAAA,EAChD,EAEe;AACjB;"}
@@ -1,63 +0,0 @@
1
- import { computed, inject } from "vue";
2
- import { deepClone } from "@milaboratories/helpers";
3
- import { pluginDataKey } from "./defineApp";
4
- import type { PluginDataAccess } from "./internal/createAppV3";
5
-
6
- /**
7
- * Composable for accessing and updating plugin-specific data.
8
- *
9
- * Plugin components are self-contained: they use this composable to read/write
10
- * their own data slice from BlockStorage without knowing about the parent block's data.
11
- *
12
- * Requires a V3 block (BlockModelV3). Throws if used in a V1/V2 block.
13
- *
14
- * @param pluginId - The plugin instance ID (must match the ID used in BlockModelV3.plugin()).
15
- * Must be a static value; changing it after mount will not re-bind the composable.
16
- * @returns `{ data, updateData }` where `data` is a reactive ref to the plugin's data,
17
- * and `updateData` returns a promise resolving to `true` if the mutation was sent,
18
- * or `false` if data is not yet available.
19
- *
20
- * @example
21
- * ```vue
22
- * <script setup lang="ts">
23
- * const { data, updateData } = usePluginData<CounterData>('counter');
24
- *
25
- * function increment() {
26
- * updateData((d) => ({ ...d, count: d.count + 1 }));
27
- * }
28
- * </script>
29
- * ```
30
- */
31
- export function usePluginData<Data>(pluginId: string) {
32
- const access = inject<PluginDataAccess>(pluginDataKey);
33
-
34
- if (!access) {
35
- throw new Error(
36
- "usePluginData requires a V3 block (BlockModelV3). " +
37
- "Make sure the block uses apiVersion 3 and the plugin is installed.",
38
- );
39
- }
40
-
41
- // Initialize the plugin data slot from snapshot if not already done
42
- access.initPluginDataSlot(pluginId);
43
-
44
- // Reactive reference to the plugin's data in the shared optimistic map
45
- const data = computed<Data | undefined>(() => {
46
- return access.pluginDataMap[pluginId] as Data | undefined;
47
- });
48
-
49
- /**
50
- * Update plugin data with optimistic feedback.
51
- *
52
- * @param cb - Callback that receives a deep clone of current data and returns new data.
53
- * @returns Promise that resolves to true when the mutation is sent
54
- */
55
- const updateData = (cb: (current: Data) => Data): Promise<boolean> => {
56
- const current = data.value;
57
- if (current === undefined) return Promise.resolve(false);
58
- const newValue = cb(deepClone(current));
59
- return access.setPluginData(pluginId, newValue);
60
- };
61
-
62
- return { data, updateData };
63
- }