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

Copy link
Copy Markdown
Contributor

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, not filteredChannels, avoids the self-narrowing dropdown, and the comment records why. unavailableOrganization fails visibly on an unknown id.

// remove the others. Association metadata is supplied by the channel Resource.
export function useChannelOrganizationFilter(channels) {
const route = useRoute();
const selectedId = computed(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: useFilter already parses route.query.organization into filter (useFilter.js:45-56); filter.value.value avoids two readers drifting. fetchQueryParams exists so the filter can drive a server-side fetch once organization is a ChannelFilter field.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: organization is in no channel payload — viewsets/channel.py has zero occurrences, despite Channel.organization (models.py:1163). KSelect renders one option forever. Add "organization_id"/"organization__name" to ChannelViewSet.values plus field_map, as OrganizationMemberViewSet does; otherwise make this PR depend on that work.

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' }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: page_size: 100 equals OrganizationPagination.max_page_size and next is unread — over 100 organizations silently truncates. Same at OrganizationDetails.vue:164.

);
} catch (e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The error object is discarded, so nothing reaches Studio's handling. useChannelList.js:40-47 dispatches errors/handleAxiosError / errors/handleGenericError.

error.value = true;
} finally {
loading.value = false;
}
}

onMounted(load);
return { organizations, loading, error, load };
}

export function useOrganization(organizationId) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: useOrganization has no importers; OrganizationDetails.vue:147-187 rehand-rolls it and copies results(). Both duplicate shared/composables/useFetch. Consume one, delete the rest.

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' }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: .ui-select-display-value / .ui-select-option-basic are keen-ui internals vendored by KDS — no other spec queries them, and this PR adds 7. Scope with within(screen.getByTestId('organization-filter')) and query by text.

).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