@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,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useViews - ce qu'une page donne a sauvegarder comme vue.
|
|
3
|
+
*
|
|
4
|
+
* Les vues (les « scopes » cote API : rien ne change en base, cf.
|
|
5
|
+
* `useScopes`) sont propres a une page : ses filtres, sa cle de stockage. Le
|
|
6
|
+
* panneau, lui, vit dans le cadre et ne voit pas la page. Une page declare
|
|
7
|
+
* donc ici de quoi lire ses filtres courants, et le cadre fait sortir la tuile
|
|
8
|
+
* « Mes vues » uniquement pour elle.
|
|
9
|
+
*
|
|
10
|
+
* Meme forme que `useExportSource` : un `shallowRef` de module, pose au
|
|
11
|
+
* montage, retire au demontage, donc rien a nettoyer a la main et rien qui
|
|
12
|
+
* traverse le rendu serveur.
|
|
13
|
+
*
|
|
14
|
+
* Chaque app du hub garde son propre magasin de scopes (`useScopes`,
|
|
15
|
+
* `useHeartScopes`...) : ce sont des caches distincts de la meme API, et leurs
|
|
16
|
+
* watchers de filtres sont deja branches dessus. Le panneau ne peut donc pas
|
|
17
|
+
* choisir a leur place — c'est l'appelant qui passe son handle, et la page qui
|
|
18
|
+
* declare l'emporte avec elle. `useHeartViews` n'est que ce branchement.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
DEEP_LINK_SCOPE_KEYS,
|
|
22
|
+
FILTER_LABELS as APP_FILTER_LABELS,
|
|
23
|
+
HAND_FILTER_EXCLUDED,
|
|
24
|
+
IGNORED_FILTERS,
|
|
25
|
+
useCardPrefs,
|
|
26
|
+
useDefaultScopes,
|
|
27
|
+
type CardPrefScope,
|
|
28
|
+
} from "#oip-app/views";
|
|
29
|
+
// Import explicite : l'auto-import de Nuxt ne pose rien pour un identifiant
|
|
30
|
+
// qui n'est pas appele — ici une valeur par defaut de parametre — et le
|
|
31
|
+
// module partait sur un `useScopes is not defined` a la premiere ouverture du
|
|
32
|
+
// panneau.
|
|
33
|
+
|
|
34
|
+
import { sameViewPage } from "../utils/viewPages";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Nom du pseudo-filtre qui porte le « Personnaliser » dans les criteres d'une
|
|
38
|
+
* vue. Ce n'est pas un filtre : rien ne part vers Elasticsearch, personne ne le
|
|
39
|
+
* compte dans la pastille de la barre. Il voyage dans `criteria` parce que
|
|
40
|
+
* c'est le seul champ libre du pulse, et il est nomme pour etre reconnu et mis
|
|
41
|
+
* de cote par les lecteurs de criteres.
|
|
42
|
+
*/
|
|
43
|
+
export const VIEW_PREFS_FILTER = "__cardPrefs";
|
|
44
|
+
|
|
45
|
+
export interface ViewsSource {
|
|
46
|
+
/**
|
|
47
|
+
* Cle de la page cote API (`pulse.page`). Les vues sont filtrees dessus,
|
|
48
|
+
* exactement comme le faisait le selecteur de scope.
|
|
49
|
+
*/
|
|
50
|
+
page: string;
|
|
51
|
+
/** Filtres poses a l'ecran, dans la forme attendue par `criteria`. */
|
|
52
|
+
criteria: () => any[];
|
|
53
|
+
/**
|
|
54
|
+
* Appele apres qu'une vue a ete appliquee, pour que la page relance sa
|
|
55
|
+
* requete si ses propres watchers ne le font pas.
|
|
56
|
+
*/
|
|
57
|
+
onApply?: (scope: any | null) => void;
|
|
58
|
+
/**
|
|
59
|
+
* Famille de page du panneau « Personnaliser », quand la page en a un. Sans
|
|
60
|
+
* elle, la vue ne porte que les filtres.
|
|
61
|
+
*/
|
|
62
|
+
prefsScope?: CardPrefScope;
|
|
63
|
+
/**
|
|
64
|
+
* Prefixe i18n des libelles de filtres, sans le point final. Les pages heart
|
|
65
|
+
* rangent les leurs sous `heart.filters.*`, le portail sous `filters.*`.
|
|
66
|
+
*/
|
|
67
|
+
filterNamespace?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Action propre a la page pour retoucher sa vue : sur les pages du portail,
|
|
70
|
+
* ouvrir le panneau de selection de produits. Elle apparait dans le menu de
|
|
71
|
+
* la pastille de vue, sous « Actions ».
|
|
72
|
+
*/
|
|
73
|
+
onEdit?: () => void;
|
|
74
|
+
/** Libelle de cette action. */
|
|
75
|
+
editLabel?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Tout ce que la page doit defaire sur un « Tout réinitialiser » : ses
|
|
78
|
+
* filtres remis a leur defaut, et le perimetre de produits vide quand elle
|
|
79
|
+
* en porte un.
|
|
80
|
+
*
|
|
81
|
+
* Elle le declare parce qu'elle seule le sait : « Effacer les filtres »
|
|
82
|
+
* vit deja dans chaque page, et le perimetre ne se range pas au
|
|
83
|
+
* meme endroit d'une famille de pages a l'autre. Ce qui est commun (la vue
|
|
84
|
+
* posee, l'affichage) est defait par `resetAll`, pas ici.
|
|
85
|
+
*/
|
|
86
|
+
onReset?: () => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Le handle de scopes d'une app : la forme rendue par `useScopes`. */
|
|
90
|
+
export type ViewsScopes = () => any;
|
|
91
|
+
|
|
92
|
+
type Registration = {
|
|
93
|
+
build: () => ViewsSource | null;
|
|
94
|
+
/** Le magasin de scopes de l'app qui a declare cette page. */
|
|
95
|
+
scopes: ViewsScopes;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const registry = shallowRef<Registration | null>(null);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pages dont la vue par defaut a deja ete posee depuis le chargement de
|
|
102
|
+
* l'application.
|
|
103
|
+
*
|
|
104
|
+
* La vue par defaut est une aide au demarrage, pas un etat a reimposer : elle
|
|
105
|
+
* s'applique a l'arrivee sur la page, puis plus jamais tant que l'onglet n'est
|
|
106
|
+
* pas recharge. Sans cela, toute navigation interne qui repasse par la page —
|
|
107
|
+
* ouvrir la fiche d'un produit puis revenir — remontait le composant, ne
|
|
108
|
+
* trouvait plus de vue posee pour cette page (la fiche a pu en poser une autre,
|
|
109
|
+
* ou l'utilisateur avoir tout efface) et rejouait la vue par defaut par-dessus
|
|
110
|
+
* la recherche en cours.
|
|
111
|
+
*
|
|
112
|
+
* Etat de module, donc vide a chaque rechargement complet : c'est exactement la
|
|
113
|
+
* duree de vie voulue. Rien ici cote serveur, `declare` ne l'ecrit que dans
|
|
114
|
+
* `onMounted`.
|
|
115
|
+
*/
|
|
116
|
+
const defaultApplied = new Set<string>();
|
|
117
|
+
|
|
118
|
+
/** Filtres qui decrivent la pagination, pas l'analyse : hors de la vue. */
|
|
119
|
+
|
|
120
|
+
/** Les criteres d'une vue, que l'API les rende en objet ou en chaine. */
|
|
121
|
+
/**
|
|
122
|
+
* Une vue posee sans etre enregistree : un lien partage, un lien profond
|
|
123
|
+
* `?brand=`, un export rejoue, un perimetre compose a la main. Elle n'a pas
|
|
124
|
+
* d'identifiant, donc rien a renommer ni a ecrire dedans, mais c'est bien elle
|
|
125
|
+
* qui est a l'ecran. Le marqueur historique etait le titre « Custom » : deux
|
|
126
|
+
* chemins l'ecrivaient, un troisieme l'oubliait, et un ecran filtre pouvait se
|
|
127
|
+
* dire sans vue. L'absence d'identifiant, elle, ne se falsifie pas.
|
|
128
|
+
*/
|
|
129
|
+
export const isTemporaryView = (scope: any): boolean => !!scope && !scope.id;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Deux chemins qui designent la meme page, ou deux pages qui partagent leurs
|
|
133
|
+
* vues (cf. `viewPages`). `trailingSlash` est actif, une vue posee par un lien
|
|
134
|
+
* profond ou par l'ancienne page de selection porte le chemin sans barre
|
|
135
|
+
* finale, celui de la page l'a : les comparer tels quels faisait dire « aucune
|
|
136
|
+
* vue » a un ecran filtre.
|
|
137
|
+
*/
|
|
138
|
+
const samePage = (a: any, b: any): boolean => sameViewPage(a, b);
|
|
139
|
+
|
|
140
|
+
export function parseViewCriteria(scope: any): any[] {
|
|
141
|
+
const raw = scope?.criteria;
|
|
142
|
+
if (Array.isArray(raw)) return raw;
|
|
143
|
+
if (typeof raw !== "string") return [];
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(raw) ?? [];
|
|
146
|
+
} catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Valeurs reellement posees sur un filtre. Le magasin garde des filtres vides
|
|
153
|
+
* (une periode sans borne vaut `[null, null]`) : les compter ferait croire a un
|
|
154
|
+
* ecart entre l'ecran et la vue, alors que l'API ne les garde pas.
|
|
155
|
+
*/
|
|
156
|
+
export function viewFilledValues(item: any): string[] {
|
|
157
|
+
if (!Array.isArray(item?.values)) return [];
|
|
158
|
+
return item.values
|
|
159
|
+
.map((value: any) =>
|
|
160
|
+
value === null || value === undefined
|
|
161
|
+
? ""
|
|
162
|
+
: typeof value === "object"
|
|
163
|
+
? String(value.value ?? "")
|
|
164
|
+
: String(value),
|
|
165
|
+
)
|
|
166
|
+
.filter((label: string) => label !== "");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Filtres poses par les composants dont le nom technique n'a pas d'entree dans
|
|
171
|
+
* les traductions, ou dont le nom lisible ne vient pas d'un filtre du tout.
|
|
172
|
+
*/
|
|
173
|
+
// Calcule au premier usage et non au chargement : le contrat #oip-app/views
|
|
174
|
+
// importe le composable de vues par defaut, qui revient ici, et la table de
|
|
175
|
+
// l'app n'est pas encore initialisee quand ce module s'evalue.
|
|
176
|
+
let filterLabelsCache: Record<string, { fr: string; en: string }> | null = null;
|
|
177
|
+
const filterLabels = () =>
|
|
178
|
+
(filterLabelsCache ??= {
|
|
179
|
+
...APP_FILTER_LABELS,
|
|
180
|
+
[VIEW_PREFS_FILTER]: { fr: "Affichage", en: "Display" },
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Libelle lisible d'un filtre : son nom connu, sa traduction dans le namespace
|
|
185
|
+
* de la page, ou son nom brut. Le panneau du rail et la modale de sauvegarde
|
|
186
|
+
* lisent le meme, sinon la meme vue se decrirait de deux facons.
|
|
187
|
+
*/
|
|
188
|
+
export function useViewFilterLabels(namespace?: () => string | undefined) {
|
|
189
|
+
const { locale, t, te } = useI18n();
|
|
190
|
+
|
|
191
|
+
return function label(name: string): string {
|
|
192
|
+
return resolveFilterLabel(name, namespace?.(), { locale: locale.value, t, te });
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Le corps de `useViewFilterLabels`, sans le `useI18n` qui exige un setup. */
|
|
197
|
+
function resolveFilterLabel(
|
|
198
|
+
name: string,
|
|
199
|
+
namespace: string | undefined,
|
|
200
|
+
i18n: { locale: string; t: (key: string) => string; te: (key: string) => boolean },
|
|
201
|
+
): string {
|
|
202
|
+
const known = filterLabels()[name];
|
|
203
|
+
if (known) return i18n.locale === "en" ? known.en : known.fr;
|
|
204
|
+
const key = `${namespace ?? "filters"}.${name}`;
|
|
205
|
+
return i18n.te(key) ? i18n.t(key) : name;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Filtres poses a la main, parmi les criteres d'un ecran.
|
|
210
|
+
*
|
|
211
|
+
* Le tri, la pagination et l'onglet courant n'en sont pas : ils portent une
|
|
212
|
+
* valeur par defaut sur toute page, et les compter dirait « filtres non
|
|
213
|
+
* enregistres » sur un ecran que personne n'a touche (« Avis HAS » s'ouvrait
|
|
214
|
+
* avec le point orange, sans un seul filtre pose : son onglet s'ecrit dans le
|
|
215
|
+
* magasin sous le nom `Type`, cf. heart/elastic/searchTypeFilter). Les pages
|
|
216
|
+
* ne s'accordent pas la-dessus, certaines les ecartent deja de leur
|
|
217
|
+
* `criteria()`, d'autres non, donc on les ecarte ici, une fois. C'est la meme
|
|
218
|
+
* liste de reglages d'affichage que `countSelectedFilters` du magasin de
|
|
219
|
+
* filtres, qui compte la pastille « Filtres » de la barre.
|
|
220
|
+
*
|
|
221
|
+
* Ils restent dans la vue enregistree : une vue rejoue l'onglet et le tri
|
|
222
|
+
* qu'elle a captures. Ce sont les compter comme des filtres poses qui est
|
|
223
|
+
* faux, pas les capturer.
|
|
224
|
+
*
|
|
225
|
+
* Le « Personnaliser » n'y figure pas non plus : il ne passe jamais par
|
|
226
|
+
* `criteria()`, il est ajoute a part par `prefsCriterion`.
|
|
227
|
+
*/
|
|
228
|
+
|
|
229
|
+
export function viewHandFilters(criteria: any[]): any[] {
|
|
230
|
+
return (Array.isArray(criteria) ? criteria : []).filter(
|
|
231
|
+
(item: any) =>
|
|
232
|
+
!HAND_FILTER_EXCLUDED.includes(item?.filter) &&
|
|
233
|
+
item?.filter !== VIEW_PREFS_FILTER &&
|
|
234
|
+
viewFilledValues(item).length > 0,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Signature d'un jeu de criteres, pour comparer l'ecran a la vue active. */
|
|
239
|
+
export function viewSignature(criteria: any[]): string {
|
|
240
|
+
return (Array.isArray(criteria) ? criteria : [])
|
|
241
|
+
.filter(
|
|
242
|
+
(item: any) => !IGNORED_FILTERS.includes(item?.filter) && viewFilledValues(item).length > 0,
|
|
243
|
+
)
|
|
244
|
+
.map((item: any) => `${item.filter}=${viewFilledValues(item).sort().join("|")}`)
|
|
245
|
+
.sort()
|
|
246
|
+
.join("&");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Etat du « Personnaliser » sous forme de critere : la liste des options
|
|
251
|
+
* affichees. Rien a enregistrer quand tout est masque, c'est l'etat par
|
|
252
|
+
* defaut.
|
|
253
|
+
*/
|
|
254
|
+
function prefsCriterion(page: string, scope?: CardPrefScope) {
|
|
255
|
+
if (!scope) return null;
|
|
256
|
+
const on = useCardPrefs().on(scope);
|
|
257
|
+
const keys = Object.keys(on).filter((key) => on[key]);
|
|
258
|
+
return keys.length > 0
|
|
259
|
+
? { filter: VIEW_PREFS_FILTER, page, values: keys }
|
|
260
|
+
: null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Remet le « Personnaliser » dans l'etat que porte la vue. Une vue sans
|
|
265
|
+
* critere d'affichage remet tout a masque : c'est ce qu'elle decrit, et sans
|
|
266
|
+
* cela l'etat du poste survivrait a l'application de la vue.
|
|
267
|
+
*/
|
|
268
|
+
function applyPrefs(scope: any, prefsScope?: CardPrefScope) {
|
|
269
|
+
if (!prefsScope) return;
|
|
270
|
+
|
|
271
|
+
let criteria = scope?.criteria;
|
|
272
|
+
if (typeof criteria === "string") {
|
|
273
|
+
try {
|
|
274
|
+
criteria = JSON.parse(criteria);
|
|
275
|
+
} catch {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (!Array.isArray(criteria)) return;
|
|
280
|
+
|
|
281
|
+
const entry = criteria.find((item: any) => item?.filter === VIEW_PREFS_FILTER);
|
|
282
|
+
const keys: string[] = Array.isArray(entry?.values) ? entry.values : [];
|
|
283
|
+
|
|
284
|
+
const prefs = useCardPrefs();
|
|
285
|
+
prefs.reset(prefsScope);
|
|
286
|
+
keys.forEach((key) => prefs.toggle(prefsScope, key));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @param scopes magasin de scopes de l'app appelante. Le portail garde le
|
|
291
|
+
* defaut ; les apps prefixees passent le leur.
|
|
292
|
+
*/
|
|
293
|
+
/**
|
|
294
|
+
* Les filtres de la page declaree, dans la forme du recapitulatif d'export.
|
|
295
|
+
*
|
|
296
|
+
* Une vue et un export decrivent le meme ecran : ce qui est enregistrable est
|
|
297
|
+
* exactement ce qu'un rapport doit citer. Les lire au meme endroit evite qu'un
|
|
298
|
+
* filtre ajoute a la page apparaisse dans les vues mais pas dans le PDF.
|
|
299
|
+
*
|
|
300
|
+
* L'etat du « Personnaliser » est ecarte : ce n'est pas un filtre, il ne
|
|
301
|
+
* change pas ce que la page interroge.
|
|
302
|
+
*/
|
|
303
|
+
export function viewsAsExportFilters(): { label: string; value: string }[] {
|
|
304
|
+
const source = registry.value?.build() ?? null;
|
|
305
|
+
if (!source) return [];
|
|
306
|
+
|
|
307
|
+
// Meme libelle que les cartes de vues, sinon le rapport citait le nom
|
|
308
|
+
// technique du filtre. Par `$i18n` et non `useI18n` : les pages appellent
|
|
309
|
+
// ceci dans le getter de leur source d'export, relu au moment d'exporter,
|
|
310
|
+
// hors de tout `setup`. Sans app Nuxt sous la main, le nom brut reste.
|
|
311
|
+
const i18n = (tryUseNuxtApp() as any)?.$i18n;
|
|
312
|
+
const label = (name: string) =>
|
|
313
|
+
i18n
|
|
314
|
+
? resolveFilterLabel(name, source.filterNamespace, {
|
|
315
|
+
locale: String(i18n.locale?.value ?? i18n.locale ?? "fr"),
|
|
316
|
+
t: (key) => String(i18n.t(key)),
|
|
317
|
+
te: (key) => i18n.te(key),
|
|
318
|
+
})
|
|
319
|
+
: name;
|
|
320
|
+
|
|
321
|
+
return (source.criteria() ?? [])
|
|
322
|
+
.filter((item: any) => item?.filter !== VIEW_PREFS_FILTER)
|
|
323
|
+
.map((item: any) => ({
|
|
324
|
+
label: label(String(item?.filter ?? "")),
|
|
325
|
+
// Meme lecture que les cartes de vues : un filtre pose mais vide ne se
|
|
326
|
+
// cite pas dans un rapport.
|
|
327
|
+
value: viewFilledValues(item).join(", "),
|
|
328
|
+
}))
|
|
329
|
+
.filter((filter) => filter.label !== "" && filter.value !== "");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function useViews(scopes: ViewsScopes = useDefaultScopes) {
|
|
333
|
+
/**
|
|
334
|
+
* Declare la page courante. A appeler dans le `setup` d'un composant de la
|
|
335
|
+
* page.
|
|
336
|
+
*
|
|
337
|
+
* La liste des vues est chargee ici, pas a l'ouverture du panneau : le
|
|
338
|
+
* bandeau nomme la vue active, et la vue par defaut doit s'appliquer des
|
|
339
|
+
* l'arrivee sur la page. Attendre l'ouverture du panneau reviendrait a
|
|
340
|
+
* demander a l'utilisateur d'aller chercher ce qu'il a justement demande
|
|
341
|
+
* d'automatiser.
|
|
342
|
+
*/
|
|
343
|
+
function declare(build: () => ViewsSource | null) {
|
|
344
|
+
// Lu dans le `setup` : dans un hook de cycle de vie, l'injection du
|
|
345
|
+
// routeur n'a plus d'instance a interroger.
|
|
346
|
+
const route = useRoute();
|
|
347
|
+
|
|
348
|
+
onMounted(async () => {
|
|
349
|
+
registry.value = { build, scopes };
|
|
350
|
+
|
|
351
|
+
const store = scopes();
|
|
352
|
+
if (store.scopeList.value.length === 0) await store.getScopes();
|
|
353
|
+
// Une autre page a pu se declarer pendant l'attente : la suite poserait
|
|
354
|
+
// sa vue par defaut, et `defaultApplied`, pour la mauvaise page.
|
|
355
|
+
if (registry.value?.build !== build) return;
|
|
356
|
+
|
|
357
|
+
const source = build();
|
|
358
|
+
if (!source) return;
|
|
359
|
+
|
|
360
|
+
// Un lien partage porte les filtres de l'expediteur (cf. usePermalink) :
|
|
361
|
+
// ils gagnent sur la vue par defaut du destinataire, sinon le lien
|
|
362
|
+
// ouvrirait un autre ecran que celui qui a ete partage. La vue posee n'a
|
|
363
|
+
// pas d'identifiant, la pastille la dit temporaire.
|
|
364
|
+
const shared = await permalinkCriteria(route.query);
|
|
365
|
+
if (registry.value?.build !== build) return;
|
|
366
|
+
if (shared.length > 0) {
|
|
367
|
+
// Pas de titre : la vue n'en a pas recu, et la pastille la nomme
|
|
368
|
+
// « Vue temporaire » plutot que d'inventer un nom que personne n'a
|
|
369
|
+
// choisi. Pas d'identifiant non plus : rien n'est enregistre tant que
|
|
370
|
+
// le destinataire ne le demande pas.
|
|
371
|
+
const scope = {
|
|
372
|
+
id: null,
|
|
373
|
+
title: "",
|
|
374
|
+
page: source.page,
|
|
375
|
+
criteria: JSON.stringify(shared),
|
|
376
|
+
};
|
|
377
|
+
store.setSelectedScope(scope);
|
|
378
|
+
applyPrefs(scope, source.prefsScope);
|
|
379
|
+
source.onApply?.(scope);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Une seule pose de la vue par defaut par page et par chargement : la
|
|
384
|
+
// suite ne s'execute qu'a la premiere arrivee sur la page. Un lien
|
|
385
|
+
// partage, lui, reste au-dessus : il porte une demande explicite dans
|
|
386
|
+
// l'URL, a chaque fois.
|
|
387
|
+
if (defaultApplied.has(source.page)) return;
|
|
388
|
+
defaultApplied.add(source.page);
|
|
389
|
+
|
|
390
|
+
// Une selection venue d'une autre page ne compte pas : elle bloquait la
|
|
391
|
+
// vue par defaut de la page d'arrivee. Celle de cette page-ci gagne, y
|
|
392
|
+
// compris sans identifiant : un deep link `?brand=` pose une vue
|
|
393
|
+
// temporaire avant le montage (cf. le middleware scope-from-query), et
|
|
394
|
+
// appliquer la vue par defaut par-dessus remplacait la selection
|
|
395
|
+
// demandee par celle du destinataire.
|
|
396
|
+
const selected = store.selectedScope.value;
|
|
397
|
+
if (selected && samePage(selected.page, source.page)) return;
|
|
398
|
+
|
|
399
|
+
// Meme raison que le lien partage ci-dessus : Hub-R n'a pas de
|
|
400
|
+
// middleware equivalent a scope-from-query pour poser une vue
|
|
401
|
+
// temporaire avant le montage — un `?brand=`/`?molecule=`/`?atc4=`/
|
|
402
|
+
// `?company=` d'arrivee (cf. useHeartScopeHandoff, le lien Prix/Avis
|
|
403
|
+
// HAS depuis Medicaments France) est donc une demande explicite qui
|
|
404
|
+
// doit gagner sur la vue par defaut plutot que d'etre effacee des
|
|
405
|
+
// qu'elle s'applique.
|
|
406
|
+
const q = route.query as Record<string, any>;
|
|
407
|
+
const hasScopeDeepLink = DEEP_LINK_SCOPE_KEYS.some((key) => {
|
|
408
|
+
const value = q[key];
|
|
409
|
+
return value != null && String(value).trim().length >= 2;
|
|
410
|
+
});
|
|
411
|
+
if (hasScopeDeepLink) return;
|
|
412
|
+
|
|
413
|
+
const wanted = store.scopeList.value.find(
|
|
414
|
+
(scope: any) => scope.isDefault === true && samePage(scope.page, source.page),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// Copie volontaire : les filtres se reappliquent sur un changement de
|
|
418
|
+
// reference de `selectedScope` (cf. les watchers des composants de
|
|
419
|
+
// filtre).
|
|
420
|
+
if (wanted) {
|
|
421
|
+
store.setSelectedScope({ ...wanted });
|
|
422
|
+
applyPrefs(wanted, source.prefsScope);
|
|
423
|
+
source.onApply?.(wanted);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
onBeforeUnmount(() => {
|
|
428
|
+
if (registry.value?.build === build) registry.value = null;
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
declare,
|
|
434
|
+
/**
|
|
435
|
+
* Criteres a enregistrer pour la page courante : ses filtres, plus l'etat
|
|
436
|
+
* du « Personnaliser ». Le panneau « Mes vues » et les modales de
|
|
437
|
+
* sauvegarde des pages passent par ici, donc les deux chemins ecrivent la
|
|
438
|
+
* meme chose.
|
|
439
|
+
*/
|
|
440
|
+
criteriaOf(): any[] {
|
|
441
|
+
const source = registry.value?.build() ?? null;
|
|
442
|
+
if (!source) return [];
|
|
443
|
+
const criteria = source.criteria() ?? [];
|
|
444
|
+
const prefs = prefsCriterion(source.page, source.prefsScope);
|
|
445
|
+
return prefs ? [...criteria, prefs] : criteria;
|
|
446
|
+
},
|
|
447
|
+
/** Rejoue le « Personnaliser » porte par une vue. */
|
|
448
|
+
applyPrefs,
|
|
449
|
+
/**
|
|
450
|
+
* Pose des criteres a l'ecran sans rien enregistrer : un template Tekkare
|
|
451
|
+
* qu'on demarre, comme un lien partage. La vue n'a pas d'identifiant, donc
|
|
452
|
+
* la pastille la dit temporaire et « Sauvegarder la vue actuelle » est ce
|
|
453
|
+
* qui l'inscrit dans les vues de l'utilisateur.
|
|
454
|
+
*
|
|
455
|
+
* Les criteres arrivent construits par l'appelant : leurs valeurs
|
|
456
|
+
* dependent de la langue de l'ecran (cf. `viewTemplates`), et c'est un
|
|
457
|
+
* composant qui la connait.
|
|
458
|
+
*/
|
|
459
|
+
applyTemporary(criteria: any[], title = "", template: string | null = null) {
|
|
460
|
+
const source = registry.value?.build() ?? null;
|
|
461
|
+
if (!source) return;
|
|
462
|
+
const store = (registry.value?.scopes ?? scopes)();
|
|
463
|
+
const scope = {
|
|
464
|
+
id: null,
|
|
465
|
+
title,
|
|
466
|
+
// D'ou vient la vue, quand c'est un template : la pastille le nomme
|
|
467
|
+
// plutot que de dire « Vue temporaire ».
|
|
468
|
+
template,
|
|
469
|
+
page: source.page,
|
|
470
|
+
// Chaque critere porte sa page, comme ceux que les composants de
|
|
471
|
+
// filtre ecrivent : les lecteurs de criteres s'y attendent.
|
|
472
|
+
criteria: JSON.stringify(
|
|
473
|
+
criteria.map((item: any) => ({ page: source.page, ...item })),
|
|
474
|
+
),
|
|
475
|
+
};
|
|
476
|
+
store.setSelectedScope(scope);
|
|
477
|
+
// Pas de `applyPrefs` ici : un template ne decrit que des filtres, et
|
|
478
|
+
// rejouer un « Personnaliser » vide remettrait tout l'affichage a
|
|
479
|
+
// masque, donc viderait l'ecran qu'on vient de demander.
|
|
480
|
+
source.onApply?.(scope);
|
|
481
|
+
},
|
|
482
|
+
/**
|
|
483
|
+
* Remet la page a l'etat vierge : plus aucune vue, plus de perimetre, les
|
|
484
|
+
* filtres et l'affichage a leur defaut.
|
|
485
|
+
*
|
|
486
|
+
* « Effacer les filtres » ne touche qu'aux filtres de l'ecran : la vue
|
|
487
|
+
* posee lui survit, et avec elle la selection de produits sur laquelle
|
|
488
|
+
* tout se lit. Ce geste-ci va jusqu'au bout, d'ou les deux
|
|
489
|
+
* commandes cote a cote dans le panneau de filtres.
|
|
490
|
+
*/
|
|
491
|
+
resetAll() {
|
|
492
|
+
const source = registry.value?.build() ?? null;
|
|
493
|
+
if (!source) return;
|
|
494
|
+
|
|
495
|
+
// La vue d'abord : elle repose les filtres de la page par ses watchers,
|
|
496
|
+
// et la defaire avant que la page remette ses defauts evite qu'elle les
|
|
497
|
+
// recouvre.
|
|
498
|
+
const store = (registry.value?.scopes ?? scopes)();
|
|
499
|
+
store.clearScope();
|
|
500
|
+
|
|
501
|
+
// Le « Personnaliser » fait partie de ce qu'une vue emporte : le laisser
|
|
502
|
+
// en place laisserait des colonnes ouvertes sur un ecran remis a zero.
|
|
503
|
+
if (source.prefsScope) useCardPrefs().reset(source.prefsScope);
|
|
504
|
+
|
|
505
|
+
source.onReset?.();
|
|
506
|
+
},
|
|
507
|
+
/** La source courante, relue a chaque acces : elle suit les filtres. */
|
|
508
|
+
current: computed(() => registry.value?.build() ?? null),
|
|
509
|
+
/** Vrai quand la page sait sauvegarder une vue. */
|
|
510
|
+
has: computed(() => (registry.value?.build() ?? null) !== null),
|
|
511
|
+
/**
|
|
512
|
+
* Le magasin de scopes de la page declaree : c'est celui-la que le panneau
|
|
513
|
+
* doit lire et ecrire, pas celui de l'app qui l'a instancie.
|
|
514
|
+
*/
|
|
515
|
+
scopes: computed<ViewsScopes>(() => registry.value?.scopes ?? scopes),
|
|
516
|
+
/**
|
|
517
|
+
* La vue active DE CETTE PAGE. Une vue = une page.
|
|
518
|
+
*
|
|
519
|
+
* Le magasin ne garde qu'une selection pour toute l'app : en changeant de
|
|
520
|
+
* page elle reste en place, et la vue de la page precedente continuait de
|
|
521
|
+
* se dire active sur la nouvelle. La pastille en portait le nom, le bouton
|
|
522
|
+
* « Sauvegarder » y ecrivait les filtres de la nouvelle page, et la vue par
|
|
523
|
+
* defaut de la page d'arrivee ne s'appliquait pas puisqu'une selection
|
|
524
|
+
* occupait deja la place — une vue a moitie appliquee, a moitie
|
|
525
|
+
* enregistree. Tout ce qui parle de la vue courante lit donc ceci, jamais
|
|
526
|
+
* `selectedScope` directement.
|
|
527
|
+
*/
|
|
528
|
+
active: computed(() => {
|
|
529
|
+
const source = registry.value?.build() ?? null;
|
|
530
|
+
if (!source) return null;
|
|
531
|
+
const store = (registry.value?.scopes ?? scopes)();
|
|
532
|
+
const selected = store.selectedScope.value;
|
|
533
|
+
if (!selected || !samePage(selected.page, source.page)) return null;
|
|
534
|
+
// Sans identifiant, la vue n'est pas enregistree — un lien partage, un
|
|
535
|
+
// export rejoue — mais c'est bien elle qui est a l'ecran. L'exiger
|
|
536
|
+
// faisait dire « aucune vue » a un ecran filtre : le destinataire d'un
|
|
537
|
+
// lien voyait les filtres appliques et la pastille les nier. Ce qui
|
|
538
|
+
// demande une vue enregistree (renommer, mettre par defaut, ecrire
|
|
539
|
+
// dedans) lit `active.id`, pas `active`.
|
|
540
|
+
return selected;
|
|
541
|
+
}),
|
|
542
|
+
/**
|
|
543
|
+
* Ce qui est a l'ecran n'est enregistre dans aucune vue.
|
|
544
|
+
*
|
|
545
|
+
* Deux cas. Avec une vue active, ses filtres ont bouge depuis qu'elle a ete
|
|
546
|
+
* appliquee : il y a quelque chose a rattraper. Sans vue active, les
|
|
547
|
+
* filtres poses ne sont enregistres nulle part : c'est le meme avertissement
|
|
548
|
+
* — l'ecran se perd en quittant la page — et il ne sortait pas, la pastille
|
|
549
|
+
* restait muette sur « Aucune vue » quelle que soit la pile de filtres.
|
|
550
|
+
*
|
|
551
|
+
* Une vue venue d'une autre page ne compte pas comme active : il n'y a rien
|
|
552
|
+
* a rattraper de ce cote.
|
|
553
|
+
*/
|
|
554
|
+
dirty: computed(() => {
|
|
555
|
+
const source = registry.value?.build() ?? null;
|
|
556
|
+
if (!source) return false;
|
|
557
|
+
const store = (registry.value?.scopes ?? scopes)();
|
|
558
|
+
const selected = store.selectedScope.value;
|
|
559
|
+
const criteria = source.criteria() ?? [];
|
|
560
|
+
|
|
561
|
+
// Meme regle que `active` : une vue d'une autre page n'est pas active
|
|
562
|
+
// ici, il n'y a rien a rattraper, mais les filtres poses ne sont
|
|
563
|
+
// enregistres nulle part.
|
|
564
|
+
if (!selected?.id || !samePage(selected.page, source.page)) {
|
|
565
|
+
return viewHandFilters(criteria).length > 0;
|
|
566
|
+
}
|
|
567
|
+
const active = selected;
|
|
568
|
+
|
|
569
|
+
const prefs = prefsCriterion(source.page, source.prefsScope);
|
|
570
|
+
const shown = prefs ? [...criteria, prefs] : criteria;
|
|
571
|
+
return viewSignature(shown) !== viewSignature(parseViewCriteria(active));
|
|
572
|
+
}),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contrat `#oip-app/alerts` : le metier des alertes propre a l'app. Valeurs
|
|
3
|
+
* neutres par defaut (pas d'alerte de produit, libelles bruts).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Famille de document d'une alerte (types de ressources de l'app). */
|
|
7
|
+
export type AlertResourceType = string;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Repertoire des marques des alertes de produit : champ agrege, champs
|
|
11
|
+
* cherches, recherche. Sans repertoire, la liste des marques reste vide.
|
|
12
|
+
*/
|
|
13
|
+
export const ALERT_BRAND_DIRECTORY = {
|
|
14
|
+
field: "brand.keyword",
|
|
15
|
+
searchFields: (_lang: "fr" | "en"): string[] => ["brand"],
|
|
16
|
+
search: async (_body: unknown): Promise<any> => ({}),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** La source d'un evenement, nommee pour le journal. */
|
|
20
|
+
export function alertEventSourceLabel(
|
|
21
|
+
_docId: string | null | undefined,
|
|
22
|
+
_t: (key: string) => string,
|
|
23
|
+
_lang: "fr" | "en",
|
|
24
|
+
): string | null {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** La page d'un projet. */
|
|
29
|
+
export const alertProjectPath = (id: string) => `/projects/${id}`;
|
|
30
|
+
|
|
31
|
+
/** Libelles des champs surveilles : premier niveau, imbriques, entrees. */
|
|
32
|
+
export const ALERT_FIELD_TOP_LEVEL: Record<string, string> = {};
|
|
33
|
+
export const ALERT_FIELD_NESTED: Record<string, string> = {};
|
|
34
|
+
export const ALERT_ENTRY_MARKERS: Record<string, string> = {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contrat `#oip-app/api` : le client de la Virtual Lab API de l'app.
|
|
3
|
+
*
|
|
4
|
+
* Les vues, les alertes, les exports enregistres et les modeles de vues du
|
|
5
|
+
* layer passent par lui (pulses, alerts, viewTemplates, admin, profile). Une
|
|
6
|
+
* app branchee sur ces panneaux pointe l'alias `#oip-app` vers son propre
|
|
7
|
+
* dossier et y exporte `useOipApi` (en general son useElysiaApi).
|
|
8
|
+
*/
|
|
9
|
+
export function useOipApi(): any {
|
|
10
|
+
throw new Error(
|
|
11
|
+
"[oip] useOipApi n'est pas fourni : pointer l'alias #oip-app vers le dossier oip/ de l'app (cf. layer/app/oip-defaults).",
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Types de la Virtual Lab API manipules par le layer (alertes). */
|
|
16
|
+
export type Alert = any;
|
|
17
|
+
export type AlertEvent = any;
|
|
18
|
+
export type AlertTrigger = any;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contrat `#oip-app/export` : ce que le panneau Export du layer demande a
|
|
3
|
+
* l'app. Valeurs neutres par defaut.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Les sources Directus d'une page, pour le classeur et le rapport :
|
|
8
|
+
* `load(uuids)` les charge, `exportSources(uuids)` les rend au format
|
|
9
|
+
* RmExportSource.
|
|
10
|
+
*/
|
|
11
|
+
export function useExportDirectusSources() {
|
|
12
|
+
return {
|
|
13
|
+
load: async (_uuids: string[]) => {},
|
|
14
|
+
exportSources: (_uuids: string[], _locale?: string): any[] => [],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Proprietes d'un evenement de telechargement (contexte de vue, espace...). */
|
|
19
|
+
export function useExportTracking() {
|
|
20
|
+
return {
|
|
21
|
+
getExportPosthogProps: (name: string, extra: Record<string, unknown> = {}) => ({ name, ...extra }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contrat `#oip-app/ui` : le bandeau de confirmation et le suivi d'usage de
|
|
3
|
+
* l'app.
|
|
4
|
+
*
|
|
5
|
+
* `oipSnackbar` recoit la configuration du bandeau OIP historique
|
|
6
|
+
* (`[{ attribute, value }]` : isVisible, text, loader, prefixIcon,
|
|
7
|
+
* hasTimeOut...). Par defaut, le texte part par le hook `oip:notify`.
|
|
8
|
+
* `oipTrack` envoie un evenement d'usage (PostHog...) ; par defaut, rien.
|
|
9
|
+
*/
|
|
10
|
+
export type OipSnackbarConfig = { attribute: string; value: unknown }[];
|
|
11
|
+
|
|
12
|
+
export function oipSnackbar(config: OipSnackbarConfig) {
|
|
13
|
+
const text = config.find((c) => c.attribute === "text")?.value;
|
|
14
|
+
const visible = config.find((c) => c.attribute === "isVisible")?.value;
|
|
15
|
+
if (!visible || typeof text !== "string") return;
|
|
16
|
+
const loading = !!config.find((c) => c.attribute === "loader")?.value;
|
|
17
|
+
if (loading) return;
|
|
18
|
+
useNuxtApp().callHook("oip:notify" as any, { text, kind: "success" });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function oipTrack(_event: string, _props?: Record<string, unknown>) {}
|