diff --git a/contentcuration/contentcuration/frontend/shared/styles/main.scss b/contentcuration/contentcuration/frontend/shared/styles/main.scss index 8e3188950c..8a6910724c 100644 --- a/contentcuration/contentcuration/frontend/shared/styles/main.scss +++ b/contentcuration/contentcuration/frontend/shared/styles/main.scss @@ -48,9 +48,12 @@ body { text-align: center; } - .button:focus-visible, .v-btn:focus-visible { - outline: 2px solid var(--v-secondary-base) !important; + // ensures that until KDS migration is complete, + // Vuetify-based buttons have a visible focus style + // consistent with KDS buttons and links + outline: 3px solid #33acf5 !important; + outline-offset: 4px !important; } > .v-dialog__content, diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharing.vue b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharing.vue index 9fbd995ced..f08183ddfa 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharing.vue +++ b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelSharing.vue @@ -59,13 +59,13 @@ - {{ $tr('inviteButton') }} - + - - - - - - + {{ + $tr('removeViewerText', { first_name: selected.first_name, last_name: selected.last_name }) + }} + - - - - + {{ $tr('deleteInvitationText', { email: selected.email }) }} + - - - - + {{ + $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, + }); }); }); });