@saooti/octopus-sdk 41.11.0-beta1 → 41.11.0

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/CHANGELOG.md CHANGED
@@ -1,10 +1,11 @@
1
1
  # CHANGELOG
2
2
 
3
- ## 41.11.0 (En cours)
3
+ ## 41.11.0 (05/06/2026)
4
4
 
5
5
  **Features**
6
6
 
7
7
  - **14327** - Ajout droits pour éléments médiathèque
8
+ - **14494** - Ajout droits pour accès enregistrements
8
9
  - Revue du système de droits
9
10
  - Propose maintenant des fonction `get*Right`, permettant d'obtenir la raison
10
11
  du rejet
@@ -14,10 +15,13 @@
14
15
  de l'utilisateur sur un élément particulier
15
16
  - Ajout du composant `ClassicTabs` pour simplifier la gestion d'onglets
16
17
  - `ClassicNav` est déprécié en conséquence
18
+ - Ajout du composant `OctopusMultiselect`, un composant plus moderne pour les
19
+ choix multiples
17
20
  - Ajout de l'api `mediathequeApi`
18
21
 
19
22
  **Fixes**
20
23
 
24
+ - **14549** - Suppression du fond de `ClassicAvatar` quand l'image est définie
21
25
  - Correction des problèmes de placement de `ClassicPopover`
22
26
  - Les props `constraintHeight` & `relativeClass` sont maintenant dépréciés
23
27
 
package/index.ts CHANGED
@@ -58,7 +58,8 @@ export * from "./src/components/buttons/";
58
58
 
59
59
  // Form
60
60
  import ClassicButtonGroup from "./src/components/form/ClassicButtonGroup.vue";
61
- export { ClassicButtonGroup };
61
+ import OctopusMultiselect from "./src/components/form/OctopusMultiselect.vue";
62
+ export { ClassicButtonGroup, OctopusMultiselect };
62
63
  export type { ButtonGroupOption } from "./src/components/form/ClassicButtonGroup.vue";
63
64
 
64
65
  //Display
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saooti/octopus-sdk",
3
- "version": "41.11.0-beta1",
3
+ "version": "41.11.0",
4
4
  "private": false,
5
5
  "description": "Javascript SDK for using octopus",
6
6
  "author": "Saooti",
@@ -31,7 +31,6 @@
31
31
  "@vueuse/core": "^13.9.0",
32
32
  "autoprefixer": "^10.4.22",
33
33
  "axios": "^1.13.2",
34
- "dayjs": "^1.11.19",
35
34
  "emoji-mart-vue-fast": "^15.0.5",
36
35
  "express": "^5.1.0",
37
36
  "globals": "^16.5.0",
@@ -91,6 +90,7 @@
91
90
  "url": "git+https://github.com/saooti/octopus-sdk.git"
92
91
  },
93
92
  "peerDependencies": {
93
+ "dayjs": "^1.11.0",
94
94
  "eslint-plugin-vue": "^10.9.1",
95
95
  "pinia": ">=2.3.0",
96
96
  "vue": "^3.5.0",
@@ -99,7 +99,6 @@
99
99
  },
100
100
  "exports": {
101
101
  ".": {
102
- "types": "./index.d.ts",
103
102
  "default": "./index.ts"
104
103
  },
105
104
  "./src/*": "./src/*",
@@ -2,6 +2,21 @@ import { PlaylistMedia } from "../stores/class/radio/playlistMedia";
2
2
  import { Mix } from "../stores/class/radio/mix";
3
3
  import { ModuleApi } from "./apiConnection";
4
4
  import classicApi from "./classicApi";
5
+ import { Cartouchier } from "@/stores/class/cartouchier/cartouchier";
6
+
7
+ /**
8
+ * Retrieve all cartouchier from the given organisation
9
+ * @param organisationId The ID of the organisation
10
+ * @param options Filtering options
11
+ * @returns List of cartouchier belonging to the organisation
12
+ */
13
+ async function getAllCartouchiers(organisationId: string, options?: { ownerId?: string }): Promise<Array<Cartouchier>> {
14
+ return classicApi.fetchData<Array<Cartouchier>>({
15
+ api: ModuleApi.MEDIA,
16
+ path: `cartouchier/list/${organisationId}`,
17
+ parameters: options
18
+ });
19
+ }
5
20
 
6
21
  /**
7
22
  * Retrieve all mix from the given organisation
@@ -29,12 +44,14 @@ async function deleteMix(mixId: number): Promise<void> {
29
44
  /**
30
45
  * Retrieve all playlist from the given organisation
31
46
  * @param organisationId The ID of the organisation
47
+ * @param options Filtering options
32
48
  * @returns List of playlist belonging to the organisation
33
49
  */
34
- async function getAllPlaylists(organisationId: string): Promise<Array<PlaylistMedia>> {
50
+ async function getAllPlaylists(organisationId: string, options?: { ownerId?: string }): Promise<Array<PlaylistMedia>> {
35
51
  return classicApi.fetchData<Array<PlaylistMedia>>({
36
52
  api: ModuleApi.MEDIA,
37
- path: `playlist/${organisationId}`
53
+ path: `playlist/${organisationId}`,
54
+ parameters: options
38
55
  });
39
56
  }
40
57
 
@@ -50,6 +67,7 @@ async function deletePlaylist(playlistId: number): Promise<void> {
50
67
  }
51
68
 
52
69
  export const mediathequeApi = {
70
+ getAllCartouchiers,
53
71
  getAllMix,
54
72
  deleteMix,
55
73
  getAllPlaylists,
@@ -1,7 +1,10 @@
1
+ <!-- A simple button with accessibility in mind -->
1
2
  <template>
2
3
  <button
3
4
  class="btn"
4
5
  :aria-disabled="disabled"
6
+ :title="title"
7
+ :aria-label="title"
5
8
  @click="onClick"
6
9
  >
7
10
  <slot />
@@ -12,6 +15,8 @@
12
15
  const props = defineProps<{
13
16
  /** Disable the button */
14
17
  disabled?: boolean;
18
+ /** Title of the button */
19
+ title?: string;
15
20
  }>();
16
21
 
17
22
  const emit = defineEmits<{
@@ -5,6 +5,7 @@ import type { Podcast } from "../../stores/class/general/podcast";
5
5
  import { PlaylistMedia } from "../../stores/class/radio/playlistMedia";
6
6
  import { Cartouchier } from "../../stores/class/cartouchier/cartouchier";
7
7
  import { Media } from "../../stores/class/general/media";
8
+ import { Conference } from "../../stores/class/conference/conference";
8
9
 
9
10
  type Role =
10
11
  'ADMIN'|'ORGANISATION'|
@@ -339,6 +340,15 @@ export const useRights = () => {
339
340
  return getEditTranscriptRight(podcast);
340
341
  }
341
342
 
343
+ // Live
344
+ function getAccessRecordingRight(conference: Conference, isLive: boolean): ActionRight {
345
+ if (isLive) {
346
+ return roleContainsAny('LIVE') ? ActionRight.Allowed : ActionRight.DeniedNoRight;
347
+ } else {
348
+ return roleContainsAny('ANIMATION', 'RESTRICTED_ANIMATION') ? ActionRight.Allowed : ActionRight.DeniedNoRight;
349
+ }
350
+ }
351
+
342
352
  // View/utility checks — outside the action pattern
343
353
  function canSeeHistory(): boolean {
344
354
  return roleContainsAny('ADMIN', 'ORGANISATION');
@@ -388,6 +398,7 @@ export const useRights = () => {
388
398
  getEditMixRight,
389
399
  getDeleteMixRight,
390
400
  // Other
401
+ getAccessRecordingRight,
391
402
  getEditCodeInsertPlayerRight,
392
403
  getEditTranscriptRight,
393
404
  getEditTranscriptVisibilityRight,
@@ -7,6 +7,7 @@
7
7
  aria-hidden="true"
8
8
  fill-color="var(--octopus-primary)"
9
9
  />
10
+ <span v-if="!hasAccess" class="rights-visually-hidden">{{ message }}</span>
10
11
  <ClassicPopover
11
12
  v-if="!hasAccess"
12
13
  :target="iconId"
@@ -15,18 +16,20 @@
15
16
  {{ message }}
16
17
  </ClassicPopover>
17
18
  </div>
18
- <ClassicAlert
19
- v-else-if="!hasAccess"
20
- type="info"
21
- >
22
- <template #icon>
23
- <LockIcon
24
- aria-hidden="true"
25
- fill-color="var(--octopus-primary)"
26
- />
27
- </template>
28
- {{ message }}
29
- </ClassicAlert>
19
+ <div aria-live="polite" aria-atomic="true">
20
+ <ClassicAlert
21
+ v-if="text && !hasAccess"
22
+ type="info"
23
+ >
24
+ <template #icon>
25
+ <LockIcon
26
+ aria-hidden="true"
27
+ fill-color="var(--octopus-primary)"
28
+ />
29
+ </template>
30
+ {{ message }}
31
+ </ClassicAlert>
32
+ </div>
30
33
  </div>
31
34
  </template>
32
35
 
@@ -213,3 +216,18 @@ const uid = getCurrentInstance()?.uid;
213
216
  /** ID of the icon for reference by the popover */
214
217
  const iconId = computed((): string => 'rights-indicator-' + uid);
215
218
  </script>
219
+
220
+ <style scoped lang="scss">
221
+ /** Helper class to keep the data accessible for screen readers */
222
+ .rights-visually-hidden {
223
+ position: absolute;
224
+ width: 1px;
225
+ height: 1px;
226
+ padding: 0;
227
+ margin: -1px;
228
+ overflow: hidden;
229
+ clip: rect(0, 0, 0, 0);
230
+ white-space: nowrap;
231
+ border: 0;
232
+ }
233
+ </style>
@@ -11,8 +11,9 @@
11
11
  <label :class="displayLabel ? '' : 'd-none'" :for="id" class="form-label">{{
12
12
  label
13
13
  }}
14
- <AsteriskIcon v-if="displayRequired" :size="10" class="ms-1 mb-2" :title="t('Mandatory input')"/>
14
+ <AsteriskIcon v-if="displayRequired" :size="10" class="ms-1 mb-2" :title="t('Mandatory input')"/>
15
15
  </label>
16
+
16
17
  <template v-if="popover">
17
18
  <button
18
19
  :id="'popover' + id"
@@ -32,6 +33,7 @@
32
33
  </ClassicPopover>
33
34
  </template>
34
35
  </div>
36
+
35
37
  <vSelect
36
38
  v-model="optionSelected"
37
39
  :input-id="id"
@@ -60,6 +62,7 @@
60
62
  <template v-if="optionCustomTemplating.length" #option="option">
61
63
  <slot :name="optionCustomTemplating" :option="option" />
62
64
  </template>
65
+
63
66
  <template v-else-if="withSelectAll" #option="option">
64
67
  <strong v-if="option.id === selectAll.id">
65
68
  {{ option[optionLabel] }}
@@ -76,6 +79,10 @@
76
79
  <slot :name="optionSelectedCustomTemplating" :option="option" />
77
80
  </template>
78
81
 
82
+ <template #selected-option-container="{ option, deselect, disabled, multiple: mul }">
83
+ <slot name="selected-option-container" v-bind="{ option, deselect, disabled, multiple: mul }" />
84
+ </template>
85
+
79
86
  <template #no-options="{ searching }">
80
87
  <span v-if="searching">{{
81
88
  t("No elements found. Consider changing the search query.")
@@ -10,66 +10,76 @@
10
10
  `selected` : true if the option is selected
11
11
  -->
12
12
  <template>
13
- <div role="radiogroup" class="d-flex" :class="isColumn !== false ? 'flex-column' : ''">
14
13
  <div
15
- v-for="option in options"
16
- :key="option.title"
17
- class="octopus-form-item"
18
- :class="isColumn !== false ? 'd-flex flex-nowrap align-items-center' : 'me-2'"
14
+ role="radiogroup"
15
+ class="d-flex"
16
+ :class="isColumn !== false ? 'flex-column' : ''"
19
17
  >
20
- <input
21
- :id="computedId + option.value"
22
- :checked="textInit === option.value"
23
- type="radio"
24
- :name="computedId"
25
- :value="option.value"
26
- :disabled="isDisabled"
27
- @input="onChange($event.target.value)"
28
- >
29
- <label class="c-hand" :for="computedId + option.value">
30
- <slot :name="'label-' + option.value" v-bind="slotBindings(option)">{{ option.title }}</slot>
31
- </label>
18
+ <div
19
+ v-for="option in options"
20
+ :key="option.title"
21
+ class="octopus-form-item"
22
+ :class="isColumn !== false ? 'd-flex flex-nowrap align-items-center' : 'me-2'"
23
+ >
24
+ <input
25
+ :id="computedId + option.value"
26
+ :checked="textInit === option.value"
27
+ type="radio"
28
+ :name="computedId"
29
+ :value="option.value"
30
+ :disabled="isDisabled"
31
+ @input="onChange($event.target.value)"
32
+ >
33
+ <label class="c-hand" :for="computedId + option.value">
34
+ <slot :name="'label-' + option.value" v-bind="slotBindings(option)">{{ option.title }}</slot>
35
+ </label>
32
36
 
33
- <slot :name="'after-' + option.value" v-bind="slotBindings(option)" />
37
+ <slot :name="'after-' + option.value" v-bind="slotBindings(option)" />
38
+ </div>
34
39
  </div>
35
- </div>
36
40
  </template>
37
41
 
38
42
  <script setup generic="T extends { title: string; value: string|undefined; }" lang="ts">
39
43
  import { computed, getCurrentInstance } from 'vue';
40
44
 
41
45
  //Props
42
- const { textInit, isColumn = true, idRadio } = defineProps<{
43
- options: Array<T>;
44
- textInit?: string;
45
- idRadio?: string;
46
- isDisabled?: boolean;
47
- isColumn?: boolean;
46
+ const { options, textInit, isColumn = true, idRadio } = defineProps<{
47
+ options: Array<T>;
48
+ textInit?: string;
49
+ idRadio?: string;
50
+ isDisabled?: boolean;
51
+ isColumn?: boolean;
48
52
  }>();
49
53
 
50
54
  //Emits
51
55
  const emit = defineEmits<{
52
- (e: 'update:textInit', value: string): void;
56
+ (e: 'update:textInit', value: string): void;
57
+ /** Emitted with update:text-init, containing the selected object */
58
+ (e: 'selected-item', value: T): void;
53
59
  }>();
54
60
 
55
61
  const uid = getCurrentInstance()?.uid;
56
62
  const computedId = computed((): string => {
57
- if (idRadio !== undefined) {
58
- return idRadio;
59
- } else {
60
- return 'classic-radio-' + uid;
61
- }
63
+ if (idRadio !== undefined) {
64
+ return idRadio;
65
+ } else {
66
+ return 'classic-radio-' + uid;
67
+ }
62
68
  });
63
69
 
64
70
  //Methods
65
- function onChange(value: string){
66
- emit('update:textInit', value)
71
+ function onChange(value: string): void {
72
+ emit('update:textInit', value);
73
+ const item = options.find(elt => elt.value === value);
74
+ if (item) {
75
+ emit('selected-item', item);
76
+ }
67
77
  }
68
78
 
69
79
  function slotBindings(option: T): { option: T; selected: boolean } {
70
- return {
71
- option,
72
- selected: textInit === option.value
73
- }
80
+ return {
81
+ option,
82
+ selected: textInit === option.value
83
+ }
74
84
  }
75
85
  </script>
@@ -0,0 +1,279 @@
1
+ <template>
2
+ <div
3
+ ref="containerRef"
4
+ class="octopus-multiselect"
5
+ :class="{ 'form-margin': label }"
6
+ >
7
+ <label
8
+ v-if="label"
9
+ :for="computedId"
10
+ class="form-label"
11
+ >
12
+ {{ label }}
13
+ </label>
14
+
15
+ <div
16
+ class="octopus-multiselect-field"
17
+ :class="{ disabled: isDisabled, open: isOpen, noBorder }"
18
+ @click="openDropdown"
19
+ >
20
+ <input
21
+ :id="computedId"
22
+ ref="inputRef"
23
+ v-model="searchQuery"
24
+ type="text"
25
+ class="octopus-multiselect-input"
26
+ :placeholder="inputPlaceholder"
27
+ :disabled="isDisabled"
28
+ @focus="openDropdown"
29
+ @input="handleInput"
30
+ >
31
+ <button
32
+ class="btn-transparent octopus-multiselect-chevron"
33
+ :disabled="isDisabled"
34
+ @click.stop="toggleDropdown"
35
+ >
36
+ <ChevronDownIcon />
37
+ </button>
38
+ </div>
39
+
40
+ <div v-if="isOpen" class="octopus-multiselect-dropdown">
41
+ <ClassicCheckbox
42
+ :text-init="allSelected"
43
+ :label="selectAllText ?? t('All')"
44
+ :is-disabled="isDisabled"
45
+ @update:text-init="toggleAll"
46
+ />
47
+
48
+ <div class="octopus-multiselect-options">
49
+ <ClassicCheckbox
50
+ v-for="(option, index) in displayedOptions"
51
+ :key="index"
52
+ :text-init="isSelected(option)"
53
+ :label="getLabel(option)"
54
+ :is-disabled="isDisabled"
55
+ @update:text-init="toggleOption(option)"
56
+ />
57
+ <span v-if="displayedOptions.length === 0" class="text-indic px-2">
58
+ {{ t('No elements found. Consider changing the search query.') }}
59
+ </span>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ </template>
64
+
65
+ <script setup lang="ts" generic="T">
66
+ import { computed, getCurrentInstance, nextTick, ref, shallowRef, watch } from 'vue';
67
+ import { onClickOutside } from '@vueuse/core';
68
+ import { useI18n } from 'vue-i18n';
69
+ import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue';
70
+ import ClassicCheckbox from './ClassicCheckbox.vue';
71
+
72
+ const props = defineProps<{
73
+ /** Optional label displayed above the field. */
74
+ label?: string;
75
+ /** Currently selected items. Bind with `v-model:selected`. */
76
+ selected?: T[];
77
+ /** Full list of options to display or filter. */
78
+ options: T[];
79
+ /** Key of each option object to use as the display label. */
80
+ optionLabel: keyof T & string;
81
+ /** Disables the field and all checkboxes when true. */
82
+ isDisabled?: boolean;
83
+ /** Placeholder shown in the input when no items are selected. Defaults to the translated "Search" string. */
84
+ placeholder?: string;
85
+ /** Label for the "select all" checkbox. Defaults to the translated "All" string. */
86
+ selectAllText?: string;
87
+ /** If provided, called on every input change; its return value replaces the displayed options. */
88
+ onSearch?: (query: string) => T[] | Promise<T[]>;
89
+ /** Disable the border around the input */
90
+ noBorder?: boolean;
91
+ }>();
92
+
93
+ const emit = defineEmits<{
94
+ /** Emitted when the selection changes. */
95
+ (e: 'update:selected', value: T[]): void;
96
+ }>();
97
+
98
+ const { t } = useI18n();
99
+
100
+ const searchQuery = ref('');
101
+ const isOpen = ref(false);
102
+ const internalOptions = shallowRef<T[]>([]);
103
+ const containerRef = ref<HTMLElement | null>(null);
104
+ const inputRef = ref<HTMLInputElement | null>(null);
105
+
106
+ const computedId = computed(() => 'multiselect-' + getCurrentInstance()?.uid);
107
+
108
+ watch(
109
+ () => props.options,
110
+ (val) => {
111
+ internalOptions.value = val;
112
+ },
113
+ { immediate: true }
114
+ );
115
+
116
+ const displayedOptions = computed((): Array<T> => {
117
+ if (props.onSearch) {
118
+ return internalOptions.value;
119
+ }
120
+ const query = searchQuery.value.toLowerCase();
121
+ if (!query) {
122
+ return props.options;
123
+ }
124
+ return props.options.filter((option) =>
125
+ getLabel(option).toLowerCase().includes(query)
126
+ );
127
+ });
128
+
129
+ const allSelected = computed(() => {
130
+ if (displayedOptions.value.length === 0) {
131
+ return false;
132
+ }
133
+ return displayedOptions.value.every((option) => isSelected(option));
134
+ });
135
+
136
+ const inputPlaceholder = computed(() => {
137
+ const selected = props.selected ?? [];
138
+ if (selected.length === 0) {
139
+ return props.placeholder ?? t('Search');
140
+ }
141
+ const labels = selected.map(getLabel);
142
+ if (labels.length <= 2) {
143
+ return labels.join(', ');
144
+ }
145
+ return `${labels.slice(0, 2).join(', ')} (+${labels.length - 2})`;
146
+ });
147
+
148
+ function getLabel(option: T): string {
149
+ return option[props.optionLabel] as string;
150
+ }
151
+
152
+ function isSelected(option: T): boolean {
153
+ return props.selected?.includes(option) ?? false;
154
+ }
155
+
156
+ function toggleOption(option: T): void {
157
+ const current = props.selected ?? [];
158
+ if (isSelected(option)) {
159
+ emit('update:selected', current.filter((item) => item !== option));
160
+ } else {
161
+ emit('update:selected', [...current, option]);
162
+ }
163
+ }
164
+
165
+ function toggleAll(val: boolean): void {
166
+ const current = props.selected ?? [];
167
+ if (val) {
168
+ const toAdd = displayedOptions.value.filter((option: T) => !isSelected(option));
169
+ emit('update:selected', [...current, ...toAdd]);
170
+ } else {
171
+ emit('update:selected', current.filter((item: T) => !displayedOptions.value.includes(item)));
172
+ }
173
+ }
174
+
175
+ function openDropdown(): void {
176
+ if (props.isDisabled) {
177
+ return;
178
+ }
179
+ isOpen.value = true;
180
+ nextTick(() => {
181
+ inputRef.value?.focus();
182
+ });
183
+ }
184
+
185
+ function closeDropdown(): void {
186
+ isOpen.value = false;
187
+ searchQuery.value = '';
188
+ }
189
+
190
+ function toggleDropdown(): void {
191
+ if (isOpen.value) {
192
+ closeDropdown();
193
+ } else {
194
+ openDropdown();
195
+ }
196
+ }
197
+
198
+ async function handleInput(): Promise<void> {
199
+ if (!props.onSearch) {
200
+ return;
201
+ }
202
+ const result = await props.onSearch(searchQuery.value);
203
+ internalOptions.value = result;
204
+ }
205
+
206
+ onClickOutside(containerRef, closeDropdown);
207
+ </script>
208
+
209
+ <style lang="scss">
210
+ .octopus-multiselect {
211
+ position: relative;
212
+
213
+ .octopus-multiselect-field {
214
+ display: flex;
215
+ align-items: center;
216
+ border: 1px solid var(--octopus-border-default);
217
+ border-radius: var(--octopus-border-radius);
218
+ background: white;
219
+ cursor: pointer;
220
+
221
+ &.open {
222
+ border-color: var(--octopus-primary);
223
+ }
224
+
225
+ &.disabled {
226
+ background: var(--octopus-secondary-lighter);
227
+ cursor: default;
228
+ }
229
+
230
+ &.noBorder {
231
+ border: none;
232
+ }
233
+ }
234
+
235
+ .octopus-multiselect-input {
236
+ flex: 1;
237
+ border: none;
238
+ background: transparent;
239
+ padding: 0.4rem 0.5rem;
240
+ height: 2rem;
241
+ outline: none;
242
+ cursor: inherit;
243
+ min-width: 0;
244
+ }
245
+
246
+ .octopus-multiselect-chevron {
247
+ padding: 0.25rem 0.5rem;
248
+ display: flex;
249
+ align-items: center;
250
+ }
251
+
252
+ .octopus-multiselect-dropdown {
253
+ position: absolute;
254
+ top: calc(100% + 2px);
255
+ left: 0;
256
+ right: 0;
257
+ z-index: 100;
258
+ background: white;
259
+ border: 1px solid var(--octopus-border-default);
260
+ border-radius: var(--octopus-border-radius);
261
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
262
+ padding: 0.25rem 0;
263
+
264
+ > .octopus-form-item {
265
+ padding: 0.25rem 0.5rem;
266
+ border-bottom: 1px solid var(--octopus-secondary);
267
+ }
268
+ }
269
+
270
+ .octopus-multiselect-options {
271
+ max-height: 14rem;
272
+ overflow-y: auto;
273
+
274
+ .octopus-form-item {
275
+ padding: 0.25rem 0.5rem;
276
+ }
277
+ }
278
+ }
279
+ </style>
@@ -44,12 +44,18 @@ const { useProxyImageUrl } = useImageProxy();
44
44
 
45
45
  const containerStyle = computed((): StyleValue => {
46
46
  const size = `${props.size}px`;
47
- return {
48
- 'background-color': colorFromString(props.name),
49
- 'font-size': `${props.size / 2}px`,
47
+ const style: StyleValue = {
50
48
  height: size,
51
49
  width: size
52
50
  };
51
+
52
+ // Add background only if there's no image to display
53
+ if (props.imageUrl === undefined) {
54
+ style['background-color'] = colorFromString(props.name);
55
+ style['font-size'] = `${props.size / 2}px`;
56
+ }
57
+
58
+ return style;
53
59
  });
54
60
  </script>
55
61
 
@@ -0,0 +1,45 @@
1
+ import { useDayjs } from "@/components/composable/useDayjs";
2
+ import { Dayjs } from "dayjs";
3
+ import { defineStore } from "pinia";
4
+ import { reactive } from "vue";
5
+
6
+ /**
7
+ * Type for cache storage
8
+ */
9
+ interface CachedData<T> {
10
+ /** The stored data */
11
+ data: T;
12
+ /** Expiration date of the cached data */
13
+ expiration: Dayjs;
14
+ }
15
+
16
+ export const useCacheStore = defineStore('cache', () => {
17
+
18
+ const { dayjs } = useDayjs();
19
+ const cachedData = reactive({} as Record<string, CachedData<unknown>>);
20
+
21
+ async function getData<T>(key: string, callback: () => Promise<T>): Promise<T> {
22
+ let cache = cachedData[key] as CachedData<T>|undefined;
23
+ if (isDataEmptyOrExpired(cache)) {
24
+ // Retrieve data from callback
25
+ const data = await callback();
26
+ const expiration = dayjs().add(5, 'minutes');
27
+ cache = { data, expiration };
28
+ cachedData[key] = cache;
29
+ }
30
+
31
+ return cache.data;
32
+ }
33
+
34
+ function isDataEmptyOrExpired(data: CachedData<unknown>|undefined): boolean {
35
+ if (!data) {
36
+ return true;
37
+ } else {
38
+ return dayjs().isAfter(data.expiration);
39
+ }
40
+ }
41
+
42
+ return {
43
+ getData
44
+ }
45
+ });
@@ -22,6 +22,8 @@ export type OrganisationAttributes = {
22
22
  MONETISABLE?: MonetisationOptions;
23
23
  /** Privacy parameters (JSON) */
24
24
  PRIVATE?: string;
25
+ /** Live recordings enabled */
26
+ 'live.active'?: boolean;
25
27
  };
26
28
 
27
29
  export interface Organisation {
@@ -0,0 +1,162 @@
1
+ import '@tests/mocks/i18n';
2
+
3
+ import OctopusMultiselect from '@/components/form/OctopusMultiselect.vue';
4
+ import { mount } from '@tests/utils';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+
7
+ const options = [
8
+ { id: 1, name: 'Alpha' },
9
+ { id: 2, name: 'Beta' },
10
+ { id: 3, name: 'Gamma' },
11
+ ];
12
+
13
+ describe('OctopusMultiselect', () => {
14
+ it('renders label when provided', async () => {
15
+ const wrapper = await mount(OctopusMultiselect, {
16
+ props: { options, optionLabel: 'name', label: 'My label' },
17
+ });
18
+ expect(wrapper.find('label.form-label').text()).toBe('My label');
19
+ });
20
+
21
+ it('does not render label when not provided', async () => {
22
+ const wrapper = await mount(OctopusMultiselect, {
23
+ props: { options, optionLabel: 'name' },
24
+ });
25
+ expect(wrapper.find('label.form-label').exists()).toBe(false);
26
+ });
27
+
28
+ it('opens dropdown on input focus', async () => {
29
+ const wrapper = await mount(OctopusMultiselect, {
30
+ props: { options, optionLabel: 'name' },
31
+ });
32
+ expect(wrapper.find('.octopus-multiselect-dropdown').exists()).toBe(false);
33
+ await wrapper.find('input').trigger('focus');
34
+ expect(wrapper.find('.octopus-multiselect-dropdown').exists()).toBe(true);
35
+ });
36
+
37
+ it('shows "All" checkbox and one checkbox per option when open', async () => {
38
+ const wrapper = await mount(OctopusMultiselect, {
39
+ props: { options, optionLabel: 'name' },
40
+ });
41
+ await wrapper.find('input').trigger('focus');
42
+ const checkboxes = wrapper.findAll('input[type="checkbox"]');
43
+ // 1 for "All" + 3 for options
44
+ expect(checkboxes).toHaveLength(4);
45
+ });
46
+
47
+ it('filters options locally by search query', async () => {
48
+ const wrapper = await mount(OctopusMultiselect, {
49
+ props: { options, optionLabel: 'name' },
50
+ });
51
+ await wrapper.find('input').trigger('focus');
52
+ await wrapper.find('input').setValue('al');
53
+ await wrapper.find('input').trigger('input');
54
+ const checkboxes = wrapper.findAll('input[type="checkbox"]');
55
+ // 1 for "All" + 1 matching "Alpha"
56
+ expect(checkboxes).toHaveLength(2);
57
+ });
58
+
59
+ it('shows no-results message when filter matches nothing', async () => {
60
+ const wrapper = await mount(OctopusMultiselect, {
61
+ props: { options, optionLabel: 'name' },
62
+ });
63
+ await wrapper.find('input').trigger('focus');
64
+ await wrapper.find('input').setValue('zzz');
65
+ await wrapper.find('input').trigger('input');
66
+ expect(wrapper.find('.text-indic').exists()).toBe(true);
67
+ });
68
+
69
+ it('calls onSearch with current query and updates displayed options', async () => {
70
+ const searchResult = [{ id: 4, name: 'Delta' }];
71
+ const onSearch = vi.fn().mockResolvedValue(searchResult);
72
+ const wrapper = await mount(OctopusMultiselect, {
73
+ props: { options, optionLabel: 'name', onSearch },
74
+ });
75
+ await wrapper.find('input').trigger('focus');
76
+ await wrapper.find('input').setValue('del');
77
+ await wrapper.find('input').trigger('input');
78
+ await vi.dynamicImportSettled();
79
+ expect(onSearch).toHaveBeenCalledWith('del');
80
+ });
81
+
82
+ it('emits update:selected when toggling a single option', async () => {
83
+ const wrapper = await mount(OctopusMultiselect, {
84
+ props: { options, optionLabel: 'name', selected: [] },
85
+ });
86
+ await wrapper.find('input').trigger('focus');
87
+ const optionCheckboxes = wrapper.findAll('.octopus-multiselect-options input[type="checkbox"]');
88
+ await optionCheckboxes[0].trigger('input');
89
+ expect(wrapper.emitted('update:selected')?.[0]).toEqual([[options[0]]]);
90
+ });
91
+
92
+ it('deselects an already-selected option', async () => {
93
+ const wrapper = await mount(OctopusMultiselect, {
94
+ props: { options, optionLabel: 'name', selected: [options[0]] },
95
+ });
96
+ await wrapper.find('input').trigger('focus');
97
+ const optionCheckboxes = wrapper.findAll('.octopus-multiselect-options input[type="checkbox"]');
98
+ await optionCheckboxes[0].trigger('input');
99
+ expect(wrapper.emitted('update:selected')?.[0]).toEqual([[]]);
100
+ });
101
+
102
+ it('toggleAll selects all displayed options', async () => {
103
+ const wrapper = await mount(OctopusMultiselect, {
104
+ props: { options, optionLabel: 'name', selected: [] },
105
+ });
106
+ await wrapper.find('input').trigger('focus');
107
+ const allCheckbox = wrapper.find('.octopus-multiselect-dropdown > .octopus-form-item input[type="checkbox"]');
108
+ await allCheckbox.trigger('input');
109
+ const emitted = wrapper.emitted('update:selected')?.[0]?.[0] as unknown[];
110
+ expect(emitted).toHaveLength(3);
111
+ });
112
+
113
+ it('toggleAll deselects all displayed options when all are selected', async () => {
114
+ const wrapper = await mount(OctopusMultiselect, {
115
+ props: { options, optionLabel: 'name', selected: [...options] },
116
+ });
117
+ await wrapper.find('input').trigger('focus');
118
+ const allCheckbox = wrapper.find('.octopus-multiselect-dropdown > .octopus-form-item input[type="checkbox"]');
119
+ await allCheckbox.trigger('input');
120
+ expect(wrapper.emitted('update:selected')?.[0]).toEqual([[]]);
121
+ });
122
+
123
+ it('shows selected items summary in placeholder', async () => {
124
+ const wrapper = await mount(OctopusMultiselect, {
125
+ props: { options, optionLabel: 'name', selected: [options[0], options[1]] },
126
+ });
127
+ const input = wrapper.find('input');
128
+ expect(input.attributes('placeholder')).toBe('Alpha, Beta');
129
+ });
130
+
131
+ it('shows count in placeholder when more than 2 items selected', async () => {
132
+ const wrapper = await mount(OctopusMultiselect, {
133
+ props: { options, optionLabel: 'name', selected: [...options] },
134
+ });
135
+ const input = wrapper.find('input');
136
+ expect(input.attributes('placeholder')).toBe('Alpha, Beta (+1)');
137
+ });
138
+
139
+ it('disables input when isDisabled is true', async () => {
140
+ const wrapper = await mount(OctopusMultiselect, {
141
+ props: { options, optionLabel: 'name', isDisabled: true },
142
+ });
143
+ expect(wrapper.find('input').attributes('disabled')).toBeDefined();
144
+ });
145
+
146
+ it('does not open dropdown when disabled', async () => {
147
+ const wrapper = await mount(OctopusMultiselect, {
148
+ props: { options, optionLabel: 'name', isDisabled: true },
149
+ });
150
+ await wrapper.find('input').trigger('focus');
151
+ expect(wrapper.find('.octopus-multiselect-dropdown').exists()).toBe(false);
152
+ });
153
+
154
+ it('uses selectAllText as the "All" checkbox label when provided', async () => {
155
+ const wrapper = await mount(OctopusMultiselect, {
156
+ props: { options, optionLabel: 'name', selectAllText: 'Tout' },
157
+ });
158
+ await wrapper.find('input').trigger('focus');
159
+ const allCheckboxLabel = wrapper.find('.octopus-multiselect-dropdown > .octopus-form-item label');
160
+ expect(allCheckboxLabel.text()).toBe('Tout');
161
+ });
162
+ });
package/tsconfig.json CHANGED
@@ -5,15 +5,13 @@
5
5
  // "strict": true,
6
6
  "jsx": "preserve",
7
7
  "importHelpers": true,
8
- "moduleResolution": "node",
8
+ "moduleResolution": "bundler",
9
9
  "experimentalDecorators": true,
10
10
  "skipLibCheck": true,
11
11
  "esModuleInterop": true,
12
12
  "allowSyntheticDefaultImports": true,
13
13
  "sourceMap": true,
14
- "baseUrl": ".",
15
14
  "types": [
16
- "webpack-env",
17
15
  "sockjs-client"
18
16
  ],
19
17
  "paths": {
@@ -21,7 +19,7 @@
21
19
  "./src/*"
22
20
  ],
23
21
  "@tests/*": [
24
- "tests/*"
22
+ "./tests/*"
25
23
  ]
26
24
  },
27
25
  "lib": [
@@ -34,9 +32,7 @@
34
32
  "include": [
35
33
  "src/**/*.ts",
36
34
  "src/**/*.tsx",
37
- "src/**/*.vue",
38
- "tests/**/*.ts",
39
- "tests/**/*.tsx"
35
+ "src/**/*.vue"
40
36
  ],
41
37
  "exclude": [
42
38
  "node_modules"
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "types": ["sockjs-client", "vitest/globals"]
5
+ },
6
+ "include": [
7
+ "src/**/*.ts",
8
+ "src/**/*.tsx",
9
+ "src/**/*.vue",
10
+ "tests/**/*.ts",
11
+ "tests/**/*.tsx"
12
+ ]
13
+ }
package/vitest.config.js CHANGED
@@ -12,7 +12,8 @@ export default defineConfig(env => mergeConfig(viteConfig(env), defineConfig({
12
12
  environment: 'happy-dom',
13
13
  //setupFiles: ['./tests/setup.ts'],
14
14
  typecheck: {
15
- enabled: true
15
+ enabled: true,
16
+ tsconfig: './tsconfig.test.json'
16
17
  }
17
18
  }
18
19
  })));