-
-
Notifications
You must be signed in to change notification settings - Fork 307
Added features from issue: 6042 #6132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: unstable
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(() => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: |
||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. blocking: |
||
| 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$, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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' }), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: |
||
| ); | ||
| } catch (e) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: The error object is discarded, so nothing reaches Studio's handling. |
||
| error.value = true; | ||
| } finally { | ||
| loading.value = false; | ||
| } | ||
| } | ||
|
|
||
| onMounted(load); | ||
| return { organizations, loading, error, load }; | ||
| } | ||
|
|
||
| export function useOrganization(organizationId) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: |
||
| 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 }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' }), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: |
||
| ).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'); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
praise: Deriving options from
channels, notfilteredChannels, avoids the self-narrowing dropdown, and the comment records why.unavailableOrganizationfails visibly on an unknown id.