@saooti/octopus-sdk 41.9.6 → 41.10.1

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 (30) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/index.ts +10 -3
  3. package/package.json +1 -1
  4. package/src/api/aggregatorsApi.ts +53 -0
  5. package/src/api/podcastApi.ts +1 -1
  6. package/src/api/radioApi.ts +19 -0
  7. package/src/components/composable/share/useSharePlatforms.ts +112 -82
  8. package/src/components/composable/useRights.ts +21 -0
  9. package/src/components/composable/useTranslation.ts +9 -7
  10. package/src/components/display/live/RadioCurrently.vue +1 -0
  11. package/src/components/display/live/RadioImage.vue +3 -3
  12. package/src/components/display/sharing/SubscribeButtons.vue +6 -1
  13. package/src/components/form/ClassicButtonGroup.vue +71 -0
  14. package/src/components/form/ClassicInputText.vue +10 -3
  15. package/src/components/form/ClassicRadio.vue +15 -4
  16. package/src/components/misc/ClassicAlert.vue +2 -0
  17. package/src/components/misc/ClassicAvatar.vue +62 -0
  18. package/src/components/misc/ClassicNotifications.vue +12 -9
  19. package/src/components/misc/TopBar.vue +1 -1
  20. package/src/helper/colorFromString.ts +15 -0
  21. package/src/stores/NotificationStore.ts +54 -0
  22. package/src/stores/PlayerStore.ts +1 -1
  23. package/src/style/_utilities.scss +7 -1
  24. package/tests/components/composable/useRights.spec.ts +18 -0
  25. package/tests/components/composable/useTranslation.spec.ts +27 -3
  26. package/tests/components/form/ClassicButtonGroup.spec.ts +52 -0
  27. package/tests/components/form/ClassicInputText.spec.ts +23 -0
  28. package/tests/components/misc/ClassicAvatar.spec.ts +68 -0
  29. package/src/components/composable/useNotifications.ts +0 -50
  30. package/src/stores/class/rss/aggregator.ts +0 -28
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 41.10.1 (04/05/2026)
4
+
5
+ **Fixes**
6
+
7
+ - **14469** - Correction récupération incorrecte de la configuration de
8
+ traduction de l'organisation
9
+
10
+ ## 41.10.0 (04/05/2026)
11
+
12
+ **Features**
13
+
14
+ - **14384** - Changements système d'aggregateurs
15
+ - Mise en place de `aggregatorsApi` pour faciliter les requêtes
16
+ - Ajout de fonctions utilitaires pour les droits
17
+ - Intégration des plateformes personnalisées à `useSharePlatforms`
18
+ - Ajout d'un composant `ClassicAvatar` pour identifier des éléments avec une
19
+ image ou lettre sur fond coloré
20
+ - Ajout event `blur` sur `ClassicInputText`
21
+ - Amélioration accessibilité `ClassicInputText`
22
+ - Ajout composant `ClassicButtonGroup`
23
+ - `ClassicRadio` calcule automatiquement un id si non défini
24
+ - Ajout `radioApi`
25
+ - Ajout d'un helper `gap-X` pour contrôler l'espacement des éléments flex
26
+ - Fonctionne comme le helper de marges `mt-X`
27
+ - Utilisation d'un store à la place d'un composable pour la gestion des
28
+ notifications
29
+
30
+ **Fixes**
31
+
32
+ - **14353** - Correction erreur sur page radio
33
+ - **14354** - Correction recherche par tag
34
+ - Correction timer dans `ClassicInputText` faisant échouer les tests de manière
35
+ aléatoire
36
+
37
+ **Misc**
38
+
39
+ - Ajustement affichage bouton play de `RadioImage`
40
+ - Correction vulnérabilité xmidom
41
+ - Mise à jour dépendances
42
+
3
43
  ## 41.9.6 (20/04/2026)
4
44
 
5
45
  **Features**
package/index.ts CHANGED
@@ -55,6 +55,11 @@ export {
55
55
  import ActionButton from "./src/components/buttons/ActionButton.vue";
56
56
  export { ActionButton };
57
57
 
58
+ // Form
59
+ import ClassicButtonGroup from "./src/components/form/ClassicButtonGroup.vue";
60
+ export { ClassicButtonGroup };
61
+ export type { ButtonGroupOption } from "./src/components/form/ClassicButtonGroup.vue";
62
+
58
63
  //Display
59
64
  export const getCategoryChooser = () => import("./src/components/display/categories/CategoryChooser.vue");
60
65
  export const getCategoryList = () => import("./src/components/display/categories/CategoryList.vue");
@@ -129,8 +134,7 @@ import {useOrganisationFilter} from "./src/components/composable/useOrganisation
129
134
  import {useInit} from "./src/components/composable/useInit.ts";
130
135
  import {useErrorHandler} from "./src/components/composable/useErrorHandler.ts";
131
136
  import { useSimplePageParam } from "./src/components/composable/route/useSimplePageParam";
132
- import { useNotifications } from "./src/components/composable/useNotifications.ts";
133
- export { useSharePlatforms, SharePlatformName, type SharePlatform } from "./src/components/composable/share/useSharePlatforms.ts";
137
+ export { useSharePlatforms, PREDEFINED_PLATFORMS, SharePlatformName, type SharePlatform } from "./src/components/composable/share/useSharePlatforms.ts";
134
138
  export { useSharePath } from "./src/components/composable/share/useSharePath.ts";
135
139
  export { useOrgaComputed } from "./src/components/composable/useOrgaComputed.ts";
136
140
  export { useSeoTitleUrl } from "./src/components/composable/route/useSeoTitleUrl.ts";
@@ -158,6 +162,7 @@ import {useFilterStore} from "./src/stores/FilterStore.ts";
158
162
  import {useCommentStore} from "./src/stores/CommentStore.ts";
159
163
  import {useApiStore} from "./src/stores/ApiStore.ts";
160
164
  import {useAuthStore} from "./src/stores/AuthStore.ts";
165
+ export * from "./src/stores/NotificationStore.ts";
161
166
  import {getApiUrl, ModuleApi} from "./src/api/apiConnection.ts";
162
167
  import classicApi from "./src/api/classicApi.ts";
163
168
 
@@ -168,6 +173,8 @@ export * from "./src/api/groupsApi.ts";
168
173
  export { organisationApi } from "./src/api/organisationApi.ts";
169
174
  export { playlistApi } from "./src/api/playlistApi.ts";
170
175
  export { podcastApi, PodcastSort, type PodcastSearchOptions } from "./src/api/podcastApi.ts";
176
+ export { radioApi } from "./src/api/radioApi.ts";
177
+ export * from "./src/api/aggregatorsApi.ts";
171
178
 
172
179
  // Types
173
180
  export { type Emission, SeasonMode, emptyEmissionData } from "./src/stores/class/general/emission.ts";
@@ -188,6 +195,7 @@ export {
188
195
  type ProviderTts,
189
196
  type Voice
190
197
  } from "./src/stores/class/transcript/transcriptParams.ts";
198
+ export { type Canal } from "./src/stores/class/radio/canal.ts";
191
199
 
192
200
  //Icons
193
201
  export const getAmazonMusicIcon = () => import("./src/components/icons/AmazonMusicIcon.vue");
@@ -233,7 +241,6 @@ export {
233
241
  useInit,
234
242
  useErrorHandler,
235
243
  useSimplePageParam,
236
- useNotifications,
237
244
  debounce,
238
245
  useVastStore,
239
246
  useSaveFetchStore,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saooti/octopus-sdk",
3
- "version": "41.9.6",
3
+ "version": "41.10.1",
4
4
  "private": false,
5
5
  "description": "Javascript SDK for using octopus",
6
6
  "author": "Saooti",
@@ -0,0 +1,53 @@
1
+ import { ModuleApi } from "./apiConnection";
2
+ import classicApi from "./classicApi";
3
+
4
+ export interface Aggregator {
5
+ /** ID of the aggregator */
6
+ aggregatorId: number;
7
+ /** Name of the aggregator */
8
+ name: string;
9
+ /** User who created this aggregator */
10
+ createdBy: string;
11
+ /** Link to image for aggregator */
12
+ image?: string;
13
+ /** IP criteria */
14
+ ip?: string;
15
+ /** IP range criteria */
16
+ ipRange?: string;
17
+ /** Regex for referer criteria */
18
+ refererRegexp?: string;
19
+ /** Slug criteria */
20
+ slug?: string;
21
+ /** Regex for user agent criteria */
22
+ uaRegexp?: string;
23
+ }
24
+
25
+ export type PredefinedAggregator = Pick<Aggregator, 'name' | 'image'>;
26
+
27
+ /**
28
+ * Return the list of predefined aggregators
29
+ */
30
+ async function getPredefined(): Promise<Array<PredefinedAggregator>> {
31
+ return classicApi.fetchData<Array<PredefinedAggregator>>({
32
+ api: ModuleApi.DEFAULT,
33
+ path: 'rss/aggregator/distributions'
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Return the list of all user-defined aggregators
39
+ * @param organisationId If defined, the aggregators will be filtered for this
40
+ * organisation
41
+ */
42
+ async function getAll(organisationId?: string): Promise<Array<Aggregator>> {
43
+ return classicApi.fetchData<Array<Aggregator>>({
44
+ api: ModuleApi.DEFAULT,
45
+ path: 'rss/aggregator/list',
46
+ parameters: organisationId ? { organisationId } : undefined
47
+ });
48
+ }
49
+
50
+ export const aggregatorsApi = {
51
+ getPredefined,
52
+ getAll
53
+ };
@@ -120,7 +120,7 @@ function processSearchParameters(search: PodcastSearchOptions): FetchParam {
120
120
  } else if (key === 'processingStatus') {
121
121
  parameters.includeStatus = value;
122
122
  } else if (key === 'tags') {
123
- parameters.includeTags = value;
123
+ parameters.includeTag = value;
124
124
  } else if (key === 'pubDateBefore') {
125
125
  parameters.before = value;
126
126
  } else if (key === 'pubDateAfter') {
@@ -0,0 +1,19 @@
1
+ import { Canal } from "../stores/class/radio/canal";
2
+ import classicApi from "./classicApi";
3
+ import { ModuleApi } from "./apiConnection";
4
+
5
+ /**
6
+ * Retrieve a radio canal by its id
7
+ * @param radioId Id of the radio to retrieve
8
+ * @returns The canal data
9
+ */
10
+ async function get(radioId: number): Promise<Canal> {
11
+ return classicApi.fetchData<Canal>({
12
+ api: ModuleApi.RADIO,
13
+ path: `canal/${radioId}`
14
+ });
15
+ }
16
+
17
+ export const radioApi = {
18
+ get
19
+ };
@@ -14,7 +14,11 @@ import RadioFranceIcon from "../../icons/RadioFranceIcon.vue";
14
14
  import YoutubeIcon from "vue-material-design-icons/Youtube.vue";
15
15
  import SpotifyIcon from "vue-material-design-icons/Spotify.vue";
16
16
  import { Annotations } from "@/stores/class/general";
17
- import { computed, markRaw, type Component } from "vue";
17
+ import { h, markRaw, onMounted, ref, type Component } from "vue";
18
+ import { aggregatorsApi } from "../../../api/aggregatorsApi";
19
+ import { useAuthStore } from "../../../stores/AuthStore";
20
+ import { useNotificationStore } from "../../../stores/NotificationStore";
21
+ import ClassicAvatar from "../../misc/ClassicAvatar.vue";
18
22
 
19
23
  export enum SharePlatformName {
20
24
  APPLE = "applePodcast",
@@ -36,13 +40,15 @@ export enum SharePlatformName {
36
40
 
37
41
  export interface SharePlatform {
38
42
  /** ID of the platform */
39
- name: SharePlatformName;
43
+ name: SharePlatformName|string;
40
44
  /** Icon of the platform */
41
45
  icon: Component;
42
46
  /** Display text/title for buttons */
43
47
  title: string;
44
48
  /** Color of the icon */
45
49
  color: string;
50
+ /** Annotation to use for url */
51
+ annotation?: string;
46
52
  }
47
53
 
48
54
  export interface SharePlatformUrl extends SharePlatform {
@@ -50,87 +56,111 @@ export interface SharePlatformUrl extends SharePlatform {
50
56
  url: string;
51
57
  }
52
58
 
59
+ export const PREDEFINED_PLATFORMS: Array<SharePlatform> = [{
60
+ name: SharePlatformName.APPLE,
61
+ icon: markRaw(ApplePodcastIcon),
62
+ title: "Apple Podcast | iTunes",
63
+ color: "#aa1dd3"
64
+ }, {
65
+ name: SharePlatformName.DEEZER,
66
+ icon: markRaw(DeezerIcon),
67
+ title: "Deezer",
68
+ color: "#a238ff",
69
+ }, {
70
+ name: SharePlatformName.SPOTIFY,
71
+ icon: markRaw(SpotifyIcon),
72
+ title: "Spotify",
73
+ color: "#1ed760",
74
+ }, {
75
+ name: SharePlatformName.AMAZON,
76
+ icon: markRaw(AmazonMusicIcon),
77
+ title: "Amazon Music",
78
+ color: "#0c6cb3",
79
+ }, {
80
+ name: SharePlatformName.I_HEART,
81
+ icon: markRaw(IHeartIcon),
82
+ title: "iHeart",
83
+ color: "#e11b22"
84
+ }, {
85
+ name: SharePlatformName.PLAYER_FM,
86
+ icon: markRaw(PlayerFmIcon),
87
+ title: "Player FM",
88
+ color: "#bb202a"
89
+ }, {
90
+ name: SharePlatformName.POCKET_CASTS,
91
+ icon: markRaw(PocketCastIcon),
92
+ title: "Pocket Casts",
93
+ color: "#f43e37"
94
+ }, {
95
+ name: SharePlatformName.PODCAST_ADDICT,
96
+ icon: markRaw(PodcastAddictIcon),
97
+ title: "Podcast Addict",
98
+ color: "#f4842d"
99
+ }, {
100
+ name: SharePlatformName.RADIOLINE,
101
+ icon: markRaw(RadiolineIcon),
102
+ title: "Radioline",
103
+ color: "#1678bd"
104
+ }, {
105
+ name: SharePlatformName.TUNE_IN,
106
+ icon: markRaw(TuninIcon),
107
+ title: "TuneIn",
108
+ color: "#36b4a7"
109
+ }, {
110
+ name: SharePlatformName.YOUTUBE,
111
+ icon: markRaw(YoutubeIcon),
112
+ title: "YouTube Music",
113
+ color: "#fe0000",
114
+ }, {
115
+ name: SharePlatformName.CASTBOX,
116
+ icon: markRaw(CastboxIcon),
117
+ title: "Castbox",
118
+ color: "#fe6222",
119
+ }, {
120
+ name: SharePlatformName.PODBEAN,
121
+ icon: markRaw(PodbeanIcon),
122
+ title: "PodBean",
123
+ color: "#428200",
124
+ }, {
125
+ name: SharePlatformName.PODCAST_REPUBLIC,
126
+ icon: markRaw(PodcastRepublicIcon),
127
+ title: "Podcast Republic",
128
+ color: "#5c85dd",
129
+ }, {
130
+ name: SharePlatformName.RADIO_FRANCE,
131
+ icon: markRaw(RadioFranceIcon),
132
+ title: "Radio France",
133
+ color: "#a90041",
134
+ }];
135
+
53
136
  export const useSharePlatforms = () => {
54
-
55
- const platforms = computed((): Array<SharePlatform> => {
56
- return [{
57
- name: SharePlatformName.APPLE,
58
- icon: markRaw(ApplePodcastIcon),
59
- title: "Apple Podcast | iTunes",
60
- color:"#aa1dd3"
61
- }, {
62
- name: SharePlatformName.DEEZER,
63
- icon: markRaw(DeezerIcon),
64
- title: "Deezer",
65
- color:"#a238ff",
66
- }, {
67
- name: SharePlatformName.SPOTIFY,
68
- icon: markRaw(SpotifyIcon),
69
- title: "Spotify",
70
- color: "#1ed760",
71
- }, {
72
- name: SharePlatformName.AMAZON,
73
- icon: markRaw(AmazonMusicIcon),
74
- title: "Amazon Music",
75
- color: "#0c6cb3",
76
- }, {
77
- name: SharePlatformName.I_HEART,
78
- icon: markRaw(IHeartIcon),
79
- title: "iHeart",
80
- color:"#e11b22"
81
- }, {
82
- name: SharePlatformName.PLAYER_FM,
83
- icon: markRaw(PlayerFmIcon),
84
- title: "Player FM",
85
- color:"#bb202a"
86
- }, {
87
- name: SharePlatformName.POCKET_CASTS,
88
- icon: markRaw(PocketCastIcon),
89
- title: "Pocket Casts",
90
- color:"#f43e37"
91
- }, {
92
- name: SharePlatformName.PODCAST_ADDICT,
93
- icon: markRaw(PodcastAddictIcon),
94
- title: "Podcast Addict",
95
- color:"#f4842d"
96
- }, {
97
- name: SharePlatformName.RADIOLINE,
98
- icon: markRaw(RadiolineIcon),
99
- title: "Radioline",
100
- color:"#1678bd"
101
- }, {
102
- name: SharePlatformName.TUNE_IN,
103
- icon: markRaw(TuninIcon),
104
- title: "TuneIn",
105
- color:"#36b4a7"
106
- }, {
107
- name: SharePlatformName.YOUTUBE,
108
- icon: markRaw(YoutubeIcon),
109
- title: "YouTube Music",
110
- color: "#fe0000",
111
- }, {
112
- name: SharePlatformName.CASTBOX,
113
- icon: markRaw(CastboxIcon),
114
- title: "Castbox",
115
- color: "#fe6222",
116
- }, {
117
- name: SharePlatformName.PODBEAN,
118
- icon: markRaw(PodbeanIcon),
119
- title: "PodBean",
120
- color: "#428200",
121
- }, {
122
- name: SharePlatformName.PODCAST_REPUBLIC,
123
- icon: markRaw(PodcastRepublicIcon),
124
- title: "Podcast Republic",
125
- color: "#5c85dd",
126
- }, {
127
- name: SharePlatformName.RADIO_FRANCE,
128
- icon: markRaw(RadioFranceIcon),
129
- title: "Radio France",
130
- color: "#a90041",
131
- }];
132
- });
133
137
 
138
+ const platforms = ref<Array<SharePlatform>>([]);
139
+
140
+ const { authOrgaId } = useAuthStore();
141
+ const { addNotification } = useNotificationStore();
142
+
143
+ onMounted(async (): Promise<void> => {
144
+ platforms.value.push(...PREDEFINED_PLATFORMS);
145
+ try {
146
+ const customPlatforms = await aggregatorsApi.getAll(authOrgaId);
147
+ customPlatforms.forEach(platform => {
148
+ platforms.value.push({
149
+ name: platform.name,
150
+ title: platform.name,
151
+ icon: markRaw(() => h(ClassicAvatar, { name: platform.name, imageUrl: platform.image })),
152
+ color: "white",
153
+ annotation: `ptfaudio_${platform.name}`
154
+ });
155
+ });
156
+ } catch(error) {
157
+ addNotification({
158
+ type: 'error',
159
+ message: error
160
+ });
161
+ }
162
+ });
163
+
134
164
  /**
135
165
  * Helper to retrieve configuration of a specific platform
136
166
  * @param platform The ID of the platform
@@ -149,7 +179,7 @@ export const useSharePlatforms = () => {
149
179
  function getPlatformsWithLinks(annotations: Annotations|undefined): Array<SharePlatformUrl> {
150
180
  const ary: Array<SharePlatformUrl> = [];
151
181
  platforms.value.forEach(p => {
152
- const url = getUrl(p.name, annotations);
182
+ const url = getUrl(p.annotation ?? p.name, annotations);
153
183
  if (url) {
154
184
  ary.push({
155
185
  ...p,
@@ -12,6 +12,7 @@ export enum EditRight {
12
12
  Restricted, // User cannot edit because element is used elsewhere
13
13
  Full // User can edit
14
14
  }
15
+
15
16
  /**
16
17
  * Composable to manage rights.
17
18
  * Based on AuthStore, but converts roles to easily usable tests for various
@@ -177,6 +178,21 @@ export const useRights = () => {
177
178
  return roleContainsAny('RESTRICTED_PRODUCTION') && !roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION');
178
179
  }
179
180
 
181
+ /** Can the current user create an aggregator */
182
+ function canCreateAggregator(): boolean {
183
+ return roleContainsAny('ADMIN', 'ORGANISATION');
184
+ }
185
+
186
+ /** Can the current user edit an aggregator */
187
+ function canEditAggregator(): boolean {
188
+ return roleContainsAny('ADMIN', 'ORGANISATION');
189
+ }
190
+
191
+ /** Can the current user delete an aggregator */
192
+ function canDeleteAggregator(): boolean {
193
+ return roleContainsAny('ADMIN', 'ORGANISATION');
194
+ }
195
+
180
196
  return {
181
197
  // Emissions
182
198
  canCreateEmission,
@@ -203,6 +219,11 @@ export const useRights = () => {
203
219
  canEditParticipant,
204
220
  canDeleteParticipant,
205
221
 
222
+ // Aggregators
223
+ canCreateAggregator,
224
+ canEditAggregator,
225
+ canDeleteAggregator,
226
+
206
227
  // Other
207
228
  canEditCodeInsertPlayer,
208
229
  canEditTranscript,
@@ -3,6 +3,8 @@ import { transcriptionApi, TranslationState, type PodcastTranslationData } from
3
3
  import { CreateTranslation, defaultTranslationConfig, TranslationConfiguration } from "../../stores/class/transcript/transcriptParams";
4
4
  import { podcastApi } from "../../api/podcastApi";
5
5
  import { Emission } from "../../stores/class/general/emission";
6
+ import { OrganisationAttributes } from "@/stores/class/general/organisation";
7
+ import { organisationApi } from "../../api/organisationApi";
6
8
 
7
9
  const DEFAULT_LANGUAGE = 'en';
8
10
 
@@ -67,21 +69,20 @@ export const useTranslation = () => {
67
69
  * @param emission *(optional)* If set, will also check in emission settings
68
70
  * @returns Whether the language is available or not
69
71
  */
70
- function getTranslationConfig(language: string, emission: Emission): CreateTranslation {
71
- const orgAttributes = emission.orga.attributes;
72
- const emissionTranslation = parseOrDefault(emission?.annotations?.['translation-config'] as string|undefined, true);
73
- const orgTranslation = parseOrDefault(orgAttributes?.['translation-config']);
72
+ function getTranslationConfig(language: string, emission: Emission, orgaAttributes: OrganisationAttributes): CreateTranslation {
73
+ const emissionTranslation = parseOrDefault(emission.annotations?.['translation-config'] as string|undefined, true);
74
+ const orgTranslation = parseOrDefault(orgaAttributes?.['translation-config']);
74
75
 
75
76
  return getConfigurationFor(language, emissionTranslation, orgTranslation);
76
77
  }
77
78
 
78
- function getLanguageAvailability(language: string, emission: Emission, translationData: PodcastTranslationData): Availability {
79
+ function getLanguageAvailability(language: string, emission: Emission, orgaAttributes: OrganisationAttributes, translationData: PodcastTranslationData): Availability {
79
80
 
80
81
  // 1. If language of podcast == language of browser, use that language
81
82
  if (language === translationData.nativeLanguage) {
82
83
  return Availability.Available;
83
84
  } else {
84
- const langConfig = getTranslationConfig(language, emission);
85
+ const langConfig = getTranslationConfig(language, emission, orgaAttributes);
85
86
 
86
87
  // 2. If the language of the browser is available, use it
87
88
  if (langConfig === CreateTranslation.ALWAYS) {
@@ -136,12 +137,13 @@ export const useTranslation = () => {
136
137
 
137
138
  const podcast = await podcastApi.get(translationData.podcastId);
138
139
  const emission = podcast.emission;
140
+ const orgaAttributes = await organisationApi.getAttributes(podcast.organisation.id);
139
141
 
140
142
  let bestReady: string|null = null;
141
143
  let bestAvailable: string|null = null;
142
144
 
143
145
  for (const language of languages) {
144
- const avaibility = getLanguageAvailability(language, emission, translationData);
146
+ const avaibility = getLanguageAvailability(language, emission, orgaAttributes, translationData);
145
147
  if (avaibility === Availability.Available && bestReady === null) {
146
148
  bestReady = language;
147
149
  break;
@@ -104,6 +104,7 @@ function updateMetadata(metadata: MediaRadio|undefined, podcast?: Podcast): void
104
104
  currentPodcast.value = podcast;
105
105
  }
106
106
  </script>
107
+
107
108
  <style lang="scss">
108
109
  .octopus-app .small-img-box {
109
110
  height: 80px;
@@ -18,8 +18,8 @@
18
18
  />
19
19
  <button class="radio-play-button" @click="playRadio">
20
20
  <PlayIcon v-if="!playingRadio" :title="t('Play')" :size="40" />
21
- <PodcastIsPlaying v-else/>
22
- <div class="ms-2">
21
+ <PodcastIsPlaying v-else />
22
+ <div class="mx-2">
23
23
  {{ playText }}
24
24
  </div>
25
25
  </button>
@@ -84,4 +84,4 @@ function playRadio(): void {
84
84
  padding: 0.2rem;
85
85
  border: 0;
86
86
  }
87
- </style>
87
+ </style>
@@ -21,7 +21,12 @@
21
21
  :href="sub.url"
22
22
  :title="t('New window', {text: sub.title})"
23
23
  >
24
- <component :is="sub.icon" :fill-color="fillColor(sub)" :size="iconSize" />
24
+ <component
25
+ :is="sub.icon"
26
+ :fill-color="fillColor(sub)"
27
+ :size="iconSize"
28
+ non-decorative
29
+ />
25
30
  </a>
26
31
  </div>
27
32
  <a
@@ -0,0 +1,71 @@
1
+ <template>
2
+ <div class="classic-button-group">
3
+ <button
4
+ v-for="option in options"
5
+ :key="option.value"
6
+ type="button"
7
+ class="btn"
8
+ :class="{ active: modelValue.includes(option.value) }"
9
+ @click="toggle(option.value)"
10
+ >
11
+ {{ option.label }}
12
+ </button>
13
+ </div>
14
+ </template>
15
+
16
+ <script setup lang="ts">
17
+ export interface ButtonGroupOption {
18
+ label: string;
19
+ value: string;
20
+ }
21
+
22
+ const props = defineProps<{
23
+ options: ButtonGroupOption[];
24
+ modelValue: string[];
25
+ }>();
26
+
27
+ const emit = defineEmits<{
28
+ 'update:modelValue': [values: string[]];
29
+ }>();
30
+
31
+ function toggle(value: string): void {
32
+ const isSelected = props.modelValue.includes(value);
33
+ if (isSelected && props.modelValue.length <= 1) { return; }
34
+ const newValues = isSelected
35
+ ? props.modelValue.filter(v => v !== value)
36
+ : [...props.modelValue, value];
37
+ emit('update:modelValue', newValues);
38
+ }
39
+ </script>
40
+
41
+ <style lang="scss" scoped>
42
+ .classic-button-group {
43
+ display: inline-flex;
44
+ border: 1px solid var(--octopus-border-default);
45
+ border-radius: var(--octopus-border-radius);
46
+ overflow: hidden;
47
+
48
+ .btn {
49
+ border: none;
50
+ border-right: 1px solid var(--octopus-border-default);
51
+ border-radius: 0;
52
+ background-color: var(--octopus-secondary);
53
+ color: var(--octopus-gray-text);
54
+ font-size: 0.8rem;
55
+ transition: background-color 0.15s ease;
56
+
57
+ &:last-child {
58
+ border-right: none;
59
+ }
60
+
61
+ &:hover:not(.active) {
62
+ background-color: var(--octopus-secondary-darker);
63
+ }
64
+
65
+ &.active {
66
+ background-color: var(--octopus-primary);
67
+ color: white;
68
+ }
69
+ }
70
+ }
71
+ </style>
@@ -60,7 +60,9 @@
60
60
  }"
61
61
  :disabled="isDisable || disabled"
62
62
  :required="!canBeNull"
63
+ :aria-required="!canBeNull"
63
64
  :autocomplete="autocompleteType"
65
+ @blur="emit('blur')"
64
66
  >
65
67
  <textarea
66
68
  v-else-if="isTextarea"
@@ -129,7 +131,7 @@
129
131
  <script setup lang="ts">
130
132
  import AsteriskIcon from "vue-material-design-icons/Asterisk.vue";
131
133
  import HelpCircleIcon from "vue-material-design-icons/HelpCircle.vue";
132
- import { computed, defineAsyncComponent, onMounted, Ref, ref, useTemplateRef, watch, getCurrentInstance } from "vue";
134
+ import { computed, defineAsyncComponent, onMounted, onUnmounted, Ref, ref, useTemplateRef, watch, getCurrentInstance } from "vue";
133
135
  import { useI18n } from "vue-i18n";
134
136
  const ClassicPopover = defineAsyncComponent(
135
137
  () => import("../misc/ClassicPopover.vue"),
@@ -187,7 +189,7 @@ const props = defineProps({
187
189
  })
188
190
 
189
191
  //Emits
190
- const emit = defineEmits(["update:textInit", "update:errorVariable"]);
192
+ const emit = defineEmits(["update:textInit", "update:errorVariable", "blur"]);
191
193
 
192
194
  //Data
193
195
  const textValue : Ref<string | undefined>= ref(undefined);
@@ -262,7 +264,7 @@ onMounted(()=>{
262
264
 
263
265
  if (props.isTextarea) {
264
266
  // Delay a scroll back to the top of the text area
265
- setTimeout(() => {
267
+ scrollTimer = setTimeout(() => {
266
268
  const textArea = document.getElementById(computedInputId.value);
267
269
  if (textArea) {
268
270
  textArea.scrollTop = 0;
@@ -270,6 +272,11 @@ onMounted(()=>{
270
272
  }, 100);
271
273
  }
272
274
  });
275
+
276
+ let scrollTimer: ReturnType<typeof setTimeout> | undefined;
277
+ onUnmounted(() => {
278
+ clearTimeout(scrollTimer);
279
+ });
273
280
 
274
281
  //Methods
275
282
  function addEmojiSelected(emoji: string) {
@@ -18,15 +18,15 @@
18
18
  :class="isColumn !== false ? 'd-flex flex-nowrap align-items-center' : 'me-2'"
19
19
  >
20
20
  <input
21
- :id="idRadio + option.value"
21
+ :id="computedId + option.value"
22
22
  :checked="textInit === option.value"
23
23
  type="radio"
24
- :name="idRadio"
24
+ :name="computedId"
25
25
  :value="option.value"
26
26
  :disabled="isDisabled"
27
27
  @input="onChange($event.target.value)"
28
28
  >
29
- <label class="c-hand" :for="idRadio + option.value">
29
+ <label class="c-hand" :for="computedId + option.value">
30
30
  <slot :name="'label-' + option.value" v-bind="slotBindings(option)">{{ option.title }}</slot>
31
31
  </label>
32
32
 
@@ -36,8 +36,10 @@
36
36
  </template>
37
37
 
38
38
  <script setup generic="T extends { title: string; value: string|undefined; }" lang="ts">
39
+ import { computed, getCurrentInstance } from 'vue';
40
+
39
41
  //Props
40
- const { textInit, isColumn = true } = defineProps<{
42
+ const { textInit, isColumn = true, idRadio } = defineProps<{
41
43
  options: Array<T>;
42
44
  textInit?: string;
43
45
  idRadio?: string;
@@ -50,6 +52,15 @@ const emit = defineEmits<{
50
52
  (e: 'update:textInit', value: string): void;
51
53
  }>();
52
54
 
55
+ const uid = getCurrentInstance()?.uid;
56
+ const computedId = computed((): string => {
57
+ if (idRadio !== undefined) {
58
+ return idRadio;
59
+ } else {
60
+ return 'classic-radio-' + uid;
61
+ }
62
+ });
63
+
53
64
  //Methods
54
65
  function onChange(value: string){
55
66
  emit('update:textInit', value)
@@ -15,6 +15,8 @@
15
15
  <component
16
16
  :is="iconComponent"
17
17
  v-if="!noIcon"
18
+ aria-hidden="true"
19
+ focusable="false"
18
20
  class="icon"
19
21
  :size="text ? 22 : 30"
20
22
  />
@@ -0,0 +1,62 @@
1
+ <template>
2
+ <div
3
+ class="avatar-container d-flex align-items-center justify-content-center"
4
+ :aria-hidden="!nonDecorative"
5
+ :style="containerStyle"
6
+ >
7
+ <img
8
+ v-if="imageUrl"
9
+ :alt="nonDecorative ? name : ''"
10
+ :width="size"
11
+ :height="size"
12
+ :src="useProxyImageUrl(imageUrl, '' + size)"
13
+ >
14
+ <template v-else>
15
+ {{ name.length > 0 ? name[0].toUpperCase() : '?' }}
16
+ </template>
17
+ </div>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ import { computed, type StyleValue } from 'vue';
22
+ import { useImageProxy } from '../composable/useImageProxy';
23
+ import { colorFromString } from '../../helper/colorFromString';
24
+
25
+ const props = withDefaults(defineProps<{
26
+ /** Name of the element to display */
27
+ name: string;
28
+ /** URL to the image to be displayed */
29
+ imageUrl?: string;
30
+ /** Size of the avatar */
31
+ size?: number;
32
+ /**
33
+ * Indicates that this element is not decorative.
34
+ * Use this when this element is the only thing to identify a platform.
35
+ * Will use accessibility options to hide this element if true.
36
+ */
37
+ nonDecorative?: boolean;
38
+ }>(), {
39
+ imageUrl: undefined,
40
+ size: 24
41
+ });
42
+
43
+ const { useProxyImageUrl } = useImageProxy();
44
+
45
+ const containerStyle = computed((): StyleValue => {
46
+ const size = `${props.size}px`;
47
+ return {
48
+ 'background-color': colorFromString(props.name),
49
+ 'font-size': `${props.size / 2}px`,
50
+ height: size,
51
+ width: size
52
+ };
53
+ });
54
+ </script>
55
+
56
+ <style scoped lang="scss">
57
+ .avatar-container {
58
+ border-radius: 30%;
59
+ font-weight: 800;
60
+ color: black;
61
+ }
62
+ </style>
@@ -3,21 +3,24 @@
3
3
  -->
4
4
  <template>
5
5
  <MessageModal
6
- v-if="notification !== null"
7
- :title="notification.title"
8
- :message="notification.message"
9
- :closeable="notification.closeable !== false"
10
- @close="clearNotifications"
6
+ v-if="currentNotification"
7
+ :title="currentNotification.title"
8
+ :message="currentNotification.message"
9
+ :closeable="currentNotification.closeable !== false"
10
+ @close="clearNotification"
11
11
  />
12
12
  </template>
13
13
 
14
14
  <script setup lang="ts">
15
+ import { storeToRefs } from 'pinia';
16
+ import { useNotificationStore } from '../../stores/NotificationStore';
15
17
  import MessageModal from './modal/MessageModal.vue';
16
18
 
17
- import { useNotifications } from '../composable/useNotifications';
19
+ const {
20
+ clearNotification
21
+ } = useNotificationStore();
18
22
 
19
23
  const {
20
- notification,
21
- clearNotifications
22
- } = useNotifications();
24
+ currentNotification,
25
+ } = storeToRefs(useNotificationStore());
23
26
  </script>
@@ -26,7 +26,7 @@
26
26
  {{ titleToDisplay }}
27
27
  </h1>
28
28
  <SubscribeButtons
29
- v-if="!authStore.isGarRole"
29
+ v-if="!authStore.isGarRole && content"
30
30
  v-show="!scrolled"
31
31
  :content="content"
32
32
  :window-width="windowWidth"
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Get a color from a string.
3
+ * Using the same string, the color will always be the same.
4
+ * @param str The string used for the color
5
+ * @param saturation Saturation of the color
6
+ * @param lightness Lightness of the color
7
+ * @returns A color
8
+ */
9
+ export function colorFromString(str: string, saturation = 80, lightness = 70) {
10
+ let hash = 0;
11
+ str.split('').forEach(char => {
12
+ hash = char.charCodeAt(0) + ((hash << 5) - hash);
13
+ });
14
+ return `hsl(${(hash + 360) % 360}, ${saturation}%, ${lightness}%)`;
15
+ }
@@ -0,0 +1,54 @@
1
+ import { defineStore } from "pinia";
2
+ import { computed, ref } from "vue";
3
+
4
+ type NotificationType = 'success'|'info'|'warning'|'error';
5
+
6
+ /**
7
+ * Type for notifications
8
+ */
9
+ interface Notification {
10
+ /** Optional title of the notification */
11
+ title?: string;
12
+ /** Message displayed by the notification */
13
+ message: string;
14
+ /** Type of notification, may affect display */
15
+ type: NotificationType;
16
+ /** When set to false, disallow manual closing of modal notification (default: true) */
17
+ closeable?: boolean;
18
+ }
19
+
20
+ export const useNotificationStore = defineStore('notifications', () => {
21
+ const notificationQueue = ref<Array<Notification>>([]);
22
+
23
+ /**
24
+ * Add a notification to the queue
25
+ * @param notification The notification to add
26
+ */
27
+ function addNotification(notification: Notification): void {
28
+ notificationQueue.value.push(notification);
29
+ }
30
+
31
+ /** Remove the current notification from the queue */
32
+ function clearNotification(): void {
33
+ const notif = currentNotification.value;
34
+ if (notif !== null) {
35
+ const idx = notificationQueue.value.indexOf(notif);
36
+ notificationQueue.value.splice(idx, 1);
37
+ }
38
+ }
39
+
40
+ /** The currently active notification */
41
+ const currentNotification = computed((): Notification|undefined => {
42
+ if (notificationQueue.value.length > 0) {
43
+ return notificationQueue.value[0];
44
+ }
45
+ return undefined;
46
+ });
47
+
48
+
49
+ return {
50
+ addNotification,
51
+ currentNotification,
52
+ clearNotification
53
+ }
54
+ });
@@ -241,7 +241,7 @@ export const usePlayerStore = defineStore("PlayerStore", {
241
241
  return;
242
242
  }
243
243
  if (param.canalId) {
244
- this.playerRadio = { ...param, ...{ isInit: false } };
244
+ this.playerRadio = { ...param, isInit: false };
245
245
  this.playerCurrentChange = -param.canalId;
246
246
  }
247
247
  },
@@ -168,6 +168,12 @@ $utilities: map.merge(
168
168
  class: flex,
169
169
  values: wrap nowrap
170
170
  ),
171
+ "gap": (
172
+ responsive: true,
173
+ property: gap,
174
+ class: gap,
175
+ values: $spacers
176
+ ),
171
177
  "justify-content": (
172
178
  responsive: true,
173
179
  property: justify-content,
@@ -428,4 +434,4 @@ $utilities: map.merge(
428
434
  @if meta.type-of($utility) == "map" {
429
435
  @include generate-utility($utility, "");
430
436
  }
431
- }
437
+ }
@@ -312,6 +312,24 @@ describe('useRights', () => {
312
312
  });
313
313
  });
314
314
 
315
+ describe('Aggregator permissions', () => {
316
+ ['canCreateAggregator', 'canEditAggregator', 'canDeleteAggregator'].forEach(method => {
317
+ describe(method, () => {
318
+ ['ADMIN', 'ORGANISATION'].forEach(role => {
319
+ it(`allows ${role}`, async () => {
320
+ await setup([role]);
321
+ expect(useRights()[method as 'canCreateAggregator']()).toBe(true);
322
+ });
323
+ });
324
+
325
+ it('denies unrelated roles', async () => {
326
+ await setup(['PRODUCTION']);
327
+ expect(useRights()[method as 'canCreateAggregator']()).toBe(false);
328
+ });
329
+ });
330
+ });
331
+ });
332
+
315
333
  describe('Other permissions', () => {
316
334
  describe('canEditCodeInsertPlayer', () => {
317
335
  ['ADMIN', 'ORGANISATION'].forEach(role => {
@@ -19,19 +19,26 @@ vi.mock('@/api/podcastApi', () => ({
19
19
  },
20
20
  }));
21
21
 
22
+ vi.mock('@/api/organisationApi', () => ({
23
+ organisationApi: {
24
+ getAttributes: vi.fn()
25
+ }
26
+ }));
27
+
22
28
  vi.mock('@/helper/language', () => ({
23
29
  getLanguage: vi.fn().mockReturnValue('fr'),
24
30
  }));
25
31
 
26
32
  import { transcriptionApi, type PodcastTranslationData } from '@/api/transcriptionApi';
27
33
  import { podcastApi } from '@/api/podcastApi';
34
+ import { organisationApi } from '@/api/organisationApi';
28
35
  import { getLanguage } from '@/helper/language';
29
36
 
30
37
  function makeTranslationData(overrides: Partial<PodcastTranslationData> = {}): PodcastTranslationData {
31
38
  return { podcastId: 1, nativeLanguage: 'fr', translations: [], ...overrides };
32
39
  }
33
40
 
34
- function makeEmission(orgConfig?: object): Emission {
41
+ function makeEmission(): Emission {
35
42
  return {
36
43
  emissionId: 0,
37
44
  name: '',
@@ -40,7 +47,6 @@ function makeEmission(orgConfig?: object): Emission {
40
47
  id: 'test-org',
41
48
  imageUrl: '',
42
49
  name: 'Test Org',
43
- attributes: orgConfig ? { 'translation-config': JSON.stringify(orgConfig) } : undefined,
44
50
  },
45
51
  beneficiaries: [],
46
52
  rubriqueIds: [],
@@ -50,7 +56,17 @@ function makeEmission(orgConfig?: object): Emission {
50
56
  }
51
57
 
52
58
  function setOrgTranslationConfig(config: object) {
53
- vi.mocked(podcastApi.get).mockResolvedValue({ emission: makeEmission(config) } as never);
59
+ vi.mocked(podcastApi.get).mockResolvedValue({
60
+ emission: makeEmission(),
61
+ organisation: { id: 1 }
62
+ } as never);
63
+ setOrgAttributes(config);
64
+ }
65
+
66
+ function setOrgAttributes(orgConfig: object) {
67
+ vi.mocked(organisationApi.getAttributes).mockResolvedValue({
68
+ 'translation-config': JSON.stringify(orgConfig)
69
+ });
54
70
  }
55
71
 
56
72
  describe('useTranslation', () => {
@@ -97,6 +113,10 @@ describe('useTranslation', () => {
97
113
  });
98
114
 
99
115
  const translationData = { nativeLanguage: 'it', podcastId: 0, translations: [] };
116
+ setOrgTranslationConfig({
117
+ createTranslation: { en: CreateTranslation.ALWAYS },
118
+ otherLanguage: CreateTranslation.NEVER,
119
+ });
100
120
 
101
121
  const result = await composable.getMostRelevantLanguage(translationData)
102
122
  expect(result.ready).toBe('it');
@@ -111,6 +131,10 @@ describe('useTranslation', () => {
111
131
  });
112
132
 
113
133
  const translationData = { nativeLanguage: 'fr', podcastId: 0, translations: [] };
134
+ setOrgTranslationConfig({
135
+ createTranslation: { en: CreateTranslation.ALWAYS },
136
+ otherLanguage: CreateTranslation.NEVER,
137
+ });
114
138
 
115
139
  const result = await composable.getMostRelevantLanguage(translationData)
116
140
  expect(result.ready).toBe('fr');
@@ -0,0 +1,52 @@
1
+ import ClassicButtonGroup from '@/components/form/ClassicButtonGroup.vue';
2
+ import { mount } from '@tests/utils';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const options = [
6
+ { label: 'Audio', value: 'PODCAST' },
7
+ { label: 'Video', value: 'VIDEO' },
8
+ { label: 'Live', value: 'LIVE' },
9
+ ];
10
+
11
+ describe('ClassicButtonGroup', () => {
12
+ it('renders one button per option', async () => {
13
+ const wrapper = await mount(ClassicButtonGroup, {
14
+ props: { options, modelValue: ['PODCAST'] },
15
+ });
16
+ expect(wrapper.findAll('button')).toHaveLength(3);
17
+ });
18
+
19
+ it('applies active class to selected options only', async () => {
20
+ const wrapper = await mount(ClassicButtonGroup, {
21
+ props: { options, modelValue: ['PODCAST', 'LIVE'] },
22
+ });
23
+ const buttons = wrapper.findAll('button');
24
+ expect(buttons[0].classes()).toContain('active');
25
+ expect(buttons[1].classes()).not.toContain('active');
26
+ expect(buttons[2].classes()).toContain('active');
27
+ });
28
+
29
+ it('emits update:modelValue with added value when clicking inactive button', async () => {
30
+ const wrapper = await mount(ClassicButtonGroup, {
31
+ props: { options, modelValue: ['PODCAST'] },
32
+ });
33
+ await wrapper.findAll('button')[1].trigger('click');
34
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['PODCAST', 'VIDEO']]);
35
+ });
36
+
37
+ it('emits update:modelValue without removed value when clicking active button (not last)', async () => {
38
+ const wrapper = await mount(ClassicButtonGroup, {
39
+ props: { options, modelValue: ['PODCAST', 'VIDEO'] },
40
+ });
41
+ await wrapper.findAll('button')[0].trigger('click');
42
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['VIDEO']]);
43
+ });
44
+
45
+ it('does not emit when clicking the only active button', async () => {
46
+ const wrapper = await mount(ClassicButtonGroup, {
47
+ props: { options, modelValue: ['PODCAST'] },
48
+ });
49
+ await wrapper.findAll('button')[0].trigger('click');
50
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined();
51
+ });
52
+ });
@@ -0,0 +1,23 @@
1
+ import '@tests/mocks/i18n';
2
+
3
+ import ClassicInputText from '@/components/form/ClassicInputText.vue';
4
+ import { mount } from '@tests/utils';
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ describe('ClassicInputText', () => {
8
+ describe('aria-required', () => {
9
+ it('sets aria-required to true when canBeNull is false (default)', async () => {
10
+ const wrapper = await mount(ClassicInputText, {
11
+ props: { canBeNull: false }
12
+ });
13
+ expect(wrapper.find('input').attributes('aria-required')).toBe('true');
14
+ });
15
+
16
+ it('sets aria-required to false when canBeNull is true', async () => {
17
+ const wrapper = await mount(ClassicInputText, {
18
+ props: { canBeNull: true }
19
+ });
20
+ expect(wrapper.find('input').attributes('aria-required')).toBe('false');
21
+ });
22
+ });
23
+ });
@@ -0,0 +1,68 @@
1
+ import ClassicAvatar from '@/components/misc/ClassicAvatar.vue';
2
+ import { mount } from '@tests/utils';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const IMAGE_URL = 'https://example.com/avatar.png';
6
+
7
+ describe('ClassicAvatar', () => {
8
+ const mountAvatar = async (props: Record<string, unknown> = {}) =>
9
+ mount(ClassicAvatar, { props: { name: 'Spotify', ...props } });
10
+
11
+ describe('image rendering', () => {
12
+ it('renders an img with correct src when imageUrl is provided', async () => {
13
+ const wrapper = await mountAvatar({ imageUrl: IMAGE_URL });
14
+ expect(wrapper.find('img').exists()).toBe(true);
15
+ expect(wrapper.find('img').attributes('src')).toContain('example.com');
16
+ });
17
+
18
+ it('does not render an img without imageUrl', async () => {
19
+ const wrapper = await mountAvatar();
20
+ expect(wrapper.find('img').exists()).toBe(false);
21
+ });
22
+ });
23
+
24
+ describe('initial letter fallback', () => {
25
+ it('shows the first letter uppercased', async () => {
26
+ const wrapper = await mountAvatar({ name: 'spotify' });
27
+ expect(wrapper.text()).toBe('S');
28
+ });
29
+
30
+ it('shows ? when name is empty', async () => {
31
+ const wrapper = await mountAvatar({ name: '' });
32
+ expect(wrapper.text()).toBe('?');
33
+ });
34
+ });
35
+
36
+ describe('container style', () => {
37
+ it('applies size-based dimensions and font-size from size prop', async () => {
38
+ const wrapper = await mountAvatar({ size: 48 });
39
+ const style = (wrapper.element as HTMLElement).style;
40
+ expect(style.height).toBe('48px');
41
+ expect(style.width).toBe('48px');
42
+ expect(style.fontSize).toBe('24px');
43
+ });
44
+
45
+ it('defaults to 24px size and applies a background-color', async () => {
46
+ const wrapper = await mountAvatar();
47
+ const style = (wrapper.element as HTMLElement).style;
48
+ expect(style.height).toBe('24px');
49
+ expect(style.width).toBe('24px');
50
+ expect(style.fontSize).toBe('12px');
51
+ expect(style.backgroundColor).toBeTruthy();
52
+ });
53
+ });
54
+
55
+ describe('accessibility', () => {
56
+ it('is aria-hidden and has empty alt on img when decorative', async () => {
57
+ const wrapper = await mountAvatar({ imageUrl: IMAGE_URL });
58
+ expect(wrapper.attributes('aria-hidden')).toBe('true');
59
+ expect(wrapper.find('img').attributes('alt')).toBe('');
60
+ });
61
+
62
+ it('is not aria-hidden and uses name as alt on img when nonDecorative', async () => {
63
+ const wrapper = await mountAvatar({ imageUrl: IMAGE_URL, nonDecorative: true });
64
+ expect(wrapper.attributes('aria-hidden')).not.toBe('true');
65
+ expect(wrapper.find('img').attributes('alt')).toBe('Spotify');
66
+ });
67
+ });
68
+ });
@@ -1,50 +0,0 @@
1
- import { ref } from "vue";
2
-
3
- type NotificationType = 'success'|'info'|'warning'|'error';
4
-
5
- /**
6
- * Type for notifications
7
- */
8
- interface Notification {
9
- /** Optional title of the notification */
10
- title?: string;
11
- /** Message displayed by the notification */
12
- message: string;
13
- /** Type of notification, may affect display */
14
- type: NotificationType;
15
- /** When set to false, disallow manual closing of modal notification (default: true) */
16
- closeable?: boolean;
17
- }
18
-
19
- /** The notification currently displayed */
20
- const notification = ref<Notification|null>(null);
21
-
22
- /**
23
- * Composable used to manage notifications
24
- */
25
- export const useNotifications = () => {
26
- /**
27
- * Add & display a notification
28
- * @param newNotif The data of the new notification
29
- */
30
- function addNotification(newNotif: Notification): void {
31
- notification.value = { ...newNotif };
32
- }
33
-
34
- /**
35
- * Remove all notifications
36
- */
37
- function clearNotifications(): void {
38
- notification.value = null;
39
- }
40
-
41
- return {
42
- /** The currently active notification */
43
- notification,
44
-
45
- /** Add a new notification to be displayed */
46
- addNotification,
47
- /** Remove all active notifications */
48
- clearNotifications
49
- }
50
- };
@@ -1,28 +0,0 @@
1
- export interface Aggregator {
2
- aggregatorId: number;
3
- createdBy: string;
4
- image?: string;
5
- matchers: Array<Matcher>;
6
- name: string;
7
- }
8
- export interface Matcher {
9
- ip?: string;
10
- ipRange?: string;
11
- matchDisplayname?: boolean;
12
- matcherId?: number;
13
- uaRegexp?: string;
14
- }
15
- export interface RuleRss {
16
- ruleId: number;
17
- aggregatorIds: Array<number>;
18
- emissionId?: number;
19
- organisationId?: string;
20
- parameters: Array<ParamRss>;
21
- }
22
- export interface ParamRss {
23
- forbid: boolean;
24
- maxAge: string;
25
- maxItem: number;
26
- minAge: string;
27
- parameterId: number;
28
- }