@antelopejs/dms 0.9.0 → 0.9.2

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.
Files changed (95) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/data/module-store/catalog.json +71 -0
  3. package/dist/config.d.ts +7 -0
  4. package/dist/config.js +8 -0
  5. package/dist/config.js.map +1 -1
  6. package/dist/implementations/dms/frontend-modules.d.ts +2 -0
  7. package/dist/implementations/dms/frontend-modules.js +29 -1
  8. package/dist/implementations/dms/frontend-modules.js.map +1 -1
  9. package/dist/implementations/dms/frontend-ready.d.ts +8 -0
  10. package/dist/implementations/dms/frontend-ready.js +34 -0
  11. package/dist/implementations/dms/frontend-ready.js.map +1 -0
  12. package/dist/implementations/dms/manifest.d.ts +1 -0
  13. package/dist/implementations/dms/manifest.js.map +1 -1
  14. package/dist/implementations/dms/page.d.ts +2 -0
  15. package/dist/implementations/dms/page.js +16 -0
  16. package/dist/implementations/dms/page.js.map +1 -1
  17. package/dist/implementations/dms-auth/index.js +1 -1
  18. package/dist/implementations/dms-auth/index.js.map +1 -1
  19. package/dist/routes/index.d.ts +1 -0
  20. package/dist/routes/index.js +1 -0
  21. package/dist/routes/index.js.map +1 -1
  22. package/dist/routes/store.d.ts +21 -0
  23. package/dist/routes/store.js +130 -0
  24. package/dist/routes/store.js.map +1 -0
  25. package/dist/store/activity.d.ts +2 -0
  26. package/dist/store/activity.js +12 -0
  27. package/dist/store/activity.js.map +1 -0
  28. package/dist/store/cache.d.ts +8 -0
  29. package/dist/store/cache.js +29 -0
  30. package/dist/store/cache.js.map +1 -0
  31. package/dist/store/catalog.d.ts +4 -0
  32. package/dist/store/catalog.js +69 -0
  33. package/dist/store/catalog.js.map +1 -0
  34. package/dist/store/compatibility.d.ts +3 -0
  35. package/dist/store/compatibility.js +45 -0
  36. package/dist/store/compatibility.js.map +1 -0
  37. package/dist/store/declarations.d.ts +18 -0
  38. package/dist/store/declarations.js +116 -0
  39. package/dist/store/declarations.js.map +1 -0
  40. package/dist/store/frontend-compatibility.d.ts +18 -0
  41. package/dist/store/frontend-compatibility.js +93 -0
  42. package/dist/store/frontend-compatibility.js.map +1 -0
  43. package/dist/store/interfaces.d.ts +6 -0
  44. package/dist/store/interfaces.js +126 -0
  45. package/dist/store/interfaces.js.map +1 -0
  46. package/dist/store/listing.d.ts +2 -0
  47. package/dist/store/listing.js +180 -0
  48. package/dist/store/listing.js.map +1 -0
  49. package/dist/store/operations.d.ts +8 -0
  50. package/dist/store/operations.js +254 -0
  51. package/dist/store/operations.js.map +1 -0
  52. package/dist/store/registry.d.ts +4 -0
  53. package/dist/store/registry.js +61 -0
  54. package/dist/store/registry.js.map +1 -0
  55. package/dist/store/requirements.d.ts +14 -0
  56. package/dist/store/requirements.js +81 -0
  57. package/dist/store/requirements.js.map +1 -0
  58. package/dist/store/runtime.d.ts +11 -0
  59. package/dist/store/runtime.js +78 -0
  60. package/dist/store/runtime.js.map +1 -0
  61. package/dist/store/tarball.d.ts +2 -0
  62. package/dist/store/tarball.js +80 -0
  63. package/dist/store/tarball.js.map +1 -0
  64. package/dist/store/types.d.ts +98 -0
  65. package/dist/store/types.js +3 -0
  66. package/dist/store/types.js.map +1 -0
  67. package/dist/test/unit/interfaces/dms/page-extension-ownership.test.js +1 -1
  68. package/dist/test/unit/interfaces/dms/page-extension-ownership.test.js.map +1 -1
  69. package/dist/test/unit/store/store.test.d.ts +1 -0
  70. package/dist/test/unit/store/store.test.js +127 -0
  71. package/dist/test/unit/store/store.test.js.map +1 -0
  72. package/dist/test/unit/utils/user-activity.test.js +4 -12
  73. package/dist/test/unit/utils/user-activity.test.js.map +1 -1
  74. package/dist/utils/user-activity.d.ts +1 -1
  75. package/dist/utils/user-activity.js +6 -3
  76. package/dist/utils/user-activity.js.map +1 -1
  77. package/dist/validation/store.schema.d.ts +39 -0
  78. package/dist/validation/store.schema.js +27 -0
  79. package/dist/validation/store.schema.js.map +1 -0
  80. package/frontend-vue/layers/dms-layout/app/build/components/pages/modules/ModuleStoreDependencyPicker.vue +136 -0
  81. package/frontend-vue/layers/dms-layout/app/build/components/pages/modules/ModuleStoreRequirements.vue +98 -0
  82. package/frontend-vue/layers/dms-layout/app/build/components/pages/modules/ModuleStoreTile.vue +247 -29
  83. package/frontend-vue/layers/dms-layout/app/build/components/pages/modules/ModuleTile.vue +19 -2
  84. package/frontend-vue/layers/dms-layout/app/build/utils/module-store-catalog.ts +186 -120
  85. package/frontend-vue/layers/dms-layout/app/build/utils/module-store-install.ts +146 -0
  86. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStore.ts +109 -0
  87. package/frontend-vue/layers/dms-layout/app/custom-pages/modules.vue +335 -34
  88. package/frontend-vue/layers/dms-layout/app/types/module-store.ts +134 -0
  89. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +107 -9
  90. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +107 -9
  91. package/frontend-vue/package.json +3 -3
  92. package/frontend-vue/pnpm-lock.yaml +16 -22
  93. package/frontend-vue/tests/module-store-catalog.test.ts +270 -51
  94. package/frontend-vue/tests/top-list-card-period-badge-ssr.test.ts +212 -0
  95. package/package.json +5 -5
@@ -1,137 +1,203 @@
1
+ import type { Tone } from "#dms-ui/app/types/tone";
2
+ import type { StatusPillDot } from "#dms-ui/app/components/status-pill/StatusPill.vue";
3
+ import type {
4
+ StoreListingEntry,
5
+ StoreModuleState,
6
+ StoreOperationKind,
7
+ } from "../../types/module-store";
1
8
  import type { DisplayResolver } from "./modules-catalog";
2
9
 
3
- /**
4
- * One official module offered by the module store preview. The list below is
5
- * static for now: it stands in for the AntelopeJS module registry the store
6
- * will read once installing from the dashboard is possible.
7
- */
8
- export interface ModuleStoreEntry {
10
+ /** A store module as the Modules page filters and draws it. */
11
+ export type ModuleStoreEntry = StoreListingEntry & {
12
+ /** Store category, a `$i18n.key` (what the category chips read). */
13
+ catalogCategory: string;
14
+ };
15
+
16
+ /** Which store modules the store section shows. */
17
+ export type StoreScope = "all" | "available" | "installed";
18
+
19
+ /** What a store tile's pill says. */
20
+ export interface StoreTileStatus {
21
+ tone: Tone;
22
+ /** i18n key under `modules.store.state`. */
23
+ labelKey: string;
24
+ dot?: StatusPillDot;
25
+ }
26
+
27
+ /** One action a store tile offers. */
28
+ export interface StoreAction {
29
+ kind: StoreOperationKind;
30
+ /** i18n key under `modules.store.actions`. */
31
+ labelKey: string;
32
+ icon: string;
33
+ /** The action runs only once the owner confirms it. */
34
+ confirm?: boolean;
9
35
  /**
10
- * Id the module registers in the catalog (`RegisterModule({ id })`): a
11
- * store entry whose id is installed is hidden.
36
+ * Offered in the tile's menu only, never as its button: pausing or
37
+ * removing a running module is not what its tile should invite.
12
38
  */
13
- id: string;
14
- /** npm package the AntelopeJS CLI adds to the project. */
15
- packageName: string;
16
- /** Display name, a `$i18n.key`. */
17
- title: string;
18
- /** One-line summary, a `$i18n.key`. */
19
- description: string;
20
- /** Iconify name, the icon the module registers once installed. */
21
- icon: string;
22
- /** Store category, a `$i18n.key`. */
23
- catalogCategory: string;
39
+ isMenuOnly?: boolean;
24
40
  }
25
41
 
26
- const CATEGORY = {
27
- ai: "$modules.store.category.ai",
28
- business: "$modules.store.category.business",
29
- content: "$modules.store.category.content",
30
- data: "$modules.store.category.data",
31
- developer: "$modules.store.category.developer",
32
- growth: "$modules.store.category.growth",
33
- operations: "$modules.store.category.operations",
34
- } as const;
35
-
36
- function storeEntry(
37
- id: string,
38
- packageName: string,
39
- icon: string,
40
- catalogCategory: string,
41
- ): ModuleStoreEntry {
42
+ const CATEGORY_PREFIX = "$modules.store.category";
43
+
44
+ /** The command that turns a production frontend into one serving new layers. */
45
+ export const FRONTEND_BUILD_COMMAND = "ajs dms build";
46
+
47
+ const STATUSES: Record<StoreModuleState, StoreTileStatus> = {
48
+ available: { tone: "neutral", labelKey: "available", dot: "none" },
49
+ busy: { tone: "info", labelKey: "busy", dot: "live" },
50
+ active: { tone: "success", labelKey: "active" },
51
+ disabled: { tone: "neutral", labelKey: "disabled" },
52
+ error: { tone: "error", labelKey: "error" },
53
+ pending: { tone: "warning", labelKey: "pending" },
54
+ };
55
+
56
+ const ACTIONS: Record<StoreOperationKind, StoreAction> = {
57
+ install: {
58
+ kind: "install",
59
+ labelKey: "install",
60
+ icon: "i-ph-download-simple",
61
+ },
62
+ update: { kind: "update", labelKey: "update", icon: "i-ph-arrow-circle-up" },
63
+ enable: { kind: "enable", labelKey: "enable", icon: "i-ph-play" },
64
+ disable: {
65
+ kind: "disable",
66
+ labelKey: "disable",
67
+ icon: "i-ph-pause",
68
+ isMenuOnly: true,
69
+ },
70
+ uninstall: {
71
+ kind: "uninstall",
72
+ labelKey: "uninstall",
73
+ icon: "i-ph-trash",
74
+ confirm: true,
75
+ isMenuOnly: true,
76
+ },
77
+ apply: { kind: "apply", labelKey: "apply", icon: "i-ph-arrows-clockwise" },
78
+ };
79
+
80
+ /** States of a module that antelope.config.ts declares. */
81
+ const INSTALLED_STATES = new Set<StoreModuleState>([
82
+ "active",
83
+ "disabled",
84
+ "pending",
85
+ ]);
86
+
87
+ export function toModuleStoreEntry(entry: StoreListingEntry): ModuleStoreEntry {
42
88
  return {
43
- id,
44
- packageName,
45
- title: `$modules.store.catalog.${id}.title`,
46
- description: `$modules.store.catalog.${id}.description`,
47
- icon,
48
- catalogCategory,
89
+ ...entry,
90
+ catalogCategory: `${CATEGORY_PREFIX}.${entry.category}`,
91
+ };
92
+ }
93
+
94
+ /** Whether the module is installed, from the store or by the config. */
95
+ export function isInstalled(entry: StoreListingEntry): boolean {
96
+ if (INSTALLED_STATES.has(entry.state)) return true;
97
+ return entry.installedVersion !== undefined;
98
+ }
99
+
100
+ export function filterByScope<T extends StoreListingEntry>(
101
+ entries: readonly T[],
102
+ scope: StoreScope,
103
+ ): T[] {
104
+ if (scope === "all") return [...entries];
105
+ const wantsInstalled = scope === "installed";
106
+ return entries.filter((entry) => isInstalled(entry) === wantsInstalled);
107
+ }
108
+
109
+ /** The store modules, alphabetical by their shown name. */
110
+ export function sortStoreModules<T extends StoreListingEntry>(
111
+ entries: readonly T[],
112
+ resolve: DisplayResolver,
113
+ ): T[] {
114
+ return [...entries].sort((a, b) =>
115
+ resolve(a.title).localeCompare(resolve(b.title)),
116
+ );
117
+ }
118
+
119
+ export function storeTileStatus(entry: StoreListingEntry): StoreTileStatus {
120
+ if (entry.state === "active" && entry.updateAvailable) {
121
+ return { tone: "warning", labelKey: "update_available" };
122
+ }
123
+ return STATUSES[entry.state];
124
+ }
125
+
126
+ /**
127
+ * What the tile offers, the main action first. A module in error offers to
128
+ * try its last step again: installing it when the file does not hold it,
129
+ * applying the file otherwise.
130
+ */
131
+ export function storeActions(entry: StoreListingEntry): StoreAction[] {
132
+ const installed = entry.installedVersion !== undefined;
133
+ const byState: Record<StoreModuleState, StoreOperationKind[]> = {
134
+ available: ["install"],
135
+ busy: [],
136
+ active: entry.updateAvailable
137
+ ? ["update", "disable", "uninstall"]
138
+ : ["disable", "uninstall"],
139
+ disabled: ["enable", "uninstall"],
140
+ error: installed ? ["apply", "uninstall"] : ["install"],
141
+ pending: ["apply", "uninstall"],
49
142
  };
143
+ return byState[entry.state].map((kind) => ACTIONS[kind]);
50
144
  }
51
145
 
52
146
  /**
53
- * The official AntelopeJS DMS modules (one repository each under the
54
- * AntelopeJS organisation). Ids and icons are the ones each module registers;
55
- * Media and Builder register no catalog module, so theirs come from their
56
- * settings page and the v2 design.
147
+ * Whether the page waits on the frontend for this module: its backend side
148
+ * changed and the frontend server has not reported serving it yet.
57
149
  */
58
- export const MODULE_STORE_CATALOG: readonly ModuleStoreEntry[] = [
59
- storeEntry("ai", "@antelopejs/dms-ai", "i-ph-robot", CATEGORY.ai),
60
- storeEntry(
61
- "api",
62
- "@antelopejs/dms-api",
63
- "i-ph-brackets-curly",
64
- CATEGORY.developer,
65
- ),
66
- storeEntry(
67
- "automation",
68
- "@antelopejs/dms-automation",
69
- "i-ph-flow-arrow",
70
- CATEGORY.operations,
71
- ),
72
- storeEntry(
73
- "builder",
74
- "@antelopejs/dms-builder",
75
- "i-ph-hammer",
76
- CATEGORY.developer,
77
- ),
78
- storeEntry(
79
- "cicd",
80
- "@antelopejs-private/dms-cicd",
81
- "i-ph-rocket-launch",
82
- CATEGORY.operations,
83
- ),
84
- storeEntry(
85
- "database",
86
- "@antelopejs/dms-database",
87
- "i-ph-database",
88
- CATEGORY.data,
89
- ),
90
- storeEntry(
91
- "lang",
92
- "@antelopejs/dms-lang",
93
- "i-ph-translate",
94
- CATEGORY.content,
95
- ),
96
- storeEntry(
97
- "mailing",
98
- "@antelopejs/dms-mailing",
99
- "i-ph-envelope-simple",
100
- CATEGORY.growth,
101
- ),
102
- storeEntry(
103
- "marketing",
104
- "@antelopejs/dms-marketing",
105
- "i-ph-chart-line-up",
106
- CATEGORY.growth,
107
- ),
108
- storeEntry("media", "@antelopejs/dms-media", "i-ph-images", CATEGORY.content),
109
- storeEntry(
110
- "saas",
111
- "@antelopejs/dms-saas",
112
- "i-ph-buildings",
113
- CATEGORY.business,
114
- ),
115
- ];
116
-
117
- /** The AntelopeJS CLI command that adds a package module to a project. */
118
- export const MODULE_INSTALL_COMMAND = "ajs project modules add";
119
-
120
- /** The full CLI command that adds the module to a project. */
121
- export function moduleInstallCommand(entry: ModuleStoreEntry): string {
122
- return `${MODULE_INSTALL_COMMAND} ${entry.packageName}`;
150
+ export function isAwaitingFrontend(entry: StoreListingEntry): boolean {
151
+ return entry.state !== "busy" && entry.frontend === "pending";
123
152
  }
124
153
 
154
+ /** Why an action the tile offers cannot run, as the tile explains it. */
155
+ export type StoreActionBlock =
156
+ | "no_release"
157
+ | "incompatible"
158
+ | "frontend"
159
+ | "interfaces"
160
+ | "dependents";
161
+
162
+ /** Actions that load a new release of the module. */
163
+ const RELEASE_ACTIONS = new Set<StoreOperationKind>(["install", "update"]);
125
164
  /**
126
- * The store entries not installed on this platform, alphabetical by their
127
- * shown name.
165
+ * Actions that pick the missing interfaces' modules in a dialog; the others
166
+ * refuse a release that misses one.
128
167
  */
129
- export function availableStoreModules(
130
- catalog: readonly ModuleStoreEntry[],
131
- installedIds: ReadonlySet<string>,
132
- resolve: DisplayResolver,
133
- ): ModuleStoreEntry[] {
134
- return catalog
135
- .filter((entry) => !installedIds.has(entry.id))
136
- .sort((a, b) => resolve(a.title).localeCompare(resolve(b.title)));
168
+ const PICKS_DEPENDENCIES = new Set<StoreOperationKind>(["install"]);
169
+ /** Actions that take a running module down. */
170
+ const TAKE_DOWN_ACTIONS = new Set<StoreOperationKind>(["disable", "uninstall"]);
171
+
172
+ function releaseBlock(
173
+ entry: StoreListingEntry,
174
+ kind: StoreOperationKind,
175
+ ): StoreActionBlock | undefined {
176
+ if (!entry.latestVersion) return "no_release";
177
+ if (!entry.compatible) return "incompatible";
178
+ if (entry.frontendRequirement) return "frontend";
179
+ if (!PICKS_DEPENDENCIES.has(kind) && entry.missingInterfaces.length > 0) {
180
+ return "interfaces";
181
+ }
182
+ return undefined;
183
+ }
184
+
185
+ /** Why `kind` cannot run on the module, or undefined when it can. */
186
+ export function storeActionBlock(
187
+ entry: StoreListingEntry,
188
+ kind: StoreOperationKind,
189
+ ): StoreActionBlock | undefined {
190
+ if (RELEASE_ACTIONS.has(kind)) return releaseBlock(entry, kind);
191
+ if (TAKE_DOWN_ACTIONS.has(kind) && entry.dependents.length > 0) {
192
+ return "dependents";
193
+ }
194
+ return undefined;
195
+ }
196
+
197
+ /** Whether `kind` first asks which modules implement the missing interfaces. */
198
+ export function needsDependencyChoice(
199
+ entry: StoreListingEntry,
200
+ kind: StoreOperationKind,
201
+ ): boolean {
202
+ return PICKS_DEPENDENCIES.has(kind) && entry.missingInterfaces.length > 0;
137
203
  }
@@ -0,0 +1,146 @@
1
+ import type {
2
+ MissingInterface,
3
+ StoreActionBody,
4
+ StoreDependencyChoice,
5
+ } from "../../types/module-store";
6
+
7
+ /**
8
+ * The radio value of the "Other npm package" choice: not an npm package
9
+ * name, so no candidate can take it.
10
+ */
11
+ export const OTHER_PACKAGE_CHOICE = ":other";
12
+
13
+ /**
14
+ * An npm package name, scoped or not: never a path, a URL or a git source,
15
+ * as the store only installs from the registry.
16
+ */
17
+ const NPM_PACKAGE_PATTERN =
18
+ /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
19
+ const NPM_PACKAGE_MAX_LENGTH = 214;
20
+
21
+ /** What the install dialog holds for one missing interface. */
22
+ export interface DependencyDraft {
23
+ interfacePackage: string;
24
+ /** A candidate's package, or {@link OTHER_PACKAGE_CHOICE}. */
25
+ choice: string;
26
+ /** The package typed for the "Other npm package" choice. */
27
+ otherPackage: string;
28
+ /** The config as typed, JSON; empty means none. */
29
+ config: string;
30
+ }
31
+
32
+ /** Why a draft cannot be sent, by field: i18n keys under the dialog's errors. */
33
+ export type DependencyDraftError =
34
+ | "package_required"
35
+ | "package_invalid"
36
+ | "config_invalid";
37
+
38
+ export interface DependencyDraftErrors {
39
+ package?: DependencyDraftError;
40
+ config?: DependencyDraftError;
41
+ }
42
+
43
+ /** A config as the dialog reads it: absent when left empty. */
44
+ export interface ParsedDependencyConfig {
45
+ isValid: boolean;
46
+ config?: Record<string, unknown>;
47
+ }
48
+
49
+ export function isNpmPackageName(value: string): boolean {
50
+ return (
51
+ value.length <= NPM_PACKAGE_MAX_LENGTH && NPM_PACKAGE_PATTERN.test(value)
52
+ );
53
+ }
54
+
55
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
56
+ typeof value === "object" && value !== null && !Array.isArray(value);
57
+
58
+ /** Reads a typed config: empty is no config, anything else a JSON object. */
59
+ export function parseDependencyConfig(text: string): ParsedDependencyConfig {
60
+ if (!text.trim()) return { isValid: true };
61
+ try {
62
+ const value: unknown = JSON.parse(text);
63
+ return isPlainObject(value)
64
+ ? { isValid: true, config: value }
65
+ : { isValid: false };
66
+ } catch {
67
+ return { isValid: false };
68
+ }
69
+ }
70
+
71
+ /**
72
+ * One draft per missing interface, the first candidate picked. A draft of
73
+ * an earlier attempt at the same interface is kept, so a retry starts from
74
+ * what the owner typed.
75
+ */
76
+ export function createDependencyDrafts(
77
+ missing: readonly MissingInterface[],
78
+ previous: readonly DependencyDraft[] = [],
79
+ ): DependencyDraft[] {
80
+ return missing.map(({ interfacePackage, candidates }) => {
81
+ const earlier = previous.find(
82
+ (draft) => draft.interfacePackage === interfacePackage,
83
+ );
84
+ if (earlier) return { ...earlier };
85
+ return {
86
+ interfacePackage,
87
+ choice: candidates[0]?.package ?? OTHER_PACKAGE_CHOICE,
88
+ otherPackage: "",
89
+ config: "",
90
+ };
91
+ });
92
+ }
93
+
94
+ /** The package a draft installs, as the owner picked or typed it. */
95
+ export function draftPackage(draft: DependencyDraft): string {
96
+ return draft.choice === OTHER_PACKAGE_CHOICE
97
+ ? draft.otherPackage.trim()
98
+ : draft.choice;
99
+ }
100
+
101
+ function packageError(
102
+ draft: DependencyDraft,
103
+ ): DependencyDraftError | undefined {
104
+ const packageName = draftPackage(draft);
105
+ if (!packageName) return "package_required";
106
+ return isNpmPackageName(packageName) ? undefined : "package_invalid";
107
+ }
108
+
109
+ export function validateDependencyDraft(
110
+ draft: DependencyDraft,
111
+ ): DependencyDraftErrors {
112
+ const errors: DependencyDraftErrors = {};
113
+ const packageProblem = packageError(draft);
114
+ if (packageProblem) errors.package = packageProblem;
115
+ if (!parseDependencyConfig(draft.config).isValid) {
116
+ errors.config = "config_invalid";
117
+ }
118
+ return errors;
119
+ }
120
+
121
+ export function areDependencyDraftsValid(
122
+ drafts: readonly DependencyDraft[],
123
+ ): boolean {
124
+ return drafts.every(
125
+ (draft) => Object.keys(validateDependencyDraft(draft)).length === 0,
126
+ );
127
+ }
128
+
129
+ function toDependencyChoice(draft: DependencyDraft): StoreDependencyChoice {
130
+ const { config } = parseDependencyConfig(draft.config);
131
+ const choice: StoreDependencyChoice = {
132
+ interfacePackage: draft.interfacePackage,
133
+ package: draftPackage(draft),
134
+ };
135
+ if (config) choice.config = config;
136
+ return choice;
137
+ }
138
+
139
+ /** The install request for module `id`, from drafts already validated. */
140
+ export function buildInstallBody(
141
+ id: string,
142
+ drafts: readonly DependencyDraft[],
143
+ ): StoreActionBody {
144
+ if (drafts.length === 0) return { id };
145
+ return { id, dependencies: drafts.map(toDependencyChoice) };
146
+ }
@@ -0,0 +1,109 @@
1
+ import type {
2
+ StoreActionBody,
3
+ StoreListing,
4
+ StoreListingEntry,
5
+ StoreOperationKind,
6
+ } from "../../types/module-store";
7
+ import { isAwaitingFrontend } from "../../build/utils/module-store-catalog";
8
+
9
+ export const ENDPOINT_STORE_LISTING = "/dms/store/listing";
10
+ const ENDPOINT_STORE_ACTION = "/dms/store";
11
+ /** How often the listing is read while an operation or the frontend runs. */
12
+ const FOLLOW_INTERVAL_MS = 1000;
13
+
14
+ /**
15
+ * The module store of the Modules page: the listing, the operations, and the
16
+ * follow-up that reads the listing again while something moves. An operation
17
+ * the owner started here reloads the page once the frontend serves its
18
+ * result, so a new module's pages appear without a manual refresh.
19
+ */
20
+ export const useModuleStore = () => {
21
+ // Taken once, in setup: the follow-up reads the listing from a timer,
22
+ // outside any component context.
23
+ const { $authFetch } = useAuthFetch();
24
+ const listing = ref<StoreListing | undefined>(undefined);
25
+ const loadingError = ref<unknown>(null);
26
+ /** Modules an operation from this page changed, until the frontend follows. */
27
+ const awaitingReload = ref(new Set<string>());
28
+ let followTimer: ReturnType<typeof setTimeout> | undefined;
29
+
30
+ const isFollowing = computed(() => {
31
+ const modules = listing.value?.modules ?? [];
32
+ return (
33
+ listing.value?.busy === true ||
34
+ modules.some((entry) => isAwaitingFrontend(entry))
35
+ );
36
+ });
37
+
38
+ async function load(): Promise<void> {
39
+ try {
40
+ listing.value = await $authFetch<StoreListing>(ENDPOINT_STORE_LISTING);
41
+ loadingError.value = null;
42
+ } catch (error) {
43
+ loadingError.value = error;
44
+ }
45
+ reloadWhenServed();
46
+ scheduleFollow();
47
+ }
48
+
49
+ /**
50
+ * Reloads the page once every module an operation from here changed is
51
+ * served by the frontend (or has no interface at all). A frontend that
52
+ * never reported stays put: the page says what to run instead.
53
+ */
54
+ function reloadWhenServed(): void {
55
+ if (import.meta.env.SSR || awaitingReload.value.size === 0) return;
56
+ const changed = (listing.value?.modules ?? []).filter((entry) =>
57
+ awaitingReload.value.has(entry.id),
58
+ );
59
+ const isSettled = changed.every(
60
+ (entry) =>
61
+ entry.state !== "busy" &&
62
+ (entry.frontend === "synced" || entry.frontend === "none"),
63
+ );
64
+ const needsReload = changed.some((entry) => entry.frontend === "synced");
65
+ if (!isSettled) return;
66
+ awaitingReload.value = new Set();
67
+ if (needsReload) window.location.reload();
68
+ }
69
+
70
+ function scheduleFollow(): void {
71
+ if (import.meta.env.SSR) return;
72
+ if (followTimer) clearTimeout(followTimer);
73
+ followTimer = undefined;
74
+ if (!isFollowing.value) return;
75
+ followTimer = setTimeout(() => void load(), FOLLOW_INTERVAL_MS);
76
+ }
77
+
78
+ /**
79
+ * Starts `kind` on `entry`, with `body` when it carries more than the id;
80
+ * rejects with the server's answer when refused.
81
+ */
82
+ async function run(
83
+ kind: StoreOperationKind,
84
+ entry: StoreListingEntry,
85
+ body: StoreActionBody = { id: entry.id },
86
+ ): Promise<void> {
87
+ listing.value = await $authFetch<StoreListing>(
88
+ `${ENDPOINT_STORE_ACTION}/${kind}`,
89
+ { method: "POST", body },
90
+ );
91
+ awaitingReload.value = new Set([...awaitingReload.value, entry.id]);
92
+ scheduleFollow();
93
+ }
94
+
95
+ function stop(): void {
96
+ if (followTimer) clearTimeout(followTimer);
97
+ followTimer = undefined;
98
+ }
99
+
100
+ return {
101
+ listing,
102
+ loadingError,
103
+ isFollowing,
104
+ awaitingReload,
105
+ load,
106
+ run,
107
+ stop,
108
+ };
109
+ };