@tekkare/romulus 0.1.144 → 0.1.145
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
CHANGED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* RmFilterTree - un filtre sur une taxonomie a plusieurs niveaux.
|
|
4
|
+
*
|
|
5
|
+
* Pour les classifications ou un noeud contient ses descendants :
|
|
6
|
+
* ATC (C -> C10 -> C10AA -> C10AA05), GHM par chapitre, CIM-10, CCAM,
|
|
7
|
+
* hierarchie geographique. L'utilisateur coche une branche entiere, ou
|
|
8
|
+
* descend pour affiner.
|
|
9
|
+
*
|
|
10
|
+
* A ne pas utiliser pour une liste plate (laboratoires, pays sans
|
|
11
|
+
* hierarchie) : RmFilterDropdown ou RmFilterComplex s'en chargent, et un
|
|
12
|
+
* arbre a un seul niveau ajoute des carets qui n'ouvrent rien.
|
|
13
|
+
*
|
|
14
|
+
* L'arbre est rendu a plat, avec un retrait par niveau, plutot qu'en
|
|
15
|
+
* composant recursif : la recherche et le pliage se calculent sur une seule
|
|
16
|
+
* liste, et une taxonomie de plusieurs milliers de codes ne monte pas autant
|
|
17
|
+
* d'instances de composant qu'elle a de noeuds.
|
|
18
|
+
*/
|
|
19
|
+
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
20
|
+
import { PhCaretDown, PhCaretRight, PhCheck, PhMagnifyingGlass, PhMinus } from '@phosphor-icons/vue'
|
|
21
|
+
import type { Component } from 'vue'
|
|
22
|
+
import { rmPanelOpened, rmPanelRegister } from '../utils/panels'
|
|
23
|
+
|
|
24
|
+
export interface RmTreeNode {
|
|
25
|
+
/** Ce qui part dans la selection. Unique dans tout l'arbre. */
|
|
26
|
+
value: string
|
|
27
|
+
label: string
|
|
28
|
+
/**
|
|
29
|
+
* Code court, rendu en monospace avant le libelle. C'est par lui que
|
|
30
|
+
* cherchent ceux qui connaissent la classification, et l'aligner en
|
|
31
|
+
* chasse fixe rend la profondeur lisible d'un coup d'oeil.
|
|
32
|
+
*/
|
|
33
|
+
code?: string
|
|
34
|
+
/**
|
|
35
|
+
* Nombre d'items que ce noeud couvre dans le jeu de donnees courant. Il dit
|
|
36
|
+
* si descendre dans la branche vaut la peine ; sans lui, on coche a
|
|
37
|
+
* l'aveugle.
|
|
38
|
+
*/
|
|
39
|
+
count?: number
|
|
40
|
+
children?: RmTreeNode[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const props = withDefaults(defineProps<{
|
|
44
|
+
nodes: RmTreeNode[]
|
|
45
|
+
/** Nom de la dimension, porte par la puce : « ATC », « GHM ». */
|
|
46
|
+
label?: string
|
|
47
|
+
icon?: Component
|
|
48
|
+
/**
|
|
49
|
+
* Rend le panneau seul, sans puce ni menu deroulant, pour le poser dans un
|
|
50
|
+
* tiroir ou un panneau du rail.
|
|
51
|
+
*
|
|
52
|
+
* Le pied « Appliquer » disparait alors, et le modele suit chaque clic :
|
|
53
|
+
* c'est la surface d'accueil qui porte la validation (le pied de son
|
|
54
|
+
* tiroir), et deux boutons « Appliquer » l'un au-dessus de l'autre ne
|
|
55
|
+
* disent pas lequel declenche quoi.
|
|
56
|
+
*/
|
|
57
|
+
inline?: boolean
|
|
58
|
+
/**
|
|
59
|
+
* Faux quand c'est l'appelant qui cherche, cote serveur : le composant ne
|
|
60
|
+
* filtre plus l'arbre lui-meme et attend des `nodes` deja reduits. Une
|
|
61
|
+
* taxonomie tronquee a mille codes se cherche en base, pas en memoire.
|
|
62
|
+
*/
|
|
63
|
+
filterSearch?: boolean
|
|
64
|
+
searchPlaceholder?: string
|
|
65
|
+
lang?: 'fr' | 'en'
|
|
66
|
+
}>(), {
|
|
67
|
+
label: '',
|
|
68
|
+
icon: undefined,
|
|
69
|
+
inline: false,
|
|
70
|
+
filterSearch: true,
|
|
71
|
+
searchPlaceholder: '',
|
|
72
|
+
lang: 'fr',
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* La selection appliquee. Elle porte tous les noeuds coches, parents compris :
|
|
77
|
+
* cocher un parent coche ses descendants, donc l'etat a l'ecran et le modele
|
|
78
|
+
* disent la meme chose, et l'appelant n'a pas a redescendre l'arbre pour
|
|
79
|
+
* savoir ce qui est couvert.
|
|
80
|
+
*/
|
|
81
|
+
const selected = defineModel<string[]>({ default: () => [] })
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* La recherche, exposee pour que l'appelant puisse chercher a sa place
|
|
85
|
+
* (cf. `filterSearch`).
|
|
86
|
+
*/
|
|
87
|
+
const search = defineModel<string>('search', { default: '' })
|
|
88
|
+
|
|
89
|
+
const emit = defineEmits<{
|
|
90
|
+
/** La selection vient d'etre validee par le pied du panneau. */
|
|
91
|
+
apply: [values: string[]]
|
|
92
|
+
}>()
|
|
93
|
+
|
|
94
|
+
const en = computed(() => props.lang === 'en')
|
|
95
|
+
|
|
96
|
+
const texts = computed(() => ({
|
|
97
|
+
search: props.searchPlaceholder || (en.value ? 'Search (code or label)...' : 'Rechercher (code ou libellé)...'),
|
|
98
|
+
clear: en.value ? 'Deselect all' : 'Tout désélectionner',
|
|
99
|
+
apply: en.value ? 'Apply' : 'Appliquer',
|
|
100
|
+
empty: en.value ? 'No match' : 'Aucun résultat',
|
|
101
|
+
}))
|
|
102
|
+
|
|
103
|
+
// ----------------------------------------------------------------- index
|
|
104
|
+
|
|
105
|
+
interface Indexed {
|
|
106
|
+
node: RmTreeNode
|
|
107
|
+
depth: number
|
|
108
|
+
parent: string | null
|
|
109
|
+
/** Les valeurs de toute la branche sous ce noeud, lui exclu. */
|
|
110
|
+
descendants: string[]
|
|
111
|
+
/** Texte sur lequel la recherche mord : code et libelle. */
|
|
112
|
+
haystack: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* L'arbre mis a plat une fois par changement de `nodes`. Tout le reste
|
|
117
|
+
* (etats, recherche, pliage) se lit dans cette table plutot qu'en reparcourant
|
|
118
|
+
* l'arbre a chaque acces.
|
|
119
|
+
*/
|
|
120
|
+
const index = computed(() => {
|
|
121
|
+
const map = new Map<string, Indexed>()
|
|
122
|
+
|
|
123
|
+
const walk = (node: RmTreeNode, depth: number, parent: string | null): string[] => {
|
|
124
|
+
const entry: Indexed = {
|
|
125
|
+
node,
|
|
126
|
+
depth,
|
|
127
|
+
parent,
|
|
128
|
+
descendants: [],
|
|
129
|
+
haystack: `${node.code ?? ''} ${node.label}`.toLowerCase(),
|
|
130
|
+
}
|
|
131
|
+
map.set(node.value, entry)
|
|
132
|
+
|
|
133
|
+
const below: string[] = []
|
|
134
|
+
node.children?.forEach((child) => {
|
|
135
|
+
below.push(child.value, ...walk(child, depth + 1, node.value))
|
|
136
|
+
})
|
|
137
|
+
entry.descendants = below
|
|
138
|
+
return below
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
props.nodes.forEach(node => walk(node, 0, null))
|
|
142
|
+
return map
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// ------------------------------------------------------------- brouillon
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Selection en cours, non validee.
|
|
149
|
+
*
|
|
150
|
+
* Les changements sont couteux a recalculer en aval (une requete par
|
|
151
|
+
* combinaison de codes) : le panneau garde donc son brouillon et n'ecrit le
|
|
152
|
+
* modele qu'au clic sur « Appliquer ». Cocher cinq branches ne declenche pas
|
|
153
|
+
* cinq requetes.
|
|
154
|
+
*/
|
|
155
|
+
const draft = ref(new Set<string>())
|
|
156
|
+
const expanded = ref(new Set<string>())
|
|
157
|
+
|
|
158
|
+
function resetDraft() {
|
|
159
|
+
draft.value = new Set(selected.value)
|
|
160
|
+
// Les branches qui portent la selection s'ouvrent : arriver sur un arbre
|
|
161
|
+
// replie alors qu'il est filtre ne dit pas sur quoi.
|
|
162
|
+
const open = new Set<string>()
|
|
163
|
+
selected.value.forEach((value) => {
|
|
164
|
+
let parent = index.value.get(value)?.parent ?? null
|
|
165
|
+
while (parent) {
|
|
166
|
+
open.add(parent)
|
|
167
|
+
parent = index.value.get(parent)?.parent ?? null
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
expanded.value = open
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
watch(selected, resetDraft, { immediate: true, deep: true })
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Remonte la coherence apres un clic : un parent est coche quand tous ses
|
|
177
|
+
* enfants le sont, et decoche des qu'il en manque un. Sans cette remontee, un
|
|
178
|
+
* parent coche pouvait survivre au decochage d'un de ses enfants et la
|
|
179
|
+
* selection affirmait couvrir une branche entiere qu'elle ne couvrait plus.
|
|
180
|
+
*/
|
|
181
|
+
function settle() {
|
|
182
|
+
const entries = [...index.value.values()].sort((a, b) => b.depth - a.depth)
|
|
183
|
+
entries.forEach(({ node }) => {
|
|
184
|
+
if (!node.children?.length) return
|
|
185
|
+
const all = node.children.every(child => draft.value.has(child.value))
|
|
186
|
+
if (all) draft.value.add(node.value)
|
|
187
|
+
else draft.value.delete(node.value)
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function toggle(value: string) {
|
|
192
|
+
const entry = index.value.get(value)
|
|
193
|
+
if (!entry) return
|
|
194
|
+
const branch = [value, ...entry.descendants]
|
|
195
|
+
if (draft.value.has(value)) branch.forEach(item => draft.value.delete(item))
|
|
196
|
+
else branch.forEach(item => draft.value.add(item))
|
|
197
|
+
settle()
|
|
198
|
+
// `Set` mute sur place : la nouvelle reference reveille les calculs.
|
|
199
|
+
draft.value = new Set(draft.value)
|
|
200
|
+
// Posé dans un tiroir, le composant n'a pas de pied a lui : c'est la
|
|
201
|
+
// surface d'accueil qui decide quand agir, donc le modele suit le clic.
|
|
202
|
+
if (props.inline) commit()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function toggleExpand(value: string) {
|
|
206
|
+
if (expanded.value.has(value)) expanded.value.delete(value)
|
|
207
|
+
else expanded.value.add(value)
|
|
208
|
+
expanded.value = new Set(expanded.value)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ------------------------------------------------------------ recherche
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Noeuds que la recherche laisse voir : ceux qui mordent, et tout leur chemin
|
|
215
|
+
* jusqu'a la racine. Un code trouve trois niveaux plus bas doit rester
|
|
216
|
+
* situable, sinon la liste rend des feuilles sans dire de quelle branche.
|
|
217
|
+
*
|
|
218
|
+
* `null` quand il n'y a pas de recherche : tout est visible, et ne rien
|
|
219
|
+
* construire evite de parcourir la taxonomie a chaque frappe.
|
|
220
|
+
*/
|
|
221
|
+
const matching = computed<Set<string> | null>(() => {
|
|
222
|
+
const needle = search.value.trim().toLowerCase()
|
|
223
|
+
if (!needle || !props.filterSearch) return null
|
|
224
|
+
|
|
225
|
+
const keep = new Set<string>()
|
|
226
|
+
index.value.forEach((entry, value) => {
|
|
227
|
+
if (!entry.haystack.includes(needle)) return
|
|
228
|
+
keep.add(value)
|
|
229
|
+
// La branche sous un noeud trouve reste consultable : chercher « C10 »
|
|
230
|
+
// doit donner ses sous-classes, pas seulement la ligne C10.
|
|
231
|
+
entry.descendants.forEach(item => keep.add(item))
|
|
232
|
+
let parent = entry.parent
|
|
233
|
+
while (parent) {
|
|
234
|
+
keep.add(parent)
|
|
235
|
+
parent = index.value.get(parent)?.parent ?? null
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
return keep
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------- lignes
|
|
242
|
+
|
|
243
|
+
const searching = computed(() => search.value.trim().length > 0)
|
|
244
|
+
|
|
245
|
+
type NodeState = 'off' | 'on' | 'partial'
|
|
246
|
+
|
|
247
|
+
interface Row {
|
|
248
|
+
node: RmTreeNode
|
|
249
|
+
depth: number
|
|
250
|
+
leaf: boolean
|
|
251
|
+
open: boolean
|
|
252
|
+
state: NodeState
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function stateOf(node: RmTreeNode): NodeState {
|
|
256
|
+
if (draft.value.has(node.value)) return 'on'
|
|
257
|
+
const entry = index.value.get(node.value)
|
|
258
|
+
if (!entry?.descendants.length) return 'off'
|
|
259
|
+
return entry.descendants.some(item => draft.value.has(item)) ? 'partial' : 'off'
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Les lignes a rendre, dans l'ordre de l'arbre. Une branche repliee ne
|
|
264
|
+
* descend pas ; une recherche en cours ouvre les branches qu'elle traverse,
|
|
265
|
+
* sans toucher au pliage choisi a la main (il revient quand la recherche se
|
|
266
|
+
* vide).
|
|
267
|
+
*/
|
|
268
|
+
const rows = computed<Row[]>(() => {
|
|
269
|
+
const visible = matching.value
|
|
270
|
+
const out: Row[] = []
|
|
271
|
+
|
|
272
|
+
const walk = (nodes: RmTreeNode[], depth: number) => {
|
|
273
|
+
nodes.forEach((node) => {
|
|
274
|
+
if (visible && !visible.has(node.value)) return
|
|
275
|
+
const leaf = !node.children?.length
|
|
276
|
+
// Une recherche en cours ouvre les branches qu'elle traverse, qu'elle
|
|
277
|
+
// soit faite ici ou par l'appelant : un code trouve trois niveaux plus
|
|
278
|
+
// bas doit se voir sans avoir a deplier sa branche a la main.
|
|
279
|
+
const open = leaf ? false : searching.value || expanded.value.has(node.value)
|
|
280
|
+
out.push({ node, depth, leaf, open, state: stateOf(node) })
|
|
281
|
+
if (open) walk(node.children!, depth + 1)
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
walk(props.nodes, 0)
|
|
286
|
+
return out
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
// ------------------------------------------------------------- puce
|
|
290
|
+
|
|
291
|
+
/** La selection validee, celle que la puce decrit, pas le brouillon. */
|
|
292
|
+
const applied = computed(() => new Set(selected.value))
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Racines de la selection : les noeuds coches dont le parent ne l'est pas.
|
|
296
|
+
* C'est ce que la selection couvre, dit au niveau le plus haut possible.
|
|
297
|
+
*/
|
|
298
|
+
const roots = computed(() => {
|
|
299
|
+
const out: RmTreeNode[] = []
|
|
300
|
+
index.value.forEach((entry, value) => {
|
|
301
|
+
if (!applied.value.has(value)) return
|
|
302
|
+
if (entry.parent && applied.value.has(entry.parent)) return
|
|
303
|
+
out.push(entry.node)
|
|
304
|
+
})
|
|
305
|
+
return out
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Libelle de la puce : le noeud parent commun quand il y en a un seul, et le
|
|
310
|
+
* nombre des autres a cote. Enumerer trois sous-classes ne tient pas dans une
|
|
311
|
+
* puce et se lit moins bien que la classe qui les contient.
|
|
312
|
+
*/
|
|
313
|
+
const pillValue = computed(() => {
|
|
314
|
+
const first = roots.value[0]
|
|
315
|
+
if (!first) return ''
|
|
316
|
+
return [first.code, first.label].filter(Boolean).join(' ')
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
const isActive = computed(() => roots.value.length > 0)
|
|
320
|
+
|
|
321
|
+
// -------------------------------------------------------------- panneau
|
|
322
|
+
|
|
323
|
+
const isOpen = ref(false)
|
|
324
|
+
const wrapRef = ref<HTMLElement>()
|
|
325
|
+
const searchRef = ref<HTMLInputElement>()
|
|
326
|
+
|
|
327
|
+
function close() {
|
|
328
|
+
isOpen.value = false
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function toggleOpen() {
|
|
332
|
+
isOpen.value = !isOpen.value
|
|
333
|
+
if (isOpen.value) rmPanelOpened(close)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Chaque ouverture repart de la selection appliquee : un brouillon abandonne
|
|
337
|
+
// la fois d'avant reapparaitrait comme s'il etait en vigueur.
|
|
338
|
+
watch(isOpen, async (open) => {
|
|
339
|
+
if (!open) return
|
|
340
|
+
search.value = ''
|
|
341
|
+
resetDraft()
|
|
342
|
+
await nextTick()
|
|
343
|
+
searchRef.value?.focus()
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
function clear() {
|
|
347
|
+
draft.value = new Set()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Ecrit le brouillon dans le modele, dans l'ordre de l'arbre et non dans
|
|
352
|
+
* celui des clics : une selection se relit comme la classification, et deux
|
|
353
|
+
* selections identiques donnent la meme liste.
|
|
354
|
+
*/
|
|
355
|
+
function commit() {
|
|
356
|
+
const values = [...index.value.keys()].filter(value => draft.value.has(value))
|
|
357
|
+
selected.value = values
|
|
358
|
+
emit('apply', values)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function apply() {
|
|
362
|
+
commit()
|
|
363
|
+
isOpen.value = false
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let unregister: (() => void) | undefined
|
|
367
|
+
onMounted(() => {
|
|
368
|
+
if (props.inline) return
|
|
369
|
+
unregister = rmPanelRegister(close, () => isOpen.value)
|
|
370
|
+
document.addEventListener('click', onClickOutside)
|
|
371
|
+
})
|
|
372
|
+
onUnmounted(() => {
|
|
373
|
+
unregister?.()
|
|
374
|
+
document.removeEventListener('click', onClickOutside)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
function onClickOutside(event: Event) {
|
|
378
|
+
if (wrapRef.value && !wrapRef.value.contains(event.target as Node)) isOpen.value = false
|
|
379
|
+
}
|
|
380
|
+
</script>
|
|
381
|
+
|
|
382
|
+
<template>
|
|
383
|
+
<div ref="wrapRef" class="rm-ftree-wrap" :class="{ 'rm-ftree-wrap--inline': inline }">
|
|
384
|
+
<button
|
|
385
|
+
v-if="!inline"
|
|
386
|
+
type="button"
|
|
387
|
+
class="rm-ftree-pill"
|
|
388
|
+
:class="{ 'rm-ftree-pill--active': isActive }"
|
|
389
|
+
@click="toggleOpen"
|
|
390
|
+
>
|
|
391
|
+
<component :is="icon" v-if="icon" :size="16" class="rm-ftree-pill__icon" />
|
|
392
|
+
<span class="rm-ftree-pill__label">
|
|
393
|
+
{{ label }}<template v-if="pillValue"> : <b>{{ pillValue }}</b></template>
|
|
394
|
+
</span>
|
|
395
|
+
<span v-if="roots.length > 1" class="rm-ftree-pill__more">+{{ roots.length - 1 }}</span>
|
|
396
|
+
<PhCaretDown
|
|
397
|
+
:size="12"
|
|
398
|
+
class="rm-ftree-pill__caret"
|
|
399
|
+
:class="{ 'rm-ftree-pill__caret--open': isOpen }"
|
|
400
|
+
/>
|
|
401
|
+
</button>
|
|
402
|
+
|
|
403
|
+
<div v-if="inline || isOpen" class="rm-ftree-panel">
|
|
404
|
+
<label class="rm-ftree-search">
|
|
405
|
+
<PhMagnifyingGlass :size="14" />
|
|
406
|
+
<input
|
|
407
|
+
ref="searchRef"
|
|
408
|
+
v-model="search"
|
|
409
|
+
type="search"
|
|
410
|
+
:placeholder="texts.search"
|
|
411
|
+
@click.stop
|
|
412
|
+
>
|
|
413
|
+
</label>
|
|
414
|
+
|
|
415
|
+
<div class="rm-ftree-list">
|
|
416
|
+
<p v-if="rows.length === 0" class="rm-ftree-empty">
|
|
417
|
+
{{ texts.empty }}
|
|
418
|
+
</p>
|
|
419
|
+
|
|
420
|
+
<div
|
|
421
|
+
v-for="row in rows"
|
|
422
|
+
:key="row.node.value"
|
|
423
|
+
class="rm-ftree-row"
|
|
424
|
+
:class="{ 'rm-ftree-row--on': row.state === 'on' }"
|
|
425
|
+
:style="{ paddingLeft: `${row.depth * 22}px` }"
|
|
426
|
+
>
|
|
427
|
+
<!-- Le caret n'ouvre que : il ne coche pas. Deux gestes distincts
|
|
428
|
+
sur la meme ligne, sinon descendre dans une branche la
|
|
429
|
+
selectionne au passage. -->
|
|
430
|
+
<button
|
|
431
|
+
v-if="!row.leaf"
|
|
432
|
+
type="button"
|
|
433
|
+
class="rm-ftree-caret"
|
|
434
|
+
:aria-expanded="row.open"
|
|
435
|
+
:aria-label="row.node.label"
|
|
436
|
+
@click.stop="toggleExpand(row.node.value)"
|
|
437
|
+
>
|
|
438
|
+
<PhCaretDown v-if="row.open" :size="12" />
|
|
439
|
+
<PhCaretRight v-else :size="12" />
|
|
440
|
+
</button>
|
|
441
|
+
<span v-else class="rm-ftree-caret rm-ftree-caret--none" />
|
|
442
|
+
|
|
443
|
+
<button type="button" class="rm-ftree-label" @click="toggle(row.node.value)">
|
|
444
|
+
<span
|
|
445
|
+
class="rm-ftree-box"
|
|
446
|
+
:class="{
|
|
447
|
+
'rm-ftree-box--on': row.state === 'on',
|
|
448
|
+
'rm-ftree-box--partial': row.state === 'partial',
|
|
449
|
+
}"
|
|
450
|
+
>
|
|
451
|
+
<PhCheck v-if="row.state === 'on'" :size="10" weight="bold" />
|
|
452
|
+
<PhMinus v-else-if="row.state === 'partial'" :size="10" weight="bold" />
|
|
453
|
+
</span>
|
|
454
|
+
<span v-if="row.node.code" class="rm-ftree-code">{{ row.node.code }}</span>
|
|
455
|
+
<span class="rm-ftree-text">{{ row.node.label }}</span>
|
|
456
|
+
<span v-if="row.node.count !== undefined" class="rm-ftree-count">{{ row.node.count }}</span>
|
|
457
|
+
</button>
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
<!-- Un pied, parce que rien ne s'applique au clic : voir le brouillon.
|
|
462
|
+
En mode inline il n'y en a pas, la surface d'accueil porte le sien. -->
|
|
463
|
+
<div v-if="!inline" class="rm-ftree-foot">
|
|
464
|
+
<button type="button" class="rm-ftree-clear" @click="clear">
|
|
465
|
+
{{ texts.clear }}
|
|
466
|
+
</button>
|
|
467
|
+
<button type="button" class="rm-ftree-apply" @click="apply">
|
|
468
|
+
{{ texts.apply }}
|
|
469
|
+
</button>
|
|
470
|
+
</div>
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
</template>
|
|
474
|
+
|
|
475
|
+
<style scoped>
|
|
476
|
+
.rm-ftree-wrap{position:relative}.rm-ftree-wrap--inline{display:flex;flex-direction:column;height:100%;min-height:0}.rm-ftree-pill{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:6px;color:var(--text-secondary);cursor:pointer;display:inline-flex;font-family:var(--font-sans);font-size:var(--font-14);font-weight:var(--weight-medium);gap:var(--space-8);height:32px;max-width:320px;padding:0 var(--space-8);transition:border-color var(--duration-fast) var(--ease-in-out);white-space:nowrap}.rm-ftree-pill--active{border-color:var(--color-healthcare);box-shadow:0 0 8px 0 rgba(var(--healthcare-rgb),.1)}.rm-ftree-pill__icon{color:var(--color-secondary);flex-shrink:0}.rm-ftree-pill__label{overflow:hidden;text-overflow:ellipsis}.rm-ftree-pill__label b{color:var(--text-primary);font-weight:var(--weight-semibold)}.rm-ftree-pill__more{align-items:center;background:var(--bg-surface-2);border-radius:100px;color:var(--text-secondary);display:inline-flex;flex-shrink:0;font-size:var(--font-11);font-weight:var(--weight-semibold);height:18px;padding:0 6px}.rm-ftree-pill__caret{color:var(--text-muted);flex-shrink:0;transition:transform var(--duration-fast) var(--ease-in-out)}.rm-ftree-pill__caret--open{transform:rotate(180deg)}.rm-ftree-panel{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:8px;display:flex;flex-direction:column;min-height:0}.rm-ftree-wrap:not(.rm-ftree-wrap--inline) .rm-ftree-panel{box-shadow:0 4px 12px rgba(var(--shadow-rgb),.08),0 2px 4px rgba(var(--shadow-rgb),.04);left:0;max-width:460px;min-width:380px;position:absolute;top:calc(100% + 4px);z-index:50}.rm-ftree-wrap--inline .rm-ftree-panel{flex:1}.rm-ftree-search{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:6px;color:var(--text-muted);display:flex;flex-shrink:0;gap:var(--space-6);margin:var(--space-8);padding:var(--space-8)}.rm-ftree-search input{background:none;border:none;color:var(--text-primary);flex:1;font-family:var(--font-sans);font-size:var(--font-14);min-width:0;outline:none}.rm-ftree-list{flex:1;max-height:340px;min-height:0;overflow-y:auto;padding:0 var(--space-4) var(--space-4)}.rm-ftree-wrap--inline .rm-ftree-list{max-height:none}.rm-ftree-empty{color:var(--text-muted);font-family:var(--font-sans);font-size:var(--font-14);margin:0;padding:var(--space-10) var(--space-8)}.rm-ftree-row{align-items:center;border-radius:4px;display:flex}.rm-ftree-row--on{background:rgba(var(--healthcare-rgb),.06)}.rm-ftree-caret{align-items:center;background:none;border:none;color:var(--text-muted);cursor:pointer;display:flex;flex-shrink:0;height:28px;justify-content:center;width:22px}.rm-ftree-caret:hover{color:var(--text-primary)}.rm-ftree-caret--none{cursor:default}.rm-ftree-label{align-items:center;background:none;border:none;border-radius:4px;color:var(--text-secondary);cursor:pointer;display:flex;flex:1;font-family:var(--font-sans);font-size:var(--font-14);gap:var(--space-8);min-width:0;padding:var(--space-6) var(--space-8);text-align:left}.rm-ftree-label:hover{background:var(--bg-surface-2)}.rm-ftree-box{align-items:center;border:1.5px solid var(--border-default);border-radius:3px;display:flex;flex-shrink:0;height:16px;justify-content:center;transition:all var(--duration-fast) var(--ease-in-out);width:16px}.rm-ftree-box--on,.rm-ftree-box--partial{background:var(--color-healthcare);border-color:var(--color-healthcare);color:var(--color-text-inverse)}.rm-ftree-box--partial{background:var(--bg-surface);color:var(--color-healthcare)}.rm-ftree-code{color:var(--color-secondary);flex-shrink:0;font-family:var(--font-mono);font-size:var(--font-12);font-weight:var(--weight-semibold)}.rm-ftree-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-ftree-count{color:var(--text-muted);flex-shrink:0;font-size:var(--font-12);font-variant-numeric:tabular-nums}.rm-ftree-foot{align-items:center;border-top:1px solid var(--border-soft);display:flex;flex-shrink:0;gap:var(--space-8);justify-content:space-between;padding:var(--space-8) var(--space-12)}.rm-ftree-clear{background:none;border:none;color:var(--text-secondary);cursor:pointer;font-family:var(--font-sans);font-size:var(--font-13);padding:0;text-decoration:underline}.rm-ftree-clear:hover{color:var(--text-primary)}.rm-ftree-apply{background:var(--color-healthcare);border:none;border-radius:6px;color:var(--color-text-inverse);cursor:pointer;font-family:var(--font-sans);font-size:var(--font-13);font-weight:var(--weight-semibold);height:30px;padding:0 var(--space-16)}.rm-ftree-apply:hover{filter:brightness(1.05)}
|
|
477
|
+
</style>
|
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
apres. Deplier la case en une liste de noms au moment du partage laisserait
|
|
8
8
|
les arrivants dehors, sans que personne s'en apercoive.
|
|
9
9
|
|
|
10
|
+
Une quatrieme portee n'apparait que pour qui administre la plateforme
|
|
11
|
+
(`canTemplate`) : publier la vue comme modele, offert a des espaces clients
|
|
12
|
+
choisis. Elle vit ici plutot que dans un ecran a part parce que c'est le
|
|
13
|
+
meme geste et la meme question — qui voit cette vue — avec un cran de plus :
|
|
14
|
+
des espaces au lieu des personnes. Une liste d'espaces vide vaut « tous »,
|
|
15
|
+
pour la meme raison que « tout l'espace » n'est pas deplie en noms.
|
|
16
|
+
|
|
10
17
|
La liste des noms reste montee quand la portee change : on revient de
|
|
11
18
|
« tout l'espace » a « des personnes » sans avoir reperdu sa selection.
|
|
12
19
|
|
|
@@ -23,7 +30,15 @@ export interface RmShareMember {
|
|
|
23
30
|
email?: string
|
|
24
31
|
}
|
|
25
32
|
|
|
26
|
-
|
|
33
|
+
/** Un espace de travail a qui un modele peut etre offert. */
|
|
34
|
+
export interface RmShareWorkspace {
|
|
35
|
+
workspaceId: string
|
|
36
|
+
name: string
|
|
37
|
+
/** Formule d'abonnement, rappelee sous le nom. */
|
|
38
|
+
tier?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type RmShareScope = 'private' | 'people' | 'workspace' | 'template'
|
|
27
42
|
|
|
28
43
|
const props = withDefaults(
|
|
29
44
|
defineProps<{
|
|
@@ -36,6 +51,19 @@ const props = withDefaults(
|
|
|
36
51
|
visibility?: 'private' | 'workspace'
|
|
37
52
|
/** Ids deja destinataires. */
|
|
38
53
|
sharedWith?: string[]
|
|
54
|
+
/** Offrir la vue comme modele est possible. Reserve a l'administration. */
|
|
55
|
+
canTemplate?: boolean
|
|
56
|
+
/** La vue est deja publiee comme modele. */
|
|
57
|
+
isTemplate?: boolean
|
|
58
|
+
/** Espaces deja vises. Vide vaut « tous ». */
|
|
59
|
+
targetWorkspaces?: string[]
|
|
60
|
+
/** Espaces proposes. L'appelant les charge quand le dialogue s'ouvre. */
|
|
61
|
+
workspaces?: RmShareWorkspace[]
|
|
62
|
+
/**
|
|
63
|
+
* Ce que la publication ne saura pas traduire, dit en une phrase par
|
|
64
|
+
* l'appelant : lui seul sait lire les criteres de la vue.
|
|
65
|
+
*/
|
|
66
|
+
templateNote?: string
|
|
39
67
|
loading?: boolean
|
|
40
68
|
lang?: 'fr' | 'en'
|
|
41
69
|
}>(),
|
|
@@ -44,6 +72,11 @@ const props = withDefaults(
|
|
|
44
72
|
members: () => [],
|
|
45
73
|
visibility: 'private',
|
|
46
74
|
sharedWith: () => [],
|
|
75
|
+
canTemplate: false,
|
|
76
|
+
isTemplate: false,
|
|
77
|
+
targetWorkspaces: () => [],
|
|
78
|
+
workspaces: () => [],
|
|
79
|
+
templateNote: '',
|
|
47
80
|
loading: false,
|
|
48
81
|
lang: 'fr',
|
|
49
82
|
},
|
|
@@ -51,7 +84,15 @@ const props = withDefaults(
|
|
|
51
84
|
|
|
52
85
|
const emit = defineEmits<{
|
|
53
86
|
'update:modelValue': [value: boolean]
|
|
54
|
-
save: [
|
|
87
|
+
save: [
|
|
88
|
+
value: {
|
|
89
|
+
visibility: 'private' | 'workspace'
|
|
90
|
+
sharedWith: string[]
|
|
91
|
+
/** Absents quand l'appelant n'a pas le droit de publier. */
|
|
92
|
+
isTemplate?: boolean
|
|
93
|
+
targetWorkspaces?: string[]
|
|
94
|
+
},
|
|
95
|
+
]
|
|
55
96
|
}>()
|
|
56
97
|
|
|
57
98
|
const en = computed(() => props.lang === 'en')
|
|
@@ -79,11 +120,26 @@ const T = computed(() => ({
|
|
|
79
120
|
: `${n} ${n > 1 ? 'personnes' : 'personne'} selectionnee${n > 1 ? 's' : ''}`,
|
|
80
121
|
cancel: en.value ? 'Cancel' : 'Annuler',
|
|
81
122
|
save: en.value ? 'Share' : 'Partager',
|
|
123
|
+
template: en.value ? 'Tekkare template' : 'Template Tekkare',
|
|
124
|
+
templateHint: en.value
|
|
125
|
+
? 'Offered as a starting point on this page, without being shared.'
|
|
126
|
+
: 'Offert comme point de depart sur cette page, sans etre partage.',
|
|
127
|
+
workspaceSearch: en.value ? 'Search for a workspace' : 'Chercher un espace',
|
|
128
|
+
noWorkspace: en.value ? 'No workspace to offer it to.' : 'Aucun espace a qui l\'offrir.',
|
|
129
|
+
allWorkspaces: en.value
|
|
130
|
+
? 'No workspace picked: every workspace is offered the template.'
|
|
131
|
+
: 'Aucun espace retenu : le template est offert a tous les espaces.',
|
|
132
|
+
workspaceCount: (n: number) =>
|
|
133
|
+
en.value
|
|
134
|
+
? `${n} ${n > 1 ? 'workspaces' : 'workspace'} selected`
|
|
135
|
+
: `${n} espace${n > 1 ? 's' : ''} retenu${n > 1 ? 's' : ''}`,
|
|
82
136
|
}))
|
|
83
137
|
|
|
84
138
|
const scope = ref<RmShareScope>('private')
|
|
85
139
|
const picked = ref<string[]>([])
|
|
86
140
|
const search = ref('')
|
|
141
|
+
const pickedWorkspaces = ref<string[]>([])
|
|
142
|
+
const workspaceSearch = ref('')
|
|
87
143
|
|
|
88
144
|
/**
|
|
89
145
|
* L'etat ouvert fait foi : le dialogue se recharge a chaque ouverture, jamais
|
|
@@ -94,13 +150,16 @@ watch(
|
|
|
94
150
|
(open) => {
|
|
95
151
|
if (!open) return
|
|
96
152
|
picked.value = [...props.sharedWith]
|
|
97
|
-
|
|
98
|
-
|
|
153
|
+
pickedWorkspaces.value = [...props.targetWorkspaces]
|
|
154
|
+
scope.value = props.isTemplate
|
|
155
|
+
? 'template'
|
|
156
|
+
: props.visibility === 'workspace'
|
|
99
157
|
? 'workspace'
|
|
100
158
|
: props.sharedWith.length
|
|
101
159
|
? 'people'
|
|
102
160
|
: 'private'
|
|
103
161
|
search.value = ''
|
|
162
|
+
workspaceSearch.value = ''
|
|
104
163
|
},
|
|
105
164
|
{ immediate: true },
|
|
106
165
|
)
|
|
@@ -122,6 +181,19 @@ function toggle(userId: string) {
|
|
|
122
181
|
picked.value = [...next]
|
|
123
182
|
}
|
|
124
183
|
|
|
184
|
+
const workspaceMatches = computed(() => {
|
|
185
|
+
const q = workspaceSearch.value.trim().toLowerCase()
|
|
186
|
+
if (!q) return props.workspaces
|
|
187
|
+
return props.workspaces.filter(ws => ws.name.toLowerCase().includes(q))
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
function toggleWorkspace(workspaceId: string) {
|
|
191
|
+
const next = new Set(pickedWorkspaces.value)
|
|
192
|
+
if (next.has(workspaceId)) next.delete(workspaceId)
|
|
193
|
+
else next.add(workspaceId)
|
|
194
|
+
pickedWorkspaces.value = [...next]
|
|
195
|
+
}
|
|
196
|
+
|
|
125
197
|
function close() {
|
|
126
198
|
emit('update:modelValue', false)
|
|
127
199
|
}
|
|
@@ -135,6 +207,12 @@ function save() {
|
|
|
135
207
|
emit('save', {
|
|
136
208
|
visibility: scope.value === 'workspace' ? 'workspace' : 'private',
|
|
137
209
|
sharedWith: scope.value === 'people' ? picked.value : [],
|
|
210
|
+
// Rien sur le modele quand l'appelant n'a pas le droit d'en publier : la
|
|
211
|
+
// simple presence des champs serait refusee par l'API.
|
|
212
|
+
...(props.canTemplate && {
|
|
213
|
+
isTemplate: scope.value === 'template',
|
|
214
|
+
targetWorkspaces: scope.value === 'template' ? pickedWorkspaces.value : [],
|
|
215
|
+
}),
|
|
138
216
|
})
|
|
139
217
|
}
|
|
140
218
|
|
|
@@ -148,6 +226,16 @@ const scopes = computed<Array<{ key: RmShareScope; icon: string; label: string;
|
|
|
148
226
|
label: T.value.workspace,
|
|
149
227
|
hint: T.value.workspaceHint,
|
|
150
228
|
},
|
|
229
|
+
...(props.canTemplate
|
|
230
|
+
? [
|
|
231
|
+
{
|
|
232
|
+
key: 'template' as const,
|
|
233
|
+
icon: 'Sparkle',
|
|
234
|
+
label: T.value.template,
|
|
235
|
+
hint: T.value.templateHint,
|
|
236
|
+
},
|
|
237
|
+
]
|
|
238
|
+
: []),
|
|
151
239
|
],
|
|
152
240
|
)
|
|
153
241
|
</script>
|
|
@@ -218,29 +306,55 @@ const scopes = computed<Array<{ key: RmShareScope; icon: string; label: string;
|
|
|
218
306
|
|
|
219
307
|
<ul v-else class="rm-share__list">
|
|
220
308
|
<li v-for="member in matches" :key="member.userId">
|
|
221
|
-
<
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
@change="toggle(member.userId)"
|
|
228
|
-
>
|
|
229
|
-
<RmIcon
|
|
230
|
-
:name="picked.includes(member.userId) ? 'CheckSquare' : 'Square'"
|
|
231
|
-
:weight="picked.includes(member.userId) ? 'fill' : 'regular'"
|
|
232
|
-
:size="18"
|
|
233
|
-
/>
|
|
234
|
-
<span class="rm-share__member-text">
|
|
235
|
-
<span class="rm-share__member-name">{{ member.name }}</span>
|
|
236
|
-
<span v-if="member.email" class="rm-share__member-mail">{{ member.email }}</span>
|
|
237
|
-
</span>
|
|
238
|
-
</label>
|
|
309
|
+
<RmCheckbox
|
|
310
|
+
:model-value="picked.includes(member.userId)"
|
|
311
|
+
:label="member.name"
|
|
312
|
+
:description="member.email"
|
|
313
|
+
@update:model-value="toggle(member.userId)"
|
|
314
|
+
/>
|
|
239
315
|
</li>
|
|
240
316
|
</ul>
|
|
241
317
|
|
|
242
318
|
<p v-if="picked.length" class="rm-share__count">{{ T.count(picked.length) }}</p>
|
|
243
319
|
</div>
|
|
320
|
+
|
|
321
|
+
<!--
|
|
322
|
+
Meme forme que la liste des personnes : une recherche, des cases, un
|
|
323
|
+
compte. Ce qui change est le defaut — aucun espace retenu ne veut pas
|
|
324
|
+
dire « personne » mais « tous », et c'est dit a l'ecran plutot que
|
|
325
|
+
laisse deviner.
|
|
326
|
+
-->
|
|
327
|
+
<div v-show="scope === 'template'" class="rm-share__people">
|
|
328
|
+
<p v-if="templateNote" class="rm-share__note">{{ templateNote }}</p>
|
|
329
|
+
|
|
330
|
+
<label class="rm-share__search">
|
|
331
|
+
<RmIcon name="MagnifyingGlass" :size="16" />
|
|
332
|
+
<input
|
|
333
|
+
v-model="workspaceSearch"
|
|
334
|
+
type="search"
|
|
335
|
+
:placeholder="T.workspaceSearch"
|
|
336
|
+
:aria-label="T.workspaceSearch"
|
|
337
|
+
>
|
|
338
|
+
</label>
|
|
339
|
+
|
|
340
|
+
<p v-if="!workspaces.length" class="rm-share__empty">{{ T.noWorkspace }}</p>
|
|
341
|
+
<p v-else-if="!workspaceMatches.length" class="rm-share__empty">{{ T.noMatch }}</p>
|
|
342
|
+
|
|
343
|
+
<ul v-else class="rm-share__list">
|
|
344
|
+
<li v-for="ws in workspaceMatches" :key="ws.workspaceId">
|
|
345
|
+
<RmCheckbox
|
|
346
|
+
:model-value="pickedWorkspaces.includes(ws.workspaceId)"
|
|
347
|
+
:label="ws.name"
|
|
348
|
+
:description="ws.tier"
|
|
349
|
+
@update:model-value="toggleWorkspace(ws.workspaceId)"
|
|
350
|
+
/>
|
|
351
|
+
</li>
|
|
352
|
+
</ul>
|
|
353
|
+
|
|
354
|
+
<p class="rm-share__count">
|
|
355
|
+
{{ pickedWorkspaces.length ? T.workspaceCount(pickedWorkspaces.length) : T.allWorkspaces }}
|
|
356
|
+
</p>
|
|
357
|
+
</div>
|
|
244
358
|
</div>
|
|
245
359
|
|
|
246
360
|
<template #footer>
|
|
@@ -257,5 +371,5 @@ const scopes = computed<Array<{ key: RmShareScope; icon: string; label: string;
|
|
|
257
371
|
</template>
|
|
258
372
|
|
|
259
373
|
<style scoped>
|
|
260
|
-
.rm-share{gap:var(--space-16)}.rm-
|
|
374
|
+
.rm-share,.rm-share :where(*){box-sizing:border-box}.rm-share{display:flex;flex-direction:column;gap:var(--space-16)}.rm-share__note{background:var(--bg-surface-2);border:1px solid var(--border-soft);border-radius:var(--radius-md);color:var(--text-secondary);font-size:var(--font-12);padding:var(--space-8) var(--space-10)}.rm-share__head{display:flex;flex-direction:column;gap:var(--space-4)}.rm-share__title{color:var(--text-primary);font-size:var(--font-16);font-weight:var(--weight-bold)}.rm-share__subject{color:var(--text-secondary);font-size:var(--font-13)}.rm-share__scopes{display:flex;flex-direction:column;gap:var(--space-8);list-style:none;margin:0;padding:0}.rm-share__scope{align-items:flex-start;background:var(--bg-surface);border:1px solid var(--border-soft);border-radius:var(--radius-lg);color:var(--text-primary);cursor:pointer;display:flex;gap:var(--space-10);padding:var(--space-10) var(--space-12);text-align:left;width:100%}.rm-share__scope:hover{background:var(--bg-hover)}.rm-share__radio{border:0;height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;clip:rect(0,0,0,0);white-space:nowrap}.rm-share__scope:has(.rm-share__radio:focus-visible){outline:2px solid var(--color-secondary);outline-offset:2px}.rm-share__scope.is-on{background:var(--bg-hover);border-color:var(--color-secondary)}.rm-share__scope-text{display:flex;flex:1;flex-direction:column;gap:2px}.rm-share__scope-label{font-size:var(--font-13);font-weight:var(--weight-medium)}.rm-share__scope-hint{color:var(--text-secondary);font-size:var(--font-12)}.rm-share__scope-mark{color:var(--color-secondary)}.rm-share__people{display:flex;flex-direction:column;gap:var(--space-8)}.rm-share__search{align-items:center;background:var(--bg-surface);border:1px solid var(--border-soft);border-radius:var(--radius-pill);color:var(--text-muted);display:flex;gap:var(--space-8);padding:var(--space-6) var(--space-10)}.rm-share__search input{background:none;border:none;color:var(--text-primary);flex:1;font-size:var(--font-13);outline:none}.rm-share__list{display:flex;flex-direction:column;gap:2px;list-style:none;margin:0;padding:0}.rm-share__count,.rm-share__empty{color:var(--text-secondary);font-size:var(--font-12)}.rm-share__foot{display:flex;gap:var(--space-8);justify-content:flex-end}
|
|
261
375
|
</style>
|