@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.
- package/dist/module.json +1 -1
- package/layer/app/components/AppUpdateToast.vue +157 -0
- package/layer/app/components/OipShell.vue +2 -0
- package/layer/app/components/auth/SplitLayout.vue +261 -0
- package/layer/app/components/layout/alertBrandSync.vue +143 -0
- package/layer/app/components/layout/firstVisitVideo.vue +165 -0
- package/layer/app/components/layout/tool/adminTemplates.vue +261 -0
- package/layer/app/components/layout/tool/alerts.vue +969 -0
- package/layer/app/components/layout/tool/export.vue +590 -0
- package/layer/app/components/layout/tool/views.vue +933 -0
- package/layer/app/components/layout/viewEmptyState.vue +278 -0
- package/layer/app/components/layout/viewPill.vue +550 -0
- package/layer/app/components/panels/OipPanelProjects.vue +94 -0
- package/layer/app/composables/useAdminAccess.ts +86 -0
- package/layer/app/composables/useAlertBrandSync.ts +85 -0
- package/layer/app/composables/useAlertSource.ts +210 -0
- package/layer/app/composables/useAlerts.ts +345 -0
- package/layer/app/composables/useExportSource.ts +236 -0
- package/layer/app/composables/useHubrEmbedded.ts +13 -0
- package/layer/app/composables/usePermalink.ts +108 -0
- package/layer/app/composables/useSavedExports.ts +286 -0
- package/layer/app/composables/useViewCardActions.ts +500 -0
- package/layer/app/composables/useViewEditing.ts +11 -0
- package/layer/app/composables/useViewTemplates.ts +369 -0
- package/layer/app/composables/useViews.ts +574 -0
- package/layer/app/oip-defaults/alerts.ts +34 -0
- package/layer/app/oip-defaults/api.ts +18 -0
- package/layer/app/oip-defaults/export.ts +23 -0
- package/layer/app/oip-defaults/ui.ts +21 -0
- package/layer/app/oip-defaults/views.ts +59 -0
- package/layer/app/utils/alertFieldLabels.ts +191 -0
- package/layer/app/utils/countries.ts +30 -0
- package/layer/app/utils/exportPage.ts +418 -0
- package/layer/app/utils/headerTabs.ts +15 -0
- package/layer/app/utils/hybridRows.ts +22 -0
- package/layer/app/utils/oipScopeStore.ts +112 -0
- package/layer/app/utils/oipScopes.ts +376 -0
- package/layer/app/utils/postLoginRedirect.ts +58 -0
- package/layer/app/utils/valueTable.ts +126 -0
- package/layer/app/utils/viewPages.ts +30 -0
- package/layer/app/utils/viewTemplateCriteria.ts +215 -0
- package/layer/app/utils/viewTemplates.ts +116 -0
- package/layer/nuxt.config.ts +9 -0
- package/layer/server/api/oip/notes.get.ts +1 -1
- 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
|
+
}
|