@saooti/octopus-sdk 41.12.0-beta2 → 41.12.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.
Files changed (38) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/CHANGELOG.md +7 -1
  3. package/index.ts +1 -1
  4. package/package.json +3 -3
  5. package/src/api/index.ts +1 -0
  6. package/src/api/radioApi.ts +18 -2
  7. package/src/api/rubriquesApi.ts +68 -0
  8. package/src/components/composable/form/useOctopusDropdown.ts +5 -2
  9. package/src/components/composable/usePresentationItem.ts +78 -0
  10. package/src/components/display/emission/EmissionInlineList.vue +1 -1
  11. package/src/components/display/emission/EmissionPresentationItem.vue +25 -16
  12. package/src/components/display/podcastmaker/PodcastmakerHeader.vue +60 -45
  13. package/src/components/display/podcasts/PodcastPresentationList.vue +103 -89
  14. package/src/components/form/ClassicButtonGroup.vue +12 -6
  15. package/src/components/form/OctopusMultiselect.vue +86 -9
  16. package/src/components/form/OctopusSelect.vue +29 -2
  17. package/src/components/layouts/PresentationItem.vue +149 -104
  18. package/src/locale/de.json +1 -0
  19. package/src/locale/en.json +1 -0
  20. package/src/locale/es.json +1 -0
  21. package/src/locale/fr.json +1 -0
  22. package/src/locale/it.json +1 -0
  23. package/src/locale/sl.json +1 -0
  24. package/src/stores/CacheStore.ts +17 -11
  25. package/src/stores/ParamSdkStore.ts +22 -0
  26. package/src/stores/class/general/organisation.ts +10 -0
  27. package/src/stores/class/radio/canal.ts +30 -8
  28. package/src/stores/class/radio/recurrence.ts +6 -5
  29. package/src/style/_variables.scss +2 -0
  30. package/src/style/bootstrap.scss +3 -2
  31. package/tests/components/composable/usePresentationItem.spec.ts +163 -0
  32. package/tests/components/display/emission/EmissionPresentationItem.spec.ts +45 -0
  33. package/tests/components/display/podcasts/PodcastPresentationList.spec.ts +107 -0
  34. package/tests/components/form/ClassicButtonGroup.spec.ts +26 -0
  35. package/tests/components/form/OctopusMultiselect.spec.ts +298 -0
  36. package/tests/components/form/OctopusSelect.spec.ts +71 -0
  37. package/tests/components/layouts/PresentationItem.spec.ts +36 -0
  38. package/tests/stores/CacheStore.spec.ts +109 -0
@@ -2,37 +2,39 @@
2
2
  Simple component to display a few podcasts
3
3
  -->
4
4
  <template>
5
- <PresentationLayout
6
- v-if="!loading && !error"
7
- :title="title"
8
- :items="podcasts"
9
- :route="href"
10
- :button-text="buttonText"
11
- >
12
- <template #item="{ item, first }">
13
- <PresentationItem
14
- :class="!isPhone && first ? 'me-3' : ''"
15
- :name="item.title"
16
- :route="route(item)"
17
- :image-url="item.imageUrl"
18
- :description="item.description"
19
- :vertical="!isPhone && first"
20
- >
21
- <template #after-image>
22
- <PodcastPlayButton
23
- :podcast="item"
24
- :hide-play="false"
25
- :show-processing="false"
26
- />
5
+ <PresentationLayout
6
+ v-if="!loading && !error"
7
+ :title="title"
8
+ :items="podcasts"
9
+ :route="href"
10
+ :button-text="buttonText"
11
+ >
12
+ <template #item="{ item, first }">
13
+ <PresentationItem
14
+ :class="!isPhone && first ? 'me-3' : ''"
15
+ :name="item.title"
16
+ :route="route(item)"
17
+ :image-url="item.imageUrl"
18
+ :description="item.description"
19
+ :vertical="!isPhone && first"
20
+ :tags="tags.get(item.podcastId)"
21
+ :additional-info="additionalInfoFor(item)"
22
+ >
23
+ <template #after-image>
24
+ <PodcastPlayButton
25
+ :podcast="item"
26
+ :hide-play="false"
27
+ :show-processing="false"
28
+ />
29
+ </template>
30
+ </PresentationItem>
27
31
  </template>
28
- </PresentationItem>
29
- </template>
30
- </PresentationLayout>
31
- <ClassicLoading
32
- v-else
33
- :loading-text="loading ? $t('Loading emissions ...') : undefined"
34
- :error-text="error ? $t(`Error`) : undefined"
35
- />
32
+ </PresentationLayout>
33
+ <ClassicLoading
34
+ v-else
35
+ :loading-text="loading ? $t('Loading emissions ...') : undefined"
36
+ :error-text="error ? $t(`Error`) : undefined"
37
+ />
36
38
  </template>
37
39
 
38
40
  <script setup lang="ts">
@@ -40,7 +42,7 @@ import classicApi from "../../../api/classicApi";
40
42
  import {useErrorHandler} from "../../composable/useErrorHandler";
41
43
  import ClassicLoading from "../../form/ClassicLoading.vue";
42
44
  import { Emission } from "@/stores/class/general/emission";
43
- import { onMounted, Ref, ref } from "vue";
45
+ import { onMounted, reactive, Ref, ref, watch } from "vue";
44
46
  import { AxiosError } from "axios";
45
47
  import {useResizePhone} from "../../composable/useResizePhone";
46
48
  import { ListClassicReturn } from "../../../stores/class/general/listReturn";
@@ -52,89 +54,101 @@ import PresentationItem from "../../layouts/PresentationItem.vue";
52
54
  import PodcastPlayButton from "./PodcastPlayButton.vue";
53
55
  import { RouteLocationRaw } from "vue-router";
54
56
  import { podcastApi, PodcastSort } from "../../../api/podcastApi";
57
+ import { usePresentationItem } from "../../composable/usePresentationItem";
55
58
 
56
59
  //Props
57
60
  const props = defineProps({
58
- organisationId: { default: undefined, type: String },
59
- title: { default: "", type: String },
60
- href: { default: undefined, type: String },
61
- buttonText: { default: undefined, type: String },
62
- isDescription: { default: false, type: Boolean },
63
- rubriquesId: { default: [], type: Array<number> },
61
+ organisationId: { default: undefined, type: String },
62
+ title: { default: "", type: String },
63
+ href: { default: undefined, type: String },
64
+ buttonText: { default: undefined, type: String },
65
+ isDescription: { default: false, type: Boolean },
66
+ rubriquesId: { default: [], type: Array<number> },
64
67
  })
65
68
 
66
69
  //Data
67
70
  const loading = ref(true);
68
71
  const error = ref(false);
69
72
  const podcasts: Ref<Array<Podcast>> = ref([]);
73
+ const tags = reactive(new Map<number, Array<string>>());
70
74
 
71
75
  //Composables
72
76
  const { isPhone } = useResizePhone();
73
- const {handle403} = useErrorHandler();
77
+ const { handle403 } = useErrorHandler();
78
+ const { tagsFor, additionalInfoFor } = usePresentationItem();
74
79
 
75
- onMounted(()=>fetchNext())
80
+ onMounted(fetchNext);
81
+
82
+ watch(podcasts, async () => {
83
+ podcasts.value.forEach(async (podcast) => {
84
+ if (!tags.has(podcast.podcastId)) {
85
+ const t = await tagsFor(podcast);
86
+ tags.set(podcast.podcastId, t);
87
+ }
88
+ });
89
+ });
76
90
 
77
91
  //Methods
78
92
  async function fetchNext(): Promise<void> {
79
- loading.value = true;
80
- try {
81
- // Retrieve latest emissions
82
- const emissions = await classicApi.fetchData<ListClassicReturn<Emission>>({
83
- api: 0,
84
- path: "emission/search",
85
- parameters: {
86
- first: 0,
87
- size: 5,
88
- organisationId: props.organisationId,
89
- sort: "LAST_PODCAST_DESC",
90
- rubriqueId: props.rubriquesId
91
- },
92
- specialTreatement: true,
93
- });
93
+ loading.value = true;
94
+ try {
95
+ // Retrieve latest emissions
96
+ const emissions = await classicApi.fetchData<ListClassicReturn<Emission>>({
97
+ api: 0,
98
+ path: "emission/search",
99
+ parameters: {
100
+ first: 0,
101
+ size: 5,
102
+ organisationId: props.organisationId,
103
+ sort: "LAST_PODCAST_DESC",
104
+ rubriqueId: props.rubriquesId
105
+ },
106
+ specialTreatement: true,
107
+ });
94
108
 
95
- const promises: Array<Promise<SimplifiedPodcast>> = [];
109
+ const promises: Array<Promise<SimplifiedPodcast>> = [];
96
110
 
97
- for (let i = 0; i < emissions.result.length; i++) {
98
- promises.push(podcastApi.search({
99
- first: 0,
100
- size: 1,
101
- organisationId: [props.organisationId],
102
- emissionId: [emissions.result[i].emissionId],
103
- sort: PodcastSort.DATE,
104
- rubriqueId: props.rubriquesId
105
- }).then(r => r.result[0]));
106
- }
111
+ for (let i = 0; i < emissions.result.length; i++) {
112
+ promises.push(podcastApi.search({
113
+ first: 0,
114
+ size: 1,
115
+ organisationId: [props.organisationId],
116
+ emissionId: [emissions.result[i].emissionId],
117
+ sort: PodcastSort.DATE,
118
+ rubriqueId: props.rubriquesId
119
+ }).then(r => r.result[0]));
120
+ }
107
121
 
108
- // Retrieve the podcasts for these emissions
109
- const data = await Promise.all(promises);
122
+ // Retrieve the podcasts for these emissions
123
+ const data = await Promise.all(promises);
110
124
 
111
- podcasts.value = podcasts.value.concat(
112
- data.filter((em: SimplifiedPodcast | null) => null !== em && undefined !== em).map(p => {
113
- // Get emission from podcast
114
- const emission = emissions.result.find(e => e.emissionId === p.emissionId);
115
- // Create full podcast from simplified + emission
116
- return simplifiedToFull(p, emission.orga, emission);
117
- })
118
- );
125
+ podcasts.value = podcasts.value.concat(
126
+ data.filter((em: SimplifiedPodcast | null) => null !== em && undefined !== em).map(p => {
127
+ // Get emission from podcast
128
+ const emission = emissions.result.find(e => e.emissionId === p.emissionId);
129
+ // Create full podcast from simplified + emission
130
+ return simplifiedToFull(p, emission.orga, emission);
131
+ })
132
+ );
119
133
 
120
- // Sort podcasts by pub date so that the most recent one is focused
121
- podcasts.value.sort((p1, p2) => {
122
- return new Date(p2.pubDate).getTime() - new Date(p1.pubDate).getTime();
123
- });
134
+ // Sort podcasts by pub date so that the most recent one is focused
135
+ podcasts.value.sort((p1, p2) => {
136
+ return new Date(p2.pubDate).getTime() - new Date(p1.pubDate).getTime();
137
+ });
124
138
 
139
+ loading.value = false;
140
+ } catch (errorWs) {
141
+ console.error(errorWs);
142
+ handle403(errorWs as AxiosError);
143
+ error.value = true;
144
+ }
125
145
  loading.value = false;
126
- } catch (errorWs) {
127
- console.error(errorWs);
128
- handle403(errorWs as AxiosError);
129
- error.value = true;
130
- }
131
- loading.value = false;
132
146
  }
133
147
 
134
148
  function route(podcast: Podcast): RouteLocationRaw {
135
- return {
136
- name: 'podcast',
137
- params: { podcastId: podcast.podcastId }
138
- }
149
+ return {
150
+ name: 'podcast',
151
+ params: { podcastId: podcast.podcastId }
152
+ }
139
153
  }
140
154
  </script>
@@ -24,6 +24,8 @@ const props = defineProps<{
24
24
  value: T[];
25
25
  /** Possible values */
26
26
  options: ButtonGroupOption<T>[];
27
+ /** Select only one option */
28
+ solo?: boolean;
27
29
  }>();
28
30
 
29
31
  const emit = defineEmits<{
@@ -31,12 +33,16 @@ const emit = defineEmits<{
31
33
  }>();
32
34
 
33
35
  function toggle(value: T): void {
34
- const isSelected = props.value.includes(value);
35
- if (isSelected && props.value.length <= 1) { return; }
36
- const newValues = isSelected
37
- ? props.value.filter(v => v !== value)
38
- : [...props.value, value];
39
- emit('update:value', newValues);
36
+ if (props.solo === true) {
37
+ emit('update:value', [value]);
38
+ } else {
39
+ const isSelected = props.value.includes(value);
40
+ if (isSelected && props.value.length <= 1) { return; }
41
+ const newValues = isSelected
42
+ ? props.value.filter(v => v !== value)
43
+ : [...props.value, value];
44
+ emit('update:value', newValues);
45
+ }
40
46
  }
41
47
  </script>
42
48
 
@@ -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);