@saooti/octopus-sdk 41.12.0-beta2 → 41.12.0-beta3

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.
@@ -0,0 +1 @@
1
+ {"sessionId":"92d5c13f-8d38-4dc6-a66d-c7256640dc6f","pid":10553,"procStart":"32347","acquiredAt":1783406560303}
package/CHANGELOG.md CHANGED
@@ -15,6 +15,8 @@
15
15
  - Mise en commun de code entre `OctopusSelect` & `OctopusMultiselect`
16
16
  - Ajustement de `OctopusMultiselect` pour fonctionner correctement avec des
17
17
  objets identiques avec références différentes
18
+ - Ajout de nouvelles options à `OctopusMultiselect` pour permettre la saisie
19
+ de données et avoir une meilleure UX en cas de longue liste
18
20
  - Ajout de nouvelles classes utilitaires dans le css
19
21
  - Ajout de nouvelles options d'apparence pour `ClassicButton`
20
22
  - Changement propriété principale de `ClassicButtonGroup` pour plus de cohérence
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saooti/octopus-sdk",
3
- "version": "41.12.0-beta2",
3
+ "version": "41.12.0-beta3",
4
4
  "private": false,
5
5
  "description": "Javascript SDK for using octopus",
6
6
  "author": "Saooti",
@@ -51,7 +51,7 @@
51
51
  "typescript-eslint": "^8.47.0",
52
52
  "video.js": "^8.23.6",
53
53
  "videojs-quality-selector-hls": "^1.1.1",
54
- "vite": "^6.4.1",
54
+ "vite": "^8.0.16",
55
55
  "vite-bundle-visualizer": "^1.2.1",
56
56
  "vue-material-design-icons": "^5.3.1",
57
57
  "vue-recaptcha": "^2.0.3",
@@ -64,7 +64,7 @@
64
64
  "@stylistic/eslint-plugin": "*",
65
65
  "@types/sockjs-client": "^1.5.4",
66
66
  "@types/webpack-env": "^1.18.8",
67
- "@vitejs/plugin-vue": "^5.2.4",
67
+ "@vitejs/plugin-vue": "^6.0.7",
68
68
  "@vue/test-utils": "^2.4.6",
69
69
  "eslint": "^9.39.1",
70
70
  "eslint-plugin-vue": "10.9.1",
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
4
4
 
5
5
  export interface OctopusDropdownProps<T> {
6
6
  options: T[];
7
- optionLabel: keyof T & string;
7
+ optionLabel?: keyof T & string;
8
8
  optionKey?: keyof T;
9
9
  isDisabled?: boolean;
10
10
  placeholder?: string;
@@ -51,7 +51,10 @@ export function useOctopusDropdown<T>(
51
51
  const inputPlaceholder = computed(() => props.placeholder ?? t('Search'));
52
52
 
53
53
  function getLabel(option: T): string {
54
- return option[props.optionLabel] as string;
54
+ if (props.optionLabel) {
55
+ return option[props.optionLabel] as string;
56
+ }
57
+ return option as unknown as string;
55
58
  }
56
59
 
57
60
  function openDropdown(): void {
@@ -41,6 +41,7 @@
41
41
  :disabled="isDisabled"
42
42
  @focus="openDropdown"
43
43
  @input="handleInput"
44
+ @keydown.enter="handleCustomValueEnter"
44
45
  >
45
46
  <button
46
47
  class="btn-transparent octopus-multiselect-chevron"
@@ -74,14 +75,20 @@
74
75
 
75
76
  <div class="octopus-multiselect-options">
76
77
  <ClassicCheckbox
77
- v-for="(option, index) in displayedOptions"
78
+ v-for="(option, index) in visibleOptions"
78
79
  :key="index"
79
80
  :text-init="isSelected(option)"
80
81
  :label="getLabel(option)"
81
82
  :is-disabled="isDisabled"
82
83
  @update:text-init="toggleOption(option)"
83
84
  />
84
- <span v-if="displayedOptions.length === 0" class="text-indic px-2">
85
+ <template v-if="allowCustomValue && searchQuery.trim()">
86
+ <hr v-if="visibleOptions.length > 0">
87
+ <span class="text-indic px-2">
88
+ {{ t('Press Enter to add this value') }}
89
+ </span>
90
+ </template>
91
+ <span v-else-if="visibleOptions.length === 0" class="text-indic px-2">
85
92
  {{ t('No elements found. Consider changing the search query.') }}
86
93
  </span>
87
94
  </div>
@@ -106,8 +113,16 @@ const props = defineProps<{
106
113
  options: T[];
107
114
  /** Key of each option object to use as the ID */
108
115
  optionKey?: keyof T;
109
- /** Key of each option object to use as the display label. */
110
- optionLabel: keyof T & string;
116
+ /** Key of each option object to use as the display label. Omit when `options` is a
117
+ * plain `string[]` each string is used as its own label. Note: omitting this for
118
+ * an object array is a runtime mistake (not caught at compile time) and will render
119
+ * "[object Object]". */
120
+ optionLabel?: keyof T & string;
121
+ /** When true, pressing Enter in the search input adds the typed text as a new
122
+ * selected value, even if it doesn't match any option. Intended for use when
123
+ * options are plain strings (`optionLabel` omitted) — casting arbitrary typed
124
+ * text into an object-shaped T would not produce a valid option. */
125
+ allowCustomValue?: boolean;
111
126
  /** Disables the field and all checkboxes when true. */
112
127
  isDisabled?: boolean;
113
128
  /** Placeholder shown in the input when no items are selected. Defaults to the translated "Search" string. */
@@ -118,6 +133,10 @@ const props = defineProps<{
118
133
  noBorder?: boolean;
119
134
  /** When true, hovering the closed field with overflow shows a tooltip listing all selected items. */
120
135
  expandOnHover?: boolean;
136
+ /** When true, options selected at the moment the dropdown opens are moved to the
137
+ * top of the list. This is a snapshot taken at open time — it does not live-reorder
138
+ * while the dropdown stays open, only on the next closed→open transition. */
139
+ pullSelectedToTop?: boolean;
121
140
  }>();
122
141
 
123
142
  const emit = defineEmits<{
@@ -166,11 +185,35 @@ function updateDropdownPosition(): void {
166
185
  }
167
186
  const visibleCount = ref(2);
168
187
 
188
+ // Selection snapshot captured the instant the dropdown opens, used only to freeze the
189
+ // sort order when pullSelectedToTop is set — does not react to later `selected` changes
190
+ // while the dropdown stays open.
191
+ const pinnedSnapshot = ref<T[]>([]);
192
+
193
+ // Selected values not present in `options` — only populated when allowCustomValue is set,
194
+ // so a custom value typed via handleCustomValueEnter still shows (checked) in the dropdown.
195
+ const customSelectedOptions = computed<T[]>(() => {
196
+ if (!props.allowCustomValue) { return []; }
197
+ return (props.selected ?? []).filter((item) => !isInOptions(item));
198
+ });
199
+
200
+ const visibleOptions = computed<T[]>(() => {
201
+ const query = searchQuery.value.toLowerCase();
202
+ const filteredCustom = query
203
+ ? customSelectedOptions.value.filter((option) => getLabel(option).toLowerCase().includes(query))
204
+ : customSelectedOptions.value;
205
+ const combined = [...displayedOptions.value, ...filteredCustom];
206
+ if (!props.pullSelectedToTop) {
207
+ return combined;
208
+ }
209
+ return [...combined.filter(isPinned), ...combined.filter((option) => !isPinned(option))];
210
+ });
211
+
169
212
  const allSelected = computed(() => {
170
- if (displayedOptions.value.length === 0) {
213
+ if (visibleOptions.value.length === 0) {
171
214
  return false;
172
215
  }
173
- return displayedOptions.value.every((option) => isSelected(option));
216
+ return visibleOptions.value.every((option) => isSelected(option));
174
217
  });
175
218
 
176
219
  const hasSelected = computed(() => (props.selected?.length ?? 0) > 0);
@@ -195,6 +238,22 @@ function isSelected(option: T): boolean {
195
238
  }
196
239
  }
197
240
 
241
+ function isInOptions(option: T): boolean {
242
+ if (props.optionKey) {
243
+ return props.options.some((opt) => opt[props.optionKey] === option[props.optionKey]);
244
+ } else {
245
+ return props.options.includes(option);
246
+ }
247
+ }
248
+
249
+ function isPinned(option: T): boolean {
250
+ if (props.optionKey) {
251
+ return pinnedSnapshot.value.some((s) => s[props.optionKey] === option[props.optionKey]);
252
+ } else {
253
+ return pinnedSnapshot.value.includes(option);
254
+ }
255
+ }
256
+
198
257
  function toggleOption(option: T): void {
199
258
  const current = props.selected ?? [];
200
259
  if (isSelected(option)) {
@@ -208,16 +267,27 @@ function toggleOption(option: T): void {
208
267
  }
209
268
  }
210
269
 
270
+ function handleCustomValueEnter(): void {
271
+ if (!props.allowCustomValue) { return; }
272
+ const value = searchQuery.value.trim();
273
+ if (!value) { return; }
274
+ const customOption = value as unknown as T;
275
+ if (!isSelected(customOption)) {
276
+ emit('update:selected', [...(props.selected ?? []), customOption]);
277
+ }
278
+ searchQuery.value = '';
279
+ }
280
+
211
281
  function toggleAll(val: boolean): void {
212
282
  const current = props.selected ?? [];
213
283
  if (val) {
214
- const toAdd = displayedOptions.value.filter((option: T) => !isSelected(option));
284
+ const toAdd = visibleOptions.value.filter((option: T) => !isSelected(option));
215
285
  emit('update:selected', [...current, ...toAdd]);
216
286
  } else {
217
287
  const key = props.optionKey;
218
288
  emit('update:selected', current.filter((item: T) => key
219
- ? !displayedOptions.value.some((opt) => opt[key] === item[key])
220
- : !displayedOptions.value.includes(item)
289
+ ? !visibleOptions.value.some((opt) => opt[key] === item[key])
290
+ : !visibleOptions.value.includes(item)
221
291
  ));
222
292
  }
223
293
  }
@@ -287,6 +357,7 @@ watch(() => props.selected, updateVisibleCount);
287
357
 
288
358
  watch(isOpen, (val) => {
289
359
  if (val) {
360
+ pinnedSnapshot.value = [...(props.selected ?? [])];
290
361
  nextTick(updateDropdownPosition);
291
362
  } else {
292
363
  nextTick(updateVisibleCount);
@@ -403,4 +474,10 @@ watch(isOpen, (val) => {
403
474
  padding: 0.25rem 0.5rem;
404
475
  }
405
476
  }
477
+
478
+ hr {
479
+ border-top: 1px solid var(--octopus-secondary);
480
+ border-bottom: none;
481
+ margin: 0;
482
+ }
406
483
  </style>
@@ -51,7 +51,7 @@
51
51
  >
52
52
  <div class="octopus-select-options">
53
53
  <button
54
- v-for="(option, index) in displayedOptions"
54
+ v-for="(option, index) in visibleOptions"
55
55
  :key="index"
56
56
  class="octopus-select-option"
57
57
  :class="{ selected: isSelected(option) }"
@@ -59,7 +59,7 @@
59
59
  >
60
60
  {{ getLabel(option) }}
61
61
  </button>
62
- <span v-if="displayedOptions.length === 0" class="text-indic px-2">
62
+ <span v-if="visibleOptions.length === 0" class="text-indic px-2">
63
63
  {{ t('No elements found. Consider changing the search query.') }}
64
64
  </span>
65
65
  </div>
@@ -93,6 +93,10 @@ const props = withDefaults(defineProps<{
93
93
  noBorder?: boolean;
94
94
  /** When true (default), clicking the already-selected option clears the selection. */
95
95
  allowDeselect?: boolean;
96
+ /** When true, the option matching the current value is moved to the top of the list
97
+ * the moment the dropdown opens. This is a snapshot taken at open time — it does not
98
+ * live-reorder while the dropdown stays open, only on the next closed→open transition. */
99
+ pullSelectedToTop?: boolean;
96
100
  }>(), {
97
101
  label: undefined,
98
102
  value: undefined,
@@ -144,8 +148,14 @@ function updateDropdownPosition(): void {
144
148
  };
145
149
  }
146
150
 
151
+ // Value snapshot captured the instant the dropdown opens, used only to freeze the
152
+ // sort order when pullSelectedToTop is set — does not react to later `value` changes
153
+ // while the dropdown stays open.
154
+ const pinnedValue = ref<T | undefined>(undefined);
155
+
147
156
  watch(isOpen, (val) => {
148
157
  if (val) {
158
+ pinnedValue.value = props.value;
149
159
  nextTick(updateDropdownPosition);
150
160
  }
151
161
  });
@@ -175,6 +185,23 @@ function isSelected(option: T): boolean {
175
185
  return toRaw(props.value as object) === toRaw(option as object);
176
186
  }
177
187
 
188
+ function isPinned(option: T): boolean {
189
+ if (pinnedValue.value === undefined) {
190
+ return false;
191
+ }
192
+ if (props.optionKey) {
193
+ return pinnedValue.value[props.optionKey] === option[props.optionKey];
194
+ }
195
+ return toRaw(pinnedValue.value as object) === toRaw(option as object);
196
+ }
197
+
198
+ const visibleOptions = computed<T[]>(() => {
199
+ if (!props.pullSelectedToTop) {
200
+ return displayedOptions.value;
201
+ }
202
+ return [...displayedOptions.value.filter(isPinned), ...displayedOptions.value.filter((option) => !isPinned(option))];
203
+ });
204
+
178
205
  function selectOption(option: T): void {
179
206
  if (isSelected(option) && props.allowDeselect) {
180
207
  emit('update:value', undefined);
@@ -31,6 +31,7 @@
31
31
  "Emission image": "Bild zur Reihe",
32
32
  "Emission name": "Titel der Reihe",
33
33
  "No elements found. Consider changing the search query.": "Keine Ergebnisse. Bitte ändern Sie die Suchanfrage.",
34
+ "Press Enter to add this value": "Drücken Sie Enter, um diesen Wert hinzuzufügen",
34
35
  "Podcast is not visible for listeners": "Podcast ist für Hörer verborgen",
35
36
  "Validate": "Bestätigen",
36
37
  "Invalidate": "Invalidate",
@@ -31,6 +31,7 @@
31
31
  "Emission image": "Series image",
32
32
  "Emission name": "Series title",
33
33
  "No elements found. Consider changing the search query.": "No elements found. Consider changing the search query.",
34
+ "Press Enter to add this value": "Press Enter to add this value",
34
35
  "Podcast is not visible for listeners": "Podcast is not visible for listeners",
35
36
  "Validate": "Validate",
36
37
  "Invalidate": "Invalidate",
@@ -31,6 +31,7 @@
31
31
  "Emission image": "Imagen del programa",
32
32
  "Emission name": "Título del programa",
33
33
  "No elements found. Consider changing the search query.": "No hay resultados. Prueba con otros criterios de búsqueda.",
34
+ "Press Enter to add this value": "Pulsa Intro para añadir este valor",
34
35
  "Podcast is not visible for listeners": "El público no puede visualizar este pódcast",
35
36
  "Validate": "Validar",
36
37
  "Invalidate": "Invalidar",
@@ -37,6 +37,7 @@
37
37
  "Emission subtitle": "Sous-titre de l'émission",
38
38
  "No elements found. Consider changing the search query.":
39
39
  "Aucun élement ne correspond à votre recherche",
40
+ "Press Enter to add this value": "Appuyez sur Entrée pour ajouter cette valeur",
40
41
  "Podcast is not visible for listeners":
41
42
  "L'épisode n'est pas disponible pour les auditeurs",
42
43
  "Validate": "Valider",
@@ -30,6 +30,7 @@
30
30
  "Emission image": "Immagine della serie",
31
31
  "Emission name": "Titolo della serie",
32
32
  "No elements found. Consider changing the search query.": "Non è stato trovato alcun elemento. Prova a cambiare i parametri di ricerca",
33
+ "Press Enter to add this value": "Premi Invio per aggiungere questo valore",
33
34
  "Podcast is not visible for listeners": "Il podcast non è visibile agli ascoltatori",
34
35
  "Validate": "Convalidare",
35
36
  "Invalidate": "Invalidate",
@@ -31,6 +31,7 @@
31
31
  "Emission image": "Slika oddaje",
32
32
  "Emission name": "Naslov oddaje",
33
33
  "No elements found. Consider changing the search query.": "Ni zadetkov. Spremenite poizvedbo",
34
+ "Press Enter to add this value": "Pritisnite Enter, da dodate to vrednost",
34
35
  "Podcast is not visible for listeners": "Za poslušalce skrit podkast",
35
36
  "Validate": "Potrdi",
36
37
  "Invalidate": "Invalidate",
@@ -24,12 +24,18 @@ export type OrganisationAttributes = {
24
24
  PRIVATE?: string;
25
25
  /** Live recordings enabled */
26
26
  'live.active'?: boolean;
27
+ /** Videos enabled */
28
+ 'video.active'?: boolean;
27
29
  /**
28
30
  * List of rubriquage **names**.
29
31
  * The rubriques from these rubriquage are stored in stats and can be queried
30
32
  * upon.
31
33
  */
32
34
  rubriquage4MMIdentifier?: string;
35
+ /**
36
+ * Enable individualized stats
37
+ */
38
+ nominal_analytic?: string;
33
39
  };
34
40
 
35
41
  export interface Organisation {
@@ -49,6 +49,8 @@
49
49
  --octopus-btn-play-bg: var(--octopus-primary-less-transparent);
50
50
  --octopus-btn-play-fg: white;
51
51
  --octopus-btn-play-radius: var(--octopus-border-radius);
52
+ --octopus-btn-social-bg: var(--octopus-secondary);
53
+ --octopus-btn-social-fg: var(--octopus-primary);
52
54
 
53
55
  // Player
54
56
  // Color for the transcript background
@@ -154,7 +154,7 @@ input:not([class^="vs__"]), button:not([class^="vs__"]), select:not([class^="vs_
154
154
  align-items: center;
155
155
  justify-content: center;
156
156
  background: var(--octopus-btn-primary-bg);
157
- border: 1px solid var(--octopus-primary);
157
+ border: 1px solid var(--octopus-btn-primary-bg);
158
158
  border-radius: var(--octopus-border-radius) !important;
159
159
  color: var(--octopus-btn-primary-fg) !important;
160
160
  font-weight: 500;
@@ -234,7 +234,8 @@ input:not([class^="vs__"]), button:not([class^="vs__"]), select:not([class^="vs_
234
234
  width: 2.5rem !important;
235
235
  height: 2.5rem !important;
236
236
  padding: 0.5rem;
237
- color : var(--octopus-primary);
237
+ color : var(--octopus-btn-social-fg);
238
+ background-color : var(--octopus-btn-social-bg);
238
239
  flex-shrink: 0;
239
240
  border-radius: 50% !important;
240
241
 
@@ -3,6 +3,7 @@ import '@tests/mocks/i18n';
3
3
  import OctopusMultiselect from '@/components/form/OctopusMultiselect.vue';
4
4
  import { DOMWrapper, type VueWrapper } from '@vue/test-utils';
5
5
  import { mount } from '@tests/utils';
6
+ import { nextTick } from 'vue';
6
7
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
8
 
8
9
  const options = [
@@ -16,6 +17,20 @@ function getDropdown(): Element | null {
16
17
  return document.body.querySelector('.octopus-multiselect-dropdown');
17
18
  }
18
19
 
20
+ function getOptionLabels(): (string | undefined)[] {
21
+ return Array.from(document.body.querySelectorAll('.octopus-multiselect-options label'))
22
+ .map((l) => l.textContent?.trim());
23
+ }
24
+
25
+ // Simulates a click outside the component to trigger onClickOutside's closeDropdown.
26
+ async function clickOutside(): Promise<void> {
27
+ const outsideElement = document.createElement('div');
28
+ document.body.appendChild(outsideElement);
29
+ outsideElement.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
30
+ await nextTick();
31
+ document.body.removeChild(outsideElement);
32
+ }
33
+
19
34
  describe('OctopusMultiselect', () => {
20
35
  // The dropdown teleports to .octopus-app, which is normally the app root (see App.vue).
21
36
  // It doesn't exist in the test DOM, so it must be created before mount.
@@ -204,4 +219,287 @@ describe('OctopusMultiselect', () => {
204
219
  await new DOMWrapper(allCheckbox).trigger('input');
205
220
  expect(wrapper.emitted('update:selected')?.[0]).toEqual([[]]);
206
221
  });
222
+
223
+ describe('pullSelectedToTop', () => {
224
+ it('does not reorder options when pullSelectedToTop is not set', async () => {
225
+ wrapper = await mount(OctopusMultiselect, {
226
+ props: { options, optionLabel: 'name', selected: [options[1]] },
227
+ });
228
+ await wrapper.find('input').trigger('focus');
229
+ expect(getOptionLabels()).toEqual(['Alpha', 'Beta', 'Gamma']);
230
+ });
231
+
232
+ it('moves the selected option to the top when pullSelectedToTop is true', async () => {
233
+ wrapper = await mount(OctopusMultiselect, {
234
+ props: { options, optionLabel: 'name', selected: [options[1]], pullSelectedToTop: true },
235
+ });
236
+ await wrapper.find('input').trigger('focus');
237
+ expect(getOptionLabels()).toEqual(['Beta', 'Alpha', 'Gamma']);
238
+ });
239
+
240
+ it('preserves relative order within pinned and unpinned groups', async () => {
241
+ wrapper = await mount(OctopusMultiselect, {
242
+ props: { options, optionLabel: 'name', selected: [options[2], options[0]], pullSelectedToTop: true },
243
+ });
244
+ await wrapper.find('input').trigger('focus');
245
+ expect(getOptionLabels()).toEqual(['Alpha', 'Gamma', 'Beta']);
246
+ });
247
+
248
+ it('does not reorder while the dropdown stays open as selection changes', async () => {
249
+ wrapper = await mount(OctopusMultiselect, {
250
+ props: { options, optionLabel: 'name', selected: [], pullSelectedToTop: true },
251
+ });
252
+ await wrapper.find('input').trigger('focus');
253
+ const optionCheckboxes = document.body.querySelectorAll('.octopus-multiselect-options input[type="checkbox"]');
254
+ await new DOMWrapper(optionCheckboxes[2] as Element).trigger('input');
255
+ await wrapper.setProps({ selected: [options[2]] });
256
+ expect(getOptionLabels()).toEqual(['Alpha', 'Beta', 'Gamma']);
257
+ });
258
+
259
+ it('re-pins using the latest selection after closing and reopening', async () => {
260
+ wrapper = await mount(OctopusMultiselect, {
261
+ props: { options, optionLabel: 'name', selected: [options[2]], pullSelectedToTop: true },
262
+ });
263
+ await wrapper.find('input').trigger('focus');
264
+ await clickOutside();
265
+ await wrapper.find('input').trigger('focus');
266
+ expect(getOptionLabels()).toEqual(['Gamma', 'Alpha', 'Beta']);
267
+ });
268
+
269
+ it('pins by optionKey even when selected objects are different references', async () => {
270
+ const selected = [{ id: 2, name: 'Beta' }];
271
+ wrapper = await mount(OctopusMultiselect, {
272
+ props: { options, optionLabel: 'name', optionKey: 'id', selected, pullSelectedToTop: true },
273
+ });
274
+ await wrapper.find('input').trigger('focus');
275
+ expect(getOptionLabels()).toEqual(['Beta', 'Alpha', 'Gamma']);
276
+ });
277
+
278
+ it('still respects the active search filter when pinning', async () => {
279
+ wrapper = await mount(OctopusMultiselect, {
280
+ props: { options, optionLabel: 'name', selected: [options[2]], pullSelectedToTop: true },
281
+ });
282
+ await wrapper.find('input').trigger('focus');
283
+ await wrapper.find('input').setValue('be');
284
+ await wrapper.find('input').trigger('input');
285
+ expect(getOptionLabels()).toEqual(['Beta']);
286
+ });
287
+
288
+ it('keeps a selected custom value pinned alongside allowCustomValue', async () => {
289
+ wrapper = await mount(OctopusMultiselect, {
290
+ props: {
291
+ options: ['Alpha', 'Beta', 'Gamma'],
292
+ selected: ['Delta'],
293
+ allowCustomValue: true,
294
+ pullSelectedToTop: true,
295
+ },
296
+ });
297
+ await wrapper.find('input').trigger('focus');
298
+ expect(getOptionLabels()).toEqual(['Delta', 'Alpha', 'Beta', 'Gamma']);
299
+ });
300
+ });
301
+
302
+ describe('with string options', () => {
303
+ const stringOptions = ['Alpha', 'Beta', 'Gamma'];
304
+
305
+ it('uses each string as its own label without optionLabel', async () => {
306
+ wrapper = await mount(OctopusMultiselect, {
307
+ props: { options: stringOptions },
308
+ });
309
+ await wrapper.find('input').trigger('focus');
310
+ const labels = document.body.querySelectorAll('.octopus-multiselect-options label');
311
+ expect(Array.from(labels).map((l) => l.textContent?.trim())).toEqual(stringOptions);
312
+ });
313
+
314
+ it('emits update:selected when toggling a string option', async () => {
315
+ wrapper = await mount(OctopusMultiselect, {
316
+ props: { options: stringOptions, selected: [] },
317
+ });
318
+ await wrapper.find('input').trigger('focus');
319
+ const optionCheckboxes = document.body.querySelectorAll('.octopus-multiselect-options input[type="checkbox"]');
320
+ await new DOMWrapper(optionCheckboxes[0] as Element).trigger('input');
321
+ expect(wrapper.emitted('update:selected')?.[0]).toEqual([['Alpha']]);
322
+ });
323
+
324
+ it('deselects an already-selected string option', async () => {
325
+ wrapper = await mount(OctopusMultiselect, {
326
+ props: { options: stringOptions, selected: ['Alpha'] },
327
+ });
328
+ await wrapper.find('input').trigger('focus');
329
+ const optionCheckboxes = document.body.querySelectorAll('.octopus-multiselect-options input[type="checkbox"]');
330
+ await new DOMWrapper(optionCheckboxes[0] as Element).trigger('input');
331
+ expect(wrapper.emitted('update:selected')?.[0]).toEqual([[]]);
332
+ });
333
+
334
+ it('filters string options locally by search query', async () => {
335
+ wrapper = await mount(OctopusMultiselect, {
336
+ props: { options: stringOptions },
337
+ });
338
+ await wrapper.find('input').trigger('focus');
339
+ await wrapper.find('input').setValue('al');
340
+ await wrapper.find('input').trigger('input');
341
+ const checkboxes = document.body.querySelectorAll('.octopus-multiselect-options input[type="checkbox"]');
342
+ expect(checkboxes).toHaveLength(1);
343
+ });
344
+
345
+ it('toggleAll selects all displayed string options', async () => {
346
+ wrapper = await mount(OctopusMultiselect, {
347
+ props: { options: stringOptions, selected: [] },
348
+ });
349
+ await wrapper.find('input').trigger('focus');
350
+ const allCheckbox = document.body.querySelector('.octopus-multiselect-dropdown > .octopus-form-item input[type="checkbox"]')!;
351
+ await new DOMWrapper(allCheckbox).trigger('input');
352
+ expect(wrapper.emitted('update:selected')?.[0]).toEqual([stringOptions]);
353
+ });
354
+
355
+ it('shows selected strings in the selection display', async () => {
356
+ wrapper = await mount(OctopusMultiselect, {
357
+ props: { options: stringOptions, selected: ['Alpha', 'Beta'] },
358
+ });
359
+ expect(wrapper.find('.octopus-multiselect-selection-text').text()).toBe('Alpha, Beta');
360
+ });
361
+
362
+ it('adds typed value as a new selection on Enter when allowCustomValue is true', async () => {
363
+ wrapper = await mount(OctopusMultiselect, {
364
+ props: { options: stringOptions, selected: [], allowCustomValue: true },
365
+ });
366
+ await wrapper.find('input').trigger('focus');
367
+ await wrapper.find('input').setValue('Delta');
368
+ await wrapper.find('input').trigger('keydown.enter');
369
+ expect(wrapper.emitted('update:selected')?.[0]).toEqual([['Delta']]);
370
+ });
371
+
372
+ it('does not add typed value on Enter when allowCustomValue is not set', async () => {
373
+ wrapper = await mount(OctopusMultiselect, {
374
+ props: { options: stringOptions, selected: [] },
375
+ });
376
+ await wrapper.find('input').trigger('focus');
377
+ await wrapper.find('input').setValue('Delta');
378
+ await wrapper.find('input').trigger('keydown.enter');
379
+ expect(wrapper.emitted('update:selected')).toBeUndefined();
380
+ });
381
+
382
+ it('does not add an empty or whitespace-only value on Enter', async () => {
383
+ wrapper = await mount(OctopusMultiselect, {
384
+ props: { options: stringOptions, selected: [], allowCustomValue: true },
385
+ });
386
+ await wrapper.find('input').trigger('focus');
387
+ await wrapper.find('input').setValue(' ');
388
+ await wrapper.find('input').trigger('keydown.enter');
389
+ expect(wrapper.emitted('update:selected')).toBeUndefined();
390
+ });
391
+
392
+ it('does not duplicate an already-selected value on Enter', async () => {
393
+ wrapper = await mount(OctopusMultiselect, {
394
+ props: { options: stringOptions, selected: ['Alpha'], allowCustomValue: true },
395
+ });
396
+ await wrapper.find('input').trigger('focus');
397
+ await wrapper.find('input').setValue('Alpha');
398
+ await wrapper.find('input').trigger('keydown.enter');
399
+ expect(wrapper.emitted('update:selected')).toBeUndefined();
400
+ });
401
+
402
+ it('clears the search input after adding a custom value on Enter', async () => {
403
+ wrapper = await mount(OctopusMultiselect, {
404
+ props: { options: stringOptions, selected: [], allowCustomValue: true },
405
+ });
406
+ await wrapper.find('input').trigger('focus');
407
+ await wrapper.find('input').setValue('Delta');
408
+ await wrapper.find('input').trigger('keydown.enter');
409
+ expect((wrapper.find('input').element as HTMLInputElement).value).toBe('');
410
+ });
411
+
412
+ it('shows a previously added custom value as a checked option in the dropdown', async () => {
413
+ wrapper = await mount(OctopusMultiselect, {
414
+ props: { options: stringOptions, selected: ['Delta'], allowCustomValue: true },
415
+ });
416
+ await wrapper.find('input').trigger('focus');
417
+ const labels = Array.from(document.body.querySelectorAll('.octopus-multiselect-options label'))
418
+ .map((l) => l.textContent?.trim());
419
+ expect(labels).toContain('Delta');
420
+ const checkboxes = document.body.querySelectorAll('.octopus-multiselect-options input[type="checkbox"]');
421
+ expect((checkboxes[labels.indexOf('Delta')] as HTMLInputElement).checked).toBe(true);
422
+ });
423
+
424
+ it('shows the checkbox for a value right after it is added via Enter', async () => {
425
+ wrapper = await mount(OctopusMultiselect, {
426
+ props: { options: stringOptions, selected: [], allowCustomValue: true },
427
+ });
428
+ await wrapper.find('input').trigger('focus');
429
+ await wrapper.find('input').setValue('Delta');
430
+ await wrapper.find('input').trigger('keydown.enter');
431
+ const emittedSelected = wrapper.emitted('update:selected')?.[0]?.[0] as string[];
432
+ await wrapper.setProps({ selected: emittedSelected });
433
+ const labels = Array.from(document.body.querySelectorAll('.octopus-multiselect-options label'))
434
+ .map((l) => l.textContent?.trim());
435
+ expect(labels).toContain('Delta');
436
+ });
437
+
438
+ it('does not show out-of-options selected values in the dropdown when allowCustomValue is not set', async () => {
439
+ wrapper = await mount(OctopusMultiselect, {
440
+ props: { options: stringOptions, selected: ['Delta'] },
441
+ });
442
+ await wrapper.find('input').trigger('focus');
443
+ const labels = Array.from(document.body.querySelectorAll('.octopus-multiselect-options label'))
444
+ .map((l) => l.textContent?.trim());
445
+ expect(labels).not.toContain('Delta');
446
+ });
447
+
448
+ it('deselects a custom value when its checkbox in the dropdown is toggled off', async () => {
449
+ wrapper = await mount(OctopusMultiselect, {
450
+ props: { options: stringOptions, selected: ['Alpha', 'Delta'], allowCustomValue: true },
451
+ });
452
+ await wrapper.find('input').trigger('focus');
453
+ const checkboxes = document.body.querySelectorAll('.octopus-multiselect-options input[type="checkbox"]');
454
+ // visibleOptions = [Alpha, Beta, Gamma, Delta] — Delta is the appended custom entry
455
+ await new DOMWrapper(checkboxes[3] as Element).trigger('input');
456
+ expect(wrapper.emitted('update:selected')?.[0]).toEqual([['Alpha']]);
457
+ });
458
+
459
+ it('toggleAll includes an already-selected custom value shown in the dropdown', async () => {
460
+ wrapper = await mount(OctopusMultiselect, {
461
+ props: { options: stringOptions, selected: ['Delta'], allowCustomValue: true },
462
+ });
463
+ await wrapper.find('input').trigger('focus');
464
+ const allCheckbox = document.body.querySelector('.octopus-multiselect-dropdown > .octopus-form-item input[type="checkbox"]')!;
465
+ await new DOMWrapper(allCheckbox).trigger('input');
466
+ const emitted = wrapper.emitted('update:selected')?.[0]?.[0] as string[];
467
+ expect(emitted).toHaveLength(4);
468
+ expect(emitted).toEqual(expect.arrayContaining(['Alpha', 'Beta', 'Gamma', 'Delta']));
469
+ });
470
+
471
+ it('shows a hint to press Enter when allowCustomValue is true and text is typed', async () => {
472
+ wrapper = await mount(OctopusMultiselect, {
473
+ props: { options: stringOptions, allowCustomValue: true },
474
+ });
475
+ await wrapper.find('input').trigger('focus');
476
+ await wrapper.find('input').setValue('Delta');
477
+ await wrapper.find('input').trigger('input');
478
+ const hints = Array.from(document.body.querySelectorAll('.octopus-multiselect-options .text-indic'))
479
+ .map((el) => el.textContent?.trim());
480
+ expect(hints).toContain('Press Enter to add this value');
481
+ });
482
+
483
+ it('does not show the Enter hint when allowCustomValue is not set', async () => {
484
+ wrapper = await mount(OctopusMultiselect, {
485
+ props: { options: stringOptions },
486
+ });
487
+ await wrapper.find('input').trigger('focus');
488
+ await wrapper.find('input').setValue('Delta');
489
+ await wrapper.find('input').trigger('input');
490
+ const hints = Array.from(document.body.querySelectorAll('.octopus-multiselect-options .text-indic'))
491
+ .map((el) => el.textContent?.trim());
492
+ expect(hints).not.toContain('Press Enter to add this value');
493
+ });
494
+
495
+ it('does not show the Enter hint when the search input is empty', async () => {
496
+ wrapper = await mount(OctopusMultiselect, {
497
+ props: { options: stringOptions, allowCustomValue: true },
498
+ });
499
+ await wrapper.find('input').trigger('focus');
500
+ const hints = Array.from(document.body.querySelectorAll('.octopus-multiselect-options .text-indic'))
501
+ .map((el) => el.textContent?.trim());
502
+ expect(hints).not.toContain('Press Enter to add this value');
503
+ });
504
+ });
207
505
  });
@@ -3,6 +3,7 @@ import '@tests/mocks/i18n';
3
3
  import OctopusSelect from '@/components/form/OctopusSelect.vue';
4
4
  import { DOMWrapper, type VueWrapper } from '@vue/test-utils';
5
5
  import { mount } from '@tests/utils';
6
+ import { nextTick } from 'vue';
6
7
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
8
 
8
9
  const options = [
@@ -16,6 +17,20 @@ function getDropdown(): Element | null {
16
17
  return document.body.querySelector('.octopus-select-dropdown');
17
18
  }
18
19
 
20
+ function getOptionLabels(): string[] {
21
+ return Array.from(document.body.querySelectorAll('.octopus-select-option'))
22
+ .map((el) => el.textContent?.trim() ?? '');
23
+ }
24
+
25
+ // Simulates a click outside the component to trigger onClickOutside's closeDropdown.
26
+ async function clickOutside(): Promise<void> {
27
+ const outsideElement = document.createElement('div');
28
+ document.body.appendChild(outsideElement);
29
+ outsideElement.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
30
+ await nextTick();
31
+ document.body.removeChild(outsideElement);
32
+ }
33
+
19
34
  describe('OctopusSelect', () => {
20
35
  // The dropdown teleports to .octopus-app, which is normally the app root (see App.vue).
21
36
  // It doesn't exist in the test DOM, so it must be created before mount.
@@ -165,4 +180,60 @@ describe('OctopusSelect', () => {
165
180
  const optionButtons = document.body.querySelectorAll('.octopus-select-option');
166
181
  expect(optionButtons[0].classList).toContain('selected');
167
182
  });
183
+
184
+ describe('pullSelectedToTop', () => {
185
+ it('does not reorder options when pullSelectedToTop is not set', async () => {
186
+ wrapper = await mount(OctopusSelect, {
187
+ props: { options, optionLabel: 'name', value: options[1] },
188
+ });
189
+ await wrapper.find('input').trigger('focus');
190
+ expect(getOptionLabels()).toEqual(['Alpha', 'Beta', 'Gamma']);
191
+ });
192
+
193
+ it('moves the selected option to the top when pullSelectedToTop is true', async () => {
194
+ wrapper = await mount(OctopusSelect, {
195
+ props: { options, optionLabel: 'name', value: options[1], pullSelectedToTop: true },
196
+ });
197
+ await wrapper.find('input').trigger('focus');
198
+ expect(getOptionLabels()).toEqual(['Beta', 'Alpha', 'Gamma']);
199
+ });
200
+
201
+ it('does not reorder while the dropdown stays open as the value changes', async () => {
202
+ wrapper = await mount(OctopusSelect, {
203
+ props: { options, optionLabel: 'name', pullSelectedToTop: true },
204
+ });
205
+ await wrapper.find('input').trigger('focus');
206
+ await wrapper.setProps({ value: options[2] });
207
+ expect(getOptionLabels()).toEqual(['Alpha', 'Beta', 'Gamma']);
208
+ });
209
+
210
+ it('re-pins using the latest value after closing and reopening', async () => {
211
+ wrapper = await mount(OctopusSelect, {
212
+ props: { options, optionLabel: 'name', value: options[2], pullSelectedToTop: true },
213
+ });
214
+ await wrapper.find('input').trigger('focus');
215
+ await clickOutside();
216
+ await wrapper.find('input').trigger('focus');
217
+ expect(getOptionLabels()).toEqual(['Gamma', 'Alpha', 'Beta']);
218
+ });
219
+
220
+ it('pins by optionKey even when the value is a different reference', async () => {
221
+ const value = { id: 2, name: 'Beta' };
222
+ wrapper = await mount(OctopusSelect, {
223
+ props: { options, optionLabel: 'name', optionKey: 'id', value, pullSelectedToTop: true },
224
+ });
225
+ await wrapper.find('input').trigger('focus');
226
+ expect(getOptionLabels()).toEqual(['Beta', 'Alpha', 'Gamma']);
227
+ });
228
+
229
+ it('still respects the active search filter when pinning', async () => {
230
+ wrapper = await mount(OctopusSelect, {
231
+ props: { options, optionLabel: 'name', value: options[2], pullSelectedToTop: true },
232
+ });
233
+ await wrapper.find('input').trigger('focus');
234
+ await wrapper.find('input').setValue('be');
235
+ await wrapper.find('input').trigger('input');
236
+ expect(getOptionLabels()).toEqual(['Beta']);
237
+ });
238
+ });
168
239
  });