@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.
- package/.claude/scheduled_tasks.lock +1 -0
- package/CHANGELOG.md +7 -1
- package/index.ts +1 -1
- package/package.json +3 -3
- package/src/api/index.ts +1 -0
- package/src/api/radioApi.ts +18 -2
- package/src/api/rubriquesApi.ts +68 -0
- package/src/components/composable/form/useOctopusDropdown.ts +5 -2
- package/src/components/composable/usePresentationItem.ts +78 -0
- package/src/components/display/emission/EmissionInlineList.vue +1 -1
- package/src/components/display/emission/EmissionPresentationItem.vue +25 -16
- package/src/components/display/podcastmaker/PodcastmakerHeader.vue +60 -45
- package/src/components/display/podcasts/PodcastPresentationList.vue +103 -89
- package/src/components/form/ClassicButtonGroup.vue +12 -6
- package/src/components/form/OctopusMultiselect.vue +86 -9
- package/src/components/form/OctopusSelect.vue +29 -2
- package/src/components/layouts/PresentationItem.vue +149 -104
- package/src/locale/de.json +1 -0
- package/src/locale/en.json +1 -0
- package/src/locale/es.json +1 -0
- package/src/locale/fr.json +1 -0
- package/src/locale/it.json +1 -0
- package/src/locale/sl.json +1 -0
- package/src/stores/CacheStore.ts +17 -11
- package/src/stores/ParamSdkStore.ts +22 -0
- package/src/stores/class/general/organisation.ts +10 -0
- package/src/stores/class/radio/canal.ts +30 -8
- package/src/stores/class/radio/recurrence.ts +6 -5
- package/src/style/_variables.scss +2 -0
- package/src/style/bootstrap.scss +3 -2
- package/tests/components/composable/usePresentationItem.spec.ts +163 -0
- package/tests/components/display/emission/EmissionPresentationItem.spec.ts +45 -0
- package/tests/components/display/podcasts/PodcastPresentationList.spec.ts +107 -0
- package/tests/components/form/ClassicButtonGroup.spec.ts +26 -0
- package/tests/components/form/OctopusMultiselect.spec.ts +298 -0
- package/tests/components/form/OctopusSelect.spec.ts +71 -0
- package/tests/components/layouts/PresentationItem.spec.ts +36 -0
- package/tests/stores/CacheStore.spec.ts +109 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import '@tests/mocks/i18n';
|
|
2
|
+
|
|
3
|
+
vi.mock('@/api', () => ({
|
|
4
|
+
rubriquesApi: { getCachedRubrique: vi.fn() },
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import { rubriquesApi } from '@/api';
|
|
8
|
+
import { usePresentationItem } from '@/components/composable/usePresentationItem';
|
|
9
|
+
import { emptyEmissionData, Emission } from '@/stores/class/general/emission';
|
|
10
|
+
import { emptyPodcastData, Podcast } from '@/stores/class/general/podcast';
|
|
11
|
+
import { Rubrique } from '@/stores/class/rubrique/rubrique';
|
|
12
|
+
import { useGeneralStore } from '@/stores/GeneralStore';
|
|
13
|
+
import { state } from '@/stores/ParamSdkStore';
|
|
14
|
+
import { setupPinia } from '@tests/utils';
|
|
15
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
16
|
+
|
|
17
|
+
function rubrique(rubriqueId: number, name: string, rubriquageId?: number): Rubrique {
|
|
18
|
+
return { rubriqueId, name, rubriquageId };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
setupPinia();
|
|
23
|
+
state.presentationItems = { tags: 'none' };
|
|
24
|
+
vi.mocked(rubriquesApi.getCachedRubrique).mockReset();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('usePresentationItem', () => {
|
|
28
|
+
describe('tagsFor', () => {
|
|
29
|
+
it('returns undefined when tags type is none', async () => {
|
|
30
|
+
const { tagsFor } = usePresentationItem();
|
|
31
|
+
expect(await tagsFor(emptyEmissionData())).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('iab type', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
state.presentationItems.tags = 'iab';
|
|
37
|
+
useGeneralStore().storedUpdateCategories([
|
|
38
|
+
{ id: 1, name: 'News' },
|
|
39
|
+
{ id: 2, name: 'Sport' },
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns the names of categories matching the emission iabIds', async () => {
|
|
44
|
+
const { tagsFor } = usePresentationItem();
|
|
45
|
+
const emission: Emission = { ...emptyEmissionData(), iabIds: [2] };
|
|
46
|
+
expect(await tagsFor(emission)).toEqual(['Sport']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('reads iabIds from the podcast emission', async () => {
|
|
50
|
+
const { tagsFor } = usePresentationItem();
|
|
51
|
+
const podcast: Podcast = emptyPodcastData();
|
|
52
|
+
podcast.emission = { ...emptyEmissionData(), iabIds: [1, 2] };
|
|
53
|
+
expect(await tagsFor(podcast)).toEqual(['News', 'Sport']);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('rubrique type', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
state.presentationItems.tags = 'rubrique';
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('fetches tags for an emission rubriqueIds', async () => {
|
|
63
|
+
vi.mocked(rubriquesApi.getCachedRubrique).mockImplementation(
|
|
64
|
+
(id) => Promise.resolve(rubrique(id, `Rubrique ${id}`))
|
|
65
|
+
);
|
|
66
|
+
const { tagsFor } = usePresentationItem();
|
|
67
|
+
const emission: Emission = { ...emptyEmissionData(), rubriqueIds: [1, 2] };
|
|
68
|
+
expect(await tagsFor(emission)).toEqual(['Rubrique 1', 'Rubrique 2']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('combines a podcast rubriqueIds with its emission rubriqueIds', async () => {
|
|
72
|
+
vi.mocked(rubriquesApi.getCachedRubrique).mockImplementation(
|
|
73
|
+
(id) => Promise.resolve(rubrique(id, `Rubrique ${id}`))
|
|
74
|
+
);
|
|
75
|
+
const { tagsFor } = usePresentationItem();
|
|
76
|
+
const podcast: Podcast = emptyPodcastData();
|
|
77
|
+
podcast.rubriqueIds = [1];
|
|
78
|
+
podcast.emission = { ...emptyEmissionData(), rubriqueIds: [2] };
|
|
79
|
+
expect(await tagsFor(podcast)).toEqual(['Rubrique 1', 'Rubrique 2']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('filters rubriques by tagsRubriquageId when set', async () => {
|
|
83
|
+
vi.mocked(rubriquesApi.getCachedRubrique).mockImplementation((id) => {
|
|
84
|
+
if (id === 1) { return Promise.resolve(rubrique(1, 'Kept', 10)); }
|
|
85
|
+
return Promise.resolve(rubrique(2, 'Discarded', 20));
|
|
86
|
+
});
|
|
87
|
+
state.presentationItems.tagsRubriquageId = 10;
|
|
88
|
+
const { tagsFor } = usePresentationItem();
|
|
89
|
+
const emission: Emission = { ...emptyEmissionData(), rubriqueIds: [1, 2] };
|
|
90
|
+
expect(await tagsFor(emission)).toEqual(['Kept']);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('limits the number of tags when tagsLimit is set', async () => {
|
|
95
|
+
state.presentationItems.tags = 'iab';
|
|
96
|
+
state.presentationItems.tagsLimit = 1;
|
|
97
|
+
useGeneralStore().storedUpdateCategories([
|
|
98
|
+
{ id: 1, name: 'News' },
|
|
99
|
+
{ id: 2, name: 'Sport' },
|
|
100
|
+
]);
|
|
101
|
+
const { tagsFor } = usePresentationItem();
|
|
102
|
+
const emission: Emission = { ...emptyEmissionData(), iabIds: [1, 2] };
|
|
103
|
+
expect(await tagsFor(emission)).toEqual(['News']);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('additionalInfoFor', () => {
|
|
108
|
+
it('returns undefined when additionalInfo is not configured', () => {
|
|
109
|
+
const { additionalInfoFor } = usePresentationItem();
|
|
110
|
+
expect(additionalInfoFor(emptyEmissionData())).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns undefined when additionalInfo is empty', () => {
|
|
114
|
+
state.presentationItems.additionalInfo = [];
|
|
115
|
+
const { additionalInfoFor } = usePresentationItem();
|
|
116
|
+
expect(additionalInfoFor(emptyEmissionData())).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('resolves productor from the emission organisation name for an emission', () => {
|
|
120
|
+
state.presentationItems.additionalInfo = ['productor'];
|
|
121
|
+
const { additionalInfoFor } = usePresentationItem();
|
|
122
|
+
const emission: Emission = { ...emptyEmissionData(), orga: { id: '1', name: 'Saooti', imageUrl: '' } };
|
|
123
|
+
expect(additionalInfoFor(emission)).toEqual(['Saooti']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('resolves productor from the podcast emission organisation name', () => {
|
|
127
|
+
state.presentationItems.additionalInfo = ['productor'];
|
|
128
|
+
const { additionalInfoFor } = usePresentationItem();
|
|
129
|
+
const podcast: Podcast = emptyPodcastData();
|
|
130
|
+
podcast.emission = { ...emptyEmissionData(), orga: { id: '1', name: 'Saooti', imageUrl: '' } };
|
|
131
|
+
expect(additionalInfoFor(podcast)).toEqual(['Saooti']);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('formats the podcast pubDate for the date info', () => {
|
|
135
|
+
state.presentationItems.additionalInfo = ['date'];
|
|
136
|
+
const { additionalInfoFor } = usePresentationItem();
|
|
137
|
+
const podcast: Podcast = emptyPodcastData();
|
|
138
|
+
podcast.pubDate = '2025-12-01T10:21:31.000+00:00';
|
|
139
|
+
expect(additionalInfoFor(podcast)).toEqual(['1 décembre 2025']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('omits the date info for an emission (no pubDate)', () => {
|
|
143
|
+
state.presentationItems.additionalInfo = ['date'];
|
|
144
|
+
const { additionalInfoFor } = usePresentationItem();
|
|
145
|
+
expect(additionalInfoFor(emptyEmissionData())).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('omits the date info for a podcast without a pubDate', () => {
|
|
149
|
+
state.presentationItems.additionalInfo = ['date'];
|
|
150
|
+
const { additionalInfoFor } = usePresentationItem();
|
|
151
|
+
expect(additionalInfoFor(emptyPodcastData())).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('combines multiple info entries in order', () => {
|
|
155
|
+
state.presentationItems.additionalInfo = ['date', 'productor'];
|
|
156
|
+
const { additionalInfoFor } = usePresentationItem();
|
|
157
|
+
const podcast: Podcast = emptyPodcastData();
|
|
158
|
+
podcast.pubDate = '2025-12-01T10:21:31.000+00:00';
|
|
159
|
+
podcast.emission = { ...emptyEmissionData(), orga: { id: '1', name: 'Saooti', imageUrl: '' } };
|
|
160
|
+
expect(additionalInfoFor(podcast)).toEqual(['1 décembre 2025', 'Saooti']);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import '@tests/mocks/i18n';
|
|
2
|
+
|
|
3
|
+
const tagsFor = vi.fn();
|
|
4
|
+
const additionalInfoFor = vi.fn();
|
|
5
|
+
|
|
6
|
+
vi.mock('@/components/composable/usePresentationItem', () => ({
|
|
7
|
+
usePresentationItem: () => ({ tagsFor, additionalInfoFor }),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import EmissionPresentationItem from '@/components/display/emission/EmissionPresentationItem.vue';
|
|
11
|
+
import { emptyEmissionData } from '@/stores/class/general/emission';
|
|
12
|
+
import { mount as testMount } from '@tests/utils';
|
|
13
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import { nextTick } from 'vue';
|
|
15
|
+
|
|
16
|
+
const mount = (props: Record<string, unknown>) => testMount(EmissionPresentationItem, { shallow: true, props });
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
tagsFor.mockReset().mockResolvedValue([]);
|
|
20
|
+
additionalInfoFor.mockReset().mockReturnValue(undefined);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('EmissionPresentationItem', () => {
|
|
24
|
+
it('calls tagsFor with the emission prop', async () => {
|
|
25
|
+
const emission = { ...emptyEmissionData(), emissionId: 42 };
|
|
26
|
+
await mount({ emission });
|
|
27
|
+
expect(tagsFor).toHaveBeenCalledWith(emission);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('resolves tagsFor asynchronously and forwards the result as the tags prop', async () => {
|
|
31
|
+
tagsFor.mockResolvedValue(['News']);
|
|
32
|
+
const wrapper = await mount({ emission: emptyEmissionData() });
|
|
33
|
+
await nextTick();
|
|
34
|
+
await nextTick();
|
|
35
|
+
const presentationItem = wrapper.findComponent({ name: 'PresentationItem' });
|
|
36
|
+
expect(presentationItem.props('tags')).toEqual(['News']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('forwards additionalInfoFor result as the additional-info prop', async () => {
|
|
40
|
+
additionalInfoFor.mockReturnValue(['Saooti']);
|
|
41
|
+
const wrapper = await mount({ emission: emptyEmissionData() });
|
|
42
|
+
const presentationItem = wrapper.findComponent({ name: 'PresentationItem' });
|
|
43
|
+
expect(presentationItem.props('additionalInfo')).toEqual(['Saooti']);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import '@tests/mocks/i18n';
|
|
2
|
+
import '@tests/mocks/useRouter';
|
|
3
|
+
|
|
4
|
+
vi.mock('@/api/classicApi', () => ({
|
|
5
|
+
default: { fetchData: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
vi.mock('@/api/podcastApi', () => ({
|
|
8
|
+
podcastApi: { search: vi.fn() },
|
|
9
|
+
PodcastSort: { DATE: 'DATE' },
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const tagsFor = vi.fn();
|
|
13
|
+
const additionalInfoFor = vi.fn();
|
|
14
|
+
vi.mock('@/components/composable/usePresentationItem', () => ({
|
|
15
|
+
usePresentationItem: () => ({ tagsFor, additionalInfoFor }),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import classicApi from '@/api/classicApi';
|
|
19
|
+
import { podcastApi } from '@/api/podcastApi';
|
|
20
|
+
import PodcastPresentationList from '@/components/display/podcasts/PodcastPresentationList.vue';
|
|
21
|
+
import { emptyEmissionData, Emission } from '@/stores/class/general/emission';
|
|
22
|
+
import { PodcastType, SimplifiedPodcast } from '@/stores/class/general/podcast';
|
|
23
|
+
import { mount as testMount } from '@tests/utils';
|
|
24
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
25
|
+
import { flushPromises } from '@vue/test-utils';
|
|
26
|
+
|
|
27
|
+
function makeEmission(emissionId: number): Emission {
|
|
28
|
+
return { ...emptyEmissionData(), emissionId, name: `Emission ${emissionId}`, orga: { id: 'org-1', name: 'Orga', imageUrl: '' } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeSimplifiedPodcast(emissionId: number, podcastId: number, pubDate: string): SimplifiedPodcast {
|
|
32
|
+
return {
|
|
33
|
+
podcastId,
|
|
34
|
+
emissionId,
|
|
35
|
+
organisationId: 'org-1',
|
|
36
|
+
audioUrl: '',
|
|
37
|
+
audioStorageUrl: '',
|
|
38
|
+
article: '',
|
|
39
|
+
imageUrl: '',
|
|
40
|
+
title: `Podcast ${podcastId}`,
|
|
41
|
+
description: undefined,
|
|
42
|
+
tags: [],
|
|
43
|
+
beneficiaries: [],
|
|
44
|
+
availability: { visibility: true, date: undefined },
|
|
45
|
+
monetisable: 'UNDEFINED',
|
|
46
|
+
pubDate,
|
|
47
|
+
conferenceId: undefined,
|
|
48
|
+
duration: 0,
|
|
49
|
+
seasonEpisodeType: PodcastType.FULL,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const mount = (props: Record<string, unknown> = {}) => testMount(PodcastPresentationList, {
|
|
54
|
+
props,
|
|
55
|
+
stubs: ['PresentationItem', 'PodcastPlayButton'],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
tagsFor.mockReset();
|
|
60
|
+
additionalInfoFor.mockReset().mockReturnValue(undefined);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('PodcastPresentationList', () => {
|
|
64
|
+
it('populates the tags prop of each item once tagsFor resolves', async () => {
|
|
65
|
+
vi.mocked(classicApi.fetchData).mockResolvedValue({ count: 1, sort: '', result: [makeEmission(1)] });
|
|
66
|
+
vi.mocked(podcastApi.search).mockResolvedValue({
|
|
67
|
+
count: 1, sort: '', result: [makeSimplifiedPodcast(1, 10, '2025-01-01T00:00:00.000Z')],
|
|
68
|
+
});
|
|
69
|
+
tagsFor.mockResolvedValue(['News']);
|
|
70
|
+
|
|
71
|
+
const wrapper = await mount();
|
|
72
|
+
await flushPromises();
|
|
73
|
+
|
|
74
|
+
const items = wrapper.findAllComponents({ name: 'PresentationItem' });
|
|
75
|
+
expect(items).toHaveLength(1);
|
|
76
|
+
expect(items[0].props('tags')).toEqual(['News']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('fetches tags only once per podcast', async () => {
|
|
80
|
+
vi.mocked(classicApi.fetchData).mockResolvedValue({ count: 1, sort: '', result: [makeEmission(1)] });
|
|
81
|
+
vi.mocked(podcastApi.search).mockResolvedValue({
|
|
82
|
+
count: 1, sort: '', result: [makeSimplifiedPodcast(1, 10, '2025-01-01T00:00:00.000Z')],
|
|
83
|
+
});
|
|
84
|
+
tagsFor.mockResolvedValue(['News']);
|
|
85
|
+
|
|
86
|
+
await mount();
|
|
87
|
+
await flushPromises();
|
|
88
|
+
await flushPromises();
|
|
89
|
+
|
|
90
|
+
expect(tagsFor).toHaveBeenCalledTimes(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('forwards additionalInfoFor result as the additional-info prop of each item', async () => {
|
|
94
|
+
vi.mocked(classicApi.fetchData).mockResolvedValue({ count: 1, sort: '', result: [makeEmission(1)] });
|
|
95
|
+
vi.mocked(podcastApi.search).mockResolvedValue({
|
|
96
|
+
count: 1, sort: '', result: [makeSimplifiedPodcast(1, 10, '2025-01-01T00:00:00.000Z')],
|
|
97
|
+
});
|
|
98
|
+
tagsFor.mockResolvedValue([]);
|
|
99
|
+
additionalInfoFor.mockReturnValue(['Orga']);
|
|
100
|
+
|
|
101
|
+
const wrapper = await mount();
|
|
102
|
+
await flushPromises();
|
|
103
|
+
|
|
104
|
+
const items = wrapper.findAllComponents({ name: 'PresentationItem' });
|
|
105
|
+
expect(items[0].props('additionalInfo')).toEqual(['Orga']);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -49,4 +49,30 @@ describe('ClassicButtonGroup', () => {
|
|
|
49
49
|
await wrapper.findAll('button')[0].trigger('click');
|
|
50
50
|
expect(wrapper.emitted('update:value')).toBeUndefined();
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
describe('solo prop', () => {
|
|
54
|
+
it('emits only the clicked value when clicking an inactive button', async () => {
|
|
55
|
+
const wrapper = await mount(ClassicButtonGroup, {
|
|
56
|
+
props: { options, value: ['PODCAST'], solo: true },
|
|
57
|
+
});
|
|
58
|
+
await wrapper.findAll('button')[1].trigger('click');
|
|
59
|
+
expect(wrapper.emitted('update:value')?.[0]).toEqual([['VIDEO']]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('emits only the clicked value when clicking the already active button', async () => {
|
|
63
|
+
const wrapper = await mount(ClassicButtonGroup, {
|
|
64
|
+
props: { options, value: ['PODCAST'], solo: true },
|
|
65
|
+
});
|
|
66
|
+
await wrapper.findAll('button')[0].trigger('click');
|
|
67
|
+
expect(wrapper.emitted('update:value')?.[0]).toEqual([['PODCAST']]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('replaces multiple active values with the clicked one', async () => {
|
|
71
|
+
const wrapper = await mount(ClassicButtonGroup, {
|
|
72
|
+
props: { options, value: ['PODCAST', 'VIDEO'], solo: true },
|
|
73
|
+
});
|
|
74
|
+
await wrapper.findAll('button')[2].trigger('click');
|
|
75
|
+
expect(wrapper.emitted('update:value')?.[0]).toEqual([['LIVE']]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
52
78
|
});
|
|
@@ -3,6 +3,7 @@ import '@tests/mocks/i18n';
|
|
|
3
3
|
import OctopusMultiselect from '@/components/form/OctopusMultiselect.vue';
|
|
4
4
|
import { DOMWrapper, type VueWrapper } from '@vue/test-utils';
|
|
5
5
|
import { mount } from '@tests/utils';
|
|
6
|
+
import { nextTick } from 'vue';
|
|
6
7
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
8
|
|
|
8
9
|
const options = [
|
|
@@ -16,6 +17,20 @@ function getDropdown(): Element | null {
|
|
|
16
17
|
return document.body.querySelector('.octopus-multiselect-dropdown');
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
function getOptionLabels(): (string | undefined)[] {
|
|
21
|
+
return Array.from(document.body.querySelectorAll('.octopus-multiselect-options label'))
|
|
22
|
+
.map((l) => l.textContent?.trim());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Simulates a click outside the component to trigger onClickOutside's closeDropdown.
|
|
26
|
+
async function clickOutside(): Promise<void> {
|
|
27
|
+
const outsideElement = document.createElement('div');
|
|
28
|
+
document.body.appendChild(outsideElement);
|
|
29
|
+
outsideElement.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
30
|
+
await nextTick();
|
|
31
|
+
document.body.removeChild(outsideElement);
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
describe('OctopusMultiselect', () => {
|
|
20
35
|
// The dropdown teleports to .octopus-app, which is normally the app root (see App.vue).
|
|
21
36
|
// It doesn't exist in the test DOM, so it must be created before mount.
|
|
@@ -204,4 +219,287 @@ describe('OctopusMultiselect', () => {
|
|
|
204
219
|
await new DOMWrapper(allCheckbox).trigger('input');
|
|
205
220
|
expect(wrapper.emitted('update:selected')?.[0]).toEqual([[]]);
|
|
206
221
|
});
|
|
222
|
+
|
|
223
|
+
describe('pullSelectedToTop', () => {
|
|
224
|
+
it('does not reorder options when pullSelectedToTop is not set', async () => {
|
|
225
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
226
|
+
props: { options, optionLabel: 'name', selected: [options[1]] },
|
|
227
|
+
});
|
|
228
|
+
await wrapper.find('input').trigger('focus');
|
|
229
|
+
expect(getOptionLabels()).toEqual(['Alpha', 'Beta', 'Gamma']);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('moves the selected option to the top when pullSelectedToTop is true', async () => {
|
|
233
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
234
|
+
props: { options, optionLabel: 'name', selected: [options[1]], pullSelectedToTop: true },
|
|
235
|
+
});
|
|
236
|
+
await wrapper.find('input').trigger('focus');
|
|
237
|
+
expect(getOptionLabels()).toEqual(['Beta', 'Alpha', 'Gamma']);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('preserves relative order within pinned and unpinned groups', async () => {
|
|
241
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
242
|
+
props: { options, optionLabel: 'name', selected: [options[2], options[0]], pullSelectedToTop: true },
|
|
243
|
+
});
|
|
244
|
+
await wrapper.find('input').trigger('focus');
|
|
245
|
+
expect(getOptionLabels()).toEqual(['Alpha', 'Gamma', 'Beta']);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('does not reorder while the dropdown stays open as selection changes', async () => {
|
|
249
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
250
|
+
props: { options, optionLabel: 'name', selected: [], pullSelectedToTop: true },
|
|
251
|
+
});
|
|
252
|
+
await wrapper.find('input').trigger('focus');
|
|
253
|
+
const optionCheckboxes = document.body.querySelectorAll('.octopus-multiselect-options input[type="checkbox"]');
|
|
254
|
+
await new DOMWrapper(optionCheckboxes[2] as Element).trigger('input');
|
|
255
|
+
await wrapper.setProps({ selected: [options[2]] });
|
|
256
|
+
expect(getOptionLabels()).toEqual(['Alpha', 'Beta', 'Gamma']);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('re-pins using the latest selection after closing and reopening', async () => {
|
|
260
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
261
|
+
props: { options, optionLabel: 'name', selected: [options[2]], pullSelectedToTop: true },
|
|
262
|
+
});
|
|
263
|
+
await wrapper.find('input').trigger('focus');
|
|
264
|
+
await clickOutside();
|
|
265
|
+
await wrapper.find('input').trigger('focus');
|
|
266
|
+
expect(getOptionLabels()).toEqual(['Gamma', 'Alpha', 'Beta']);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('pins by optionKey even when selected objects are different references', async () => {
|
|
270
|
+
const selected = [{ id: 2, name: 'Beta' }];
|
|
271
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
272
|
+
props: { options, optionLabel: 'name', optionKey: 'id', selected, pullSelectedToTop: true },
|
|
273
|
+
});
|
|
274
|
+
await wrapper.find('input').trigger('focus');
|
|
275
|
+
expect(getOptionLabels()).toEqual(['Beta', 'Alpha', 'Gamma']);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('still respects the active search filter when pinning', async () => {
|
|
279
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
280
|
+
props: { options, optionLabel: 'name', selected: [options[2]], pullSelectedToTop: true },
|
|
281
|
+
});
|
|
282
|
+
await wrapper.find('input').trigger('focus');
|
|
283
|
+
await wrapper.find('input').setValue('be');
|
|
284
|
+
await wrapper.find('input').trigger('input');
|
|
285
|
+
expect(getOptionLabels()).toEqual(['Beta']);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('keeps a selected custom value pinned alongside allowCustomValue', async () => {
|
|
289
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
290
|
+
props: {
|
|
291
|
+
options: ['Alpha', 'Beta', 'Gamma'],
|
|
292
|
+
selected: ['Delta'],
|
|
293
|
+
allowCustomValue: true,
|
|
294
|
+
pullSelectedToTop: true,
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
await wrapper.find('input').trigger('focus');
|
|
298
|
+
expect(getOptionLabels()).toEqual(['Delta', 'Alpha', 'Beta', 'Gamma']);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('with string options', () => {
|
|
303
|
+
const stringOptions = ['Alpha', 'Beta', 'Gamma'];
|
|
304
|
+
|
|
305
|
+
it('uses each string as its own label without optionLabel', async () => {
|
|
306
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
307
|
+
props: { options: stringOptions },
|
|
308
|
+
});
|
|
309
|
+
await wrapper.find('input').trigger('focus');
|
|
310
|
+
const labels = document.body.querySelectorAll('.octopus-multiselect-options label');
|
|
311
|
+
expect(Array.from(labels).map((l) => l.textContent?.trim())).toEqual(stringOptions);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('emits update:selected when toggling a string option', async () => {
|
|
315
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
316
|
+
props: { options: stringOptions, selected: [] },
|
|
317
|
+
});
|
|
318
|
+
await wrapper.find('input').trigger('focus');
|
|
319
|
+
const optionCheckboxes = document.body.querySelectorAll('.octopus-multiselect-options input[type="checkbox"]');
|
|
320
|
+
await new DOMWrapper(optionCheckboxes[0] as Element).trigger('input');
|
|
321
|
+
expect(wrapper.emitted('update:selected')?.[0]).toEqual([['Alpha']]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('deselects an already-selected string option', async () => {
|
|
325
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
326
|
+
props: { options: stringOptions, selected: ['Alpha'] },
|
|
327
|
+
});
|
|
328
|
+
await wrapper.find('input').trigger('focus');
|
|
329
|
+
const optionCheckboxes = document.body.querySelectorAll('.octopus-multiselect-options input[type="checkbox"]');
|
|
330
|
+
await new DOMWrapper(optionCheckboxes[0] as Element).trigger('input');
|
|
331
|
+
expect(wrapper.emitted('update:selected')?.[0]).toEqual([[]]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('filters string options locally by search query', async () => {
|
|
335
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
336
|
+
props: { options: stringOptions },
|
|
337
|
+
});
|
|
338
|
+
await wrapper.find('input').trigger('focus');
|
|
339
|
+
await wrapper.find('input').setValue('al');
|
|
340
|
+
await wrapper.find('input').trigger('input');
|
|
341
|
+
const checkboxes = document.body.querySelectorAll('.octopus-multiselect-options input[type="checkbox"]');
|
|
342
|
+
expect(checkboxes).toHaveLength(1);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('toggleAll selects all displayed string options', async () => {
|
|
346
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
347
|
+
props: { options: stringOptions, selected: [] },
|
|
348
|
+
});
|
|
349
|
+
await wrapper.find('input').trigger('focus');
|
|
350
|
+
const allCheckbox = document.body.querySelector('.octopus-multiselect-dropdown > .octopus-form-item input[type="checkbox"]')!;
|
|
351
|
+
await new DOMWrapper(allCheckbox).trigger('input');
|
|
352
|
+
expect(wrapper.emitted('update:selected')?.[0]).toEqual([stringOptions]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('shows selected strings in the selection display', async () => {
|
|
356
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
357
|
+
props: { options: stringOptions, selected: ['Alpha', 'Beta'] },
|
|
358
|
+
});
|
|
359
|
+
expect(wrapper.find('.octopus-multiselect-selection-text').text()).toBe('Alpha, Beta');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('adds typed value as a new selection on Enter when allowCustomValue is true', async () => {
|
|
363
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
364
|
+
props: { options: stringOptions, selected: [], allowCustomValue: true },
|
|
365
|
+
});
|
|
366
|
+
await wrapper.find('input').trigger('focus');
|
|
367
|
+
await wrapper.find('input').setValue('Delta');
|
|
368
|
+
await wrapper.find('input').trigger('keydown.enter');
|
|
369
|
+
expect(wrapper.emitted('update:selected')?.[0]).toEqual([['Delta']]);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('does not add typed value on Enter when allowCustomValue is not set', async () => {
|
|
373
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
374
|
+
props: { options: stringOptions, selected: [] },
|
|
375
|
+
});
|
|
376
|
+
await wrapper.find('input').trigger('focus');
|
|
377
|
+
await wrapper.find('input').setValue('Delta');
|
|
378
|
+
await wrapper.find('input').trigger('keydown.enter');
|
|
379
|
+
expect(wrapper.emitted('update:selected')).toBeUndefined();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('does not add an empty or whitespace-only value on Enter', async () => {
|
|
383
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
384
|
+
props: { options: stringOptions, selected: [], allowCustomValue: true },
|
|
385
|
+
});
|
|
386
|
+
await wrapper.find('input').trigger('focus');
|
|
387
|
+
await wrapper.find('input').setValue(' ');
|
|
388
|
+
await wrapper.find('input').trigger('keydown.enter');
|
|
389
|
+
expect(wrapper.emitted('update:selected')).toBeUndefined();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('does not duplicate an already-selected value on Enter', async () => {
|
|
393
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
394
|
+
props: { options: stringOptions, selected: ['Alpha'], allowCustomValue: true },
|
|
395
|
+
});
|
|
396
|
+
await wrapper.find('input').trigger('focus');
|
|
397
|
+
await wrapper.find('input').setValue('Alpha');
|
|
398
|
+
await wrapper.find('input').trigger('keydown.enter');
|
|
399
|
+
expect(wrapper.emitted('update:selected')).toBeUndefined();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('clears the search input after adding a custom value on Enter', async () => {
|
|
403
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
404
|
+
props: { options: stringOptions, selected: [], allowCustomValue: true },
|
|
405
|
+
});
|
|
406
|
+
await wrapper.find('input').trigger('focus');
|
|
407
|
+
await wrapper.find('input').setValue('Delta');
|
|
408
|
+
await wrapper.find('input').trigger('keydown.enter');
|
|
409
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('shows a previously added custom value as a checked option in the dropdown', async () => {
|
|
413
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
414
|
+
props: { options: stringOptions, selected: ['Delta'], allowCustomValue: true },
|
|
415
|
+
});
|
|
416
|
+
await wrapper.find('input').trigger('focus');
|
|
417
|
+
const labels = Array.from(document.body.querySelectorAll('.octopus-multiselect-options label'))
|
|
418
|
+
.map((l) => l.textContent?.trim());
|
|
419
|
+
expect(labels).toContain('Delta');
|
|
420
|
+
const checkboxes = document.body.querySelectorAll('.octopus-multiselect-options input[type="checkbox"]');
|
|
421
|
+
expect((checkboxes[labels.indexOf('Delta')] as HTMLInputElement).checked).toBe(true);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('shows the checkbox for a value right after it is added via Enter', async () => {
|
|
425
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
426
|
+
props: { options: stringOptions, selected: [], allowCustomValue: true },
|
|
427
|
+
});
|
|
428
|
+
await wrapper.find('input').trigger('focus');
|
|
429
|
+
await wrapper.find('input').setValue('Delta');
|
|
430
|
+
await wrapper.find('input').trigger('keydown.enter');
|
|
431
|
+
const emittedSelected = wrapper.emitted('update:selected')?.[0]?.[0] as string[];
|
|
432
|
+
await wrapper.setProps({ selected: emittedSelected });
|
|
433
|
+
const labels = Array.from(document.body.querySelectorAll('.octopus-multiselect-options label'))
|
|
434
|
+
.map((l) => l.textContent?.trim());
|
|
435
|
+
expect(labels).toContain('Delta');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('does not show out-of-options selected values in the dropdown when allowCustomValue is not set', async () => {
|
|
439
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
440
|
+
props: { options: stringOptions, selected: ['Delta'] },
|
|
441
|
+
});
|
|
442
|
+
await wrapper.find('input').trigger('focus');
|
|
443
|
+
const labels = Array.from(document.body.querySelectorAll('.octopus-multiselect-options label'))
|
|
444
|
+
.map((l) => l.textContent?.trim());
|
|
445
|
+
expect(labels).not.toContain('Delta');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('deselects a custom value when its checkbox in the dropdown is toggled off', async () => {
|
|
449
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
450
|
+
props: { options: stringOptions, selected: ['Alpha', 'Delta'], allowCustomValue: true },
|
|
451
|
+
});
|
|
452
|
+
await wrapper.find('input').trigger('focus');
|
|
453
|
+
const checkboxes = document.body.querySelectorAll('.octopus-multiselect-options input[type="checkbox"]');
|
|
454
|
+
// visibleOptions = [Alpha, Beta, Gamma, Delta] — Delta is the appended custom entry
|
|
455
|
+
await new DOMWrapper(checkboxes[3] as Element).trigger('input');
|
|
456
|
+
expect(wrapper.emitted('update:selected')?.[0]).toEqual([['Alpha']]);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('toggleAll includes an already-selected custom value shown in the dropdown', async () => {
|
|
460
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
461
|
+
props: { options: stringOptions, selected: ['Delta'], allowCustomValue: true },
|
|
462
|
+
});
|
|
463
|
+
await wrapper.find('input').trigger('focus');
|
|
464
|
+
const allCheckbox = document.body.querySelector('.octopus-multiselect-dropdown > .octopus-form-item input[type="checkbox"]')!;
|
|
465
|
+
await new DOMWrapper(allCheckbox).trigger('input');
|
|
466
|
+
const emitted = wrapper.emitted('update:selected')?.[0]?.[0] as string[];
|
|
467
|
+
expect(emitted).toHaveLength(4);
|
|
468
|
+
expect(emitted).toEqual(expect.arrayContaining(['Alpha', 'Beta', 'Gamma', 'Delta']));
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('shows a hint to press Enter when allowCustomValue is true and text is typed', async () => {
|
|
472
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
473
|
+
props: { options: stringOptions, allowCustomValue: true },
|
|
474
|
+
});
|
|
475
|
+
await wrapper.find('input').trigger('focus');
|
|
476
|
+
await wrapper.find('input').setValue('Delta');
|
|
477
|
+
await wrapper.find('input').trigger('input');
|
|
478
|
+
const hints = Array.from(document.body.querySelectorAll('.octopus-multiselect-options .text-indic'))
|
|
479
|
+
.map((el) => el.textContent?.trim());
|
|
480
|
+
expect(hints).toContain('Press Enter to add this value');
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('does not show the Enter hint when allowCustomValue is not set', async () => {
|
|
484
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
485
|
+
props: { options: stringOptions },
|
|
486
|
+
});
|
|
487
|
+
await wrapper.find('input').trigger('focus');
|
|
488
|
+
await wrapper.find('input').setValue('Delta');
|
|
489
|
+
await wrapper.find('input').trigger('input');
|
|
490
|
+
const hints = Array.from(document.body.querySelectorAll('.octopus-multiselect-options .text-indic'))
|
|
491
|
+
.map((el) => el.textContent?.trim());
|
|
492
|
+
expect(hints).not.toContain('Press Enter to add this value');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('does not show the Enter hint when the search input is empty', async () => {
|
|
496
|
+
wrapper = await mount(OctopusMultiselect, {
|
|
497
|
+
props: { options: stringOptions, allowCustomValue: true },
|
|
498
|
+
});
|
|
499
|
+
await wrapper.find('input').trigger('focus');
|
|
500
|
+
const hints = Array.from(document.body.querySelectorAll('.octopus-multiselect-options .text-indic'))
|
|
501
|
+
.map((el) => el.textContent?.trim());
|
|
502
|
+
expect(hints).not.toContain('Press Enter to add this value');
|
|
503
|
+
});
|
|
504
|
+
});
|
|
207
505
|
});
|