diff --git a/contentcuration/contentcuration/frontend/channelList/composables/useChannelOrganizationFilter.js b/contentcuration/contentcuration/frontend/channelList/composables/useChannelOrganizationFilter.js new file mode 100644 index 0000000000..08c8bbc66a --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/composables/useChannelOrganizationFilter.js @@ -0,0 +1,63 @@ +import { computed } from 'vue'; +import { useRoute } from 'vue-router/composables'; +import { useFilter } from 'shared/composables/useFilter'; +import { createTranslator } from 'shared/i18n'; + +const strings = createTranslator('ChannelOrganizationFilter', { + filterByOrganization: { + message: 'Filter by organization', + context: 'Label for filtering the current channel list by organization', + }, + allOrganizations: { + message: 'All organizations', + context: 'Show all channels, including channels without an organization', + }, + unavailableOrganization: { + message: 'Unavailable organization', + context: 'Selected organization has no accessible channels in this list', + }, +}); + +// Derive options from the unfiltered list so selecting one organization does not +// remove the others. Association metadata is supplied by the channel Resource. +export function useChannelOrganizationFilter(channels) { + const route = useRoute(); + const selectedId = computed(() => { + const value = route.query.organization; + return typeof value === 'string' ? value : ''; + }); + const filterMap = computed(() => { + const organizations = new Map(); + for (const channel of channels.value) { + if (channel.organization && channel.organization_name) { + organizations.set(channel.organization, channel.organization_name); + } + } + const entries = [...organizations.entries()].sort((a, b) => a[1].localeCompare(b[1])); + const map = Object.fromEntries([ + ['', { label: strings.allOrganizations$() }], + ...entries.map(([id, label]) => [id, { label }]), + ]); + if (selectedId.value && !organizations.has(selectedId.value)) { + map[selectedId.value] = { label: strings.unavailableOrganization$() }; + } + return map; + }); + const { filter, options } = useFilter({ + name: 'organization', + filterMap, + defaultValue: '', + }); + const filteredChannels = computed(() => + selectedId.value + ? channels.value.filter(channel => channel.organization === selectedId.value) + : channels.value, + ); + + return { + organizationFilter: filter, + organizationOptions: options, + filteredChannels, + filterByOrganization$: strings.filterByOrganization$, + }; +} diff --git a/contentcuration/contentcuration/frontend/channelList/composables/useOrganizations.js b/contentcuration/contentcuration/frontend/channelList/composables/useOrganizations.js new file mode 100644 index 0000000000..5c798e2584 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/composables/useOrganizations.js @@ -0,0 +1,53 @@ +import { onMounted, ref } from 'vue'; +import { Organization } from 'shared/data/resources'; + +const PAGE_SIZE = 100; + +function results(data) { + return Array.isArray(data) ? data : data?.results || []; +} + +export function useOrganizationList() { + const loading = ref(true); + const error = ref(false); + const organizations = ref([]); + + async function load() { + loading.value = true; + error.value = false; + try { + organizations.value = results( + await Organization.fetchCollection({ page_size: PAGE_SIZE, ordering: 'name' }), + ); + } catch (e) { + error.value = true; + } finally { + loading.value = false; + } + } + + onMounted(load); + return { organizations, loading, error, load }; +} + +export function useOrganization(organizationId) { + const loading = ref(Boolean(organizationId)); + const error = ref(false); + const organization = ref(null); + + async function load() { + if (!organizationId) return; + loading.value = true; + error.value = false; + try { + organization.value = await Organization.fetchModel(organizationId); + } catch (e) { + error.value = true; + } finally { + loading.value = false; + } + } + + onMounted(load); + return { organization, loading, error, load }; +} diff --git a/contentcuration/contentcuration/frontend/channelList/constants.js b/contentcuration/contentcuration/frontend/channelList/constants.js index b0599932ca..6e69b6a9e2 100644 --- a/contentcuration/contentcuration/frontend/channelList/constants.js +++ b/contentcuration/contentcuration/frontend/channelList/constants.js @@ -14,6 +14,9 @@ export const ChannelInvitationMapping = { export const RouteNames = { CHANNELS_EDITABLE: 'CHANNELS_EDITABLE', + ORGANIZATIONS: 'ORGANIZATIONS', + NEW_ORGANIZATION: 'NEW_ORGANIZATION', + ORGANIZATION_DETAILS: 'ORGANIZATION_DETAILS', CHANNELS_STARRED: 'CHANNELS_STARRED', CHANNELS_VIEW_ONLY: 'CHANNELS_VIEW_ONLY', CHANNELS_PUBLIC: 'CHANNELS_PUBLIC', diff --git a/contentcuration/contentcuration/frontend/channelList/router.js b/contentcuration/contentcuration/frontend/channelList/router.js index dfe37388cd..430aaa8542 100644 --- a/contentcuration/contentcuration/frontend/channelList/router.js +++ b/contentcuration/contentcuration/frontend/channelList/router.js @@ -1,6 +1,9 @@ import VueRouter from 'vue-router'; import CommunityChannelDetailsModal from './views/Channel/CommunityLibraryList/CommunityChannelDetailsModal.vue'; import StudioMyChannels from './views/Channel/StudioMyChannels'; +import StudioOrganizations from './views/Organization/StudioOrganizations.vue'; +import NewOrganization from './views/Organization/NewOrganization.vue'; +import OrganizationDetails from './views/Organization/OrganizationDetails.vue'; import StudioStarredChannels from './views/Channel/StudioStarredChannels'; import StudioViewOnlyChannels from './views/Channel/StudioViewOnlyChannels'; import StudioCollectionsTable from './views/ChannelSet/StudioCollectionsTable'; @@ -20,6 +23,22 @@ const router = new VueRouter({ path: '/my-channels', component: StudioMyChannels, }, + { + name: RouteNames.ORGANIZATIONS, + path: '/organizations', + component: StudioOrganizations, + }, + { + name: RouteNames.NEW_ORGANIZATION, + path: '/organizations/new', + component: NewOrganization, + }, + { + name: RouteNames.ORGANIZATION_DETAILS, + path: '/organizations/:organizationId', + component: OrganizationDetails, + props: true, + }, { name: RouteNames.CHANNEL_SETS, path: '/collections', diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/__tests__/StudioMyChannels.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/__tests__/StudioMyChannels.spec.js index 43c1989d6f..8b022df8ef 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/__tests__/StudioMyChannels.spec.js +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/__tests__/StudioMyChannels.spec.js @@ -13,6 +13,7 @@ jest.mock('shared/utils/navigation', () => ({ const router = new VueRouter({ routes: [ { name: 'NEW_CHANNEL', path: '/new' }, + { name: 'NEW_ORGANIZATION', path: '/organizations/new' }, { name: 'CHANNEL_DETAILS', path: '/:channelId/details' }, { name: 'CHANNEL_EDIT', path: '/:channelId/:tab' }, ], @@ -22,6 +23,8 @@ const CHANNELS = [ { id: 'channel-id-1', name: 'Channel title 1', + organization: 'organization-1', + organization_name: 'Learning Together', language: 'en', description: 'Channel description', edit: true, @@ -53,7 +56,7 @@ const mockLoadInvitationList = jest.fn(); const mockDeleteChannel = jest.fn(); const mockBookmarkChannel = jest.fn(); -function createStore() { +function createStore(channelData = CHANNELS) { return new Store({ state: { session: { @@ -67,8 +70,8 @@ function createStore() { channel: { namespaced: true, getters: { - channels: () => CHANNELS, - getChannel: () => id => CHANNELS.find(c => c.id === id), + channels: () => channelData, + getChannel: () => id => channelData.find(c => c.id === id), }, actions: { loadChannelList: mockLoadChannelList, @@ -90,9 +93,9 @@ function createStore() { }); } -function renderComponent(props = {}) { +function renderComponent(props = {}, channelData = CHANNELS) { return render(StudioMyChannels, { - store: createStore(), + store: createStore(channelData), routes: router, props: { ...props, @@ -123,6 +126,119 @@ describe('StudioMyChannels', () => { expect(mockLoadInvitationList).toHaveBeenCalled(); }); + describe('organization filter', () => { + it('keeps legacy channels without association metadata visible by default', async () => { + const legacyChannels = CHANNELS.map(channel => ({ + ...channel, + organization: undefined, + organization_name: undefined, + })); + renderComponent({}, legacyChannels); + expect(await screen.findAllByTestId('channel-card')).toHaveLength(2); + expect( + await screen.findByText('All organizations', { selector: '.ui-select-display-value' }), + ).toBeInTheDocument(); + }); + + it('deduplicates organization options and keeps options after filtering', async () => { + renderComponent({}, [ + ...CHANNELS, + { ...CHANNELS[0], id: 'channel-id-3', name: 'Third channel' }, + { + ...CHANNELS[0], + id: 'channel-id-4', + name: 'Fourth channel', + organization: 'organization-2', + organization_name: 'Another organization', + }, + ]); + await screen.findAllByTestId('channel-card'); + await userEvent.click(screen.getByText('Filter by organization')); + expect( + screen.getAllByText('Learning Together', { selector: '.ui-select-option-basic' }), + ).toHaveLength(1); + await userEvent.click( + screen.getByText('Learning Together', { selector: '.ui-select-option-basic' }), + ); + await waitFor(() => expect(screen.getAllByTestId('channel-card')).toHaveLength(2)); + await userEvent.click(screen.getByText('Filter by organization')); + await userEvent.click( + screen.getByText('Another organization', { selector: '.ui-select-option-basic' }), + ); + await waitFor(() => expect(screen.getAllByTestId('channel-card')).toHaveLength(1)); + expect(screen.getByTestId('channel-card')).toHaveTextContent('Fourth channel'); + }); + + it('does not include deleted or noneditable channels in organization options', async () => { + renderComponent({}, [ + ...CHANNELS, + { + ...CHANNELS[0], + id: 'deleted', + deleted: true, + organization: 'deleted-org', + organization_name: 'Deleted organization', + }, + { + ...CHANNELS[0], + id: 'view-only', + edit: false, + organization: 'viewer-org', + organization_name: 'Viewer organization', + }, + ]); + expect(await screen.findAllByTestId('channel-card')).toHaveLength(2); + await userEvent.click(screen.getByText('Filter by organization')); + expect(screen.queryByText('Deleted organization')).not.toBeInTheDocument(); + expect(screen.queryByText('Viewer organization')).not.toBeInTheDocument(); + }); + + it('filters channels from the URL and preserves unrelated query parameters when cleared', async () => { + await router.push({ query: { organization: 'organization-1', other: 'keep' } }); + renderComponent(); + + const cards = await screen.findAllByTestId('channel-card'); + expect(cards).toHaveLength(1); + expect(cards[0]).toHaveTextContent('Channel title 1'); + + await userEvent.click(screen.getByText('Filter by organization')); + await userEvent.click( + await screen.findByText('All organizations', { selector: '.ui-select-option-basic' }), + ); + + await waitFor(() => expect(screen.getAllByTestId('channel-card')).toHaveLength(2)); + expect(router.currentRoute.query).toEqual({ other: 'keep' }); + }); + + it('selects an organization and restores the list when navigating back', async () => { + renderComponent(); + await screen.findAllByTestId('channel-card'); + await userEvent.click(screen.getByText('Filter by organization')); + await userEvent.click( + await screen.findByText('Learning Together', { selector: '.ui-select-option-basic' }), + ); + + await waitFor(() => expect(screen.getAllByTestId('channel-card')).toHaveLength(1)); + expect(router.currentRoute.query.organization).toBe('organization-1'); + + router.back(); + await waitFor(() => expect(screen.getAllByTestId('channel-card')).toHaveLength(2)); + }); + + it('does not silently show all channels for an unavailable organization', async () => { + await router.push({ query: { organization: 'unavailable' } }); + renderComponent(); + + await waitFor(() => expect(screen.getByText('No channels found')).toBeInTheDocument()); + expect(screen.queryAllByTestId('channel-card')).toHaveLength(0); + expect( + await screen.findByText('Unavailable organization', { + selector: '.ui-select-display-value', + }), + ).toBeInTheDocument(); + }); + }); + it('shows the visually hidden title and all channel cards in correct semantic structure', async () => { renderComponent(); const title = screen.getByRole('heading', { name: /my channels/i }); @@ -154,6 +270,17 @@ describe('StudioMyChannels', () => { }); }); + it('navigates to the new organization route from the filter actions', async () => { + renderComponent(); + await screen.findAllByTestId('channel-card'); + + await userEvent.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(router.currentRoute.path).toBe('/organizations/new'); + }); + }); + it('navigates to channel via window.location when card clicked', async () => { renderComponent(); const cards = await screen.findAllByTestId('channel-card'); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/index.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/index.vue index 2f91566a1f..ef61574cdd 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/index.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/index.vue @@ -16,6 +16,19 @@ :text="$tr('newChannel')" @click="newChannel" /> +
+ {{ $tr('saveError') }} +
++ {{ organization.description }} +
+{{ $tr('loadError') }}
++ {{ organization.public ? $tr('publicOrganization') : $tr('privateOrganization') }} +
++ {{ organization.description || $tr('noDescription') }} +
++ {{ $tr('channelsUnavailable') }} +
++ {{ $tr('noChannels') }} +
++ {{ $tr('membersUnavailable') }} +
++ {{ $tr('noMembers') }} +
+{{ $tr('loadError') }}
++ {{ $tr('empty') }} +
+