diff --git a/contentcuration/contentcuration/frontend/channelList/__tests__/organizationRoutes.spec.js b/contentcuration/contentcuration/frontend/channelList/__tests__/organizationRoutes.spec.js new file mode 100644 index 0000000000..b84588515f --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/__tests__/organizationRoutes.spec.js @@ -0,0 +1,21 @@ +import router from '../router'; +import { RouteNames } from '../constants'; + +describe('organization routes', () => { + it.each(['/organizations', '/my-organizations'])('opens the organization list at %s', path => { + expect(router.resolve(path).route.name).toBe(RouteNames.MY_ORGANIZATIONS); + }); + + it.each(['/organizations/new', '/organization/new'])('opens the creation form at %s', path => { + expect(router.resolve(path).route.name).toBe(RouteNames.NEW_ORGANIZATION); + }); + + it.each(['/organizations/org-1', '/organization/org-1/details'])( + 'opens organization details at %s', + path => { + const route = router.resolve(path).route; + expect(route.name).toBe(RouteNames.ORGANIZATION_EDIT); + expect(route.params.organizationId).toBe('org-1'); + }, + ); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/composables/useChannelOrganizationFilter.js b/contentcuration/contentcuration/frontend/channelList/composables/useChannelOrganizationFilter.js new file mode 100644 index 0000000000..fc83842c00 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/composables/useChannelOrganizationFilter.js @@ -0,0 +1,90 @@ +import { computed, onMounted, ref } from 'vue'; +import { useRoute } from 'vue-router/composables'; +import { Organization } from 'shared/data/resources'; +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', + }, + loadError: { + message: 'Unable to load organizations. Please try again.', + context: 'Error loading organization filter options', + }, + retry: { + message: 'Retry', + context: 'Reload organization filter options', + }, +}); + +// Memberships include organizations with no channels in this list. Also retain +// organizations on directly shared channels where the user is not a member. +export function useChannelOrganizationFilter(channels) { + const route = useRoute(); + const memberships = ref([]); + const organizationLoadError = ref(false); + async function loadOrganizations() { + organizationLoadError.value = false; + try { + memberships.value = await Organization.fetchCollection({ member: true, page_size: 100 }); + } catch (error) { + organizationLoadError.value = true; + } + } + onMounted(loadOrganizations); + 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); + } + } + for (const organization of memberships.value) { + organizations.set(organization.id, 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 { + organizationLoadError, + loadOrganizations, + organizationLoadError$: strings.loadError$, + retryOrganizations$: strings.retry$, + organizationFilter: filter, + organizationOptions: options, + filteredChannels, + filterByOrganization$: strings.filterByOrganization$, + }; +} diff --git a/contentcuration/contentcuration/frontend/channelList/composables/useOrganizationList.js b/contentcuration/contentcuration/frontend/channelList/composables/useOrganizationList.js index 1386607be1..646b601520 100644 --- a/contentcuration/contentcuration/frontend/channelList/composables/useOrganizationList.js +++ b/contentcuration/contentcuration/frontend/channelList/composables/useOrganizationList.js @@ -4,14 +4,14 @@ import { Organization } from 'shared/data/resources'; const MAX_PAGE_SIZE = 100; /** - * Composable for fetching the organizations the current user belongs to. + * Fetch organizations visible to the user, including public organizations. */ export function useOrganizationList() { const loading = ref(true); const organizations = ref([]); function loadOrganizations() { - return Organization.fetchCollection({ page_size: MAX_PAGE_SIZE, member: true }).then(data => { + return Organization.fetchCollection({ page_size: MAX_PAGE_SIZE }).then(data => { organizations.value = data; }); } diff --git a/contentcuration/contentcuration/frontend/channelList/router.js b/contentcuration/contentcuration/frontend/channelList/router.js index 3d9660b47b..dd0573def5 100644 --- a/contentcuration/contentcuration/frontend/channelList/router.js +++ b/contentcuration/contentcuration/frontend/channelList/router.js @@ -25,14 +25,24 @@ const router = new VueRouter({ { name: RouteNames.MY_ORGANIZATIONS, path: '/my-organizations', + alias: '/organizations', component: StudioMyOrganizations, }, { name: RouteNames.NEW_ORGANIZATION, path: '/organization/new', + alias: '/organizations/new', component: OrganizationEditPage, props: true, }, + { + path: '/organizations/:organizationId', + redirect: to => ({ + name: RouteNames.ORGANIZATION_EDIT, + params: { organizationId: to.params.organizationId, tab: 'details' }, + query: to.query, + }), + }, { name: RouteNames.ORGANIZATION_EDIT, path: '/organization/:organizationId/:tab', 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..2d5c80bdbe 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 @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import VueRouter from 'vue-router'; import { Store } from 'vuex'; import StudioMyChannels from '../index.vue'; +import { Organization } from 'shared/data/resources'; import { ChannelListTypes } from 'shared/constants'; import { redirectBrowser } from 'shared/utils/navigation'; @@ -13,6 +14,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 +24,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 +57,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 +71,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 +94,9 @@ function createStore() { }); } -function renderComponent(props = {}) { +function renderComponent(props = {}, channelData = CHANNELS) { return render(StudioMyChannels, { - store: createStore(), + store: createStore(channelData), routes: router, props: { ...props, @@ -103,6 +107,7 @@ function renderComponent(props = {}) { describe('StudioMyChannels', () => { beforeEach(() => { jest.clearAllMocks(); + jest.spyOn(Organization, 'fetchCollection').mockResolvedValue([]); router.push('/').catch(() => {}); }); @@ -123,6 +128,164 @@ describe('StudioMyChannels', () => { expect(mockLoadInvitationList).toHaveBeenCalled(); }); + describe('organization filter', () => { + it('lists all three memberships even when no channels have organization metadata', async () => { + Organization.fetchCollection.mockResolvedValue([ + { id: 'one', name: 'Aurora' }, + { id: 'two', name: 'Beacon' }, + { id: 'three', name: 'Cedar' }, + ]); + renderComponent( + {}, + CHANNELS.map(channel => ({ + ...channel, + organization: undefined, + organization_name: undefined, + })), + ); + await screen.findAllByTestId('channel-card'); + await userEvent.click(screen.getByText('Filter by organization')); + for (const name of ['Aurora', 'Beacon', 'Cedar']) { + expect( + await screen.findByText(name, { selector: '.ui-select-option-basic' }), + ).toBeInTheDocument(); + } + expect(Organization.fetchCollection).toHaveBeenCalledWith({ member: true, page_size: 100 }); + await userEvent.click(screen.getByText('Cedar', { selector: '.ui-select-option-basic' })); + await waitFor(() => expect(screen.getByText('No channels found')).toBeInTheDocument()); + expect( + screen.getByText('Cedar', { selector: '.ui-select-display-value' }), + ).toBeInTheDocument(); + }); + + it('lets the user retry a failed membership lookup without hiding channels', async () => { + Organization.fetchCollection.mockRejectedValueOnce(new Error('Network error')); + renderComponent(); + expect(await screen.findByRole('alert')).toHaveTextContent('Unable to load organizations'); + expect(await screen.findAllByTestId('channel-card')).toHaveLength(2); + Organization.fetchCollection.mockResolvedValue([ + { id: 'retry-org', name: 'Recovered organization' }, + ]); + await userEvent.click(screen.getByRole('button', { name: 'Retry' })); + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); + await userEvent.click(screen.getByText('Filter by organization')); + expect( + await screen.findByText('Recovered organization', { selector: '.ui-select-option-basic' }), + ).toBeInTheDocument(); + }); + + 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 +317,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..6be4ef96cd 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/index.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/index.vue @@ -16,7 +16,30 @@ :text="$tr('newChannel')" @click="newChannel" /> +
+ {{ organizationLoadError$() }}
+