|
-
-
-
- {{ $tr('optionsDropdown') }}
-
-
+
+
+
-
-
-
- {{ $tr('resendInvitation') }}
-
-
- {{ $tr('deleteInvitation') }}
-
-
-
-
- {{ $tr('makeEditor') }}
-
-
- {{ $tr('removeViewer') }}
-
-
-
-
+
|
-
-
-
-
- {{ $tr('cancelButton') }}
-
-
- {{ $tr('removeViewerConfirm') }}
-
-
-
+ {{
+ $tr('removeViewerText', { first_name: selected.first_name, last_name: selected.last_name })
+ }}
+
-
-
-
-
- {{ $tr('cancelButton') }}
-
-
- {{ $tr('deleteInvitationConfirm') }}
-
-
-
+ {{ $tr('deleteInvitationText', { email: selected.email }) }}
+
-
-
-
-
- {{ $tr('cancelButton') }}
-
-
- {{ $tr('makeEditorConfirm') }}
-
-
-
+ {{
+ $tr('makeEditorText', { first_name: selected.first_name, last_name: selected.last_name })
+ }}
+
@@ -173,13 +98,9 @@
import { mapActions, mapGetters, mapState } from 'vuex';
import { SharingPermissions } from 'shared/constants';
- import MessageDialog from 'shared/views/MessageDialog';
export default {
name: 'ChannelSharingTable',
- components: {
- MessageDialog,
- },
props: {
channelId: {
type: String,
@@ -246,6 +167,40 @@
'makeEditor',
'removeViewer',
]),
+ getMenuOptions(item) {
+ if (item.pending) {
+ return [
+ { id: 'resend-invitation', label: this.$tr('resendInvitation'), item },
+ { id: 'delete-invitation', label: this.$tr('deleteInvitation'), item },
+ ];
+ } else {
+ return [
+ { id: 'make-editor', label: this.$tr('makeEditor'), item },
+ { id: 'remove-viewer', label: this.$tr('removeViewer'), item },
+ ];
+ }
+ },
+ onMenuSelect(selection) {
+ switch (selection.id) {
+ case 'resend-invitation':
+ this.resendInvitation(selection.item.email);
+ break;
+ case 'delete-invitation':
+ this.selected = selection.item;
+ this.showDeleteInvitation = true;
+ break;
+ case 'make-editor':
+ this.selected = selection.item;
+ this.showMakeEditor = true;
+ break;
+ case 'remove-viewer':
+ this.selected = selection.item;
+ this.showRemoveViewer = true;
+ break;
+ default:
+ break;
+ }
+ },
getUserText(user) {
const nameParams = {
first_name: user.first_name,
diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelSharingTable.spec.js b/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelSharingTable.spec.js
index 5b50e04c02..e12f95fd87 100644
--- a/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelSharingTable.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelSharingTable.spec.js
@@ -1,118 +1,292 @@
-import { mount } from '@vue/test-utils';
-import ChannelSharingTable from './../ChannelSharingTable';
-import storeFactory from 'shared/vuex/baseStore';
+import { render, screen } from '@testing-library/vue';
+import userEvent from '@testing-library/user-event';
+import { createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import VueRouter from 'vue-router';
+import ChannelSharingTable from '../ChannelSharingTable';
import { SharingPermissions } from 'shared/constants';
-const store = storeFactory();
-const currentUser = { id: 'testId' };
-store.state.session.currentUser = currentUser;
+const localVue = createLocalVue();
+localVue.use(Vuex);
const channelId = 'test-channel';
-const channelUsers = [{ id: 'other-user' }, currentUser];
-const channelInvitations = [{ id: 'invitation-test' }];
-function makeWrapper(users, invites) {
- return mount(ChannelSharingTable, {
- store,
- propsData: {
- channelId,
- mode: SharingPermissions.EDIT,
- },
- computed: {
- getChannelUsers() {
- return () => users || channelUsers;
+const currentUser = {
+ id: 'current-user-id',
+ email: 'current@example.com',
+ first_name: 'Current',
+ last_name: 'User',
+};
+const otherUser = {
+ id: 'other-user-id',
+ email: 'other@example.com',
+ first_name: 'Other',
+ last_name: 'User',
+};
+const pendingInvitation = {
+ id: 'pending-invitation',
+ email: 'pending@example.com',
+ first_name: 'Pending',
+ last_name: 'User',
+ pending: true,
+ declined: false,
+};
+
+const mockActions = {
+ sendInvitation: jest.fn(() => Promise.resolve()),
+ deleteInvitation: jest.fn(() => Promise.resolve()),
+ makeEditor: jest.fn(() => Promise.resolve()),
+ removeViewer: jest.fn(() => Promise.resolve()),
+ showSnackbar: jest.fn(() => Promise.resolve()),
+};
+
+const createMockStore = (users = [currentUser, otherUser], invitations = [pendingInvitation]) => {
+ return new Vuex.Store({
+ state: {
+ session: {
+ currentUser,
},
- getChannelInvitations() {
- return () => invites || channelInvitations;
+ },
+ modules: {
+ channel: {
+ namespaced: true,
+ actions: {
+ sendInvitation: mockActions.sendInvitation,
+ deleteInvitation: mockActions.deleteInvitation,
+ makeEditor: mockActions.makeEditor,
+ removeViewer: mockActions.removeViewer,
+ },
+ getters: {
+ getChannelUsers: () => () => users,
+ getChannelInvitations: () => () => invitations,
+ },
},
},
+ actions: {
+ showSnackbar: mockActions.showSnackbar,
+ },
});
-}
+};
+
+const renderComponent = (props = {}, storeOverrides = {}) => {
+ const defaultProps = {
+ channelId,
+ mode: SharingPermissions.VIEW_ONLY,
+ ...props,
+ };
-describe('channelSharingTable', () => {
- let wrapper;
+ const store = createMockStore(storeOverrides.users, storeOverrides.invitations);
+ return render(ChannelSharingTable, {
+ localVue,
+ store,
+ props: defaultProps,
+ routes: new VueRouter(),
+ });
+};
+
+describe('ChannelSharingTable', () => {
beforeEach(() => {
- wrapper = makeWrapper();
+ jest.clearAllMocks();
+ });
+
+ it('shows "No users found" message when no users exist', () => {
+ renderComponent({}, { users: [], invitations: [] });
+ expect(screen.getByText('No users found')).toBeInTheDocument();
+ });
+
+ it('displays current user correctly', () => {
+ renderComponent();
+ const cell = screen.getByText('Current User (you)');
+ expect(cell).toBeInTheDocument();
+ const row = cell.closest('tr');
+ expect(row).toHaveTextContent('current@example.com');
+ expect(row).not.toHaveTextContent('Invite pending');
});
- it('should add current user to top of list', () => {
- expect(wrapper.vm.users[0].id).toBe(currentUser.id);
- expect(wrapper.vm.users[1].id).toBe(channelUsers[0].id);
+ it('displays other user correctly', () => {
+ renderComponent();
+ const cell = screen.getByText('Other User');
+ expect(cell).toBeInTheDocument();
+ const row = cell.closest('tr');
+ expect(row).toHaveTextContent('other@example.com');
+ expect(row).not.toHaveTextContent('Invite pending');
});
- it('should set all invitations as pending', () => {
- expect(wrapper.vm.invitations[0].pending).toBe(true);
+ it('displays pending user correctly', () => {
+ renderComponent();
+ const cell = screen.getByText('Pending User');
+ expect(cell).toBeInTheDocument();
+ const row = cell.closest('tr');
+ expect(row).toHaveTextContent('pending@example.com');
+ expect(row).toHaveTextContent('Invite pending');
});
- describe('confirmation modals', () => {
- it('clicking make editor option should open makeEditor confirmation modal', async () => {
- await wrapper.setProps({ mode: SharingPermissions.VIEW_ONLY });
- await wrapper.findComponent('[data-test="makeeditor"]').trigger('click');
- expect(wrapper.vm.showMakeEditor).toBe(true);
+ describe('in edit mode', () => {
+ it('displays the correct header', () => {
+ renderComponent({ mode: SharingPermissions.EDIT });
+ expect(screen.getByText(/users who can edit/)).toBeInTheDocument();
});
- it('clicking remove viewer option should open removeViewer confirmation modal', async () => {
- await wrapper.setProps({ mode: SharingPermissions.VIEW_ONLY });
- await wrapper.findComponent('[data-test="removeviewer"]').trigger('click');
- expect(wrapper.vm.showRemoveViewer).toBe(true);
+ it('does not display the options dropdown for the current user', () => {
+ renderComponent({ mode: SharingPermissions.EDIT });
+ const row = screen.getByText('Current User (you)').closest('tr');
+ const optionsButton = row.querySelector('button');
+ expect(optionsButton).not.toBeInTheDocument();
+ });
+
+ it('does not display the options dropdown for users who accepted invitations', () => {
+ renderComponent({ mode: SharingPermissions.EDIT });
+ const row = screen.getByText('Other User').closest('tr');
+ const optionsButton = row.querySelector('button');
+ expect(optionsButton).not.toBeInTheDocument();
+ });
+
+ it('displays the options dropdown with correct options for pending invitations', async () => {
+ renderComponent({ mode: SharingPermissions.EDIT });
+ const row = screen.getByText('Pending User').closest('tr');
+ const optionsButton = row.querySelector('button');
+ expect(optionsButton).toBeInTheDocument();
+
+ await userEvent.click(optionsButton);
+
+ expect(screen.getByText('Resend invitation')).toBeInTheDocument();
+ expect(screen.getByText('Delete invitation')).toBeInTheDocument();
+ expect(screen.queryByText('Grant edit permissions')).not.toBeInTheDocument();
+ expect(screen.queryByText('Revoke view permissions')).not.toBeInTheDocument();
});
});
- describe('actions', () => {
- const invite = {
- id: 'test-invitation',
- email: 'test@testing.com',
- declined: false,
- };
- const user = {
- id: 'test-user',
- };
-
- beforeEach(() => {
- wrapper = makeWrapper([user], [invite]);
+ describe('in view-only mode', () => {
+ it('displays the correct header', () => {
+ renderComponent({ mode: SharingPermissions.VIEW_ONLY });
+ expect(screen.getByText(/users who can view/)).toBeInTheDocument();
});
- it('resendInvitation should call sendInvitation', async () => {
- const sendInvitation = jest.spyOn(wrapper.vm, 'sendInvitation');
- sendInvitation.mockImplementation(() => Promise.resolve());
- await wrapper.find('[data-test="resend"]').trigger('click');
- expect(sendInvitation).toHaveBeenCalledWith({
- email: invite.email,
- channelId,
- shareMode: SharingPermissions.EDIT,
- });
+ it('displays the options dropdown with correct options for pending invitations', async () => {
+ renderComponent({ mode: SharingPermissions.VIEW_ONLY });
+ const row = screen.getByText('Pending User').closest('tr');
+ const optionsButton = row.querySelector('button');
+ expect(optionsButton).toBeInTheDocument();
+
+ await userEvent.click(optionsButton);
+
+ expect(screen.getByText('Resend invitation')).toBeInTheDocument();
+ expect(screen.getByText('Delete invitation')).toBeInTheDocument();
+ expect(screen.queryByText('Grant edit permissions')).not.toBeInTheDocument();
+ expect(screen.queryByText('Revoke view permissions')).not.toBeInTheDocument();
});
- it('handleDelete should call deleteInvitation', async () => {
- await wrapper.setData({ selected: invite });
- const deleteInvitation = jest.spyOn(wrapper.vm, 'deleteInvitation');
- deleteInvitation.mockImplementation(() => Promise.resolve());
- await wrapper.findComponent('[data-test="confirm-delete"]').trigger('click');
- expect(deleteInvitation).toHaveBeenCalledWith(invite.id);
+ it('displays the options dropdown with correct options for users who accepted invitations', async () => {
+ renderComponent({ mode: SharingPermissions.VIEW_ONLY });
+ const row = screen.getByText('Other User').closest('tr');
+ const optionsButton = row.querySelector('button');
+ expect(optionsButton).toBeInTheDocument();
+
+ await userEvent.click(optionsButton);
+
+ expect(screen.getByText('Grant edit permissions')).toBeInTheDocument();
+ expect(screen.getByText('Revoke view permissions')).toBeInTheDocument();
+ expect(screen.queryByText('Resend invitation')).not.toBeInTheDocument();
+ expect(screen.queryByText('Delete invitation')).not.toBeInTheDocument();
});
+ });
+
+ describe(`clicking 'Resend invitation' menu option`, () => {
+ it('should resend the invitation and show a success message', async () => {
+ const user = userEvent.setup();
- it('clicking delete option should open delete confirmation modal', async () => {
- await wrapper.findComponent('[data-test="delete"]').trigger('click');
- expect(wrapper.vm.showDeleteInvitation).toBe(true);
+ renderComponent();
+ const row = screen.getByText('Pending User').closest('tr');
+ const optionsButton = row.querySelector('button');
+
+ await userEvent.click(optionsButton);
+ const resendOption = screen.getByText('Resend invitation');
+ await user.click(resendOption);
+
+ expect(mockActions.sendInvitation).toHaveBeenCalledWith(expect.any(Object), {
+ email: 'pending@example.com',
+ shareMode: SharingPermissions.VIEW_ONLY,
+ channelId: channelId,
+ });
});
+ });
+
+ describe(`clicking 'Delete invitation' menu option`, () => {
+ it('should open confirmation modal and delete invitation on confirm', async () => {
+ const dialogMessage = `Are you sure you would like to delete the invitation for pending@example.com?`;
+ const user = userEvent.setup();
+
+ renderComponent();
+ const row = screen.getByText('Pending User').closest('tr');
+ const optionsButton = row.querySelector('button');
+
+ expect(screen.queryByText(dialogMessage)).not.toBeInTheDocument();
- it('grantEditAccess should call makeEditor', async () => {
- await wrapper.setData({ selected: user });
- await wrapper.setProps({ mode: SharingPermissions.VIEW_ONLY });
- const makeEditor = jest.spyOn(wrapper.vm, 'makeEditor');
- makeEditor.mockImplementation(() => Promise.resolve());
- await wrapper.findComponent('[data-test="confirm-makeeditor"]').trigger('click');
- expect(makeEditor).toHaveBeenCalledWith({ userId: user.id, channelId });
+ await userEvent.click(optionsButton);
+ const deleteOption = screen.getByText('Delete invitation');
+ await user.click(deleteOption);
+
+ expect(screen.getByText(dialogMessage)).toBeInTheDocument();
+ const confirmButton = screen.getByRole('button', { name: 'Delete invitation' });
+ await user.click(confirmButton);
+
+ expect(mockActions.deleteInvitation).toHaveBeenCalledWith(
+ expect.any(Object),
+ pendingInvitation.id,
+ );
});
+ });
+
+ describe(`clicking 'Grant edit permissions' menu option`, () => {
+ it('should open confirmation modal and upgrade user on confirm', async () => {
+ const dialogMessage = `Are you sure you would like to grant edit permissions to Other User?`;
+ const user = userEvent.setup();
+
+ renderComponent();
+ const row = screen.getByText('Other User').closest('tr');
+ const optionsButton = row.querySelector('button');
+
+ expect(screen.queryByText(dialogMessage)).not.toBeInTheDocument();
+
+ await userEvent.click(optionsButton);
+ const grantOption = screen.getByText('Grant edit permissions');
+ await user.click(grantOption);
- it('handleRemoveViewer should call removeViewer', async () => {
- await wrapper.setData({ selected: user });
- await wrapper.setProps({ mode: SharingPermissions.VIEW_ONLY });
- const removeViewer = jest.spyOn(wrapper.vm, 'removeViewer');
- removeViewer.mockImplementation(() => Promise.resolve());
- await wrapper.findComponent('[data-test="confirm-remove"]').trigger('click');
- expect(removeViewer).toHaveBeenCalledWith({ userId: user.id, channelId });
+ expect(screen.getByText(dialogMessage)).toBeInTheDocument();
+ const confirmButton = screen.getByRole('button', { name: 'Yes, grant permissions' });
+ await user.click(confirmButton);
+
+ expect(mockActions.makeEditor).toHaveBeenCalledWith(expect.any(Object), {
+ channelId,
+ userId: otherUser.id,
+ });
+ });
+ });
+
+ describe(`clicking 'Revoke view permissions' menu option`, () => {
+ it('should open confirmation modal and remove viewer on confirm', async () => {
+ const dialogMessage = `Are you sure you would like to revoke view permissions for Other User?`;
+ const user = userEvent.setup();
+
+ renderComponent();
+ const row = screen.getByText('Other User').closest('tr');
+ const optionsButton = row.querySelector('button');
+
+ expect(screen.queryByText(dialogMessage)).not.toBeInTheDocument();
+
+ await userEvent.click(optionsButton);
+ const revokeOption = screen.getByText('Revoke view permissions');
+ await user.click(revokeOption);
+
+ expect(screen.getByText(dialogMessage)).toBeInTheDocument();
+ const confirmButton = screen.getByRole('button', { name: 'Yes, revoke' });
+ await user.click(confirmButton);
+
+ expect(mockActions.removeViewer).toHaveBeenCalledWith(expect.any(Object), {
+ channelId,
+ userId: otherUser.id,
+ });
});
});
});