@saooti/octopus-sdk 41.11.0-beta2 → 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 +5 -1
- package/index.ts +2 -1
- package/package.json +2 -3
- package/src/components/buttons/ClassicButton.vue +5 -0
- package/src/components/composable/useRights.ts +11 -0
- package/src/components/display/RightsIndicator.vue +30 -12
- package/src/components/form/ClassicMultiselect.vue +8 -1
- package/src/components/form/ClassicRadio.vue +47 -37
- package/src/components/form/OctopusMultiselect.vue +279 -0
- package/src/components/misc/ClassicAvatar.vue +9 -3
- package/src/stores/CacheStore.ts +45 -0
- package/src/stores/class/general/organisation.ts +2 -0
- package/tests/components/form/OctopusMultiselect.spec.ts +162 -0
- package/tsconfig.json +3 -7
- package/tsconfig.test.json +13 -0
- package/vitest.config.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
-
## 41.11.0 (
|
|
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
|
-
|
|
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
|
|
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/*",
|
|
@@ -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
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
+
});
|
|
@@ -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": "
|
|
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"
|
package/vitest.config.js
CHANGED