Skip to content
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const RouteNames = {
MIGRATIONS: 'MIGRATIONS',
CHANNELS: 'CHANNELS',
CHANNEL: 'CHANNEL',
USERS: 'USERS',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
<VTab :to="usersLink">
{{ $tr('usersLabel') }}
</VTab>
<VTab :to="migrationsLink">
{{ $tr('migrationsLabel') }}
</VTab>
</template>
</AppBar>
<VContent>
Expand Down Expand Up @@ -61,6 +64,9 @@
name: RouteNames.CHANNELS,
};
},
migrationsLink() {
return { name: RouteNames.MIGRATIONS };
},
usersLink() {
return {
name: RouteNames.USERS,
Expand All @@ -70,6 +76,7 @@
$trs: {
channelsLabel: 'Channels',
usersLabel: 'Users',
migrationsLabel: 'Migrations',
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@
flat
class="px-5"
>
<ChannelOrganization
:channelId="channelId"
standalone
/>
<StudioDetailsPanel
v-if="channel && details"
:details="channelWithDetails"
Expand Down Expand Up @@ -86,6 +90,7 @@
import { mapActions, mapGetters } from 'vuex';
import { RouteNames } from '../../constants';
import ChannelActionsDropdown from './ChannelActionsDropdown';
import ChannelOrganization from 'shared/views/channel/ChannelOrganization.vue';
import ChannelSharing from 'shared/views/channel/ChannelSharing';
import StudioDetailsPanel from 'shared/views/details/StudioDetailsPanel';
import { routerMixin } from 'shared/mixins';
Expand All @@ -96,6 +101,7 @@
export default {
name: 'ChannelDetails',
components: {
ChannelOrganization,
StudioDetailsPanel,
LoadingText,
FullscreenModal,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<template>

<section ref="tableRoot">
<h1
ref="heading"
tabindex="-1"
>
{{ loading || error ? strings.title$() : strings.count$({ count: migrations.length }) }}
</h1>
<p
role="status"
aria-live="polite"
aria-atomic="true"
>
{{ confirmation }}
</p>
<KCircularLoader v-if="loading" />
<p
v-else-if="error"
role="alert"
>
{{ strings.error$() }}
<KButton
:text="strings.retry$()"
@click="load"
/>
</p>
<p v-else-if="!migrations.length">{{ strings.empty$() }}</p>
<KTable
v-else
:caption="strings.title$()"
:headers="headers"
:rows="rows"
>
<template #cell="{ content, colIndex }">
<KRouterLink
v-if="colIndex === 0"
class="notranslate"
:text="content.name"
:to="{ name: channelRoute, params: { channelId: content.id } }"
/>
<KButton
v-else-if="colIndex === 3"
:text="strings.options$()"
:data-migration-id="content"
hasDropdown
:disabled="Boolean(resolving)"

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: Every row's button is named "Options", so a screen reader hears N identical buttons. Give each one a row-specific ariaLabel, e.g. the channel name.

Separately, resolving one row disables every row's button. Compare resolving to this row's id instead of Boolean(resolving).

@keydown.enter.native.stop
>
<template #menu>
<KDropdownMenu
:options="actions"
@select="option => resolve(content, option.value)"
/>
</template>
</KButton>
<span
v-else
class="notranslate"
dir="auto"
>{{ content }}</span>
</template>
</KTable>
</section>

</template>


<script>

import { computed, nextTick, onMounted, ref } from 'vue';
import { RouteNames } from '../../constants';
import { Invitation } from 'shared/data/resources';
import { migrationTableStrings as strings } from 'shared/strings/organizationStrings';

export default {
name: 'MigrationTable',
setup() {
const tableRoot = ref(null);
const heading = ref(null);
const confirmation = ref('');
const migrations = ref([]);
const loading = ref(true);
const error = ref(false);
const resolving = ref(null);
const headers = computed(() =>
['channel', 'organization', 'user', 'options'].map(key => ({
label: strings[`${key}$`](),
columnId: key,
dataType: 'string',
})),
);
const rows = computed(() =>
migrations.value.map(item => [
{ id: item.channel, name: item.channel_name },
item.organization_name,
item.sender_email,
item.id,
]),
);
const actions = computed(() => [
{ label: strings.accept$(), value: 'accept' },
{ label: strings.decline$(), value: 'decline' },
]);
async function load() {
loading.value = true;
error.value = false;
try {
const data = await Invitation.fetchCollection({ migration: true });
migrations.value = Array.isArray(data) ? data : data.results;
} catch (e) {
error.value = true;
} finally {
loading.value = false;
}
}
async function resolve(id, action) {
if (resolving.value) return;
const index = migrations.value.findIndex(item => item.id === id);
const migration = migrations.value[index];
if (!migration) return;
confirmation.value = '';
resolving.value = id;
error.value = false;
try {
await Invitation[action](id);
migrations.value = migrations.value.filter(item => item.id !== id);
Comment thread
rtibblesbot marked this conversation as resolved.
confirmation.value =
action === 'accept'
? strings.accepted$({ channel: migration.channel_name })
: strings.declined$({ channel: migration.channel_name });
} catch (e) {
error.value = true;

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: One failed resolve hides every row behind the load-error branch. load and resolve share error. QA reproduced this with a concurrent admin resolve (400 "already resolved"). The heading also kept the stale count. Show resolve failures as a notice above the table, with the server message.

} finally {
resolving.value = null;
await nextTick();
const buttons = tableRoot.value?.querySelectorAll('[data-migration-id]');
const target = buttons?.[Math.min(index, buttons.length - 1)] || heading.value;
target?.focus();
}
}
onMounted(load);
return {
tableRoot,
heading,
confirmation,
strings,
channelRoute: RouteNames.CHANNEL,
migrations,
loading,
error,
resolving,
headers,
rows,
actions,
load,
resolve,
};
},
};

</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { render, screen, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import MigrationTable from '../MigrationTable.vue';
import { Invitation } from 'shared/data/resources';
import { migrationTableStrings as strings } from 'shared/strings/organizationStrings';

jest.mock('shared/data/resources', () => ({
Invitation: { fetchCollection: jest.fn(), accept: jest.fn(), decline: jest.fn() },
}));

const migration = {
id: 'request',
channel: 'channel',
channel_name: 'Example channel',
organization: 'org',
organization_name: 'Destination',
sender_email: 'requestor@example.com',
};

describe('MigrationTable', () => {
beforeEach(() => {
jest.resetAllMocks();
Invitation.fetchCollection.mockResolvedValue([migration]);

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: Removing the { migration: true } filter leaves both tests passing, though the table would then list every invitation. Assert fetchCollection is called with { migration: true }.

});

it.each(['accept', 'decline'])('can %s a pending migration', async method => {
Invitation[method].mockResolvedValue();
render(MigrationTable, { routes: [{ name: 'CHANNEL', path: '/channels/:channelId' }] });
await screen.findByRole('heading', { name: strings.count$({ count: 1 }) });
await userEvent.click(screen.getByRole('button', { name: strings.options$() }));
await userEvent.click(await screen.findByText(strings[`${method}$`]()));
await waitFor(() => expect(Invitation[method]).toHaveBeenCalledWith('request'));
expect(await screen.findByText(strings.empty$())).toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('heading')).toHaveFocus());
expect(screen.getByRole('status')).toHaveTextContent(
strings[method === 'accept' ? 'accepted$' : 'declined$']({ channel: migration.channel_name }),
);
});

it.each(['accept', 'decline'])('focuses the next row after keyboard %s', async method => {
const user = userEvent.setup();
Invitation.fetchCollection.mockResolvedValue([
migration,
{ ...migration, id: 'second', channel_name: 'Next channel' },
]);
render(MigrationTable, { routes: [{ name: 'CHANNEL', path: '/channels/:channelId' }] });
const buttons = await screen.findAllByRole('button', { name: strings.options$() });
buttons[0].focus();
await user.keyboard('{Enter}');
const accept = (await screen.findByText(strings.accept$())).closest('li');
// JSDOM has no layout; KDropdownMenu checks these dimensions before
// handling arrow keys in an open popover.
const popover = accept.closest('.ui-popover');
Object.defineProperties(popover, {
clientWidth: { value: 200 },
clientHeight: { value: 100 },
});
await waitFor(() => expect(accept).toHaveFocus());
if (method === 'decline') {
await user.keyboard('{ArrowDown}');
await waitFor(() => expect(screen.getByText(strings.decline$()).closest('li')).toHaveFocus());
}
await user.keyboard('{Enter}');
await waitFor(() => expect(Invitation[method]).toHaveBeenCalledWith('request'));
await waitFor(() =>
expect(screen.getAllByRole('button', { name: strings.options$() })).toHaveLength(1),
);
await waitFor(() =>
expect(screen.getByRole('button', { name: strings.options$() })).toHaveFocus(),
);
expect(screen.getByRole('status')).toHaveTextContent(
strings[method === 'accept' ? 'accepted$' : 'declined$']({ channel: migration.channel_name }),
);
});

it('recovers after the initial list request fails', async () => {
Invitation.fetchCollection.mockRejectedValueOnce(new Error('Network error'));
render(MigrationTable, { routes: [{ name: 'CHANNEL', path: '/channels/:channelId' }] });
await screen.findByRole('alert');
expect(screen.getByRole('heading')).toHaveTextContent(strings.title$());
expect(screen.queryByText(strings.count$({ count: 0 }))).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: strings.retry$() }));
await screen.findByRole('button', { name: strings.options$() });
expect(Invitation.fetchCollection).toHaveBeenCalledTimes(2);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

it('shows a recoverable error when resolution fails', async () => {
Invitation.accept.mockRejectedValue(new Error('Network error'));
render(MigrationTable, { routes: [{ name: 'CHANNEL', path: '/channels/:channelId' }] });
await userEvent.click(await screen.findByRole('button', { name: strings.options$() }));
await userEvent.click(await screen.findByText(strings.accept$()));
expect(await screen.findByRole('alert')).toBeInTheDocument();
expect(screen.getByRole('heading')).toHaveTextContent(strings.title$());
await userEvent.click(screen.getByRole('button', { name: strings.retry$() }));
expect(await screen.findByText('Example channel')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import VueRouter from 'vue-router';
import MigrationTable from './pages/Migrations/MigrationTable.vue';
import { RouteNames } from './constants';
import ChannelTable from './pages/Channels/ChannelTable';
import ChannelDetails from './pages/Channels/ChannelDetails';
Expand All @@ -8,6 +9,7 @@ import SubmissionDetailsModal from 'shared/views/communityLibrary/SubmissionDeta

const router = new VueRouter({
routes: [
{ name: RouteNames.MIGRATIONS, path: '/migrations/', component: MigrationTable },
{
name: RouteNames.CHANNELS,
path: '/channels/',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { UpdatedDescendantsChange } from '../changes';
import { ViewerM2M, ChannelUser, Channel, ContentNode } from '../resources';
import { ViewerM2M, ChannelUser, Channel, ContentNode, Invitation } from '../resources';
import db from 'shared/data/db';
import { CHANGE_TYPES, TABLE_NAMES } from 'shared/data/constants';
import { ContentKindsNames } from 'shared/leUtils/ContentKinds';
Expand Down Expand Up @@ -222,3 +222,33 @@ describe('Resources', () => {
});
});
});

describe('Organization migration resources', () => {
afterEach(() => jest.restoreAllMocks());

it('uses the generated channel action URL for eligibility checks', async () => {
const get = jest.spyOn(client, 'get').mockResolvedValue({ data: { uncontested: true } });
const url = jest
.spyOn(Channel, 'getUrlFunction')
.mockReturnValue(() => '/api/channel/c/organization_migration');

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: Mocking getUrlFunction makes the URL assertion restate the mock. A mismatch with the DRF action name would still pass. Spy on urls.channel_organization_migration instead, as line 184 does.

expect(await Channel.checkOrganizationMigration('c', 'org')).toEqual({ uncontested: true });
expect(url).toHaveBeenCalledWith('organization_migration');
expect(get).toHaveBeenCalledWith('/api/channel/c/organization_migration', {
params: { organization: 'org' },
});
});

it('does not expose cached migration requests as sharing invitations', async () => {
await Invitation.transaction({ mode: 'rw' }, () =>
Invitation.table.bulkPut([
{ id: 'migration', channel: 'migration-channel', organization: 'org' },
{ id: 'sharing', channel: 'migration-channel', email: 'editor@example.com' },
]),
);
const invitations = await Invitation.where({ channel: 'migration-channel' }, false);
expect(invitations.map(item => item.id)).toEqual(['sharing']);
await Invitation.transaction({ mode: 'rw' }, () =>
Invitation.table.bulkDelete(['migration', 'sharing']),
);
});
});
Loading
Loading