@tekkare/romulus 0.1.104 → 0.1.105

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
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.103",
7
+ "version": "0.1.104",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -18,12 +18,29 @@
18
18
  -->
19
19
  <script setup lang="ts">
20
20
  import { computed, ref, watch } from 'vue'
21
- import type { RmAlertDraft, RmAlertField, RmAlertTrigger } from '../utils/alerts'
21
+ import type {
22
+ RmAlertCadence,
23
+ RmAlertDraft,
24
+ RmAlertField,
25
+ RmAlertTrigger,
26
+ RmAlertViewOption,
27
+ } from '../utils/alerts'
28
+ import { RM_ALERT_ANY_FIELD } from '../utils/alerts'
22
29
 
23
30
  const props = withDefaults(
24
31
  defineProps<{
25
32
  /** Nom de la vue surveillee, affiche et repris comme titre par defaut. */
26
33
  viewTitle: string
34
+ /**
35
+ * Vues enregistrees de la page. Avec plusieurs, le formulaire propose un
36
+ * selecteur : une alerte peut se poser sans avoir la vue a l'ecran.
37
+ */
38
+ views?: RmAlertViewOption[]
39
+ /**
40
+ * L'ecran porte des filtres qui pourraient devenir une vue. Ajoute
41
+ * l'entree « ecran actuel » au selecteur.
42
+ */
43
+ canSaveScreen?: boolean
27
44
  /** Champs surveillables de la page. Vide : seule l'apparition compte. */
28
45
  fields?: RmAlertField[]
29
46
  /** Nombre d'elements que la vue tient a l'ecran, s'il est connu. */
@@ -31,15 +48,32 @@ const props = withDefaults(
31
48
  submitting?: boolean
32
49
  lang?: 'fr' | 'en'
33
50
  }>(),
34
- { fields: () => [], scopeCount: null, submitting: false, lang: 'fr' },
51
+ {
52
+ views: () => [],
53
+ canSaveScreen: false,
54
+ fields: () => [],
55
+ scopeCount: null,
56
+ submitting: false,
57
+ lang: 'fr',
58
+ },
35
59
  )
36
60
 
37
61
  const emit = defineEmits<{
38
62
  submit: [draft: RmAlertDraft]
39
63
  cancel: []
64
+ /**
65
+ * Une autre vue vient d'etre choisie. L'appelant doit l'appliquer a la
66
+ * page : c'est elle qui construit la requete Elasticsearch, donc l'ecran et
67
+ * la vue surveillee doivent dire la meme chose au moment de la capture.
68
+ */
69
+ 'select-view': [id: string]
40
70
  }>()
41
71
 
42
72
  const FR = {
73
+ screenOption: 'Écran actuel (une vue sera créée)',
74
+ viewSelect: 'Vue à surveiller',
75
+ viewApplied: 'La vue choisie est appliquée à l’écran : c’est lui qui fournit la requête surveillée.',
76
+ screenNote: 'Vos filtres actuels seront enregistrés comme une vue portant le nom de l’alerte.',
43
77
  name: 'Nom de l’alerte',
44
78
  view: 'Vue surveillée',
45
79
  viewHint: 'L’alerte se déclenche sur le périmètre de cette vue, tel qu’il est à l’écran.',
@@ -55,17 +89,18 @@ const FR = {
55
89
  fieldsHint: 'Un seul message par élément, quel que soit le nombre de champs cochés.',
56
90
  fieldsRequired: 'Cochez au moins un champ pour surveiller les changements.',
57
91
  noFields: 'Cette page ne propose aucun champ surveillable pour le moment.',
92
+ anyField: 'N’importe quel changement',
93
+ anyFieldHint:
94
+ 'Tout le document est comparé. Plus large, mais plus bavard : une correction de libellé compte autant qu’un mouvement de prix.',
58
95
  channels: 'Canaux',
59
96
  inApp: 'Dans l’app (pastille sur la tuile Alertes)',
60
97
  email: 'Email',
61
98
  cadence: 'Fréquence d’envoi du mail',
62
99
  daily: 'Quotidienne (un envoi par jour au plus)',
63
100
  weekly: 'Hebdomadaire (un envoi par semaine au plus)',
101
+ monthly: 'Mensuelle (un envoi par mois au plus)',
64
102
  cadenceHint:
65
103
  'Un plafond, pas un rythme : rien ne part quand rien n’a bougé, et la détection suit celle des données.',
66
- visibility: 'Partage',
67
- private: 'Pour moi seul',
68
- workspace: 'Visible par mon espace de travail',
69
104
  submit: 'Créer l’alerte',
70
105
  cancel: 'Annuler',
71
106
  baseline:
@@ -74,6 +109,10 @@ const FR = {
74
109
  }
75
110
 
76
111
  const EN = {
112
+ screenOption: 'Current screen (a view will be created)',
113
+ viewSelect: 'View to watch',
114
+ viewApplied: 'The chosen view is applied to the screen: the screen is what provides the watched query.',
115
+ screenNote: 'Your current filters will be saved as a view named after the alert.',
77
116
  name: 'Alert name',
78
117
  view: 'Watched view',
79
118
  viewHint: 'The alert fires on this view’s scope, as it stands on screen.',
@@ -89,17 +128,18 @@ const EN = {
89
128
  fieldsHint: 'One message per item, however many fields are ticked.',
90
129
  fieldsRequired: 'Tick at least one field to watch for changes.',
91
130
  noFields: 'This page offers no watchable field yet.',
131
+ anyField: 'Any change',
132
+ anyFieldHint:
133
+ 'The whole document is compared. Wider, but noisier: a label fix counts as much as a price move.',
92
134
  channels: 'Channels',
93
135
  inApp: 'In app (badge on the Alerts tile)',
94
136
  email: 'Email',
95
137
  cadence: 'Mail frequency',
96
138
  daily: 'Daily (at most one a day)',
97
139
  weekly: 'Weekly (at most one a week)',
140
+ monthly: 'Monthly (at most one a month)',
98
141
  cadenceHint:
99
142
  'A cap, not a rhythm: nothing goes out when nothing moved, and detection follows the data.',
100
- visibility: 'Sharing',
101
- private: 'Just me',
102
- workspace: 'Visible to my workspace',
103
143
  submit: 'Create the alert',
104
144
  cancel: 'Cancel',
105
145
  baseline:
@@ -109,20 +149,47 @@ const EN = {
109
149
 
110
150
  const T = computed(() => (props.lang === 'en' ? EN : FR))
111
151
 
152
+ /**
153
+ * Vue retenue. `null` designe l'ecran actuel, qui deviendra une vue au moment
154
+ * de la creation.
155
+ */
156
+ const selectedViewId = ref<string | null>(props.views[0]?.id ?? null)
157
+
158
+ /** Entrees du selecteur, l'ecran actuel en tete quand il a des filtres. */
159
+ const viewOptions = computed(() => [
160
+ ...(props.canSaveScreen ? [{ id: null as string | null, title: T.value.screenOption }] : []),
161
+ ...props.views.map((view) => ({ id: view.id as string | null, title: view.title })),
162
+ ])
163
+
164
+ const selectedView = computed(
165
+ () => viewOptions.value.find((option) => option.id === selectedViewId.value) ?? viewOptions.value[0],
166
+ )
167
+
112
168
  const title = ref(props.viewTitle)
113
169
  const triggers = ref<RmAlertTrigger[]>(['appeared'])
114
170
  const watchedFields = ref<string[]>([])
171
+ /** Surveiller le document entier plutot qu'une liste de champs. */
172
+ const anyField = ref(false)
115
173
  const emailOn = ref(false)
116
- const cadence = ref<'daily' | 'weekly'>('weekly')
117
- const shared = ref(false)
174
+ const cadence = ref<RmAlertCadence>('weekly')
175
+
176
+ /** Titre libre des que l'utilisateur y touche : on ne l'ecrase plus. */
177
+ const titleTouched = ref(false)
118
178
 
119
179
  watch(
120
180
  () => props.viewTitle,
121
181
  (value) => {
122
- title.value = value
182
+ if (!titleTouched.value) title.value = value
123
183
  },
124
184
  )
125
185
 
186
+ watch(selectedViewId, (id) => {
187
+ const view = props.views.find((entry) => entry.id === id)
188
+ if (view && !titleTouched.value) title.value = view.title
189
+ // Une vue choisie doit etre posee a l'ecran avant la capture.
190
+ if (id) emit('select-view', id)
191
+ })
192
+
126
193
  /**
127
194
  * Deux bascules plutot qu'une generique : dans le template, un `ref` est
128
195
  * deballe, donc lui passer la reference elle-meme ne marche pas.
@@ -146,7 +213,22 @@ const watchesChanges = computed(() => triggers.value.includes('changed'))
146
213
  * moteur comparerait un hachage vide et ne trouverait jamais rien.
147
214
  */
148
215
  const missingFields = computed(
149
- () => watchesChanges.value && watchedFields.value.length === 0 && props.fields.length > 0,
216
+ () =>
217
+ watchesChanges.value &&
218
+ !anyField.value &&
219
+ watchedFields.value.length === 0 &&
220
+ props.fields.length > 0,
221
+ )
222
+
223
+ /** Options de cadence, dans l'ordre du plus espace au plus frequent. */
224
+ const cadenceOptions = computed(() => [
225
+ { value: 'monthly' as const, label: T.value.monthly },
226
+ { value: 'weekly' as const, label: T.value.weekly },
227
+ { value: 'daily' as const, label: T.value.daily },
228
+ ])
229
+
230
+ const cadenceLabel = computed(
231
+ () => cadenceOptions.value.find((option) => option.value === cadence.value)?.label ?? '',
150
232
  )
151
233
 
152
234
  const valid = computed(
@@ -157,35 +239,65 @@ function submit() {
157
239
  if (!valid.value) return
158
240
  emit('submit', {
159
241
  title: title.value.trim(),
242
+ viewId: selectedViewId.value,
160
243
  triggers: triggers.value,
161
244
  // Les champs ne servent qu'au declencheur « changement » : les garder
162
245
  // sans lui ferait porter a l'alerte une promesse qu'elle ne tient pas.
163
- watchedFields: watchesChanges.value ? watchedFields.value : [],
246
+ watchedFields: watchesChanges.value
247
+ ? anyField.value
248
+ ? [RM_ALERT_ANY_FIELD]
249
+ : watchedFields.value
250
+ : [],
164
251
  channels: emailOn.value ? ['in_app', 'email'] : ['in_app'],
165
252
  cadence: cadence.value,
166
- visibility: shared.value ? 'workspace' : 'private',
253
+ // Le partage viendra plus tard : le schema le porte deja, l'ecran non.
254
+ visibility: 'private',
167
255
  })
168
256
  }
169
257
  </script>
170
258
 
171
259
  <template>
172
260
  <form class="rm-alert-form" @submit.prevent="submit">
173
- <RmInput v-model="title" :label="T.name" required />
261
+ <RmInput
262
+ :model-value="title"
263
+ :label="T.name"
264
+ required
265
+ @update:model-value="
266
+ titleTouched = true;
267
+ title = $event;
268
+ "
269
+ />
174
270
 
175
271
  <div class="rm-alert-form__view">
176
272
  <p class="rm-alert-form__label">{{ T.view }}</p>
177
- <p class="rm-alert-form__view-name">
273
+ <p class="rm-alert-form__hint">
274
+ {{ viewOptions.length > 1 ? T.viewApplied : T.viewHint }}
275
+ </p>
276
+
277
+ <RmDropdownSelect
278
+ v-if="viewOptions.length > 1"
279
+ label=""
280
+ :options="viewOptions.map((option) => option.title)"
281
+ :model-value="selectedView?.title ?? ''"
282
+ @update:model-value="
283
+ selectedViewId =
284
+ viewOptions.find((option) => option.title === $event)?.id ?? null
285
+ "
286
+ />
287
+ <p v-else class="rm-alert-form__view-name">
178
288
  <RmIcon name="BookmarkSimple" :size="14" />
179
- {{ viewTitle }}
180
- <span v-if="scopeCount" class="rm-alert-form__count">
181
- {{ scopeCount }} {{ T.scope }}
182
- </span>
289
+ {{ selectedView?.title ?? viewTitle }}
183
290
  </p>
184
- <p class="rm-alert-form__hint">{{ T.viewHint }}</p>
291
+
292
+ <p v-if="scopeCount && selectedViewId" class="rm-alert-form__count">
293
+ {{ scopeCount }} {{ T.scope }}
294
+ </p>
295
+ <p v-if="!selectedViewId" class="rm-alert-form__hint">{{ T.screenNote }}</p>
185
296
  </div>
186
297
 
187
298
  <fieldset class="rm-alert-form__group">
188
299
  <legend class="rm-alert-form__label">{{ T.triggers }}</legend>
300
+ <p class="rm-alert-form__hint">{{ T.triggersHint }}</p>
189
301
  <RmCheckbox
190
302
  v-for="entry in [
191
303
  { key: 'appeared', label: T.appeared, hint: T.appearedHint },
@@ -198,20 +310,21 @@ function submit() {
198
310
  :tooltip="entry.hint"
199
311
  @update:model-value="toggleTrigger(entry.key as RmAlertTrigger)"
200
312
  />
201
- <p class="rm-alert-form__hint">{{ T.triggersHint }}</p>
202
313
  </fieldset>
203
314
 
204
315
  <fieldset v-if="watchesChanges" class="rm-alert-form__group">
205
316
  <legend class="rm-alert-form__label">{{ T.fields }}</legend>
317
+ <p class="rm-alert-form__hint">{{ anyField ? T.anyFieldHint : T.fieldsHint }}</p>
206
318
  <template v-if="fields.length">
319
+ <RmCheckbox v-model="anyField" :label="T.anyField" />
207
320
  <RmCheckbox
208
321
  v-for="field in fields"
209
322
  :key="field.value"
210
- :model-value="watchedFields.includes(field.value)"
323
+ :model-value="anyField || watchedFields.includes(field.value)"
211
324
  :label="field.label"
325
+ :disabled="anyField"
212
326
  @update:model-value="toggleField(field.value)"
213
327
  />
214
- <p class="rm-alert-form__hint">{{ T.fieldsHint }}</p>
215
328
  <p v-if="missingFields" class="rm-alert-form__error">{{ T.fieldsRequired }}</p>
216
329
  </template>
217
330
  <p v-else class="rm-alert-form__hint">{{ T.noFields }}</p>
@@ -223,21 +336,20 @@ function submit() {
223
336
  <RmCheckbox v-model="emailOn" :label="T.email" />
224
337
 
225
338
  <div v-if="emailOn" class="rm-alert-form__nested">
339
+ <p class="rm-alert-form__label">{{ T.cadence }}</p>
340
+ <p class="rm-alert-form__hint">{{ T.cadenceHint }}</p>
226
341
  <RmDropdownSelect
227
- :label="T.cadence"
228
- :options="[T.weekly, T.daily]"
229
- :model-value="cadence === 'weekly' ? T.weekly : T.daily"
230
- @update:model-value="cadence = $event === T.daily ? 'daily' : 'weekly'"
342
+ label=""
343
+ :options="cadenceOptions.map((option) => option.label)"
344
+ :model-value="cadenceLabel"
345
+ @update:model-value="
346
+ cadence =
347
+ cadenceOptions.find((option) => option.label === $event)?.value ?? 'weekly'
348
+ "
231
349
  />
232
- <p class="rm-alert-form__hint">{{ T.cadenceHint }}</p>
233
350
  </div>
234
351
  </fieldset>
235
352
 
236
- <fieldset class="rm-alert-form__group">
237
- <legend class="rm-alert-form__label">{{ T.visibility }}</legend>
238
- <RmCheckbox v-model="shared" :label="T.workspace" />
239
- </fieldset>
240
-
241
353
  <p class="rm-alert-form__baseline">
242
354
  <RmIcon name="Info" :size="14" />
243
355
  {{ T.baseline }}
@@ -258,5 +370,5 @@ function submit() {
258
370
  </template>
259
371
 
260
372
  <style scoped>
261
- .rm-alert-form{display:flex;flex-direction:column;gap:18px}.rm-alert-form__label{color:var(--rm-text,#111827);font-size:13px;font-weight:600;margin:0 0 6px;padding:0}.rm-alert-form__group{border:0;display:flex;flex-direction:column;gap:8px;margin:0;padding:0}.rm-alert-form__view-name{align-items:center;color:var(--rm-text,#111827);display:flex;flex-wrap:wrap;font-size:14px;gap:6px;margin:0}.rm-alert-form__count{color:var(--rm-text-soft,#9ca3af);font-size:11.5px}.rm-alert-form__hint{color:var(--rm-text-soft,#9ca3af);font-size:12px;line-height:1.45;margin:0}.rm-alert-form__error{color:var(--rm-danger,#b91c1c);font-size:12px;margin:0}.rm-alert-form__nested{display:flex;flex-direction:column;gap:6px;padding-left:26px}.rm-alert-form__baseline{align-items:flex-start;background:var(--rm-surface-muted,#f9fafb);border-radius:8px;color:var(--rm-text-soft,#4b5563);display:flex;font-size:12.5px;gap:8px;line-height:1.45;margin:0;padding:10px 12px}.rm-alert-form__actions{display:flex;gap:8px;justify-content:flex-end}
373
+ .rm-alert-form{display:flex;flex-direction:column;gap:18px}.rm-alert-form__label{color:var(--rm-text,#111827);font-size:13px;font-weight:600;margin:0 0 2px;padding:0}.rm-alert-form__group{border:0;display:flex;flex-direction:column;gap:8px;margin:0;padding:0}.rm-alert-form__view-name{align-items:center;color:var(--rm-text,#111827);display:flex;flex-wrap:wrap;font-size:14px;gap:6px;margin:0}.rm-alert-form__count{color:var(--rm-text-soft,#9ca3af);font-size:11.5px}.rm-alert-form__hint{color:var(--rm-text-soft,#9ca3af);font-size:12px;line-height:1.45;margin:0 0 4px}.rm-alert-form__error{color:var(--rm-danger,#b91c1c);font-size:12px;margin:0}.rm-alert-form__nested{display:flex;flex-direction:column;gap:6px;padding-left:26px}.rm-alert-form__baseline{align-items:flex-start;background:var(--rm-surface-muted,#f9fafb);border-radius:8px;color:var(--rm-text-soft,#4b5563);display:flex;font-size:12.5px;gap:8px;line-height:1.45;margin:0;padding:10px 12px}.rm-alert-form__actions{display:flex;gap:8px;justify-content:flex-end}
262
374
  </style>
@@ -101,5 +101,5 @@ const modalStyle = computed(() => {
101
101
  </template>
102
102
 
103
103
  <style scoped>
104
- .rm-modal-overlay{align-items:center;backdrop-filter:blur(2px);background:rgba(0,0,0,.4);display:flex;inset:0;justify-content:center;position:fixed;z-index:100}.rm-modal{background:var(--bg-surface);border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.15);display:flex;flex-direction:column;max-height:90vh;max-width:95vw}.rm-modal__header{align-items:flex-start;display:flex;gap:var(--space-16);justify-content:space-between;padding:var(--space-20) var(--space-24) 0}.rm-modal__header-content{flex:1;min-width:0}.rm-modal__close{align-items:center;background:none;border:none;border-radius:var(--r-radius-md);color:var(--text-muted);cursor:pointer;display:flex;flex-shrink:0;height:32px;justify-content:center;transition:all var(--r-transition-fast);width:32px}.rm-modal__close:hover{background:var(--bg-surface-3);color:var(--text-primary)}.rm-modal__tabs{padding:0}.rm-modal__body{flex:1;overflow-y:auto;padding:var(--space-24)}.rm-modal__footer{border-top:1px solid var(--border-soft);padding:var(--space-16) var(--space-24)}.rm-modal-enter-active,.rm-modal-leave-active{transition:opacity .2s ease}.rm-modal-enter-active .rm-modal,.rm-modal-leave-active .rm-modal{transition:transform .2s ease}.rm-modal-enter-from,.rm-modal-leave-to{opacity:0}.rm-modal-enter-from .rm-modal,.rm-modal-leave-to .rm-modal{transform:scale(.95)}.rm-modal-leave-active,.rm-modal-leave-to{pointer-events:none}
104
+ .rm-modal-overlay{align-items:center;backdrop-filter:blur(2px);background:rgba(0,0,0,.4);display:flex;font-family:var(--r-font-family);inset:0;justify-content:center;position:fixed;z-index:100}.rm-modal{background:var(--bg-surface);border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.15);display:flex;flex-direction:column;max-height:90vh;max-width:95vw}.rm-modal__header{align-items:flex-start;display:flex;gap:var(--space-16);justify-content:space-between;padding:var(--space-20) var(--space-24) 0}.rm-modal__header-content{flex:1;min-width:0}.rm-modal__close{align-items:center;background:none;border:none;border-radius:var(--r-radius-md);color:var(--text-muted);cursor:pointer;display:flex;flex-shrink:0;height:32px;justify-content:center;transition:all var(--r-transition-fast);width:32px}.rm-modal__close:hover{background:var(--bg-surface-3);color:var(--text-primary)}.rm-modal__tabs{padding:0}.rm-modal__body{flex:1;overflow-y:auto;padding:var(--space-24)}.rm-modal__footer{border-top:1px solid var(--border-soft);padding:var(--space-16) var(--space-24)}.rm-modal-enter-active,.rm-modal-leave-active{transition:opacity .2s ease}.rm-modal-enter-active .rm-modal,.rm-modal-leave-active .rm-modal{transition:transform .2s ease}.rm-modal-enter-from,.rm-modal-leave-to{opacity:0}.rm-modal-enter-from .rm-modal,.rm-modal-leave-to .rm-modal{transform:scale(.95)}.rm-modal-leave-active,.rm-modal-leave-to{pointer-events:none}
105
105
  </style>
@@ -6,6 +6,14 @@
6
6
  * importer un `.vue`. Meme raison que pour `utils/export.ts`.
7
7
  */
8
8
  export type RmAlertStatus = 'seeding' | 'active' | 'paused' | 'stale' | 'failing';
9
+ /** Plafond d'envoi du mail, jamais une promesse de frequence. */
10
+ export type RmAlertCadence = 'daily' | 'weekly' | 'monthly';
11
+ /**
12
+ * Surveiller le document entier plutot qu'une liste de champs. Les champs
13
+ * d'ingestion (horodatage, date de collecte) restent exclus cote moteur,
14
+ * sinon chaque reindexation ferait bouger chaque document.
15
+ */
16
+ export declare const RM_ALERT_ANY_FIELD = "*";
9
17
  /** Ce qu'une alerte sait detecter. Les trois sortent du meme passage de diff. */
10
18
  export type RmAlertTrigger = 'appeared' | 'changed' | 'disappeared';
11
19
  export interface RmAlertMenuItem {
@@ -66,12 +74,22 @@ export interface RmAlertField {
66
74
  value: string;
67
75
  label: string;
68
76
  }
77
+ /** Une vue proposee dans le selecteur du formulaire. */
78
+ export interface RmAlertViewOption {
79
+ id: string;
80
+ title: string;
81
+ }
69
82
  /** Ce que le formulaire de creation rend a l'appelant. */
70
83
  export interface RmAlertDraft {
71
84
  title: string;
85
+ /**
86
+ * Vue a surveiller. `null` : l'ecran actuel, que l'appelant enregistrera
87
+ * comme vue avant de creer l'alerte.
88
+ */
89
+ viewId: string | null;
72
90
  triggers: RmAlertTrigger[];
73
91
  watchedFields: string[];
74
92
  channels: string[];
75
- cadence: 'daily' | 'weekly';
93
+ cadence: RmAlertCadence;
76
94
  visibility: 'private' | 'workspace';
77
95
  }
@@ -0,0 +1 @@
1
+ export const RM_ALERT_ANY_FIELD = "*";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.104",
3
+ "version": "0.1.105",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",