@tekkare/romulus 0.1.242 → 0.1.244

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 (45) hide show
  1. package/dist/module.json +1 -1
  2. package/layer/app/components/AppUpdateToast.vue +157 -0
  3. package/layer/app/components/OipShell.vue +2 -0
  4. package/layer/app/components/auth/SplitLayout.vue +261 -0
  5. package/layer/app/components/layout/alertBrandSync.vue +143 -0
  6. package/layer/app/components/layout/firstVisitVideo.vue +165 -0
  7. package/layer/app/components/layout/tool/adminTemplates.vue +261 -0
  8. package/layer/app/components/layout/tool/alerts.vue +969 -0
  9. package/layer/app/components/layout/tool/export.vue +590 -0
  10. package/layer/app/components/layout/tool/views.vue +933 -0
  11. package/layer/app/components/layout/viewEmptyState.vue +278 -0
  12. package/layer/app/components/layout/viewPill.vue +550 -0
  13. package/layer/app/components/panels/OipPanelProjects.vue +94 -0
  14. package/layer/app/composables/useAdminAccess.ts +86 -0
  15. package/layer/app/composables/useAlertBrandSync.ts +85 -0
  16. package/layer/app/composables/useAlertSource.ts +210 -0
  17. package/layer/app/composables/useAlerts.ts +345 -0
  18. package/layer/app/composables/useExportSource.ts +236 -0
  19. package/layer/app/composables/useHubrEmbedded.ts +13 -0
  20. package/layer/app/composables/usePermalink.ts +108 -0
  21. package/layer/app/composables/useSavedExports.ts +286 -0
  22. package/layer/app/composables/useViewCardActions.ts +500 -0
  23. package/layer/app/composables/useViewEditing.ts +11 -0
  24. package/layer/app/composables/useViewTemplates.ts +369 -0
  25. package/layer/app/composables/useViews.ts +574 -0
  26. package/layer/app/oip-defaults/alerts.ts +34 -0
  27. package/layer/app/oip-defaults/api.ts +18 -0
  28. package/layer/app/oip-defaults/export.ts +23 -0
  29. package/layer/app/oip-defaults/ui.ts +21 -0
  30. package/layer/app/oip-defaults/views.ts +59 -0
  31. package/layer/app/utils/alertFieldLabels.ts +191 -0
  32. package/layer/app/utils/countries.ts +30 -0
  33. package/layer/app/utils/exportPage.ts +418 -0
  34. package/layer/app/utils/headerTabs.ts +15 -0
  35. package/layer/app/utils/hybridRows.ts +22 -0
  36. package/layer/app/utils/oipScopeStore.ts +112 -0
  37. package/layer/app/utils/oipScopes.ts +376 -0
  38. package/layer/app/utils/postLoginRedirect.ts +58 -0
  39. package/layer/app/utils/valueTable.ts +126 -0
  40. package/layer/app/utils/viewPages.ts +30 -0
  41. package/layer/app/utils/viewTemplateCriteria.ts +215 -0
  42. package/layer/app/utils/viewTemplates.ts +116 -0
  43. package/layer/nuxt.config.ts +9 -0
  44. package/layer/server/api/oip/notes.get.ts +1 -1
  45. package/package.json +4 -2
@@ -0,0 +1,376 @@
1
+ // Composable for scope operations - Using Pulses REST API (Virtual Lab)
2
+
3
+ import { storeToRefs } from "pinia";
4
+ import { useOipApi } from "#oip-app/api";
5
+ import { oipSnackbar, oipTrack } from "#oip-app/ui";
6
+ import type { defineOipScopeStore } from "./oipScopeStore";
7
+
8
+ /**
9
+ * Fabrique du composable de vues enregistrees d'une famille (useScopes,
10
+ * useHeartScopes...) : memes appels a la Virtual Lab (pulses), seul le store
11
+ * change.
12
+ */
13
+ export function createOipScopes(scopeStore: ReturnType<typeof defineOipScopeStore>) {
14
+ return function useScopes() {
15
+ // variables
16
+ const scopes = ref([] as Array<any>);
17
+ const scopeLoading = ref(false as boolean);
18
+ const scopeError = ref(null as any);
19
+ const router = useRouter();
20
+ const scopePinia = scopeStore();
21
+ const scopeState = storeToRefs(scopePinia);
22
+ const elysiaApi = useOipApi();
23
+ // Par `$i18n` et non `useI18n` : ce composable est aussi appele hors d'un
24
+ // `setup` (tiroirs de selection, gestionnaires d'evenements), ou `useI18n`
25
+ // leve. Les snackbars parlent la langue de l'ecran, et de « vue », le nom
26
+ // que porte l'objet a l'ecran (« scope » ne reste que cote API).
27
+ const { $i18n } = useNuxtApp() as any;
28
+ const fr = () => $i18n?.locale?.value !== "en";
29
+ const say = (texts: { fr: string; en: string }) => (fr() ? texts.fr : texts.en);
30
+
31
+ // Load scopes using the Pulses REST API
32
+ async function getScopes() {
33
+ const domain = window.location.host;
34
+
35
+ try {
36
+ scopeLoading.value = true;
37
+ // `limit` explicite : l'API rend 100 pulses par defaut, les plus recents
38
+ // d'abord, et les exports conserves partagent ce magasin. Sans plafond
39
+ // releve, une vingtaine d'exports frais poussent les vues les plus
40
+ // anciennes hors de la fenetre, sans erreur ni message.
41
+ const response = await elysiaApi.pulses.list({ domain, limit: 500 });
42
+
43
+ // criteria must be a JSON string for backward compatibility with components
44
+ const mappedScopes = response.data.map((pulse) => ({
45
+ id: pulse.id,
46
+ user_id: pulse.userId,
47
+ title: pulse.title,
48
+ description: pulse.description || "",
49
+ url: pulse.url,
50
+ page: pulse.page,
51
+ criteria:
52
+ typeof pulse.criteria === "string"
53
+ ? pulse.criteria
54
+ : JSON.stringify(pulse.criteria || []),
55
+ starred: pulse.starred ?? false,
56
+ isDefault: pulse.isDefault ?? false,
57
+ // Partage : la vue peut venir de quelqu'un d'autre. `isOwner` decide
58
+ // de ce que l'ecran laisse faire, jamais l'egalite des identifiants
59
+ // d'utilisateur, que le pulse d'une vue partagee ne porte pas.
60
+ visibility: pulse.visibility ?? "private",
61
+ isOwner: pulse.isOwner ?? true,
62
+ ownerName: pulse.ownerName ?? null,
63
+ sharedWith: pulse.sharedWith ?? [],
64
+ // Publication en template Tekkare : le dialogue de partage en a
65
+ // besoin pour rouvrir sur ce qui est deja pose.
66
+ isTemplate: pulse.isTemplate ?? false,
67
+ targetWorkspaces: pulse.targetWorkspaces ?? [],
68
+ createdAt: pulse.createdAt,
69
+ projects: pulse.projects || [],
70
+ }));
71
+
72
+ scopes.value = mappedScopes;
73
+ scopePinia.setList(scopes.value);
74
+ scopeLoading.value = false;
75
+ } catch (err) {
76
+ scopeLoading.value = false;
77
+ scopeError.value = err;
78
+ console.error("[useScopes] Error loading scopes:", err);
79
+ }
80
+ }
81
+
82
+ interface newScopeObject {
83
+ title: String;
84
+ description?: String;
85
+ criteria: Array<criteriaArray>;
86
+ currentPage?: String;
87
+ }
88
+
89
+ interface criteriaArray {
90
+ filter: String;
91
+ page?: String;
92
+ values: any;
93
+ }
94
+
95
+ // Save new scope using the Pulses REST API
96
+ async function createScope({
97
+ title = "",
98
+ description = "",
99
+ criteria = [],
100
+ currentPage,
101
+ }: newScopeObject) {
102
+ const url = window.location.href;
103
+ const page = currentPage ?? router.currentRoute.value.path;
104
+
105
+ try {
106
+ scopeLoading.value = true;
107
+ oipSnackbar([
108
+ { attribute: "isVisible", value: true },
109
+ { attribute: "text", value: say({ fr: "Enregistrement de la vue", en: "Saving your view" }) },
110
+ { attribute: "loader", value: true },
111
+ ]);
112
+
113
+ await elysiaApi.pulses.create({
114
+ title: title as string,
115
+ description: description as string,
116
+ url: url,
117
+ page: page as string,
118
+ criteria: criteria, // sent as an object, API handles it
119
+ });
120
+
121
+ oipSnackbar([
122
+ { attribute: "isVisible", value: true },
123
+ {
124
+ attribute: "text",
125
+ value: say({ fr: "Vue enregistrée", en: "Your view has been saved" }),
126
+ },
127
+ { attribute: "loader", value: false },
128
+ { attribute: "prefixIcon", value: "Check" },
129
+ { attribute: "prefixIconColor", value: "#32D175" },
130
+ { attribute: "hasTimeOut", value: true },
131
+ ]);
132
+
133
+ scopeLoading.value = false;
134
+ oipTrack("scope_created", { title, page: page as string });
135
+
136
+ await getScopes();
137
+ } catch (err) {
138
+ scopeLoading.value = false;
139
+ scopeError.value = err;
140
+ console.error("[useScopes] Error creating scope:", err);
141
+ oipSnackbar([
142
+ { attribute: "isVisible", value: true },
143
+ {
144
+ attribute: "text",
145
+ value: say({
146
+ fr: "L'enregistrement de la vue a échoué",
147
+ en: "Something went wrong saving your view",
148
+ }),
149
+ },
150
+ { attribute: "hasTimeOut", value: true },
151
+ { attribute: "loader", value: false },
152
+ ]);
153
+ }
154
+ }
155
+
156
+ // Update existing scope using the Pulses REST API
157
+ async function updateScope({
158
+ id,
159
+ title = "",
160
+ description = "",
161
+ criteria = [],
162
+ currentPage,
163
+ refreshAlerts = true,
164
+ }: newScopeObject & {
165
+ id: string;
166
+ /**
167
+ * Faux quand les criteres ne bougent pas (un renommage) : les alertes de la
168
+ * vue n'ont rien a repointer, et la requete de l'ecran courant n'a rien a
169
+ * faire dans une vue peut-etre retouchee depuis une autre page.
170
+ */
171
+ refreshAlerts?: boolean;
172
+ }) {
173
+ const url = window.location.href;
174
+ const page = currentPage ?? router.currentRoute.value.path;
175
+
176
+ try {
177
+ scopeLoading.value = true;
178
+ oipSnackbar([
179
+ { attribute: "isVisible", value: true },
180
+ { attribute: "text", value: say({ fr: "Mise à jour de la vue", en: "Updating your view" }) },
181
+ { attribute: "loader", value: true },
182
+ ]);
183
+
184
+ // Les alertes de cette vue portent une requete figee, que seule la page
185
+ // sait construire : l'envoyer permet a l'API de les repointer elle-meme.
186
+ // Sans elle, elles s'endorment et attendent un rearmement a la main.
187
+ const result = await elysiaApi.pulses.update(id, {
188
+ title: title as string,
189
+ description: description as string,
190
+ url: url,
191
+ page: page as string,
192
+ criteria: criteria,
193
+ ...(refreshAlerts ? alertRefreshPayload(page) : {}),
194
+ });
195
+
196
+ oipSnackbar([
197
+ { attribute: "isVisible", value: true },
198
+ {
199
+ attribute: "text",
200
+ value: `${say({ fr: "Vue mise à jour", en: "Your view has been updated" })}${alertRefreshNotice(result)}`,
201
+ },
202
+ { attribute: "loader", value: false },
203
+ { attribute: "prefixIcon", value: "Check" },
204
+ { attribute: "prefixIconColor", value: "#32D175" },
205
+ { attribute: "hasTimeOut", value: true },
206
+ ]);
207
+
208
+ scopeLoading.value = false;
209
+ oipTrack("scope_updated", { id, title, page: page as string });
210
+
211
+ await getScopes();
212
+ } catch (err) {
213
+ scopeLoading.value = false;
214
+ scopeError.value = err;
215
+ console.error("[useScopes] Error updating scope:", err);
216
+ oipSnackbar([
217
+ { attribute: "isVisible", value: true },
218
+ {
219
+ attribute: "text",
220
+ value: say({
221
+ fr: "La mise à jour de la vue a échoué",
222
+ en: "Something went wrong updating your view",
223
+ }),
224
+ },
225
+ { attribute: "hasTimeOut", value: true },
226
+ { attribute: "loader", value: false },
227
+ ]);
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Favori et vue par defaut : deux marques portees par le pulse, ecrites
233
+ * seules.
234
+ *
235
+ * Elles ne passent pas par `updateScope` : celui-la reecrit titre,
236
+ * description et criteres, donc marquer un favori depuis une autre page que
237
+ * celle de la vue y ecraserait ses criteres. L'unicite de la vue par defaut
238
+ * (une par page) est tenue par l'API.
239
+ */
240
+ async function setStarred(id: string, starred: boolean) {
241
+ try {
242
+ await elysiaApi.pulses.setPrefs(id, { starred });
243
+ await getScopes();
244
+ } catch (err) {
245
+ scopeError.value = err;
246
+ console.error("[useScopes] Error starring scope:", err);
247
+ }
248
+ }
249
+
250
+ async function setDefault(id: string, isDefault: boolean) {
251
+ try {
252
+ await elysiaApi.pulses.setPrefs(id, { isDefault });
253
+ await getScopes();
254
+ } catch (err) {
255
+ scopeError.value = err;
256
+ console.error("[useScopes] Error setting default scope:", err);
257
+ }
258
+ }
259
+
260
+ /**
261
+ * A qui la vue est ouverte : personne, des gens nommes, tout l'espace.
262
+ *
263
+ * Reserve au proprietaire cote API. Les deux champs partent ensemble, la
264
+ * portee et la liste se lisant l'une l'autre : passer a « tout l'espace »
265
+ * sans vider la liste laisserait des destinataires que plus rien n'affiche.
266
+ *
267
+ * Rend l'issue de l'ecriture : refusee, elle ne change rien a l'ecran, et
268
+ * l'appelant doit pouvoir le dire. Un partage qui echoue en silence se lit
269
+ * comme un partage qui a marche.
270
+ */
271
+ async function shareScope(
272
+ id: string,
273
+ share: {
274
+ visibility: "private" | "workspace";
275
+ sharedWith: string[];
276
+ /**
277
+ * Publication en template Tekkare, quand le dialogue la propose
278
+ * (superadmin seul). Absents sinon : l'API refuse les champs a qui n'a
279
+ * pas le droit de les poser.
280
+ */
281
+ isTemplate?: boolean;
282
+ targetWorkspaces?: string[];
283
+ templateCriteria?: any;
284
+ },
285
+ ) {
286
+ try {
287
+ await elysiaApi.pulses.update(id, share);
288
+ await getScopes();
289
+ return true;
290
+ } catch (err) {
291
+ scopeError.value = err;
292
+ console.error("[useScopes] Error sharing scope:", err);
293
+ return false;
294
+ }
295
+ }
296
+
297
+ // Delete scope using the Pulses REST API
298
+ async function deleteScope(id: string) {
299
+ try {
300
+ scopeLoading.value = true;
301
+ await elysiaApi.pulses.delete(id as string);
302
+ scopeLoading.value = false;
303
+ await getScopes();
304
+ } catch (err) {
305
+ scopeLoading.value = false;
306
+ scopeError.value = err;
307
+ console.error("[useScopes] Error deleting scope:", err);
308
+ }
309
+ }
310
+
311
+ const scopeList = computed(() => {
312
+ return scopeState.list.value;
313
+ });
314
+
315
+ const selectedScope = computed(() => {
316
+ return scopeState.selectedScope.value;
317
+ });
318
+
319
+ const scopeLoadingState = computed(() => {
320
+ return scopeLoading.value as boolean;
321
+ });
322
+
323
+ function clearScope() {
324
+ scopePinia.clearScope();
325
+ clearAppFilters();
326
+ oipTrack("scope_cleared", {});
327
+ }
328
+
329
+ function setSelectedScope(scope: any) {
330
+ scopePinia.setScoped(true);
331
+ scopePinia.setSelectedScope(scope);
332
+
333
+ // Parse criteria if it's a string (backward compatibility)
334
+ let criteriaData = scope?.criteria;
335
+ if (typeof criteriaData === "string") {
336
+ try {
337
+ criteriaData = JSON.parse(criteriaData);
338
+ } catch (e) {
339
+ console.error("[useScopes] Error parsing criteria:", e);
340
+ criteriaData = [];
341
+ }
342
+ }
343
+
344
+ scopePinia.setScopeFilter(criteriaData);
345
+ oipTrack("scope_applied", { scopeId: scope?.id, title: scope?.title });
346
+ }
347
+
348
+ const getAppFilters = computed(() => {
349
+ return scopePinia.appFilters;
350
+ });
351
+
352
+ function clearAppFilters() {
353
+ scopePinia.clearAppFilters();
354
+ }
355
+
356
+ return {
357
+ scopes,
358
+ scopeLoading,
359
+ scopeError,
360
+ getScopes,
361
+ createScope,
362
+ updateScope,
363
+ setStarred,
364
+ setDefault,
365
+ shareScope,
366
+ deleteScope,
367
+ scopeList,
368
+ setSelectedScope,
369
+ selectedScope,
370
+ getAppFilters,
371
+ clearAppFilters,
372
+ clearScope,
373
+ scopeLoadingState,
374
+ };
375
+ };
376
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Post-login redirect handling.
3
+ *
4
+ * When an unauthenticated user hits a protected deep link, the auth middleware
5
+ * bounces them to `/?redirect=<path>`. That query param is enough for in-SPA
6
+ * login flows, but it is lost as soon as we leave the app (OAuth round-trip
7
+ * through FusionAuth, magic-link email opened later). We therefore also persist
8
+ * the intended destination in sessionStorage so it survives a full reload.
9
+ */
10
+
11
+ const STORAGE_KEY = "postLoginRedirect";
12
+
13
+ /**
14
+ * Only accept in-app relative paths to avoid open-redirect attacks.
15
+ * Must start with a single "/" (not "//" or "/\" which browsers treat as
16
+ * protocol-relative external URLs).
17
+ */
18
+ function isSafeRedirectPath(target: unknown): target is string {
19
+ return (
20
+ typeof target === "string" &&
21
+ target.startsWith("/") &&
22
+ !target.startsWith("//") &&
23
+ !target.startsWith("/\\")
24
+ );
25
+ }
26
+
27
+ /** Persist the intended destination (no-op if invalid). */
28
+ export function savePostLoginRedirect(target: unknown): void {
29
+ if (import.meta.client && isSafeRedirectPath(target)) {
30
+ sessionStorage.setItem(STORAGE_KEY, target);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Resolve the best post-login destination without mutating storage: query
36
+ * param first (most explicit), then persisted storage, then "/".
37
+ */
38
+ export function getPostLoginRedirect(queryRedirect?: unknown): string {
39
+ if (isSafeRedirectPath(queryRedirect)) {
40
+ return queryRedirect;
41
+ }
42
+ if (import.meta.client) {
43
+ const stored = sessionStorage.getItem(STORAGE_KEY);
44
+ if (isSafeRedirectPath(stored)) {
45
+ return stored;
46
+ }
47
+ }
48
+ return "/";
49
+ }
50
+
51
+ /** Same as getPostLoginRedirect but clears storage so it is used only once. */
52
+ export function takePostLoginRedirect(queryRedirect?: unknown): string {
53
+ const target = getPostLoginRedirect(queryRedirect);
54
+ if (import.meta.client) {
55
+ sessionStorage.removeItem(STORAGE_KEY);
56
+ }
57
+ return target;
58
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * valueTable - un tableau Auriga rendu par RmTable.
3
+ *
4
+ * Les analyses du portail construisent leurs tableaux comme `arg-table` les
5
+ * attendait : une liste de libelles de tete, et des lignes `{ values: [...] }`
6
+ * dans le meme ordre. RmTable, lui, lit des colonnes nommees et des lignes
7
+ * indexees par ces noms. La conversion est mecanique : elle vit ici plutot que
8
+ * recopiee dans chaque composant, et le calcul des valeurs reste ou il est.
9
+ *
10
+ * Les colonnes portent des cles positionnelles (`c0`, `c1`...) : les libelles
11
+ * de tete sont traduits et ne font pas des cles stables. `valueKeys` donne les
12
+ * colonnes de valeurs, celles dont le composant rend le contenu par un slot.
13
+ *
14
+ * `types` reprend `column-type` d'`arg-table`, `style` reprend `columnStyle`.
15
+ */
16
+ export interface ValueTableRow {
17
+ values: any[];
18
+ }
19
+
20
+ export interface ValueTableStyle {
21
+ /** Parts de largeur, comme `columnStyle.size` d'`arg-table`. */
22
+ size?: readonly number[];
23
+ /** Alignements imposes, comme `columnStyle.align`. Priment sur `types`. */
24
+ align?: readonly ("left" | "right" | "center")[];
25
+ /**
26
+ * Largeur plancher par colonne (CSS). A la difference de `size`, qui repartit
27
+ * la place restante, elle ne se rogne pas : la table defile plutot que
28
+ * d'ecraser une colonne de libelles longs.
29
+ */
30
+ minWidth?: readonly (string | undefined)[];
31
+ }
32
+
33
+ export function valueTable(
34
+ heads: any[],
35
+ contents: any[],
36
+ types?: any[],
37
+ style?: ValueTableStyle,
38
+ ) {
39
+ // Sans types, la premiere colonne porte le libelle et les suivantes des
40
+ // valeurs. Certains tableaux en ont deux (le nom puis le code) : leurs types
41
+ // de colonnes le disent deja, une colonne sans type ne porte pas un nombre.
42
+ // `url` est la colonne d'action d'`arg-table` : un bouton se centre.
43
+ const alignOf = (index: number): "left" | "right" | "center" => {
44
+ if (style?.align?.[index]) return style.align[index];
45
+ if (!types) return index === 0 ? "left" : "right";
46
+ if (!types[index]) return "left";
47
+ return types[index] === "url" ? "center" : "right";
48
+ };
49
+
50
+ // `arg-table` reglait la largeur des colonnes par des parts (`size: [1, 4, 1]`)
51
+ // : la colonne a 4 prend quatre fois la place de celle a 1. RmTable prend une
52
+ // largeur CSS, on rend donc la part en pourcentage du total.
53
+ const sizes = style?.size;
54
+ const total = sizes ? sizes.reduce((sum, part) => sum + part, 0) : 0;
55
+ const widthOf = (index: number) =>
56
+ sizes && total && sizes[index]
57
+ ? `${Math.round((sizes[index] * 1000) / total) / 10}%`
58
+ : undefined;
59
+
60
+ const allColumns = heads.map((label: any, index: number) => ({
61
+ key: `c${index}`,
62
+ label: String(label),
63
+ sortable: true,
64
+ align: alignOf(index),
65
+ width: widthOf(index),
66
+ minWidth: style?.minWidth?.[index],
67
+ }));
68
+
69
+ // La colonne de type `More` d'`arg-table` ne portait pas une valeur a lire :
70
+ // elle tenait le chevron de depliage et la charge utile du detail. RmTable
71
+ // pose son propre chevron (`expandable`) dans une colonne a lui, celle-ci
72
+ // sort donc de la tete ; la charge reste dans la ligne, sous sa cle, ou le
73
+ // slot `expanded` va la chercher.
74
+ const moreIndex = (types ?? []).findIndex((type: any) => type === "More");
75
+ // Cle vide plutot que `null` quand la table n'a pas de colonne `More` : les
76
+ // pages lisent `row[table.moreKey]` dans leurs slots, et une cle nullable ne
77
+ // s'indexe pas. Une cle vide ne designe aucune colonne, ce que `null`
78
+ // disait deja.
79
+ const moreKey = moreIndex >= 0 ? `c${moreIndex}` : "";
80
+ const columns = allColumns.filter((column) => column.key !== moreKey);
81
+
82
+ const rows = (Array.isArray(contents) ? contents : [])
83
+ .filter((row: any) => Array.isArray(row?.values))
84
+ .map((row: ValueTableRow) =>
85
+ Object.fromEntries(row.values.map((value, index) => [`c${index}`, value])),
86
+ );
87
+
88
+ // `arg-table` nommait ses slots de cellule par le type de colonne
89
+ // (`#Date`, `#Price`), plusieurs colonnes pouvant partager un type. RmTable
90
+ // nomme les siens par la cle de colonne : `typeKeys` dit quelles cles porte
91
+ // un type, pour que la page rende le slot une fois par cle.
92
+ const typeKeys: Record<string, string[]> = {};
93
+ (types ?? []).forEach((type: any, index: number) => {
94
+ if (!type || type === "More" || !allColumns[index]) return;
95
+ (typeKeys[type] ??= []).push(allColumns[index]!.key);
96
+ });
97
+
98
+ return {
99
+ columns,
100
+ rows,
101
+ typeKeys,
102
+ /** Vrai quand la table avait une colonne `More` : passer `expandable` a RmTable. */
103
+ expandable: moreIndex >= 0,
104
+ /** Cle ou vit la charge utile du detail, a lire dans le slot `expanded`. Vide sans colonne `More`. */
105
+ moreKey,
106
+ valueKeys: columns.slice(1).map((column) => column.key),
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Les cles de colonne qui portent un type donne, quand la table a ete
112
+ * construite sans ses `types` : `columnKeysOfType(tableTypes, "int")` rend
113
+ * `["c2", "c3"]`, de quoi rendre le slot de cellule une fois par colonne.
114
+ *
115
+ * C'est le pendant de `typeKeys` ci-dessus pour les tableaux qui gardent
116
+ * leurs types a part, parce que les passer a `valueTable` changerait aussi
117
+ * l'alignement des colonnes. Vingt et un composants le recopiaient, sous
118
+ * trois ecritures differentes.
119
+ */
120
+ export const columnKeysOfType = (
121
+ types: readonly any[] | undefined,
122
+ wanted: string,
123
+ ): string[] =>
124
+ (types ?? [])
125
+ .map((type, index) => (type === wanted ? `c${index}` : null))
126
+ .filter((key): key is string => key !== null);
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pages de vues : une vue enregistree sur une page vaut pour les pages de sa
3
+ * famille (portail, PHMEV...), et les anciennes cles de page restent lues.
4
+ * Les familles et les alias historiques sont propres a l'app (contrat
5
+ * #oip-app/views) ; la regle vit ici.
6
+ */
7
+ import { LEGACY_VIEW_PAGES, VIEW_PAGE_FAMILIES } from "#oip-app/views";
8
+ import { normalizePage } from "./viewTemplates";
9
+
10
+ /** La cle de page d'une vue, dans la forme du jour. */
11
+ export function canonicalViewPage(path: unknown): string {
12
+ const page = normalizePage(String(path ?? ""));
13
+ return LEGACY_VIEW_PAGES[page] ?? page;
14
+ }
15
+
16
+ /** Les pages dont les vues valent pour `path`, `path` compris. */
17
+ export function viewPageFamily(path: unknown): string[] {
18
+ const page = canonicalViewPage(path);
19
+ return VIEW_PAGE_FAMILIES.find((family) => family.includes(page)) ?? [page];
20
+ }
21
+
22
+ /**
23
+ * Deux chemins qui designent la meme page, ou deux pages d'une meme famille.
24
+ * Prefixe de langue et barre finale ne comptent pas : une vue posee par un
25
+ * lien profond porte le chemin sans barre finale, la page l'a (`trailingSlash`),
26
+ * et un lecteur en anglais enregistre sous `/en/...`.
27
+ */
28
+ export function sameViewPage(a: unknown, b: unknown): boolean {
29
+ return viewPageFamily(a).includes(canonicalViewPage(b));
30
+ }