Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(() => {
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$,
};
}
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' }),
);
} 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 };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
19 changes: 19 additions & 0 deletions contentcuration/contentcuration/frontend/channelList/router.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
Expand All @@ -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,
Expand Down Expand Up @@ -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: {
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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');
Expand Down
Loading
Loading