From 64b79c763d215f121c312b341ecf0bca540788e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Moritz=20F=C3=BCrneisen?= Date: Fri, 9 Aug 2024 16:04:20 +0200 Subject: [PATCH 1/2] Added UI components for managing edit sessions. --- ui/package-lock.json | 6 +- ui/src/App.css | 53 +- ui/src/App.tsx | 4 +- .../get_hierarchy_integration.test.tsx | 18 +- ui/src/column_menu/components/form.tsx | 221 +++--- ui/src/column_menu/components/menu.tsx | 4 +- ui/src/column_menu/components/selection.tsx | 51 +- ui/src/comments/components.tsx | 15 +- ui/src/contribution/columns/components.tsx | 39 +- ui/src/merge_request/components.tsx | 110 ++- ui/src/merge_request/conflicts/components.tsx | 156 ++--- .../add_participant_integration.test.tsx | 269 ++++++++ .../__tests__/button_integration.test.tsx | 166 +++++ .../patch_session_integration.test.tsx | 177 +++++ .../remove_participant_integration.test.tsx | 230 +++++++ .../__tests__/sessions_integration.test.tsx | 566 +++++++++++++++ ui/src/session/components.tsx | 648 ++++++++++++++++++ ui/src/session/selectors.ts | 65 ++ ui/src/session/slice.ts | 209 ++++++ ui/src/session/state.ts | 97 +++ ui/src/session/thunks.ts | 274 ++++++++ ui/src/store.ts | 4 +- .../__tests__/column_header_menu.test.tsx | 28 +- .../__tests__/get_data_integration.test.tsx | 27 +- .../open_modals_integration.test.tsx | 27 +- .../components/__tests__/open_reason.test.tsx | 27 +- .../submit_data_integration.test.tsx | 44 +- ui/src/table/components/modals.tsx | 8 +- ui/src/table/components/table.tsx | 9 +- .../select_owner_integration.test.tsx | 6 +- .../components/__tests__/integration.test.tsx | 55 +- ui/src/user/thunks.ts | 35 + .../components/__tests__/stepper.test.tsx | 8 +- ui/src/util/components/misc.tsx | 14 +- ui/src/util/components/tabs.tsx | 8 +- 35 files changed, 3321 insertions(+), 357 deletions(-) create mode 100644 ui/src/session/__tests__/add_participant_integration.test.tsx create mode 100644 ui/src/session/__tests__/button_integration.test.tsx create mode 100644 ui/src/session/__tests__/patch_session_integration.test.tsx create mode 100644 ui/src/session/__tests__/remove_participant_integration.test.tsx create mode 100644 ui/src/session/__tests__/sessions_integration.test.tsx create mode 100644 ui/src/session/components.tsx create mode 100644 ui/src/session/selectors.ts create mode 100644 ui/src/session/slice.ts create mode 100644 ui/src/session/state.ts create mode 100644 ui/src/session/thunks.ts diff --git a/ui/package-lock.json b/ui/package-lock.json index 5a35a050..616d9e42 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -18293,9 +18293,9 @@ } }, "node_modules/react-bootstrap-icons": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/react-bootstrap-icons/-/react-bootstrap-icons-1.10.3.tgz", - "integrity": "sha512-j4hSby6gT9/enhl3ybB1tfr1slZNAYXDVntcRrmVjxB3//2WwqrzpESVqKhyayYVaWpEtnwf9wgUQ03cuziwrw==", + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/react-bootstrap-icons/-/react-bootstrap-icons-1.11.4.tgz", + "integrity": "sha512-lnkOpNEZ/Zr7mNxvjA9efuarCPSgtOuGA55XiRj7ASJnBjb1wEAdtJOd2Aiv9t07r7FLI1IgyZPg9P6jqWD/IA==", "dependencies": { "prop-types": "^15.7.2" }, diff --git a/ui/src/App.css b/ui/src/App.css index 403d27ee..c636691b 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -30,13 +30,6 @@ html { justify-content: center; } -.vran-page-body { - display: block; - height: 100%; - flex: 1; - padding: 0px 12px 12px 12px; -} - .min-h-0 { min-height: 0px; } @@ -46,6 +39,10 @@ html { .vh-85{ height: 85vh; } + +.vh-65 { + height: 65vh; +} .min-h-200px { min-height: 200px !important; } @@ -91,10 +88,15 @@ html { border-radius: 12px; } -.scroll-gutter { +.scroll-gutter-both { scrollbar-gutter: stable both-edges; } +.scroll-gutter { + scrollbar-gutter: stable; +} + + .modal, .modal-backdrop { position: absolute !important; @@ -184,6 +186,10 @@ svg { vertical-align: text-bottom !important; } +.d-contents { + display: contents !important; +} + .form-label { width: 100%; padding-left: 0px !important; @@ -248,3 +254,34 @@ div#layers > div > .container { .z-toast { z-index: 5000; } + + +.d-grid { + display: grid; +} + +.grid-r-1fr-1fr-c-1fr-1fr { + display: grid; + grid-template-rows: 1fr 1fr; + grid-template-columns: 1fr 1fr; +} + +.grid-row-1 { + grid-row: 1; +} + +.grid-row-2 { + grid-row: 2; +} + +.grid-col-1 { + grid-column: 1; +} + +.grid-col-2 { + grid-column: 2; +} + +.grid-row-1-3 { + grid-row: 1 / 3; +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index e1de488f..e8caf791 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -39,7 +39,7 @@ export function VranRoot() { const dispatch: AppDispatch = useDispatch() return ( <> - +
@@ -86,7 +86,7 @@ export function VranRoot() { -
+
diff --git a/ui/src/column_menu/__tests__/get_hierarchy_integration.test.tsx b/ui/src/column_menu/__tests__/get_hierarchy_integration.test.tsx index b5600aaa..70deaf4b 100644 --- a/ui/src/column_menu/__tests__/get_hierarchy_integration.test.tsx +++ b/ui/src/column_menu/__tests__/get_hierarchy_integration.test.tsx @@ -191,7 +191,7 @@ describe('get hierarchy', () => { initialResponseSequence(fetchMock) renderWithProviders( , @@ -210,7 +210,7 @@ describe('get hierarchy', () => { withLabel2[2]?.parentElement?.parentElement?.children[0]?.children[1] expect(expandIcon?.children[0]?.children[0]?.getAttribute('d')).toEqual( - 'M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z' + 'M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2' ) }) ;(expandIcon as HTMLElement | undefined)?.click() @@ -225,7 +225,7 @@ describe('get hierarchy', () => { withLabel2[2]?.parentElement?.parentElement?.children[0]?.children[1] expect(collapseIcon?.children[0]?.children[0]?.getAttribute('d')).toEqual( - 'M2 8a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11A.5.5 0 0 1 2 8Z' + 'M2 8a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11A.5.5 0 0 1 2 8' ) }) ;(collapseIcon as HTMLElement | undefined)?.click() @@ -245,7 +245,7 @@ describe('get hierarchy', () => { addResponseSequence(fetchMock, [[500, { msg: errorMsg }]]) const { store } = renderWithProviders( , @@ -298,7 +298,7 @@ describe('create tag definition', () => { initialResponseSequence(fetchMock) renderWithProviders( , @@ -333,7 +333,7 @@ describe('create tag definition', () => { initialResponseSequence(fetchMock) renderWithProviders( , @@ -378,7 +378,7 @@ describe('create tag definition', () => { addResponseSequence(fetchMock, [[500, { msg: errorMsg }]]) const { store } = renderWithProviders( , @@ -401,7 +401,7 @@ test('open edit menu', async () => { initialResponseSequence(fetchMock) const { store } = renderWithProviders( , @@ -417,7 +417,7 @@ test('open edit menu', async () => { 'M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z' ) expect(svg?.children[1].getAttribute('d')).toEqual( - 'M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5v11z' + 'M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5z' ) if (svg !== undefined) { user.click(svg) diff --git a/ui/src/column_menu/components/form.tsx b/ui/src/column_menu/components/form.tsx index bbeb0992..379bb43b 100644 --- a/ui/src/column_menu/components/form.tsx +++ b/ui/src/column_menu/components/form.tsx @@ -117,110 +117,133 @@ function ColumnTypeCreateFormBody(props: { children: (formProps: ColumnTypeCreateFormProps) => ReactNode }): JSX.Element { return ( -
- - - - - - - - {props.touchedValues.columnType && !!props.formErrors.columnType ? ( - Choose a type: - ) : ( - Choose a type: - )} - - - - - - - - - - - +
+ + + + + + + + + + + {props.touchedValues.columnType && + !!props.formErrors.columnType ? ( + + Choose a type: + + ) : ( + Choose a type: + )} + + + + + + + + + + + + + + + + - - - - - Select parent from below - - -
- {props.children({ - setParent: props.setParent, - selectedParent: props.formValues.parent, - errors: props.formErrors - })} + + + + + Select parent from below + + +
+ {props.children({ + setParent: props.setParent, + selectedParent: props.formValues.parent, + errors: props.formErrors + })} +
+
- - + + + + ) diff --git a/ui/src/column_menu/components/menu.tsx b/ui/src/column_menu/components/menu.tsx index c0fc514d..e828042e 100644 --- a/ui/src/column_menu/components/menu.tsx +++ b/ui/src/column_menu/components/menu.tsx @@ -102,7 +102,7 @@ export function CreateTabBody({ existingTagDefinition?: TagDefinition }) { return ( -
+
{(columnTypeCreateFormProps: ColumnTypeCreateFormProps) => ( void }) { return ( -
+
{ diff --git a/ui/src/column_menu/components/selection.tsx b/ui/src/column_menu/components/selection.tsx index 11fd45ec..dfd3b123 100644 --- a/ui/src/column_menu/components/selection.tsx +++ b/ui/src/column_menu/components/selection.tsx @@ -105,35 +105,35 @@ export function ColumnSelector({ // ) // } return ( - <> - - {/* + + {/* */} + - - - {mkListItems({ - tagSelectionEntries, - level: 0, - path: [], - mkTailElement, - toggleExpansionCallback, - startEditCallback: setEditTagDefinitionCallback, - additionalEntries, - changeParentCallback, - dragTagDefinitionStartCallback, - dragTagDefinitionEndCallback, - allowEdit - })} - - - - + + + + {mkListItems({ + tagSelectionEntries, + level: 0, + path: [], + mkTailElement, + toggleExpansionCallback, + startEditCallback: setEditTagDefinitionCallback, + additionalEntries, + changeParentCallback, + dragTagDefinitionStartCallback, + dragTagDefinitionEndCallback, + allowEdit + })} + + + ) } @@ -146,12 +146,13 @@ export function EditModal() { show={editTagDefinition.value !== undefined} size="xl" onHide={closeEditCallback} - className="h-100 overflow-hidden" + className="overflow-hidden" + contentClassName="vh-95 d-flex flex-column bg-secondary flex-sm-wrap flex-md-nowrap" > - +
Edit Tag Definition
- + {} diff --git a/ui/src/comments/components.tsx b/ui/src/comments/components.tsx index bfe3c81e..19934fed 100644 --- a/ui/src/comments/components.tsx +++ b/ui/src/comments/components.tsx @@ -20,15 +20,15 @@ export function CommentHistoryAndForm({ idPersistent }: { idPersistent: string } //eslint-disable-next-line react-hooks/exhaustive-deps }, [idPersistent]) return ( - + - - + +
- - +
+
- +
@@ -70,8 +70,9 @@ export function CommentElement({ comment }: { comment: Comment }) {
} + bodyClassName="d-flex" > -
{comment.content}
+
{comment.content}
diff --git a/ui/src/contribution/columns/components.tsx b/ui/src/contribution/columns/components.tsx index 7fa855a9..d53ffc06 100644 --- a/ui/src/contribution/columns/components.tsx +++ b/ui/src/contribution/columns/components.tsx @@ -241,7 +241,7 @@ export function ContributionColumnAssignmentForm({
- + dispatch(setColumnDefinitionFormTab(false))} data-testid="create-column-modal" size="lg" - className="overflow-hidden text-dark" + className="overflow-hidden" + contentClassName="vh-95 d-flex flex-column bg-secondary flex-sm-wrap flex-md-nowrap" > - + Create a new tag - + @@ -457,14 +458,22 @@ export function PreviewComponent() { return
} return ( - - - + + + - - + + @@ -474,10 +483,12 @@ export function PreviewComponent() { export function PreviewColumn({ values }: { values: string[] }) { return ( -
    - {values.map((val, idx) => ( -
  • {val}
  • - ))} -
+
+
    + {values.map((val, idx) => ( +
  • {val}
  • + ))} +
+
) } diff --git a/ui/src/merge_request/components.tsx b/ui/src/merge_request/components.tsx index e3dc1348..03356a57 100644 --- a/ui/src/merge_request/components.tsx +++ b/ui/src/merge_request/components.tsx @@ -41,76 +41,56 @@ export function ReviewList() { return } return ( - - - +
+ Tag Definition Merge Requests Assigned to You} > - - Tag Definition Merge Requests Assigned to You - } - className="h-100" - > - - - - {assigned.map((mergeRequest) => ( - - ))} - - - - - - - - - Tag Definition Merge Requests Opened by You - } - className="h-100" - > - - - - {created.map((mergeRequest) => ( - - ))} - - - - - - - +
+ + {assigned.map((mergeRequest) => ( + + ))} + +
+
+
+
+ Tag Definition Merge Requests Opened by You}> +
+ + {created.map((mergeRequest) => ( + + ))} + +
+
+
{(permissionGroup === UserPermissionGroup.EDITOR || permissionGroup === UserPermissionGroup.COMMISSIONER) && ( - - Entity Merge Requests} className="h-100"> - - - - - +
+ Entity Merge Requests}> +
+ +
- +
)} -
+
) } diff --git a/ui/src/merge_request/conflicts/components.tsx b/ui/src/merge_request/conflicts/components.tsx index c22a38c2..08b59484 100644 --- a/ui/src/merge_request/conflicts/components.tsx +++ b/ui/src/merge_request/conflicts/components.tsx @@ -41,26 +41,28 @@ import { CommentHistoryAndForm } from '../../comments/components' export function MergeRequestConflictView() { const idMergeRequestPersistent = useLoaderData() as string return ( - - ) - }, - { - name: 'Resolve', - component: ( - - ) - } - ]} - /> +
+ + ) + }, + { + name: 'Resolve', + component: ( + + ) + } + ]} + /> +
) } export function MergeRequestConflictResolutionView({ @@ -113,63 +115,62 @@ export function MergeRequestConflictResolutionView({ return VrAnLoading() } return ( - - - - - - dispatch(startMerge(idMergeRequestPersistent)) - } - isLoading={startMergeValue.value} - /> - + + + + dispatch(startMerge(idMergeRequestPersistent))} + isLoading={startMergeValue.value} + /> + + + + + + + When this toggle is enabled, the origin tag definition, + marked with + + + + + + + + will be disabled. I.e., the tag definition will not + appear anymore in the the tag definition explorer but is + still kept in the history. + + + } + placement="left" + > - - - - When this toggle is enabled, the origin tag - definition, marked with - - - - - - - - will be disabled. I.e., the tag definition will not - appear anymore in the the tag definition explorer - but is still kept in the history. - - - } - placement="left" - > - - - - - - - - - + + + + + + + + {conflictsByCategoryValue.updated.length > 0 && ( @@ -221,9 +222,10 @@ export function MergeRequestConflictResolutionView({ - - - + + + + ) } diff --git a/ui/src/session/__tests__/add_participant_integration.test.tsx b/ui/src/session/__tests__/add_participant_integration.test.tsx new file mode 100644 index 00000000..9e5e637b --- /dev/null +++ b/ui/src/session/__tests__/add_participant_integration.test.tsx @@ -0,0 +1,269 @@ +/** + * @jest-environment jsdom + */ +import { render, RenderOptions, screen, waitFor } from '@testing-library/react' +import { + EditSessionParticipantType, + EditSessionState, + newEditSession, + newEditSessionParticipant, + newEditSessionState +} from '../state' +import { editSessionReducer } from '../slice' +import { configureStore } from '@reduxjs/toolkit' +import { Provider } from 'react-redux' +import { PropsWithChildren } from 'react' +import { EditSessionEditor } from '../components' +import { userEvent } from '@testing-library/user-event' +import { newRemote } from '../../util/state' +import { + newNotification, + newNotificationManager, + NotificationManager, + notificationReducer, + NotificationType +} from '../../util/notification/slice' + +const nameAddedParticipant = 'added participant' +const idAddedParticipant = 'id-added-participant' +const nameOtherSearchResult = 'search result participant' +const idOtherSearchResult = 'id-search-result-participant' +const searchResults = { + search_result_list: [ + { + name_participant: nameOtherSearchResult, + id_participant: idOtherSearchResult, + type_participant: 'INTERNAL' + }, + { + name_participant: nameAddedParticipant, + id_participant: idAddedParticipant, + type_participant: 'INTERNAL' + } + ] +} +test('add participant success', async () => { + const fetchMock = jest.fn() + addResponseSequence(fetchMock, [ + [200, searchResults], + [ + 200, + { + name_participant: nameAddedParticipant, + type_participant: 'INTERNAL', + id_participant: idAddedParticipant + } + ] + ]) + const { store } = renderWithProviders(, fetchMock) + await searchParticipant() + await waitFor(() => { + expect(store.getState().editSession.participantSearchResults).toEqual( + newRemote([ + newEditSessionParticipant({ + id: idOtherSearchResult, + name: nameOtherSearchResult, + type: EditSessionParticipantType.internal + }), + newEditSessionParticipant({ + id: idAddedParticipant, + name: nameAddedParticipant, + type: EditSessionParticipantType.internal + }) + ]) + ) + }) + await selectSearchResult() + await goBack() + await waitFor(() => { + screen.getByText(nameParticipant1) + screen.getByText(nameParticipant2) + screen.getByText(nameAddedParticipant) + expect( + store.getState().editSession.currentEditSession.value?.participantList + .length + ).toEqual(3) + }) + expect(fetchMock.mock.calls).toEqual([ + [ + 'http://127.0.0.1:8000/vran/api/edit_sessions/search', + { + body: JSON.stringify({ + search_term: 'name' + }), + credentials: 'include', + method: 'POST' + } + ], + [ + `http://127.0.0.1:8000/vran/api/edit_sessions/${idSession}/participants`, + { + method: 'PUT', + credentials: 'include', + body: JSON.stringify({ + type_participant: 'INTERNAL', + id_participant: idAddedParticipant, + name_participant: nameAddedParticipant + }) + } + ] + ]) +}) + +test('add participant error', async () => { + const fetchMock = jest.fn() + const testError = 'Error while adding participant' + addResponseSequence(fetchMock, [ + [200, searchResults], + [ + 500, + { + msg: testError + } + ] + ]) + const { store } = renderWithProviders(, fetchMock) + await searchParticipant() + await selectSearchResult() + await waitFor(() => { + expect(store.getState().notification.notificationList).toEqual([ + newNotification({ + msg: testError, + type: NotificationType.Error, + id: expect.anything() + }) + ]) + }) +}) + +test('search participant error', async () => { + const fetchMock = jest.fn() + const testError = 'Error while searching participants' + addResponseSequence(fetchMock, [ + [ + 500, + { + msg: testError + } + ] + ]) + const { store } = renderWithProviders(, fetchMock) + await searchParticipant() + await waitFor(() => { + expect(store.getState().notification.notificationList).toEqual([ + newNotification({ + msg: testError, + type: NotificationType.Error, + id: expect.anything() + }) + ]) + }) +}) + +async function searchParticipant() { + screen.getByText(nameParticipant1) + screen.getByText(nameParticipant2) + const button = screen.getByRole('button', { name: 'Add Participant' }) + const user = userEvent.setup() + await user.click(button) + await waitFor(() => { + const textInput = screen.getByRole('textbox') + user.type(textInput, 'name') + }) +} + +async function selectSearchResult() { + await waitFor(() => { + screen.getByRole('button', { name: nameOtherSearchResult }) + const button = screen.getByRole('button', { name: nameAddedParticipant }) + button.click() + }) +} + +async function goBack() { + await waitFor(() => { + const backButton = screen.getAllByRole('button')[0] + expect(backButton.textContent).toEqual('') + backButton.click() + }) +} + +function addResponseSequence(mock: jest.Mock, responses: [number, unknown][]) { + for (const tpl of responses) { + const [status_code, rsp] = tpl + mock.mockImplementationOnce( + jest.fn(() => + Promise.resolve({ + status: status_code, + json: () => Promise.resolve(rsp) + }) + ) as jest.Mock + ) + } +} + +interface ExtendedRenderOptions extends Omit { + preloadedState?: { + editSession: EditSessionState + notification: NotificationManager + } +} + +const idSession = 'id-session-test' +export function renderWithProviders( + ui: React.ReactElement, + fetchMock: jest.Mock, + { + preloadedState = { + editSession: newEditSessionState({ + currentEditSession: newRemote( + newEditSession({ + idPersistent: idSession, + name: 'edit session for test', + owner: newEditSessionParticipant({ + type: EditSessionParticipantType.internal, + name: '', + id: 'id-owner' + }), + participantList: [ + newEditSessionParticipant({ + id: idParticipant1, + name: nameParticipant1, + type: EditSessionParticipantType.internal + }), + newEditSessionParticipant({ + id: idParticipant2, + name: nameParticipant2, + type: EditSessionParticipantType.internal + }) + ], + participantMap: {} + }) + ) + }), + notification: newNotificationManager({}) + }, + ...renderOptions + }: ExtendedRenderOptions = {} +) { + const store = configureStore({ + reducer: { + editSession: editSessionReducer, + notification: notificationReducer + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ thunk: { extraArgument: fetchMock } }), + preloadedState + }) + function Wrapper({ children }: PropsWithChildren): JSX.Element { + return {children} + } + + // Return an object with the store and all of RTL's query functions + return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) } +} + +const idParticipant1 = 'id-participant-1' +const nameParticipant1 = 'Participant 1' +const idParticipant2 = 'id-participant-2' +const nameParticipant2 = 'Participant 2' diff --git a/ui/src/session/__tests__/button_integration.test.tsx b/ui/src/session/__tests__/button_integration.test.tsx new file mode 100644 index 00000000..53c01576 --- /dev/null +++ b/ui/src/session/__tests__/button_integration.test.tsx @@ -0,0 +1,166 @@ +/** + * @jest-environment jsdom + */ +import { render, RenderOptions, screen, waitFor } from '@testing-library/react' +import { + EditSessionParticipantType, + EditSessionState, + newEditSession, + newEditSessionParticipant, + newEditSessionState +} from '../state' +import { editSessionReducer } from '../slice' +import { configureStore } from '@reduxjs/toolkit' +import { Provider } from 'react-redux' +import { PropsWithChildren } from 'react' +import { EditSessionButton } from '../components' +import userEvent from '@testing-library/user-event' +import { newRemote } from '../../util/state' + +test('shows participant number', async () => { + const fetchMock = jest.fn() + renderWithProviders( + , + fetchMock, + { + preloadedState: { + editSession: newEditSessionState({ + currentEditSession: newRemote( + newEditSession({ + idPersistent: 'id-session-test', + name: 'edit session for test', + owner: newEditSessionParticipant({ + type: EditSessionParticipantType.internal, + name: '', + id: 'id-owner' + }), + participantList: [ + newEditSessionParticipant({ + id: 'id-participant-1', + type: EditSessionParticipantType.internal + }), + newEditSessionParticipant({ + id: 'id-participant-2', + type: EditSessionParticipantType.internal + }) + ], + participantMap: {} + }) + ) + }) + } + } + ) + const button = screen.getByRole('button') + const icons = button.children[0].children[0].children[0] + const person = icons.children[0].children[0] + const number = icons.children[1] + expect(person.children[0].getAttribute('d')).toEqual( + 'M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6m-5.784 6A2.24 2.24 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.3 6.3 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1zM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5' + ) + expect(number.textContent).toEqual('2') +}) + +test('shows plus sign', async () => { + const fetchMock = jest.fn() + renderWithProviders( + , + fetchMock + ) + const button = screen.getByRole('button') + const icons = button.children[0].children[0].children[0] + const person = icons.children[0].children[0] + const plus = icons.children[1].children[0] + expect(person.children[0].getAttribute('d')).toEqual( + 'M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6' + ) + expect(plus.children[0].getAttribute('d')).toEqual( + 'M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2' + ) +}) + +test('show tooltip', async () => { + const fetchMock = jest.fn() + renderWithProviders( + , + fetchMock + ) + const tooltipText = 'Click to manage edit sessions' + expect(screen.queryByText(tooltipText)).toBeNull() + const user = userEvent.setup() + const button = screen.getByRole('button') + await user.hover(button) + await waitFor(() => { + screen.getByText(tooltipText) + }) +}) + +test('show popover', async () => { + const fetchMock = jest.fn() + renderWithProviders( + , + fetchMock, + { + preloadedState: { + editSession: newEditSessionState({ + currentEditSession: newRemote( + newEditSession({ + idPersistent: 'id-session-test', + name: 'edit session for test', + owner: newEditSessionParticipant({ + type: EditSessionParticipantType.internal, + name: '', + id: 'id-owner' + }), + participantList: [], + participantMap: {} + }) + ) + }) + } + } + ) + const editorLabel = 'Edit Session Participant List' + expect(screen.queryByLabelText(editorLabel)).toBeNull() + const user = userEvent.setup() + const button = screen.getByRole('button') + await user.click(button) + await waitFor(() => { + screen.getByText('Current') + screen.getByText('Owner') + screen.getByText('Participant') + screen.getByLabelText(editorLabel) + }) +}) + +interface ExtendedRenderOptions extends Omit { + preloadedState?: { + editSession: EditSessionState + } +} + +export function renderWithProviders( + ui: React.ReactElement, + fetchMock: jest.Mock, + { + preloadedState = { + editSession: newEditSessionState({}) + }, + ...renderOptions + }: ExtendedRenderOptions = {} +) { + const store = configureStore({ + reducer: { + editSession: editSessionReducer + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ thunk: { extraArgument: fetchMock } }), + preloadedState + }) + function Wrapper({ children }: PropsWithChildren): JSX.Element { + return {children} + } + + // Return an object with the store and all of RTL's query functions + return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) } +} diff --git a/ui/src/session/__tests__/patch_session_integration.test.tsx b/ui/src/session/__tests__/patch_session_integration.test.tsx new file mode 100644 index 00000000..3ad7f2cb --- /dev/null +++ b/ui/src/session/__tests__/patch_session_integration.test.tsx @@ -0,0 +1,177 @@ +/** + * @jest-environment jsdom + */ +import { render, RenderOptions, screen, waitFor } from '@testing-library/react' +import { + EditSessionParticipantType, + EditSessionState, + newEditSession, + newEditSessionParticipant, + newEditSessionState +} from '../state' +import { editSessionReducer } from '../slice' +import { configureStore } from '@reduxjs/toolkit' +import { Provider } from 'react-redux' +import { PropsWithChildren } from 'react' +import { EditSessionEditor } from '../components' +import { newRemote } from '../../util/state' +import { + newNotification, + newNotificationManager, + NotificationManager, + notificationReducer, + NotificationType +} from '../../util/notification/slice' +import userEvent from '@testing-library/user-event' + +describe('change name', () => { + test('success', async () => { + const fetchMock = jest.fn() + addResponseSequence(fetchMock, [[200, { ...sessionApi, name: changedName }]]) + const { store } = renderWithProviders(, fetchMock) + await setName() + await waitFor(() => { + expect(store.getState().editSession.currentEditSession).toEqual( + newRemote({ + ...session, + name: changedName + }) + ) + }) + }) + test('error', async () => { + const testError = 'Could not patch edit session' + const fetchMock = jest.fn() + addResponseSequence(fetchMock, [[500, { msg: testError }]]) + const { store } = renderWithProviders(, fetchMock) + await setName() + await waitFor(() => { + expect(store.getState().notification.notificationList).toEqual([ + newNotification({ + msg: testError, + type: NotificationType.Error, + id: expect.anything() + }) + ]) + }) + expect(store.getState().editSession.currentEditSession).toEqual( + newRemote(session) + ) + }) +}) + +async function setName() { + const user = userEvent.setup() + const input = screen.getByRole('textbox') + await user.type(input, changedName) + const button = input.parentElement?.parentElement?.parentElement?.children[1] + const icon = button?.children[0] + expect(icon?.children[0].getAttribute('d')).toEqual('M12 2h-2v3h2z') + expect(icon?.children[1].getAttribute('d')).toEqual( + 'M1.5 0A1.5 1.5 0 0 0 0 1.5v13A1.5 1.5 0 0 0 1.5 16h13a1.5 1.5 0 0 0 1.5-1.5V2.914a1.5 1.5 0 0 0-.44-1.06L14.147.439A1.5 1.5 0 0 0 13.086 0zM4 6a1 1 0 0 1-1-1V1h10v4a1 1 0 0 1-1 1zM3 9h10a1 1 0 0 1 1 1v5H2v-5a1 1 0 0 1 1-1' + ) + ;(button as HTMLInputElement).click() +} + +function addResponseSequence(mock: jest.Mock, responses: [number, unknown][]) { + for (const tpl of responses) { + const [status_code, rsp] = tpl + mock.mockImplementationOnce( + jest.fn(() => + Promise.resolve({ + status: status_code, + json: () => Promise.resolve(rsp) + }) + ) as jest.Mock + ) + } +} + +interface ExtendedRenderOptions extends Omit { + preloadedState?: { + editSession: EditSessionState + notification: NotificationManager + } +} + +export function renderWithProviders( + ui: React.ReactElement, + fetchMock: jest.Mock, + { + preloadedState = { + editSession: newEditSessionState({ + currentEditSession: newRemote(session) + }), + notification: newNotificationManager({}) + }, + ...renderOptions + }: ExtendedRenderOptions = {} +) { + const store = configureStore({ + reducer: { + editSession: editSessionReducer, + notification: notificationReducer + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ thunk: { extraArgument: fetchMock } }), + preloadedState + }) + function Wrapper({ children }: PropsWithChildren): JSX.Element { + return {children} + } + + // Return an object with the store and all of RTL's query functions + return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) } +} + +const idParticipant1 = 'id-participant-1' +const nameParticipant1 = 'Participant 1' +const idParticipant2 = 'id-participant-2' +const nameParticipant2 = 'Participant 2' + +const idSession = 'id-session-test' +const nameSession = 'edit session for test' +const changedName = 'Changed edit session' + +const sessionApi = { + name: nameSession, + id_persistent: idSession, + owner: { + id_participant: idParticipant1, + type_participant: 'INTERNAL' + }, + participant_list: [ + { + name_participant: nameParticipant1, + id_participant: idParticipant1, + type_participant: 'INTERNAL' + }, + { + name_participant: nameParticipant2, + id_participant: idParticipant2, + type_participant: 'INTERNAL' + } + ] +} + +const session = newEditSession({ + idPersistent: idSession, + name: nameSession, + owner: newEditSessionParticipant({ + type: EditSessionParticipantType.internal, + id: idParticipant1 + }), + participantList: [ + newEditSessionParticipant({ + id: idParticipant1, + name: nameParticipant1, + type: EditSessionParticipantType.internal + }), + newEditSessionParticipant({ + id: idParticipant2, + name: nameParticipant2, + type: EditSessionParticipantType.internal + }) + ], + participantMap: { [idParticipant1]: 0, [idParticipant2]: 1 } +}) diff --git a/ui/src/session/__tests__/remove_participant_integration.test.tsx b/ui/src/session/__tests__/remove_participant_integration.test.tsx new file mode 100644 index 00000000..1eecde41 --- /dev/null +++ b/ui/src/session/__tests__/remove_participant_integration.test.tsx @@ -0,0 +1,230 @@ +/** + * @jest-environment jsdom + */ +import { render, RenderOptions, screen, waitFor } from '@testing-library/react' +import { + EditSessionParticipantType, + EditSessionState, + newEditSession, + newEditSessionParticipant, + newEditSessionState +} from '../state' +import { editSessionReducer } from '../slice' +import { configureStore } from '@reduxjs/toolkit' +import { Provider } from 'react-redux' +import { PropsWithChildren } from 'react' +import { EditSessionEditor } from '../components' +import { newRemote } from '../../util/state' +import { + newNotification, + newNotificationManager, + NotificationManager, + notificationReducer, + NotificationType +} from '../../util/notification/slice' + +test('remove participant success', async () => { + const fetchMock = jest.fn() + addResponseSequence(fetchMock, [[200, {}]]) + const { store } = renderWithProviders(, fetchMock) + await removeParticipant() + await confirmRemoval() + await waitFor(() => { + expect(store.getState().editSession.currentEditSession.value).toEqual( + newEditSession({ + name: nameSession, + idPersistent: idSession, + owner: newEditSessionParticipant({ + id: idOwner, + name: '', + type: EditSessionParticipantType.internal + }), + participantList: [ + newEditSessionParticipant({ + id: idParticipant2, + name: nameParticipant2, + type: EditSessionParticipantType.internal + }) + ], + participantMap: { [idParticipant2]: 0 } + }) + ) + }) + expect(fetchMock.mock.calls).toEqual([ + [ + `http://127.0.0.1:8000/vran/api/edit_sessions/${idSession}/participants`, + { + method: 'DELETE', + credentials: 'include', + body: JSON.stringify({ + id_participant: idParticipant1, + type_participant: 'INTERNAL' + }) + } + ] + ]) +}) +test('remove participant error', async () => { + const fetchMock = jest.fn() + const testError = 'Can not remove yourself' + addResponseSequence(fetchMock, [[500, { msg: testError }]]) + const { store } = renderWithProviders(, fetchMock) + await removeParticipant() + await confirmRemoval() + await waitFor(() => { + expect(store.getState().notification.notificationList).toEqual([ + newNotification({ + msg: testError, + type: NotificationType.Error, + id: expect.anything() + }) + ]) + }) +}) + +test('cancel removal', async () => { + const fetchMock = jest.fn() + addResponseSequence(fetchMock, [[200, {}]]) + const { store } = renderWithProviders(, fetchMock) + await removeParticipant() + await cancelRemoval() + await waitFor(() => { + screen.getByText(nameParticipant2) + expect(store.getState().editSession.currentEditSession.value).toEqual( + newEditSession({ + name: nameSession, + idPersistent: idSession, + owner: newEditSessionParticipant({ + id: idOwner, + name: '', + type: EditSessionParticipantType.internal + }), + participantList: [ + newEditSessionParticipant({ + id: idParticipant1, + name: nameParticipant1, + type: EditSessionParticipantType.internal + }), + newEditSessionParticipant({ + id: idParticipant2, + name: nameParticipant2, + type: EditSessionParticipantType.internal + }) + ], + participantMap: { [idParticipant1]: 0, [idParticipant2]: 1 } + }) + ) + expect(fetchMock.mock.calls).toEqual([]) + }) +}) + +async function removeParticipant() { + await waitFor(() => { + const buttons = screen.getAllByRole('button') + const circleParent = buttons[0].children[0] + expect(circleParent.children[0].getAttribute('d')).toEqual( + 'M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16' + ) + expect(circleParent.children[1].getAttribute('d')).toEqual( + 'M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708' + ) + buttons[0].click() + }) +} + +async function confirmRemoval() { + await waitFor(() => { + const confirmButton = screen.getByRole('button', { name: 'Remove Participant' }) + confirmButton.click() + }) +} + +async function cancelRemoval() { + await waitFor(() => { + const cancelButton = screen.getByRole('button', { name: 'Cancel' }) + cancelButton.click() + }) +} + +function addResponseSequence(mock: jest.Mock, responses: [number, unknown][]) { + for (const tpl of responses) { + const [status_code, rsp] = tpl + mock.mockImplementationOnce( + jest.fn(() => + Promise.resolve({ + status: status_code, + json: () => Promise.resolve(rsp) + }) + ) as jest.Mock + ) + } +} + +interface ExtendedRenderOptions extends Omit { + preloadedState?: { + editSession: EditSessionState + notification: NotificationManager + } +} + +export function renderWithProviders( + ui: React.ReactElement, + fetchMock: jest.Mock, + { + preloadedState = { + editSession: newEditSessionState({ + currentEditSession: newRemote( + newEditSession({ + idPersistent: idSession, + name: nameSession, + owner: newEditSessionParticipant({ + type: EditSessionParticipantType.internal, + name: '', + id: idOwner + }), + participantList: [ + newEditSessionParticipant({ + id: idParticipant1, + name: nameParticipant1, + type: EditSessionParticipantType.internal + }), + newEditSessionParticipant({ + id: idParticipant2, + name: nameParticipant2, + type: EditSessionParticipantType.internal + }) + ], + participantMap: { [idParticipant1]: 0, [idParticipant2]: 1 } + }) + ) + }), + notification: newNotificationManager({}) + }, + ...renderOptions + }: ExtendedRenderOptions = {} +) { + const store = configureStore({ + reducer: { + editSession: editSessionReducer, + notification: notificationReducer + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ thunk: { extraArgument: fetchMock } }), + preloadedState + }) + function Wrapper({ children }: PropsWithChildren): JSX.Element { + return {children} + } + + // Return an object with the store and all of RTL's query functions + return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) } +} + +const idParticipant1 = 'id-participant-1' +const nameParticipant1 = 'Participant 1' +const idParticipant2 = 'id-participant-2' +const nameParticipant2 = 'Participant 2' + +const idSession = 'id-session-test' +const nameSession = 'edit session for test' +const idOwner = 'id-owner' diff --git a/ui/src/session/__tests__/sessions_integration.test.tsx b/ui/src/session/__tests__/sessions_integration.test.tsx new file mode 100644 index 00000000..1515b729 --- /dev/null +++ b/ui/src/session/__tests__/sessions_integration.test.tsx @@ -0,0 +1,566 @@ +/** + * @jest-environment jsdom + */ +import { + getByRole, + render, + RenderOptions, + screen, + waitFor +} from '@testing-library/react' +import { + EditSessionParticipantType, + EditSessionState, + newEditSession, + newEditSessionParticipant, + newEditSessionState +} from '../state' +import { createEditSessionError, editSessionReducer } from '../slice' +import { configureStore } from '@reduxjs/toolkit' +import { Provider } from 'react-redux' +import { PropsWithChildren } from 'react' +import { EditSessionOwnerList, EditSessionParticipantList } from '../components' +import { newRemote } from '../../util/state' +import { + newNotification, + newNotificationManager, + NotificationManager, + notificationReducer, + NotificationType +} from '../../util/notification/slice' +import userEvent from '@testing-library/user-event' +import store from '../../store' +import { + newUserInfo, + newUserState, + UserPermissionGroup, + UserState +} from '../../user/state' +import { userSlice } from '../../user/slice' + +test('change session success', async () => { + const fetchMock = jest.fn() + addResponseSequence(fetchMock, [ + [200, sessionListApi], + [200, sessionApi2] + ]) + const backCallback = jest.fn() + const { store } = renderWithProviders( + , + fetchMock + ) + await selectSession() + await waitFor(() => { + expect(store.getState().editSession.editSessionOwnerList).toEqual( + successSessionList + ) + expect(store.getState().editSession.currentEditSession.value).toEqual(session2) + }) + expect(fetchMock.mock.calls).toEqual([ + [ + 'http://127.0.0.1:8000/vran/api/edit_sessions/owner', + { credentials: 'include' } + ], + [ + 'http://127.0.0.1:8000/vran/api/user/edit_session', + { + credentials: 'include', + body: JSON.stringify({ id_edit_session_persistent: idSession2 }), + method: 'POST' + } + ] + ]) + expect(backCallback.mock.calls).toEqual([[]]) +}) +describe('select edit session', () => { + test('error retrieving sessions', async () => { + const fetchMock = jest.fn() + const testError = 'Could not get sessions' + addResponseSequence(fetchMock, [[500, { msg: testError }]]) + const backCallback = jest.fn() + const { store } = renderWithProviders( + , + fetchMock + ) + await waitFor(() => { + expect(store.getState().notification.notificationList).toEqual([ + newNotification({ + msg: testError, + type: NotificationType.Error, + id: expect.anything() + }) + ]) + }) + expect(fetchMock.mock.calls).toEqual([ + [ + 'http://127.0.0.1:8000/vran/api/edit_sessions/owner', + { credentials: 'include' } + ] + ]) + expect(store.getState().editSession).toEqual(initialSessionState) + }) + + test('error setting session', async () => { + const fetchMock = jest.fn() + const testError = 'Could not get sessions' + addResponseSequence(fetchMock, [ + [200, sessionListApi], + [500, { msg: testError }] + ]) + const backCallback = jest.fn() + const { store } = renderWithProviders( + , + fetchMock + ) + await selectSession() + await waitFor(() => { + expect(store.getState().notification.notificationList).toEqual([ + newNotification({ + msg: testError, + type: NotificationType.Error, + id: expect.anything() + }) + ]) + }) + expect(fetchMock.mock.calls).toEqual([ + [ + 'http://127.0.0.1:8000/vran/api/edit_sessions/owner', + { credentials: 'include' } + ], + [ + 'http://127.0.0.1:8000/vran/api/user/edit_session', + { + credentials: 'include', + body: JSON.stringify({ id_edit_session_persistent: idSession2 }), + method: 'POST' + } + ] + ]) + expect(store.getState().editSession).toEqual({ + ...initialSessionState, + editSessionOwnerList: successSessionList, + editSessionOwnerMap: { [idSession1]: 0, [idSession2]: 1 } + }) + expect(backCallback.mock.calls).toEqual([]) + }) +}) + +describe('owner', () => { + describe('create session', () => { + test('success', async () => { + const fetchMock = jest.fn() + addResponseSequence(fetchMock, [ + [200, { edit_session_list: [] }], + [200, sessionApi2] + ]) + const backMock = jest.fn() + const { store } = renderWithProviders( + , + fetchMock + ) + await createSession() + await waitFor(() => { + expect(store.getState().editSession).toEqual( + newEditSessionState({ + currentEditSession: newRemote(session2), + editSessionOwnerList: newRemote([session2]), + editSessionOwnerMap: { [idSession2]: 0 } + }) + ) + }) + expect(fetchMock.mock.calls).toEqual([ + [ + 'http://127.0.0.1:8000/vran/api/edit_sessions/owner', + { credentials: 'include' } + ], + [ + 'http://127.0.0.1:8000/vran/api/edit_sessions', + { + credentials: 'include', + method: 'PUT', + body: JSON.stringify({ name: nameSession2 }) + } + ] + ]) + expect(backMock.mock.calls).toEqual([]) + }) + test('error', async () => { + const fetchMock = jest.fn() + const testError = 'Could not create session' + addResponseSequence(fetchMock, [ + [200, { edit_session_list: [] }], + [500, { msg: testError }] + ]) + const backMock = jest.fn() + const { store } = renderWithProviders( + , + fetchMock + ) + await createSession() + await waitFor(() => { + expect(store.getState().notification.notificationList).toEqual([ + newNotification({ + msg: testError, + type: NotificationType.Error, + id: expect.anything() + }) + ]) + expect(store.getState().editSession).toEqual(initialSessionState) + }) + }) + }) +}) +describe('participant', () => { + describe('remove from session', () => { + test('success', async () => { + const fetchMock = jest.fn() + addResponseSequence(fetchMock, [ + [200, sessionListApi], + [200, {}] + ]) + const { store } = renderWithProviders( + , + fetchMock + ) + await removeFromSession() + await confirmRemoval() + await waitFor(() => { + expect(store.getState().editSession.editSessionParticipantList).toEqual( + newRemote([session2]) + ) + }) + expect(fetchMock.mock.calls).toEqual([ + [ + 'http://127.0.0.1:8000/vran/api/edit_sessions/participant', + { credentials: 'include' } + ], + [ + `http://127.0.0.1:8000/vran/api/edit_sessions/${idSession2}/participants`, + { + credentials: 'include', + method: 'DELETE', + body: JSON.stringify({ + id_participant: 'id-user', + type_participant: 'INTERNAL' + }) + } + ] + ]) + }) + test('error deleting', async () => { + const fetchMock = jest.fn() + const testError = 'Could not remove participant' + addResponseSequence(fetchMock, [ + [200, sessionListApi], + [500, { msg: testError }] + ]) + const { store } = renderWithProviders( + , + fetchMock + ) + await removeFromSession() + await confirmRemoval() + await waitFor(() => { + expect(store.getState().notification.notificationList).toEqual([ + newNotification({ + type: NotificationType.Error, + msg: testError, + id: expect.anything() + }) + ]) + expect(store.getState().editSession).toEqual({ + ...initialSessionState, + editSessionParticipantList: successSessionList, + editSessionParticipantMap: { [idSession1]: 0, [idSession2]: 1 } + }) + }) + }) + test('error retrieving sessions', async () => { + const fetchMock = jest.fn() + const testError = 'Could not remove participant' + addResponseSequence(fetchMock, [[500, { msg: testError }]]) + const { store } = renderWithProviders( + , + fetchMock + ) + await waitFor(() => { + const state = store.getState() + expect(state.notification.notificationList).toEqual([ + newNotification({ + msg: testError, + type: NotificationType.Error, + id: expect.anything() + }) + ]) + expect(state.editSession).toEqual({ + ...initialSessionState + }) + }) + expect(fetchMock.mock.calls).toEqual([ + [ + 'http://127.0.0.1:8000/vran/api/edit_sessions/participant', + { credentials: 'include' } + ] + ]) + }) + test('can cancel', async () => { + const fetchMock = jest.fn() + const testError = 'Could not remove participant' + addResponseSequence(fetchMock, [ + [200, sessionListApi], + [500, { msg: testError }] + ]) + const { store } = renderWithProviders( + , + fetchMock + ) + await removeFromSession() + await waitFor(() => { + const button = screen.getByRole('button', { name: 'Cancel' }) + button.click() + }) + await waitFor(() => { + screen.getByText(nameSession1) + screen.getByText(nameSession2) + }) + expect(store.getState().editSession).toEqual({ + ...initialSessionState, + editSessionParticipantList: successSessionList, + editSessionParticipantMap: { [idSession1]: 0, [idSession2]: 1 } + }) + }) + }) +}) + +async function selectSession() { + await waitFor(() => { + screen.getByRole('button', { name: nameSession1 }) + const button = screen.getByRole('button', { name: nameSession2 }) + button.click() + }) +} + +async function createSession() { + await waitFor(async () => { + const user = userEvent.setup() + const textInput = screen.getByRole('textbox') + await user.type(textInput, nameSession2) + const button = screen.getByRole('button', { name: 'New Edit Session' }) + await user.click(button) + }) +} + +async function removeFromSession() { + await waitFor(() => { + const sessionLabel = screen.getByText(nameSession2) + const row = sessionLabel.parentElement + const button = row?.children[1] + const circleParent = button?.children[0] + expect(circleParent?.children[0].getAttribute('d')).toEqual( + 'M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16' + ) + expect(circleParent?.children[1].getAttribute('d')).toEqual( + 'M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708' + ) + ;(button as HTMLInputElement)?.click() + }) +} + +async function confirmRemoval() { + await waitFor(() => { + const button = screen.getByRole('button', { name: 'Remove' }) + button.click() + }) +} + +function addResponseSequence(mock: jest.Mock, responses: [number, unknown][]) { + for (const tpl of responses) { + const [status_code, rsp] = tpl + mock.mockImplementationOnce( + jest.fn(() => + Promise.resolve({ + status: status_code, + json: () => Promise.resolve(rsp) + }) + ) as jest.Mock + ) + } +} + +interface ExtendedRenderOptions extends Omit { + preloadedState?: { + editSession: EditSessionState + notification: NotificationManager + user: UserState + } +} + +export function renderWithProviders( + ui: React.ReactElement, + fetchMock: jest.Mock, + { + preloadedState = { + editSession: initialSessionState, + notification: newNotificationManager({}), + user: newUserState({ + userInfo: newUserInfo({ + username: 'user-test', + email: 'mail@test.org', + namesPersonal: ' name test', + permissionGroup: UserPermissionGroup.APPLICANT, + idPersistent: 'id-user' + }) + }) + }, + ...renderOptions + }: ExtendedRenderOptions = {} +) { + const store = configureStore({ + reducer: { + editSession: editSessionReducer, + notification: notificationReducer, + user: userSlice.reducer + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ thunk: { extraArgument: fetchMock } }), + preloadedState + }) + function Wrapper({ children }: PropsWithChildren): JSX.Element { + return {children} + } + + // Return an object with the store and all of RTL's query functions + return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) } +} + +const idParticipant1 = 'id-participant-1' +const nameParticipant1 = 'Participant 1' +const idParticipant2 = 'id-participant-2' +const nameParticipant2 = 'Participant 2' +const idParticipant3 = 'id-participant-3' +const nameParticipant3 = 'Participant 3' + +const idSession1 = 'id-session-test' +const nameSession1 = 'edit session for test' +const idSession2 = 'id-session-test-2' +const nameSession2 = 'second edit session for test' +const idOwner = 'id-owner' + +const initialSessionState = newEditSessionState({ + currentEditSession: newRemote( + newEditSession({ + idPersistent: idSession1, + name: nameSession1, + owner: newEditSessionParticipant({ + type: EditSessionParticipantType.internal, + name: '', + id: idOwner + }), + participantList: [ + newEditSessionParticipant({ + id: idParticipant1, + name: nameParticipant1, + type: EditSessionParticipantType.internal + }), + newEditSessionParticipant({ + id: idParticipant2, + name: nameParticipant2, + type: EditSessionParticipantType.internal + }) + ], + participantMap: { [idParticipant1]: 0, [idParticipant2]: 1 } + }) + ) +}) + +const sessionApi2 = { + name: nameSession2, + id_persistent: idSession2, + owner: { + id_participant: idParticipant1, + type_participant: 'INTERNAL' + }, + participant_list: [ + { + name_participant: nameParticipant1, + id_participant: idParticipant1, + type_participant: 'INTERNAL' + }, + { + name_participant: nameParticipant3, + id_participant: idParticipant3, + type_participant: 'INTERNAL' + } + ] +} +const sessionListApi = { + edit_session_list: [ + { + name: nameSession1, + id_persistent: idSession1, + owner: { + id_participant: idParticipant1, + type_participant: 'INTERNAL' + }, + participant_list: [ + { + name_participant: nameParticipant1, + id_participant: idParticipant1, + type_participant: 'INTERNAL' + }, + { + name_participant: nameParticipant2, + id_participant: idParticipant2, + type_participant: 'INTERNAL' + } + ] + }, + sessionApi2 + ] +} + +const session2 = newEditSession({ + idPersistent: idSession2, + name: nameSession2, + owner: newEditSessionParticipant({ + id: idParticipant1, + type: EditSessionParticipantType.internal + }), + participantList: [ + newEditSessionParticipant({ + name: nameParticipant1, + id: idParticipant1, + type: EditSessionParticipantType.internal + }), + newEditSessionParticipant({ + name: nameParticipant3, + id: idParticipant3, + type: EditSessionParticipantType.internal + }) + ], + participantMap: { [idParticipant1]: 0, [idParticipant3]: 1 } +}) +const successSessionList = newRemote([ + newEditSession({ + idPersistent: idSession1, + name: nameSession1, + owner: newEditSessionParticipant({ + id: idParticipant1, + type: EditSessionParticipantType.internal + }), + participantList: [ + newEditSessionParticipant({ + name: nameParticipant1, + id: idParticipant1, + type: EditSessionParticipantType.internal + }), + newEditSessionParticipant({ + name: nameParticipant2, + id: idParticipant2, + type: EditSessionParticipantType.internal + }) + ], + participantMap: { [idParticipant1]: 0, [idParticipant2]: 1 } + }), + session2 +]) diff --git a/ui/src/session/components.tsx b/ui/src/session/components.tsx new file mode 100644 index 00000000..f478a81a --- /dev/null +++ b/ui/src/session/components.tsx @@ -0,0 +1,648 @@ +import { + Badge, + Button, + Col, + ListGroup, + OverlayTrigger, + Popover, + Row, + Spinner, + Tooltip +} from 'react-bootstrap' +import { + ArrowLeftShort, + CheckCircle, + Floppy2Fill, + PeopleFill, + PersonFill, + PlusLg, + XCircle +} from 'react-bootstrap-icons' +import { useAppDispatch, useAppSelector } from '../hooks' +import { + selectAddEditSessionParticipant, + selectedEditSessionParticipantIds, + selectEditSessionOwnerList, + selectCurrentEditSessionName, + selectCurrentEditSessionParticipantList, + selectEditSessionParticipantNumber, + selectIdCurrentEditSessionPersistent, + selectParticipantSearchResults, + selectEditSessionParticipantList +} from './selectors' +import { VrAnLoading } from '../util/components/misc' +import { ChangeEvent, useEffect, useState } from 'react' +import { + addEditSessionParticipantThunk, + createEditSessionThunk, + getEditSessionOwnerListThunk, + getEditSessionParticipantListThunk, + patchEditSessionThunk, + removeEditSessionParticipantThunk, + searchEditSessionParticipantThunk +} from './thunks' +import { Placement } from 'react-bootstrap/esm/types' +import { FormField } from '../util/form' +import { debounce } from 'debounce' +import { AppDispatch } from '../store' +import { + EditSession, + EditSessionParticipant, + EditSessionParticipantType, + newEditSessionParticipant +} from './state' +import { clearParticipantSearchResults } from './slice' +import { TabView } from '../util/components/tabs' +import { selectUserInfo } from '../user/selectors' +import { setCurrentEditSessionThunk } from '../user/thunks' + +export function EditSessionButton({ + popoverPlacement, + tooltipPlacement +}: { + popoverPlacement: Placement + tooltipPlacement: Placement +}) { + const countParticipants = useAppSelector(selectEditSessionParticipantNumber) + return ( + + + + } + > +
+ Click to manage edit sessions} + delay={{ show: 250, hide: 400 }} + rootClose + > + + + {countParticipants < 2 ? ( + + + + + + + + + + + ) : ( + + + + + + + {countParticipants} + + + + )} + + + +
+
+ ) +} + +export function EditSessionTabs() { + const [tabIdx, setTabIdx] = useState(0) + const backCallback = () => setTabIdx(0) + return ( + + }, + { + name: 'Owner', + component: + }, + { + name: 'Participant', + component: + } + ]} + /> + + ) +} + +enum EditSessionEditorView { + participants, + add +} + +export function EditSessionEditor() { + const idCurrentEditSession = useAppSelector(selectIdCurrentEditSessionPersistent) + const editSessionParticipantList = useAppSelector( + selectCurrentEditSessionParticipantList + ) + const [view, setView] = useState(EditSessionEditorView.participants) + const [removeDialogForParticipant, setRemoveDialogForParticipant] = useState< + EditSessionParticipant | undefined + >(undefined) + if (idCurrentEditSession === undefined) { + return <> + } + const backCallback = () => setView(EditSessionEditorView.participants) + if (view === EditSessionEditorView.add) { + return ( + + ) + } + if (removeDialogForParticipant !== undefined) { + const closeDialogCallback = () => setRemoveDialogForParticipant(undefined) + + return ( + + ) + } + return ( + + + + + + Participants of Current Edit Session + + + + {editSessionParticipantList?.map((participant, idx) => ( + + ))} + + + + + + + ) +} + +export function CreateEditSessionForm() { + const dispatch = useAppDispatch() + const [name, setName] = useState(undefined) + return ( + + ) => + setName(e.target.value) + } + /> + + + + + ) +} + +export function EditSessionNameForm({ + idEditSessionPersistent +}: { + idEditSessionPersistent: string +}) { + const editSessionName = useAppSelector(selectCurrentEditSessionName) + return ( + + ) +} + +function EditSessionNameFormComponent({ + idEditSessionPersistent, + editSessionName +}: { + idEditSessionPersistent: string + editSessionName: string | undefined +}) { + const [name, setName] = useState(editSessionName ?? '') + const dispatch = useAppDispatch() + return ( + + + ) => + setName(e.target.value) + } + /> + + + dispatch(patchEditSessionThunk({ idEditSessionPersistent, name })) + } + > + + + + ) +} + +function EditSessionParticipantItem({ + participant, + removeParticipantCallback +}: { + participant: EditSessionParticipant + removeParticipantCallback: (participant: EditSessionParticipant) => void +}) { + return ( + + + {participant.name} + removeParticipantCallback(participant)} + role="button" + > + + + + + ) +} + +function ParticipantRemoveDialog({ + participant, + closeDialogCallback, + idCurrentEditSessionPersistent +}: { + participant: EditSessionParticipant + closeDialogCallback: VoidFunction + idCurrentEditSessionPersistent: string +}) { + const dispatch = useAppDispatch() + return ( + + + {`This will remove ${participant.name} from all existing edits made with the + current edit session.`} + + + + + + + + + ) +} + +const debouncedSearchDispatch = debounce( + (searchTerm: string, dispatch: AppDispatch) => + dispatch(searchEditSessionParticipantThunk(searchTerm)), + 400 +) + +const debouncedSearchDispatchThunk = (searchTerm: string) => (dispatch: AppDispatch) => + debouncedSearchDispatch(searchTerm, dispatch) + +export function AddParticipantForm({ + backCallback, + idCurrentEditSession +}: { + backCallback: VoidFunction + idCurrentEditSession: string +}) { + const [searchString, setSearchString] = useState('') + const dispatch = useAppDispatch() + return ( + + + { + dispatch(clearParticipantSearchResults()) + backCallback() + }} + > + + + + + ) => { + const searchTerm = e.target.value + setSearchString(searchTerm) + debouncedSearchDispatchThunk(searchTerm)(dispatch) + }} + > + + + + + + Adding a participant to an edit session will attribute co-authorship for + past and future edits of this session. + + + ) +} + +export function ParticipantSearchResults({ + idCurrentEditSession +}: { + idCurrentEditSession: string +}) { + const searchResults = useAppSelector(selectParticipantSearchResults) + const participantIds = useAppSelector(selectedEditSessionParticipantIds) + const addingParticipant = useAppSelector(selectAddEditSessionParticipant) + const dispatch = useAppDispatch() + function addParticipantCallback(participant: EditSessionParticipant) { + dispatch(addEditSessionParticipantThunk(idCurrentEditSession, participant)) + } + + if (searchResults.isLoading) { + return + } + const searchResultsValue = searchResults.value ?? [] + return ( + + {searchResultsValue.map((participant, idx) => ( + + ))} + + ) +} + +function EditSessionSearchResultEntry({ + participant, + addParticipantCallback, + currentSessionMemberIdMap, + isAdding +}: { + participant: EditSessionParticipant + addParticipantCallback: (participant: EditSessionParticipant) => void + currentSessionMemberIdMap: { [key: string]: boolean } + isAdding: boolean +}) { + return ( + addParticipantCallback(participant)} + > + + {participant.name} + + {isAdding ? ( + + ) : ( + currentSessionMemberIdMap[participant.id] && ( + + + + ) + )} + + + + ) +} + +export function EditSessionOwnerList({ backCallback }: { backCallback: VoidFunction }) { + const dispatch = useAppDispatch() + useEffect(() => { + dispatch(getEditSessionOwnerListThunk()) + }) + return ( + + + Please select an edit session + + + + + + + + + ) +} + +export function EditSessionParticipantList() { + const dispatch = useAppDispatch() + useEffect(() => { + dispatch(getEditSessionParticipantListThunk()) + }) + return ( + + + You can remove yourself from edit sessions where you are a participant + + + + + + ) +} + +function EditSessionOwnerListComponent({ + backCallback +}: { + backCallback: VoidFunction +}) { + const editSessions = useAppSelector(selectEditSessionOwnerList) + const idCurrentEditSession = useAppSelector(selectIdCurrentEditSessionPersistent) + const dispatch = useAppDispatch() + return ( + + {editSessions.value.map((session, idx) => ( + + dispatch(setCurrentEditSessionThunk(session.idPersistent)).then( + (result) => { + if (result) { + backCallback() + } + } + ) + } + > + + {session.name} + + {idCurrentEditSession == session.idPersistent && ( + + )} + + + + ))} + + ) +} + +function RemoveSelfFromEditSessionDialog({ + session, + closeDialogCallback +}: { + session: EditSession + closeDialogCallback: VoidFunction +}) { + const userInfo = useAppSelector(selectUserInfo) + const dispatch = useAppDispatch() + const closeRow = ( + + + + + + ) + if (userInfo === undefined) { + return closeRow + } + return ( + + + Remove yourself from session with name {session.name}? This will remove + your attribution past and future edits made by that session. + + + + + + + {closeRow} + + ) +} + +function EditSessionParticipantListComponent() { + const editSessions = useAppSelector(selectEditSessionParticipantList) + const [showRemove, setShowRemove] = useState(undefined) + if (editSessions.isLoading) { + return + } + if (showRemove !== undefined) { + return ( + setShowRemove(undefined)} + /> + ) + } + if (editSessions.isLoading) { + return + } + return ( + + {editSessions.value.map((session, idx) => ( + + + {session.name} + setShowRemove(session)} + > + + + + + ))} + + ) +} diff --git a/ui/src/session/selectors.ts b/ui/src/session/selectors.ts new file mode 100644 index 00000000..3b05bf5e --- /dev/null +++ b/ui/src/session/selectors.ts @@ -0,0 +1,65 @@ +import { createSelector } from '@reduxjs/toolkit' +import { RootState } from '../store' + +function selectEditSessionState(state: RootState) { + return state.editSession +} + +export const selectEditSessionOwnerList = createSelector( + selectEditSessionState, + (state) => state.editSessionOwnerList +) +export const selectEditSessionParticipantList = createSelector( + selectEditSessionState, + (state) => state.editSessionParticipantList +) +export const selectCurrentEditSession = createSelector( + selectEditSessionState, + (state) => state.currentEditSession +) + +export const selectIdCurrentEditSessionPersistent = createSelector( + selectCurrentEditSession, + (session) => session.value?.idPersistent +) + +export const selectCurrentEditSessionParticipantList = createSelector( + selectCurrentEditSession, + (state) => state.value?.participantList +) + +export const selectEditSessionParticipantNumber = createSelector( + selectCurrentEditSessionParticipantList, + (state) => state?.length ?? 0 +) + +export const selectParticipantSearchResults = createSelector( + selectEditSessionState, + (state) => state.participantSearchResults +) + +export const selectedEditSessionParticipantIds = createSelector( + selectCurrentEditSession, + (session) => + Object.fromEntries( + (session.value?.participantList ?? []).map((participant) => [ + participant.id, + true + ]) + ) +) + +export const selectAddEditSessionParticipant = createSelector( + selectEditSessionState, + (state) => state.addEditSessionParticipant +) + +export const selectCurrentEditSessionName = createSelector( + selectCurrentEditSession, + (session) => session.value?.name +) + +export const selectShowEditSessionList = createSelector( + selectEditSessionState, + (state) => state.showEditSessionList +) diff --git a/ui/src/session/slice.ts b/ui/src/session/slice.ts new file mode 100644 index 00000000..4d39d30c --- /dev/null +++ b/ui/src/session/slice.ts @@ -0,0 +1,209 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { + EditSession, + EditSessionParticipant, + EditSessionState, + newEditSessionState +} from './state' +import { newRemote } from '../util/state' + +const editSessionSlice = createSlice({ + name: 'editSession', + initialState: newEditSessionState({}), + reducers: { + setShowEditSessionList( + state: EditSessionState, + action: PayloadAction + ) { + state.showEditSessionList = action.payload + }, + setCurrentEditSession( + state: EditSessionState, + action: PayloadAction + ) { + state.currentEditSession.value = action.payload + }, + getEditSessionOwnerListStart(state: EditSessionState) { + state.editSessionOwnerList.isLoading = true + }, + getEditSessionOwnerListSuccess( + state: EditSessionState, + action: PayloadAction + ) { + state.editSessionOwnerList.isLoading = false + state.editSessionOwnerList.value = action.payload + state.editSessionOwnerMap = Object.fromEntries( + action.payload.map((session, idx) => [session.idPersistent, idx]) + ) + }, + getEditSessionOwnerListError(state: EditSessionState) { + state.editSessionOwnerList.isLoading = false + }, + getEditSessionParticipantListStart(state: EditSessionState) { + state.editSessionParticipantList.isLoading = true + }, + getEditSessionParticipantListSuccess( + state: EditSessionState, + action: PayloadAction + ) { + state.editSessionParticipantList.isLoading = false + state.editSessionParticipantList.value = action.payload + state.editSessionParticipantMap = Object.fromEntries( + action.payload.map((session, idx) => [session.idPersistent, idx]) + ) + }, + getEditSessionParticipantListError(state: EditSessionState) { + state.editSessionParticipantList.isLoading = false + }, + searchParticipantsStart(state: EditSessionState) { + state.participantSearchResults.isLoading = true + }, + searchParticipantsSuccess( + state: EditSessionState, + action: PayloadAction + ) { + state.participantSearchResults.isLoading = false + state.participantSearchResults.value = action.payload + }, + searchParticipantsError(state: EditSessionState) { + state.participantSearchResults.isLoading = false + }, + addEditSessionParticipantStart( + state: EditSessionState, + action: PayloadAction + ) { + state.addEditSessionParticipant = newRemote(action.payload, true) + }, + addEditSessionParticipantSuccess( + state: EditSessionState, + action: PayloadAction + ) { + const currentEditSession = state.currentEditSession.value + if (currentEditSession !== undefined) { + currentEditSession.participantMap[action.payload.id] = + currentEditSession.participantList.length + + currentEditSession.participantList?.push(action.payload) + } + if (action.payload.id == state.addEditSessionParticipant.value) { + state.addEditSessionParticipant = newRemote(undefined) + } + }, + addEditSessionParticipantError( + state: EditSessionState, + action: PayloadAction + ) { + if (action.payload == state.addEditSessionParticipant.value) { + state.addEditSessionParticipant = newRemote(undefined) + } + }, + clearParticipantSearchResults(state: EditSessionState) { + state.participantSearchResults.value = [] + }, + removeEditSessionParticipantStart( + state: EditSessionState, + action: PayloadAction + ) { + state.removeEditSessionParticipant = newRemote(action.payload, true) + }, + removeEditSessionParticipantSuccess( + state: EditSessionState, + action: PayloadAction + ) { + const idParticipant = action.payload.id + const currentEditSession = state.currentEditSession.value + if (currentEditSession !== undefined) { + const idx = currentEditSession?.participantMap[idParticipant] + if (idx !== undefined) { + currentEditSession?.participantList.splice(idx, 1) + currentEditSession.participantMap = Object.fromEntries( + currentEditSession.participantList.map((participant, idx) => [ + participant.id, + idx + ]) + ) + } + } + if (state.removeEditSessionParticipant.value?.id == idParticipant) { + state.removeEditSessionParticipant = newRemote(undefined) + } + const idxParticipant = state.editSessionParticipantMap[action.payload.id] + if (idParticipant !== undefined) { + state.editSessionParticipantList.value?.splice(idxParticipant, 1) + state.editSessionParticipantMap = Object.fromEntries( + (state.editSessionParticipantList.value ?? []).map( + (session, idx) => [session.idPersistent, idx] + ) + ) + } + }, + removeEditSessionParticipantError( + state: EditSessionState, + action: PayloadAction + ) { + const idParticipant = action.payload.id + if (state.removeEditSessionParticipant.value?.id == idParticipant) { + state.removeEditSessionParticipant = newRemote(undefined) + } + }, + createEditSessionStart(state: EditSessionState) { + state.currentEditSession.isLoading = true + }, + createEditSessionSuccess( + state: EditSessionState, + action: PayloadAction + ) { + state.currentEditSession = newRemote(action.payload) + const editSessionOwnerList = state.editSessionOwnerList.value + if (editSessionOwnerList !== undefined) { + state.editSessionOwnerMap[action.payload.idPersistent] = + editSessionOwnerList.length + editSessionOwnerList.push(action.payload) + } + }, + createEditSessionError(state: EditSessionState) { + state.currentEditSession.isLoading = false + }, + patchEditSessionStart(state: EditSessionState) { + state.currentEditSession.isLoading = true + }, + patchEditSessionSuccess( + state: EditSessionState, + action: PayloadAction + ) { + state.currentEditSession = newRemote(action.payload) + }, + patchEditSessionError(state: EditSessionState) { + state.currentEditSession.isLoading = false + } + } +}) + +export const editSessionReducer = editSessionSlice.reducer + +export const { + addEditSessionParticipantStart, + addEditSessionParticipantSuccess, + addEditSessionParticipantError, + setShowEditSessionList, + setCurrentEditSession, + getEditSessionOwnerListStart, + getEditSessionOwnerListSuccess, + getEditSessionOwnerListError, + getEditSessionParticipantListError, + getEditSessionParticipantListStart, + getEditSessionParticipantListSuccess, + searchParticipantsStart, + searchParticipantsSuccess, + searchParticipantsError, + clearParticipantSearchResults, + removeEditSessionParticipantError, + removeEditSessionParticipantStart, + removeEditSessionParticipantSuccess, + createEditSessionError, + createEditSessionStart, + createEditSessionSuccess, + patchEditSessionError, + patchEditSessionStart, + patchEditSessionSuccess +} = editSessionSlice.actions diff --git a/ui/src/session/state.ts b/ui/src/session/state.ts new file mode 100644 index 00000000..4b31b2c2 --- /dev/null +++ b/ui/src/session/state.ts @@ -0,0 +1,97 @@ +import { RemoteInterface, newRemote } from '../util/state' + +export enum EditSessionParticipantType { + internal = 'internal', + orcid = 'orcid' +} + +export interface EditSessionParticipant { + id: string + type: EditSessionParticipantType + name: string | undefined +} +export interface EditSession { + idPersistent: string + name: string + owner: EditSessionParticipant + participantList: EditSessionParticipant[] + participantMap: { [key: string]: number } +} + +export interface EditSessionState { + showEditSessionList: boolean + currentEditSession: RemoteInterface + editSessionOwnerList: RemoteInterface + editSessionOwnerMap: { [key: string]: number } + editSessionParticipantList: RemoteInterface + editSessionParticipantMap: { [key: string]: number } + participantSearchResults: RemoteInterface + addEditSessionParticipant: RemoteInterface + removeEditSessionParticipant: RemoteInterface +} + +export function newEditSessionState({ + showEditSessionList = false, + currentEditSession = newRemote(undefined), + editSessionOwnerList = newRemote([]), + editSessionOwnerMap = {}, + editSessionParticipantList = newRemote([]), + editSessionParticipantMap = {}, + participantSearchResults = newRemote(undefined), + addEditSessionParticipant = newRemote(undefined), + removeEditSessionParticipant = newRemote(undefined) +}: { + showEditSessionList?: boolean + currentEditSession?: RemoteInterface + editSessionOwnerList?: RemoteInterface + editSessionOwnerMap?: { [key: string]: number } + editSessionParticipantList?: RemoteInterface + editSessionParticipantMap?: { [key: string]: number } + participantSearchResults?: RemoteInterface + addEditSessionParticipant?: RemoteInterface + removeEditSessionParticipant?: RemoteInterface +}): EditSessionState { + return { + showEditSessionList, + currentEditSession, + editSessionOwnerList, + editSessionOwnerMap, + editSessionParticipantList, + editSessionParticipantMap, + participantSearchResults, + addEditSessionParticipant, + removeEditSessionParticipant + } +} + +export function newEditSession({ + idPersistent, + name, + owner, + participantList, + participantMap +}: { + idPersistent: string + name: string + owner: EditSessionParticipant + participantList: EditSessionParticipant[] + participantMap: { [key: string]: number } +}): EditSession { + return { idPersistent, name, owner, participantList, participantMap } +} + +export function newEditSessionParticipant({ + id, + type = EditSessionParticipantType.internal, + name = undefined +}: { + id: string + type?: EditSessionParticipantType + name?: string | undefined +}): EditSessionParticipant { + return { + id, + type, + name + } +} diff --git a/ui/src/session/thunks.ts b/ui/src/session/thunks.ts new file mode 100644 index 00000000..102325fe --- /dev/null +++ b/ui/src/session/thunks.ts @@ -0,0 +1,274 @@ +import { config } from '../config' +import { errorMessageFromApi, exceptionMessage } from '../util/exception' +import { addError, addSuccessVanish } from '../util/notification/slice' +import { ThunkWithFetch } from '../util/type' +import { + addEditSessionParticipantError, + addEditSessionParticipantStart, + addEditSessionParticipantSuccess, + createEditSessionError, + createEditSessionStart, + createEditSessionSuccess, + getEditSessionOwnerListError, + getEditSessionOwnerListStart, + getEditSessionOwnerListSuccess, + getEditSessionParticipantListError, + getEditSessionParticipantListStart, + getEditSessionParticipantListSuccess, + patchEditSessionError, + patchEditSessionStart, + patchEditSessionSuccess, + removeEditSessionParticipantError, + removeEditSessionParticipantStart, + removeEditSessionParticipantSuccess, + searchParticipantsError, + searchParticipantsStart, + searchParticipantsSuccess +} from './slice' +import { + EditSessionParticipant, + EditSessionParticipantType, + newEditSession, + newEditSessionParticipant +} from './state' + +export function getEditSessionOwnerListThunk(): ThunkWithFetch { + return async (dispatch, _getState, fetch) => { + dispatch(getEditSessionOwnerListStart()) + try { + const rsp = await fetch(config.api_path + '/edit_sessions/owner', { + credentials: 'include' + }) + const json = await rsp.json() + if (rsp.status == 200) { + const editSessionList = json['edit_session_list'].map( + (session: unknown) => parseEditSessionFromApi(session) + ) + dispatch(getEditSessionOwnerListSuccess(editSessionList)) + } else { + dispatch(addError(errorMessageFromApi(json))) + dispatch(getEditSessionOwnerListError()) + } + } catch (e: unknown) { + dispatch(getEditSessionOwnerListError()) + dispatch(addError(exceptionMessage(e))) + } + } +} + +export function getEditSessionParticipantListThunk(): ThunkWithFetch { + return async (dispatch, _getState, fetch) => { + dispatch(getEditSessionParticipantListStart()) + try { + const rsp = await fetch(config.api_path + '/edit_sessions/participant', { + credentials: 'include' + }) + const json = await rsp.json() + if (rsp.status == 200) { + const editSessionList = json['edit_session_list'].map( + (session: unknown) => parseEditSessionFromApi(session) + ) + dispatch(getEditSessionParticipantListSuccess(editSessionList)) + } else { + dispatch(addError(errorMessageFromApi(json))) + dispatch(getEditSessionParticipantListError()) + } + } catch (e: unknown) { + dispatch(getEditSessionParticipantListError()) + dispatch(addError(exceptionMessage(e))) + } + } +} + +export function addEditSessionParticipantThunk( + idEditSessionPersistent: string, + participant: EditSessionParticipant +): ThunkWithFetch { + return async (dispatch, _getState, fetch) => { + dispatch(addEditSessionParticipantStart(participant.id)) + try { + const rsp = await fetch( + config.api_path + + `/edit_sessions/${idEditSessionPersistent}/participants`, + { + method: 'PUT', + credentials: 'include', + body: JSON.stringify({ + type_participant: participant.type.toString().toUpperCase(), + id_participant: participant.id, + name_participant: participant.name + }) + } + ) + const json = await rsp.json() + if (rsp.status == 200) { + dispatch(addEditSessionParticipantSuccess(participant)) + return true + } + dispatch(addEditSessionParticipantError(participant.id)) + dispatch(addError(errorMessageFromApi(json))) + } catch (e: unknown) { + dispatch(addEditSessionParticipantError(participant.id)) + dispatch(addError(exceptionMessage(e))) + } + return false + } +} + +export function searchEditSessionParticipantThunk( + searchTerm: string +): ThunkWithFetch { + return async (dispatch, _getState, fetch) => { + dispatch(searchParticipantsStart()) + try { + const rsp = await fetch(config.api_path + '/edit_sessions/search', { + credentials: 'include', + method: 'POST', + body: JSON.stringify({ search_term: searchTerm }) + }) + const json = await rsp.json() + if (rsp.status == 200) { + const results = json['search_result_list'].map((json: unknown) => + parseEditSessionParticipant(json) + ) + dispatch(searchParticipantsSuccess(results)) + } else { + dispatch(searchParticipantsError()) + dispatch(addError(errorMessageFromApi(json))) + } + } catch (e: unknown) { + dispatch(searchParticipantsError()) + dispatch(addError(exceptionMessage(e))) + } + } +} + +export function removeEditSessionParticipantThunk( + idEditSession: string, + participant: EditSessionParticipant +): ThunkWithFetch { + return async (dispatch, _getState, fetch) => { + dispatch(removeEditSessionParticipantStart(participant)) + try { + const rsp = await fetch( + config.api_path + `/edit_sessions/${idEditSession}/participants`, + { + credentials: 'include', + method: 'DELETE', + body: JSON.stringify({ + id_participant: participant.id, + type_participant: participant.type.toString().toUpperCase() + }) + } + ) + if (rsp.status == 200) { + dispatch(removeEditSessionParticipantSuccess(participant)) + dispatch( + addSuccessVanish( + `Removed participant ${participant.name} from edit session` + ) + ) + return true + } + const json = await rsp.json() + dispatch(removeEditSessionParticipantError(participant)) + dispatch(addError(errorMessageFromApi(json))) + } catch (e: unknown) { + dispatch(removeEditSessionParticipantError(participant)) + dispatch(addError(exceptionMessage(e))) + } + return false + } +} + +export function createEditSessionThunk( + nameEditSession: string | undefined +): ThunkWithFetch { + return async (dispatch, _getState, fetch) => { + dispatch(createEditSessionStart()) + try { + const rsp = await fetch(config.api_path + '/edit_sessions', { + credentials: 'include', + method: 'PUT', + body: JSON.stringify({ name: nameEditSession }) + }) + const json = await rsp.json() + if (rsp.status == 200) { + const editSession = parseEditSessionFromApi(json) + dispatch(createEditSessionSuccess(editSession)) + } else { + dispatch(addError(errorMessageFromApi(json))) + dispatch(createEditSessionError()) + } + } catch (e: unknown) { + dispatch(addError(exceptionMessage(e))) + dispatch(createEditSessionError()) + } + } +} + +export function patchEditSessionThunk({ + idEditSessionPersistent, + name +}: { + idEditSessionPersistent: string + name: string +}): ThunkWithFetch { + return async (dispatch, _getState, fetch) => { + dispatch(patchEditSessionStart()) + try { + const rsp = await fetch( + config.api_path + `/edit_sessions/${idEditSessionPersistent}`, + { + credentials: 'include', + method: 'PATCH', + body: JSON.stringify({ name }) + } + ) + const json = await rsp.json() + if (rsp.status == 200) { + const editSession = parseEditSessionFromApi(json) + dispatch(patchEditSessionSuccess(editSession)) + dispatch(addSuccessVanish('Edit session name successfully changed.')) + } else { + dispatch(addError(errorMessageFromApi(json))) + dispatch(patchEditSessionError()) + } + } catch (e: unknown) { + dispatch(addError(exceptionMessage(e))) + dispatch(patchEditSessionError()) + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function parseEditSessionFromApi(json: any) { + const participantList = json['participant_list'].map((participant: unknown) => + parseEditSessionParticipant(participant) + ) + return newEditSession({ + idPersistent: json['id_persistent'], + name: json['name'], + owner: parseEditSessionParticipant(json['owner']), + participantList, + participantMap: Object.fromEntries( + participantList.map((entry: EditSessionParticipant, idx: number) => [ + entry.id, + idx + ]) + ) + }) +} +const editSessionParticipantTypeMap: { [key: string]: EditSessionParticipantType } = { + INTERNAL: EditSessionParticipantType.internal, + ORCID: EditSessionParticipantType.orcid +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function parseEditSessionParticipant(json: any): EditSessionParticipant { + return newEditSessionParticipant({ + id: json['id_participant'], + type: editSessionParticipantTypeMap[json['type_participant']], + name: json['name_participant'] ?? undefined + }) +} diff --git a/ui/src/store.ts b/ui/src/store.ts index 9191f5ab..b93f7fcd 100644 --- a/ui/src/store.ts +++ b/ui/src/store.ts @@ -14,6 +14,7 @@ import { tagMergeRequestsReducer } from './merge_request/slice' import { tagMergeRequestConflictsReducer } from './merge_request/conflicts/slice' import { commentsReducer } from './comments/slice' import { tableReducer } from './table/slice' +import { editSessionReducer } from './session/slice' const rootReducer = combineReducers({ notification: notificationReducer, @@ -30,7 +31,8 @@ const rootReducer = combineReducers({ entityMergeRequestConflicts: entityMergeRequestConflictSlice.reducer, displayTxtManagement: displayTxtManagementReducer, comments: commentsReducer, - table: tableReducer + table: tableReducer, + editSession: editSessionReducer }) export function setupStore(preloadedState?: PreloadedState) { diff --git a/ui/src/table/__tests__/column_header_menu.test.tsx b/ui/src/table/__tests__/column_header_menu.test.tsx index 09e72f42..5f0d334e 100644 --- a/ui/src/table/__tests__/column_header_menu.test.tsx +++ b/ui/src/table/__tests__/column_header_menu.test.tsx @@ -36,6 +36,15 @@ import { selectColumnStates } from '../selectors' import { Button, Col, Row } from 'react-bootstrap' import { RemoteDataTable } from '../components/table' import { tableReducer } from '../slice' +import { + EditSessionParticipantType, + EditSessionState, + newEditSession, + newEditSessionParticipant, + newEditSessionState +} from '../../session/state' +import { newRemote } from '../../util/state' +import { editSessionReducer } from '../../session/slice' const rectangle = { x: 0, y: 1, width: 2, height: 4 } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars @@ -211,6 +220,7 @@ interface ExtendedRenderOptions extends Omit { table: TableState tableSelection: TableSelectionState user: UserState + editSession: EditSessionState } } @@ -229,6 +239,21 @@ export function renderWithProviders( namesPersonal: 'names personal', columns: [tagDefTest] }) + }), + editSession: newEditSessionState({ + currentEditSession: newRemote( + newEditSession({ + idPersistent: 'id-session-test', + name: 'edit session for tests', + owner: newEditSessionParticipant({ + type: EditSessionParticipantType.internal, + name: 'edit session owner test', + id: idUserTest + }), + participantList: [], + participantMap: {} + }) + ) }) }, ...renderOptions @@ -239,7 +264,8 @@ export function renderWithProviders( notification: notificationReducer, tableSelection: tableSelectionSlice.reducer, table: tableReducer, - user: userSlice.reducer + user: userSlice.reducer, + editSession: editSessionReducer }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ thunk: { extraArgument: fetchMock } }), diff --git a/ui/src/table/components/__tests__/get_data_integration.test.tsx b/ui/src/table/components/__tests__/get_data_integration.test.tsx index f6e9f6d8..305bf042 100644 --- a/ui/src/table/components/__tests__/get_data_integration.test.tsx +++ b/ui/src/table/components/__tests__/get_data_integration.test.tsx @@ -37,6 +37,14 @@ import { RemoteDataTable } from '../table' import { userSlice } from '../../../user/slice' import { TableSelectionState, tableSelectionSlice } from '../../selection/slice' import { newRemote } from '../../../util/state' +import { + EditSessionParticipantType, + EditSessionState, + newEditSession, + newEditSessionParticipant, + newEditSessionState +} from '../../../session/state' +import { editSessionReducer } from '../../../session/slice' // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars function MockTable(props: any) { @@ -439,6 +447,7 @@ interface ExtendedRenderOptions extends Omit { table: TableState tableSelection: TableSelectionState user: UserState + editSession: EditSessionState } } @@ -457,6 +466,21 @@ export function renderWithProviders( namesPersonal: 'names personal', columns: [tagDefTest] }) + }), + editSession: newEditSessionState({ + currentEditSession: newRemote( + newEditSession({ + idPersistent: 'id-session-test', + name: 'edit session for tests', + owner: newEditSessionParticipant({ + type: EditSessionParticipantType.internal, + name: 'edit session owner test', + id: idUserTest + }), + participantList: [], + participantMap: {} + }) + ) }) }, ...renderOptions @@ -467,7 +491,8 @@ export function renderWithProviders( notification: notificationReducer, tableSelection: tableSelectionSlice.reducer, table: tableReducer, - user: userSlice.reducer + user: userSlice.reducer, + editSession: editSessionReducer }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ thunk: { extraArgument: fetchMock } }), diff --git a/ui/src/table/components/__tests__/open_modals_integration.test.tsx b/ui/src/table/components/__tests__/open_modals_integration.test.tsx index 5a401a26..2e680a2b 100644 --- a/ui/src/table/components/__tests__/open_modals_integration.test.tsx +++ b/ui/src/table/components/__tests__/open_modals_integration.test.tsx @@ -52,6 +52,14 @@ import { newEntityMergeRequestConflictsState } from '../../../merge_request/entity/conflicts/state' import { entityMergeRequestConflictSlice } from '../../../merge_request/entity/conflicts/slice' +import { + EditSessionParticipantType, + EditSessionState, + newEditSession, + newEditSessionParticipant, + newEditSessionState +} from '../../../session/state' +import { editSessionReducer } from '../../../session/slice' // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars function MockTable(props: any) { @@ -81,6 +89,7 @@ interface ExtendedRenderOptions extends Omit { tagSelection: TagSelectionState entityMergeRequests: EntityMergeRequestState entityMergeRequestConflicts: EntityMergeRequestConflictsState + editSession: EditSessionState } } @@ -102,6 +111,21 @@ export function renderWithProviders( namesPersonal: 'names personal', columns: [tagDefTest] }) + }), + editSession: newEditSessionState({ + currentEditSession: newRemote( + newEditSession({ + idPersistent: 'id-session-test', + name: 'edit session for tests', + owner: newEditSessionParticipant({ + type: EditSessionParticipantType.internal, + name: 'edit session owner test', + id: idUserTest + }), + participantList: [], + participantMap: {} + }) + ) }) }, ...renderOptions @@ -115,7 +139,8 @@ export function renderWithProviders( user: userSlice.reducer, tagSelection: tagSelectionSlice.reducer, entityMergeRequests: entityMergeRequestsReducer, - entityMergeRequestConflicts: entityMergeRequestConflictSlice.reducer + entityMergeRequestConflicts: entityMergeRequestConflictSlice.reducer, + editSession: editSessionReducer }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ thunk: { extraArgument: fetchMock } }), diff --git a/ui/src/table/components/__tests__/open_reason.test.tsx b/ui/src/table/components/__tests__/open_reason.test.tsx index 7c299eae..9575a914 100644 --- a/ui/src/table/components/__tests__/open_reason.test.tsx +++ b/ui/src/table/components/__tests__/open_reason.test.tsx @@ -46,6 +46,14 @@ import userEvent, { UserEvent } from '@testing-library/user-event' import { act } from 'react-dom/test-utils' import { tagSelectionSlice } from '../../../column_menu/slice' import { newRemote } from '../../../util/state' +import { + EditSessionParticipantType, + EditSessionState, + newEditSession, + newEditSessionParticipant, + newEditSessionState +} from '../../../session/state' +import { editSessionReducer } from '../../../session/slice' // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars function MockTable(props: any) { @@ -414,6 +422,7 @@ interface ExtendedRenderOptions extends Omit { tableSelection: TableSelectionState tagSelection: TagSelectionState user: UserState + editSession: EditSessionState } } @@ -444,6 +453,21 @@ export function renderWithProviders( namesPersonal: 'names personal', columns: [tagDefTest] }) + }), + editSession: newEditSessionState({ + currentEditSession: newRemote( + newEditSession({ + idPersistent: 'id-session-test', + name: 'edit session for tests', + owner: newEditSessionParticipant({ + type: EditSessionParticipantType.internal, + name: 'edit session owner test', + id: idUserTest + }), + participantList: [], + participantMap: {} + }) + ) }) }, ...renderOptions @@ -455,7 +479,8 @@ export function renderWithProviders( tableSelection: tableSelectionSlice.reducer, tagSelection: tagSelectionSlice.reducer, table: tableReducer, - user: userSlice.reducer + user: userSlice.reducer, + editSession: editSessionReducer }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ thunk: { extraArgument: fetchMock } }), diff --git a/ui/src/table/components/__tests__/submit_data_integration.test.tsx b/ui/src/table/components/__tests__/submit_data_integration.test.tsx index 26b4c2d2..40244e29 100644 --- a/ui/src/table/components/__tests__/submit_data_integration.test.tsx +++ b/ui/src/table/components/__tests__/submit_data_integration.test.tsx @@ -38,9 +38,18 @@ import { RemoteDataTable } from '../table' import { userSlice } from '../../../user/slice' import { TableSelectionState, tableSelectionSlice } from '../../selection/slice' import { FormField } from '../../../util/form' +import { + EditSessionParticipantType, + EditSessionState, + newEditSession, + newEditSessionParticipant, + newEditSessionState +} from '../../../session/state' import { GridCellKind, Item } from '@glideapps/glide-data-grid' import userEvent from '@testing-library/user-event' import { debounce } from 'debounce' +import { newRemote } from '../../../util/state' +import { editSessionReducer } from '../../../session/slice' const debounced = debounce( (changeCallback: (item: Item, value: string) => void, item: Item, value: string) => @@ -409,22 +418,6 @@ const test_person_rsp_1 = { disabled: false } -const entities_test = [ - newEntity({ - idPersistent: idPersistent0, - displayTxt: 'test display txt 0', - displayTxtDetails: 'display_txt_detail', - version: 0, - disabled: false - }), - newEntity({ - idPersistent: idPersistent1, - displayTxt: 'test display txt 1', - displayTxtDetails: 'display_txt_detail', - version: 1, - disabled: false - }) -] const columnNameTest = 'column name test' const idTagDefPersistent = 'column_id_test' const nameUserTest = 'user_test' @@ -500,6 +493,7 @@ interface ExtendedRenderOptions extends Omit { table: TableState tableSelection: TableSelectionState user: UserState + editSession: EditSessionState } } @@ -518,6 +512,21 @@ export function renderWithProviders( namesPersonal: 'names personal', columns: [tagDefTest] }) + }), + editSession: newEditSessionState({ + currentEditSession: newRemote( + newEditSession({ + idPersistent: 'id-session-test', + name: 'edit session for tests', + owner: newEditSessionParticipant({ + type: EditSessionParticipantType.internal, + name: 'edit session owner test', + id: idUserTest + }), + participantList: [], + participantMap: {} + }) + ) }) }, ...renderOptions @@ -528,7 +537,8 @@ export function renderWithProviders( notification: notificationReducer, tableSelection: tableSelectionSlice.reducer, table: tableReducer, - user: userSlice.reducer + user: userSlice.reducer, + editSession: editSessionReducer }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ thunk: { extraArgument: fetchMock } }), diff --git a/ui/src/table/components/modals.tsx b/ui/src/table/components/modals.tsx index 4129d691..1bba9e77 100644 --- a/ui/src/table/components/modals.tsx +++ b/ui/src/table/components/modals.tsx @@ -37,6 +37,7 @@ import { useEffect } from 'react' import { CommentForm, CommentsHistory } from '../../comments/components' import { justificationColumnId } from '../state' import { clearSelection } from '../selection/slice' +import { ColumnSelector } from '../../column_menu/components/selection' export function EntityMergingModal() { const dispatch = useAppDispatch() @@ -109,14 +110,15 @@ export function ColumnModal({ onHide={() => dispatch(hideColumnAddMenu())} size="xl" key="column-menu-modal" - className="h-100 overflow-hidden" + className="overflow-hidden" + contentClassName="vh-95 d-flex flex-column bg-secondary flex-sm-wrap flex-md-nowrap" > - + Show Additional Tag Values - + + + + - + { const paths = checkmarkSpan.childNodes[0].childNodes expect(paths.length).toEqual(2) expect((paths[0] as Element).getAttribute('d')).toEqual( - 'M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z' + 'M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16' ) expect((paths[1] as Element).getAttribute('d')).toEqual( - 'M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z' + 'm10.97 4.97-.02.022-3.473 4.425-2.093-2.094a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05' ) }) expect(fetchMock.mock.calls).toEqual([ @@ -252,7 +252,7 @@ describe('Ownership search', () => { const paths = checkmarkSpan.childNodes[0].childNodes expect(paths.length).toEqual(1) expect((paths[0] as Element).getAttribute('d')).toEqual( - 'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z' + 'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293z' ) }) await waitFor(() => { diff --git a/ui/src/user/components/__tests__/integration.test.tsx b/ui/src/user/components/__tests__/integration.test.tsx index 8ad07af8..81df0fc5 100644 --- a/ui/src/user/components/__tests__/integration.test.tsx +++ b/ui/src/user/components/__tests__/integration.test.tsx @@ -15,9 +15,22 @@ import { NotificationType, notificationReducer } from '../../../util/notification/slice' +import { + EditSessionParticipantType, + EditSessionState, + newEditSession, + newEditSessionParticipant, + newEditSessionState +} from '../../../session/state' import { act } from 'react-dom/test-utils' +import { selectIdCurrentEditSessionPersistent } from '../../../session/selectors' +import { editSessionReducer } from '../../../session/slice' interface ExtendedRenderOptions extends Omit { - preloadedState?: { user: UserState; notification: NotificationManager } + preloadedState?: { + user: UserState + notification: NotificationManager + editSession: EditSessionState + } } const idErrorTest = 'id-error-test' @@ -33,13 +46,18 @@ export function renderWithProviders( { preloadedState = { user: newUserState({}), - notification: { notificationList: [], notificationMap: {} } + notification: { notificationList: [], notificationMap: {} }, + editSession: newEditSessionState({}) }, ...renderOptions }: ExtendedRenderOptions = {} ) { const store = configureStore({ - reducer: { user: userReducer, notification: notificationReducer }, + reducer: { + user: userReducer, + notification: notificationReducer, + editSession: editSessionReducer + }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ thunk: { extraArgument: fetchMock } }), preloadedState @@ -72,6 +90,8 @@ const passwordTest = 'pA$sw0rd-1234' const namesPersonalTest = 'names personal test' const testError = 'test error message' const idPersistentTest = 'id-user-test' +const idEditSession = 'id-session-test' +const nameEditSession = 'edit session for tests' const userInfoApi = { username: userNameTest, names_personal: namesPersonalTest, @@ -79,7 +99,17 @@ const userInfoApi = { names_family: '', tag_definition_list: [], id_persistent: idPersistentTest, - permission_group: 'CONTRIBUTOR' + permission_group: 'CONTRIBUTOR', + edit_session: { + id_persistent: idEditSession, + name: nameEditSession, + owner: { + id_participant: idPersistentTest, + name_participant: userNameTest, + type_participant: 'INTERNAL' + }, + participant_list: [] + } } const userInfoUi = newUserState({ userInfo: { @@ -117,6 +147,23 @@ describe('login', () => { expect(await screen.getByText('You are logged in')).toBeDefined() }) expect(store.getState().user).toEqual(userInfoUi) + expect(store.getState().editSession).toEqual( + newEditSessionState({ + currentEditSession: newRemote( + newEditSession({ + idPersistent: idEditSession, + name: nameEditSession, + owner: newEditSessionParticipant({ + type: EditSessionParticipantType.internal, + name: userNameTest, + id: idPersistentTest + }), + participantList: [], + participantMap: {} + }) + ) + }) + ) }) test('successful login', async () => { const fetchMock = jest.fn() diff --git a/ui/src/user/thunks.ts b/ui/src/user/thunks.ts index 50daa6b5..320da238 100644 --- a/ui/src/user/thunks.ts +++ b/ui/src/user/thunks.ts @@ -23,6 +23,8 @@ import { config } from '../config' import { ThunkWithFetch } from '../util/type' import { parseColumnDefinitionsFromApi } from '../column_menu/thunks' import { justificationColumnId } from '../table/state' +import { setCurrentEditSession } from '../session/slice' +import { parseEditSessionFromApi } from '../session/thunks' export function login(userName: string, password: string): ThunkWithFetch { return async (dispatch: AppDispatch, _getState, fetch) => { @@ -45,6 +47,11 @@ export function login(userName: string, password: string): ThunkWithFetch dispatch(addError(errorMessageFromApi(json))) } else { dispatch(loginSuccess(parseUserInfoFromJson(json))) + dispatch( + setCurrentEditSession( + parseEditSessionFromApi(json['edit_session']) + ) + ) } } else { const json = await rsp.json() @@ -85,6 +92,11 @@ export function refresh({ const userInfo = parseUserInfoFromJson(json) if (withDispatch) { dispatch(refreshSuccess(userInfo)) + dispatch( + setCurrentEditSession( + parseEditSessionFromApi(json['edit_session']) + ) + ) } return userInfo } else { @@ -97,6 +109,29 @@ export function refresh({ } } +export function setCurrentEditSessionThunk( + id_edit_session_persistent: string +): ThunkWithFetch { + return async (dispatch, _getState, fetch) => { + try { + const rsp = await fetch(config.api_path + '/user/edit_session', { + credentials: 'include', + method: 'POST', + body: JSON.stringify({ id_edit_session_persistent }) + }) + const json = await rsp.json() + if (rsp.status == 200) { + dispatch(setCurrentEditSession(parseEditSessionFromApi(json))) + return true + } + dispatch(addError(errorMessageFromApi(json))) + } catch (e: unknown) { + dispatch(addError(exceptionMessage(e))) + } + return false + } +} + export function registration({ userName, namesPersonal, diff --git a/ui/src/util/components/__tests__/stepper.test.tsx b/ui/src/util/components/__tests__/stepper.test.tsx index c11ece0c..8328f1de 100644 --- a/ui/src/util/components/__tests__/stepper.test.tsx +++ b/ui/src/util/components/__tests__/stepper.test.tsx @@ -12,7 +12,7 @@ describe('stepper title', () => { expect(paths.length).toEqual(1) expect(paths[0].getAttribute('d')).toEqual( // path for filed circle with 1 - 'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0ZM9.283 4.002H7.971L6.072 5.385v1.271l1.834-1.318h.065V12h1.312V4.002Z' + 'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M9.283 4.002H7.971L6.072 5.385v1.271l1.834-1.318h.065V12h1.312z' ) }) test('active smaller', async () => { @@ -21,10 +21,10 @@ describe('stepper title', () => { expect(paths.length).toEqual(2) // paths for filed circle with 0 expect(paths[0].getAttribute('d')).toEqual( - 'M8 4.951c-1.008 0-1.629 1.09-1.629 2.895v.31c0 1.81.627 2.895 1.629 2.895s1.623-1.09 1.623-2.895v-.31c0-1.8-.621-2.895-1.623-2.895Z' + 'M8 4.951c-1.008 0-1.629 1.09-1.629 2.895v.31c0 1.81.627 2.895 1.629 2.895s1.623-1.09 1.623-2.895v-.31c0-1.8-.621-2.895-1.623-2.895' ) expect(paths[1].getAttribute('d')).toEqual( - 'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-8.012 4.158c1.858 0 2.96-1.582 2.96-3.99V7.84c0-2.426-1.079-3.996-2.936-3.996-1.864 0-2.965 1.588-2.965 3.996v.328c0 2.42 1.09 3.99 2.941 3.99Z' + 'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-8.012 4.158c1.858 0 2.96-1.582 2.96-3.99V7.84c0-2.426-1.079-3.996-2.936-3.996-1.864 0-2.965 1.588-2.965 3.996v.328c0 2.42 1.09 3.99 2.941 3.99' ) }) test('inactive', async () => { @@ -32,7 +32,7 @@ describe('stepper title', () => { const paths = container.getElementsByTagName('path') expect(paths.length).toEqual(1) expect(paths[0].getAttribute('d')).toEqual( - 'M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8Zm15 0A8 8 0 1 1 0 8a8 8 0 0 1 16 0ZM6.646 6.24v.07H5.375v-.064c0-1.213.879-2.402 2.637-2.402 1.582 0 2.613.949 2.613 2.215 0 1.002-.6 1.667-1.287 2.43l-.096.107-1.974 2.22v.077h3.498V12H5.422v-.832l2.97-3.293c.434-.475.903-1.008.903-1.705 0-.744-.557-1.236-1.313-1.236-.843 0-1.336.615-1.336 1.306Z' + 'M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8m15 0A8 8 0 1 1 0 8a8 8 0 0 1 16 0M6.646 6.24v.07H5.375v-.064c0-1.213.879-2.402 2.637-2.402 1.582 0 2.613.949 2.613 2.215 0 1.002-.6 1.667-1.287 2.43l-.096.107-1.974 2.22v.077h3.498V12H5.422v-.832l2.97-3.293c.434-.475.903-1.008.903-1.705 0-.744-.557-1.236-1.313-1.236-.843 0-1.336.615-1.336 1.306' ) }) }) diff --git a/ui/src/util/components/misc.tsx b/ui/src/util/components/misc.tsx index b4485846..7b771081 100644 --- a/ui/src/util/components/misc.tsx +++ b/ui/src/util/components/misc.tsx @@ -84,21 +84,25 @@ export function ChoiceButton({ export function VranCard({ header, children, - className = '' + className = '', + bodyClassName = 'd-contents' }: { header?: ReactNode children: ReactNode className?: string + bodyClassName?: string }) { return ( - - -
+ + +
{header !== undefined && header}
-
{children}
+
+ {children} +
) diff --git a/ui/src/util/components/tabs.tsx b/ui/src/util/components/tabs.tsx index 26148139..c6da0645 100644 --- a/ui/src/util/components/tabs.tsx +++ b/ui/src/util/components/tabs.tsx @@ -49,14 +49,14 @@ export function TabView({ body = } return ( -
- +
+ -
    +
      {tabItems}
    - {body} +
    {body}
) From a67f5b69a77fc5f8a15dd96169ba7644122ee5e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Moritz=20F=C3=BCrneisen?= Date: Fri, 9 Aug 2024 16:10:33 +0200 Subject: [PATCH 2/2] Add backend endpoints for managing edit sessions. --- tests/conftest.py | 56 +++ tests/edit_session/__init__.py | 0 tests/edit_session/api/__init__.py | 0 .../edit_session/api/integration/__init__.py | 0 .../integration/delete_participant_test.py | 86 +++++ .../integration/get_sessions_owner_test.py | 63 ++++ .../get_sessions_participant_test.py | 76 ++++ .../integration/patch_edit_session_test.py | 73 ++++ .../api/integration/put_edit_session_test.py | 85 +++++ .../api/integration/put_participant_test.py | 113 ++++++ .../edit_session/api/integration/requests.py | 71 ++++ .../integration/search_participants_test.py | 57 +++ tests/edit_session/common.py | 15 + tests/edit_session/conftest.py | 35 ++ tests/entity/queue_test.py | 3 + tests/user/api/integration/post_login_test.py | 13 + .../user/api/integration/post_refresh_test.py | 13 + .../api/integration/post_register_test.py | 6 + tests/user/api/integration/requests.py | 9 + .../api/integration/set_edit_session_test.py | 65 ++++ tests/user/conftest.py | 11 + vran/edit_session/api.py | 348 ++++++++++++++++++ vran/edit_session/models_django.py | 100 +++++ vran/entity/queue.py | 1 + ...tsession_vranuser_edit_session_and_more.py | 70 ++++ vran/migrations/0011_default_edit_session.py | 44 +++ vran/models.py | 1 + vran/urls.py | 2 + vran/user/api.py | 76 +++- vran/user/models_api/login.py | 2 + vran/util/__init__.py | 8 + 31 files changed, 1489 insertions(+), 13 deletions(-) create mode 100644 tests/edit_session/__init__.py create mode 100644 tests/edit_session/api/__init__.py create mode 100644 tests/edit_session/api/integration/__init__.py create mode 100644 tests/edit_session/api/integration/delete_participant_test.py create mode 100644 tests/edit_session/api/integration/get_sessions_owner_test.py create mode 100644 tests/edit_session/api/integration/get_sessions_participant_test.py create mode 100644 tests/edit_session/api/integration/patch_edit_session_test.py create mode 100644 tests/edit_session/api/integration/put_edit_session_test.py create mode 100644 tests/edit_session/api/integration/put_participant_test.py create mode 100644 tests/edit_session/api/integration/requests.py create mode 100644 tests/edit_session/api/integration/search_participants_test.py create mode 100644 tests/edit_session/common.py create mode 100644 tests/edit_session/conftest.py create mode 100644 tests/user/api/integration/set_edit_session_test.py create mode 100644 vran/edit_session/api.py create mode 100644 vran/edit_session/models_django.py create mode 100644 vran/migrations/0010_editsession_vranuser_edit_session_and_more.py create mode 100644 vran/migrations/0011_default_edit_session.py diff --git a/tests/conftest.py b/tests/conftest.py index 77158ba8..531eb219 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,10 +7,12 @@ from django.db import IntegrityError from pytest_redis import factories +from tests.edit_session import common as cs from tests.entity import common as ce from tests.tag import common as ct from tests.user import common as cu from tests.user.api.integration.requests import post_login, post_register +from vran.edit_session.models_django import EditSession, EditSessionParticipant from vran.entity.models_django import Entity, EntityJustification from vran.management.display_txt.util import DISPLAY_TXT_ORDER_CONFIG_KEY from vran.management.models_django import ConfigValue @@ -202,6 +204,11 @@ def auth_server_commissioner(live_server, user_commissioner): @pytest.fixture def user(db): # pylint: disable=unused-argument + session = EditSession.objects.create( + id_persistent=cs.id_session_user, + id_owner_persistent=cu.test_uuid, + name=cs.name_session_user, + ) try: user = VranUser.objects.create_user( username=cu.test_username, @@ -210,6 +217,13 @@ def user(db): # pylint: disable=unused-argument first_name=cu.test_names_personal, id_persistent=cu.test_uuid, permission_group=VranUser.CONTRIBUTOR, + edit_session=session, + ) + EditSessionParticipant.add( + id_session_persistent=session.id_persistent, + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user.id_persistent, + name_participant=user.username, ) return user except IntegrityError: @@ -219,12 +233,24 @@ def user(db): # pylint: disable=unused-argument @pytest.fixture def user1(db): # pylint: disable=unused-argument try: + session = EditSession.objects.create( + id_persistent=cs.id_session_user1, + id_owner_persistent=cu.test_uuid1, + name=cs.name_session_user1, + ) user = VranUser.objects.create_user( username=cu.test_username1, password=cu.test_password1, email=cu.test_email1, first_name=cu.test_names_personal1, id_persistent=cu.test_uuid1, + edit_session=session, + ) + EditSessionParticipant.add( + id_session_persistent=session.id_persistent, + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user.id_persistent, + name_participant=user.username, ) return user except IntegrityError: @@ -234,6 +260,11 @@ def user1(db): # pylint: disable=unused-argument @pytest.fixture def user_commissioner(db): # pylint: disable=unused-argument try: + session = EditSession.objects.create( + id_persistent=cs.id_session_commissioner, + id_owner_persistent=cu.test_uuid_commissioner, + name=cs.name_session_commissioner, + ) user = VranUser.objects.create_user( username=cu.test_username_commissioner, password=cu.test_password_commissioner, @@ -241,6 +272,13 @@ def user_commissioner(db): # pylint: disable=unused-argument first_name=cu.test_names_personal_commissioner, id_persistent=cu.test_uuid_commissioner, permission_group=VranUser.COMMISSIONER, + edit_session=session, + ) + EditSessionParticipant.add( + id_session_persistent=session.id_persistent, + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user.id_persistent, + name_participant=user.username, ) return user except IntegrityError: @@ -252,6 +290,11 @@ def user_commissioner(db): # pylint: disable=unused-argument @pytest.fixture def user_editor(db): # pylint: disable=unused-argument try: + session = EditSession.objects.create( + id_persistent=cs.id_session_editor, + id_owner_persistent=cu.test_uuid_editor, + name=cs.name_session_editor, + ) user = VranUser.objects.create_user( username=cu.test_username_editor, password=cu.test_password_editor, @@ -259,6 +302,13 @@ def user_editor(db): # pylint: disable=unused-argument first_name=cu.test_names_personal_editor, id_persistent=cu.test_uuid_editor, permission_group=VranUser.EDITOR, + edit_session=session, + ) + EditSessionParticipant.add( + id_session_persistent=session.id_persistent, + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user.id_persistent, + name_participant=user.username, ) return user except IntegrityError: @@ -269,11 +319,17 @@ def user_editor(db): # pylint: disable=unused-argument @pytest.fixture def super_user(db): # pylint: disable=unused-argument + session = EditSession.objects.create( + id_persistent=cs.id_session_super, + id_owner_persistent=cu.test_uuid_super, + name=cs.name_session_super, + ) super_user = VranUser.objects.create_superuser( email=cu.test_email_super, username=cu.test_username_super, password=cu.test_password, id_persistent=cu.test_uuid_super, + edit_session=session, ) return super_user diff --git a/tests/edit_session/__init__.py b/tests/edit_session/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/edit_session/api/__init__.py b/tests/edit_session/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/edit_session/api/integration/__init__.py b/tests/edit_session/api/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/edit_session/api/integration/delete_participant_test.py b/tests/edit_session/api/integration/delete_participant_test.py new file mode 100644 index 00000000..c8e186ce --- /dev/null +++ b/tests/edit_session/api/integration/delete_participant_test.py @@ -0,0 +1,86 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements,duplicate-code + + +from unittest.mock import MagicMock, patch + +import tests.edit_session.common as c +import tests.user.common as cu +from tests.edit_session.api.integration import requests as req +from vran.edit_session.models_django import EditSessionParticipant +from vran.exception import NotAuthenticatedException + +new_name = "patched name for user session" + + +def test_unknown_user(auth_server, session_participant_commissioner): + "Test response when user can not be authenticated" + server, cookies = auth_server + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + with patch("vran.edit_session.api.check_user", mock): + rsp = req.delete_participant( + server.url, c.id_session_user, cu.test_uuid_commissioner, cookies=cookies + ) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server, session_participant_commissioner): + "Check error code for missing cookies" + server, _cookies = auth_server + rsp = req.delete_participant( + server.url, c.id_session_user, cu.test_uuid_commissioner, cookies=None + ) + assert rsp.status_code == 401 + + +def test_applicant(auth_server_applicant, session_participant_commissioner): + "Make sure applicant can not search for participants." + server, cookies = auth_server_applicant + rsp = req.delete_participant( + server.url, c.id_session_user, cu.test_uuid_commissioner, cookies=cookies + ) + assert rsp.status_code == 403 + + +def test_wrong_user(auth_server1, session_participant_commissioner): + server, _cookies, cookies = auth_server1 + rsp = req.delete_participant( + server.url, c.id_session_user, cu.test_uuid_commissioner, cookies=cookies + ) + assert rsp.status_code == 403 + + +def test_remove_as_owner(auth_server, session_participant_commissioner): + "Make sure it can retrieve sessions owned by a user" + server, cookies = auth_server + assert ( + len(EditSessionParticipant.objects.filter(edit_session_id=c.id_session_user)) + == 2 + ) + rsp = req.delete_participant( + server.url, c.id_session_user, cu.test_uuid_commissioner, cookies=cookies + ) + assert rsp.status_code == 200 + assert ( + len(EditSessionParticipant.objects.filter(edit_session_id=c.id_session_user)) + == 1 + ) + + +def test_remove_as_participant( + user, auth_server_commissioner, session_participant_commissioner +): + "Make sure it can retrieve sessions owned by a user" + assert ( + len(EditSessionParticipant.objects.filter(edit_session_id=c.id_session_user)) + == 2 + ) + server, cookies = auth_server_commissioner + rsp = req.delete_participant( + server.url, c.id_session_user, cu.test_uuid_commissioner, cookies=cookies + ) + assert rsp.status_code == 200 + assert ( + len(EditSessionParticipant.objects.filter(edit_session_id=c.id_session_user)) + == 1 + ) diff --git a/tests/edit_session/api/integration/get_sessions_owner_test.py b/tests/edit_session/api/integration/get_sessions_owner_test.py new file mode 100644 index 00000000..aa3fcf74 --- /dev/null +++ b/tests/edit_session/api/integration/get_sessions_owner_test.py @@ -0,0 +1,63 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements + + +from unittest.mock import MagicMock, patch + +import tests.edit_session.common as c +import tests.user.common as cu +from tests.edit_session.api.integration import requests as req +from vran.exception import NotAuthenticatedException + + +def test_unknown_user(auth_server): + "Test response when user can not be authenticated" + server, cookies = auth_server + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + with patch("vran.edit_session.api.check_user", mock): + rsp = req.get_sessions_owner(server.url, cookies=cookies) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server): + "Check error code for missing cookies" + server, _cookies = auth_server + rsp = req.get_sessions_owner(server.url, cookies=None) + assert rsp.status_code == 401 + + +def test_applicant(auth_server_applicant): + "Make sure applicant can not search for participants." + server, cookies = auth_server_applicant + rsp = req.get_sessions_owner(server.url, cookies=cookies) + assert rsp.status_code == 403 + + +def test_get_sessions_owner(auth_server, other_session): + "Make sure it can retrieve sessions owned by a user" + server, cookies = auth_server + rsp = req.get_sessions_owner(server.url, cookies=cookies) + assert rsp.status_code == 200 + participant_json = { + "id_participant": cu.test_uuid, + "type_participant": "INTERNAL", + "name_participant": cu.test_username, + } + owner_json = participant_json.copy() + owner_json.pop("name_participant") + json = rsp.json() + session_list = json["edit_session_list"] + assert session_list == [ + { + "id_persistent": c.id_session_user, + "owner": owner_json, + "name": c.name_session_user, + "participant_list": [participant_json], + }, + { + "id_persistent": c.id_session_user_changed, + "owner": owner_json, + "name": c.name_session_user_changed, + "participant_list": [participant_json], + }, + ] diff --git a/tests/edit_session/api/integration/get_sessions_participant_test.py b/tests/edit_session/api/integration/get_sessions_participant_test.py new file mode 100644 index 00000000..9f277c6c --- /dev/null +++ b/tests/edit_session/api/integration/get_sessions_participant_test.py @@ -0,0 +1,76 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements + + +from unittest.mock import MagicMock, patch + +import tests.edit_session.common as c +import tests.user.common as cu +from tests.edit_session.api.integration import requests as req +from vran.exception import NotAuthenticatedException + + +def test_unknown_user(auth_server): + "Test response when user can not be authenticated" + server, cookies = auth_server + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + with patch("vran.edit_session.api.check_user", mock): + rsp = req.get_sessions_participant(server.url, cookies=cookies) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server): + "Check error code for missing cookies" + server, _cookies = auth_server + rsp = req.get_sessions_participant(server.url, cookies=None) + assert rsp.status_code == 401 + + +def test_applicant(auth_server_applicant): + "Make sure applicant can not search for participants." + server, cookies = auth_server_applicant + rsp = req.get_sessions_participant(server.url, cookies=cookies) + assert rsp.status_code == 403 + + +def test_get_sessions_participant( + auth_server_commissioner, + other_session, + session_participant_commissioner, + other_session_participant_commissioner, +): + "Make sure it can retrieve sessions owned by a user" + server, cookies = auth_server_commissioner + rsp = req.get_sessions_participant(server.url, cookies=cookies) + assert rsp.status_code == 200 + participant_json = { + "id_participant": cu.test_uuid, + "type_participant": "INTERNAL", + "name_participant": cu.test_username, + } + owner_json = participant_json.copy() + owner_json.pop("name_participant") + participant_list = [ + participant_json, + { + "id_participant": cu.test_uuid_commissioner, + "type_participant": "INTERNAL", + "name_participant": cu.test_username_commissioner, + }, + ] + json = rsp.json() + session_list = json["edit_session_list"] + assert session_list == [ + { + "id_persistent": c.id_session_user, + "owner": owner_json, + "name": c.name_session_user, + "participant_list": participant_list, + }, + { + "id_persistent": c.id_session_user_changed, + "owner": owner_json, + "name": c.name_session_user_changed, + "participant_list": participant_list, + }, + ] diff --git a/tests/edit_session/api/integration/patch_edit_session_test.py b/tests/edit_session/api/integration/patch_edit_session_test.py new file mode 100644 index 00000000..1476038f --- /dev/null +++ b/tests/edit_session/api/integration/patch_edit_session_test.py @@ -0,0 +1,73 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements,duplicate-code + + +from unittest.mock import MagicMock, patch + +import tests.edit_session.common as c +import tests.user.common as cu +from tests.edit_session.api.integration import requests as req +from vran.exception import NotAuthenticatedException + +new_name = "patched name for user session" + + +def test_unknown_user(auth_server): + "Test response when user can not be authenticated" + server, cookies = auth_server + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + with patch("vran.edit_session.api.check_user", mock): + rsp = req.patch_edit_session( + server.url, c.id_session_user, new_name, cookies=cookies + ) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server): + "Check error code for missing cookies" + server, _cookies = auth_server + rsp = req.patch_edit_session(server.url, c.id_session_user, new_name, cookies=None) + assert rsp.status_code == 401 + + +def test_applicant(auth_server_applicant): + "Make sure applicant can not search for participants." + server, cookies = auth_server_applicant + rsp = req.patch_edit_session( + server.url, c.id_session_user, new_name, cookies=cookies + ) + assert rsp.status_code == 403 + + +def test_wrong_user(auth_server1): + server, _cookies, cookies = auth_server1 + rsp = req.patch_edit_session( + server.url, c.id_session_user, new_name, cookies=cookies + ) + assert rsp.status_code == 403 + + +def test_change_name(auth_server): + "Make sure it can retrieve sessions owned by a user" + server, cookies = auth_server + rsp = req.patch_edit_session( + server.url, c.id_session_user, new_name, cookies=cookies + ) + assert rsp.status_code == 200 + participant_json = { + "id_participant": cu.test_uuid, + "type_participant": "INTERNAL", + "name_participant": cu.test_username, + } + owner_json = participant_json.copy() + owner_json.pop("name_participant") + participant_list = [ + participant_json, + ] + json = rsp.json() + assert json == { + "id_persistent": c.id_session_user, + "owner": owner_json, + "name": new_name, + "participant_list": participant_list, + } diff --git a/tests/edit_session/api/integration/put_edit_session_test.py b/tests/edit_session/api/integration/put_edit_session_test.py new file mode 100644 index 00000000..7a3862a8 --- /dev/null +++ b/tests/edit_session/api/integration/put_edit_session_test.py @@ -0,0 +1,85 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements +from unittest.mock import MagicMock, patch + +import tests.edit_session.common as c +import tests.user.common as cu +from tests.edit_session.api.integration import requests as req +from vran.exception import NotAuthenticatedException +from vran.util import VranUser + + +def test_unknown_user(auth_server): + "Test response when user can not be authenticated" + server, cookies = auth_server + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + with patch("vran.edit_session.api.check_user", mock): + rsp = req.put_edit_session(server.url, cookies=cookies) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server): + "Check error code for missing cookies" + server, _cookies = auth_server + rsp = req.put_edit_session(server.url, cookies=None) + assert rsp.status_code == 401 + + +def test_applicant(auth_server_applicant): + "Make sure applicants can not create sessions" + server, cookies = auth_server_applicant + rsp = req.put_edit_session(server.url, cookies=cookies) + assert rsp.status_code == 403 + + +def test_create_edit_session_without_name(auth_server): + mock = MagicMock() + mock.return_value = c.id_session_user_changed + server, cookies = auth_server + with patch("vran.edit_session.api.uuid4", mock): + rsp = req.put_edit_session(server.url, cookies=cookies) + user = VranUser.objects.filter(id_persistent=cu.test_uuid).get() + assert user.edit_session_id == c.id_session_user_changed + assert rsp.status_code == 200 + assert rsp.json() == { + "name": "Edit Session 1", + "id_persistent": c.id_session_user_changed, + "owner": { + "id_participant": user.id_persistent, + "type_participant": "INTERNAL", + }, + "participant_list": [ + { + "id_participant": user.id_persistent, + "type_participant": "INTERNAL", + "name_participant": user.username, + } + ], + } + + +def test_create_edit_session_with_name(auth_server): + mock = MagicMock() + mock.return_value = c.id_session_user_changed + server, cookies = auth_server + name = "session name" + with patch("vran.edit_session.api.uuid4", mock): + rsp = req.put_edit_session(server.url, name=name, cookies=cookies) + user = VranUser.objects.filter(id_persistent=cu.test_uuid).get() + assert user.edit_session_id == c.id_session_user_changed + assert rsp.status_code == 200 + assert rsp.json() == { + "name": name, + "id_persistent": c.id_session_user_changed, + "owner": { + "id_participant": user.id_persistent, + "type_participant": "INTERNAL", + }, + "participant_list": [ + { + "id_participant": user.id_persistent, + "type_participant": "INTERNAL", + "name_participant": user.username, + } + ], + } diff --git a/tests/edit_session/api/integration/put_participant_test.py b/tests/edit_session/api/integration/put_participant_test.py new file mode 100644 index 00000000..8a19671e --- /dev/null +++ b/tests/edit_session/api/integration/put_participant_test.py @@ -0,0 +1,113 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements +from unittest.mock import MagicMock, patch + +import tests.edit_session.common as c +import tests.user.common as cu +from tests.edit_session.api.integration import requests as req +from vran.edit_session.models_django import EditSessionParticipant +from vran.exception import NotAuthenticatedException + + +def test_unknown_user(auth_server): + "Test response when user can not be authenticated" + server, cookies = auth_server + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + with patch("vran.edit_session.api.check_user", mock): + rsp = req.put_participant( + server.url, + c.id_session_user, + "INTERNAL", + cu.test_uuid1, + cu.test_username1, + cookies=cookies, + ) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server): + "Test response when cookies are missing" + server, _cookies = auth_server + rsp = req.put_participant( + server.url, + c.id_session_user, + "INTERNAL", + cu.test_uuid1, + cu.test_username1, + cookies=None, + ) + assert rsp.status_code == 401 + + +def test_wrong_user(auth_server1, user): + "Make sure you can not edit others edit sessions." + server, _cookies, cookies = auth_server1 + rsp = req.put_participant( + server.url, + c.id_session_user, + "INTERNAL", + cu.test_uuid1, + cu.test_username1, + cookies=cookies, + ) + assert rsp.status_code == 403 + + +def test_applicant(auth_server_applicant): + "Make sure applicants can not add edit session participants" + server, cookies = auth_server_applicant + rsp = req.put_participant( + server.url, + c.id_session_user, + "INTERNAL", + cu.test_uuid1, + cu.test_username1, + cookies=cookies, + ) + assert rsp.status_code == 403 + + +def test_can_add(auth_server): + "Make sure you can add to your own session." + server, cookies = auth_server + rsp = req.put_participant( + server.url, + c.id_session_user, + "INTERNAL", + cu.test_uuid1, + cu.test_username1, + cookies=cookies, + ) + assert rsp.status_code == 200 + assert rsp.json() == { + "id_participant": cu.test_uuid1, + "name_participant": cu.test_username1, + "type_participant": "INTERNAL", + } + + +def test_idempotent(auth_server): + "Make sure participant is not added twice." + server, cookies = auth_server + rsp = req.put_participant( + server.url, + c.id_session_user, + "INTERNAL", + cu.test_uuid, + cu.test_username, + cookies=cookies, + ) + assert rsp.status_code == 200 + rsp = req.put_participant( + server.url, + c.id_session_user, + "INTERNAL", + cu.test_uuid, + cu.test_username, + cookies=cookies, + ) + assert rsp.status_code == 200 + assert ( + len(EditSessionParticipant.objects.filter(edit_session_id=c.id_session_user)) + == 1 + ) diff --git a/tests/edit_session/api/integration/requests.py b/tests/edit_session/api/integration/requests.py new file mode 100644 index 00000000..abaa13ac --- /dev/null +++ b/tests/edit_session/api/integration/requests.py @@ -0,0 +1,71 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements + +import requests + + +def put_edit_session(url, name=None, cookies=None): + body = {} + if name is not None: + body["name"] = name + return requests.put( + url + "/vran/api/edit_sessions", cookies=cookies, timeout=900, json=body + ) + + +def put_participant( + url, + id_edit_session_persistent, + type_participant, + id_participant, + name_participant, + cookies=None, +): + return requests.put( + url + f"/vran/api/edit_sessions/{id_edit_session_persistent}/participants", + json={ + "type_participant": type_participant, + "id_participant": id_participant, + "name_participant": name_participant, + }, + cookies=cookies, + timeout=900, + ) + + +def post_search_participant(url, search_term, cookies=None): + return requests.post( + url + "/vran/api/edit_sessions/search", + json={"search_term": search_term}, + cookies=cookies, + timeout=900, + ) + + +def get_sessions_owner(url, cookies=None): + return requests.get( + url + "/vran/api/edit_sessions/owner", cookies=cookies, timeout=900 + ) + + +def get_sessions_participant(url, cookies=None): + return requests.get( + url + "/vran/api/edit_sessions/participant", cookies=cookies, timeout=900 + ) + + +def patch_edit_session(url, id_edit_session_persistent, name, cookies=None): + return requests.patch( + url + f"/vran/api/edit_sessions/{id_edit_session_persistent}", + json={"name": name}, + cookies=cookies, + timeout=900, + ) + + +def delete_participant(url, id_edit_session, id_participant, cookies=None): + return requests.delete( + url + f"/vran/api/edit_sessions/{id_edit_session}/participants", + json={"id_participant": id_participant, "type_participant": "INTERNAL"}, + cookies=cookies, + timeout=900, + ) diff --git a/tests/edit_session/api/integration/search_participants_test.py b/tests/edit_session/api/integration/search_participants_test.py new file mode 100644 index 00000000..1d17f923 --- /dev/null +++ b/tests/edit_session/api/integration/search_participants_test.py @@ -0,0 +1,57 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements + + +from unittest.mock import MagicMock, patch + +import tests.user.common as cu +from tests.edit_session.api.integration import requests as req +from vran.exception import NotAuthenticatedException + + +def test_unknown_user(auth_server): + "Test response when user can not be authenticated" + server, cookies = auth_server + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + with patch("vran.edit_session.api.check_user", mock): + rsp = req.post_search_participant(server.url, "", cookies=cookies) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server): + "Check error code for missing cookies" + server, _cookies = auth_server + rsp = req.post_search_participant(server.url, "", cookies=None) + assert rsp.status_code == 401 + + +def test_applicant(auth_server_applicant): + "Make sure applicant can not search for participants." + server, cookies = auth_server_applicant + rsp = req.post_search_participant(server.url, "", cookies=cookies) + assert rsp.status_code == 403 + + +def test_search_participants(auth_server, user1, user_editor): + server, cookies = auth_server + rsp = req.post_search_participant(server.url, "", cookies=cookies) + assert rsp.status_code == 200 + json = rsp.json() + search_result_list = json["search_result_list"] + assert len(search_result_list) == 3 + search_result_map = {user["id_participant"]: user for user in search_result_list} + assert search_result_map[cu.test_uuid] == { + "id_participant": cu.test_uuid, + "type_participant": "INTERNAL", + "name_participant": cu.test_username, + } + assert search_result_map[cu.test_uuid1] == { + "id_participant": cu.test_uuid1, + "type_participant": "INTERNAL", + "name_participant": cu.test_username1, + } + assert search_result_map[cu.test_uuid_editor] == { + "id_participant": cu.test_uuid_editor, + "type_participant": "INTERNAL", + "name_participant": cu.test_username_editor, + } diff --git a/tests/edit_session/common.py b/tests/edit_session/common.py new file mode 100644 index 00000000..5230ffe9 --- /dev/null +++ b/tests/edit_session/common.py @@ -0,0 +1,15 @@ +# pylint: disable=missing-module-docstring, missing-function-docstring,redefined-outer-name,invalid-name,unused-argument +id_session_user = "40815019-eed0-455b-9b19-cd19f5c119e7" +id_session_user_changed = "28415c17-0bba-4648-b67e-dd3904d54e40" +id_session_user1 = "93b1cac1-e958-4b4f-8d2e-e7323d4d0633" +id_session_editor = "a88b5c90-36c6-4a46-ae89-f6e29531f00f" +id_session_commissioner = "5086f3b3-4306-417d-b51f-180365a60adc" +id_session_super = "0a16ad58-853d-49fc-b2db-7c42ae99ddca" + + +name_session_user = "Test Edit Session User" +name_session_user_changed = "Changed Test Edit Session User" +name_session_user1 = "Test Edit Session User 1" +name_session_editor = "Test Edit Session Editor" +name_session_commissioner = "Test Edit Session Commissioner" +name_session_super = "Test Edit Session Super User" diff --git a/tests/edit_session/conftest.py b/tests/edit_session/conftest.py new file mode 100644 index 00000000..bf83ffb8 --- /dev/null +++ b/tests/edit_session/conftest.py @@ -0,0 +1,35 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,no-member,redefined-outer-name,unused-argument + +import pytest + +import tests.edit_session.common as c +from vran.edit_session.models_django import EditSession, EditSessionParticipant + + +@pytest.fixture() +def other_session(user): + return EditSession.create( + id_persistent=c.id_session_user_changed, + name=c.name_session_user_changed, + user=user, + ) + + +@pytest.fixture() +def session_participant_commissioner(user, user_commissioner): + return EditSessionParticipant.add( + id_session_persistent=c.id_session_user, + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user_commissioner.id_persistent, + name_participant=user_commissioner.username, + ) + + +@pytest.fixture() +def other_session_participant_commissioner(user, user_commissioner): + return EditSessionParticipant.add( + id_session_persistent=c.id_session_user_changed, + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user_commissioner.id_persistent, + name_participant=user_commissioner.username, + ) diff --git a/tests/entity/queue_test.py b/tests/entity/queue_test.py index bd762cc0..4a5d073a 100644 --- a/tests/entity/queue_test.py +++ b/tests/entity/queue_test.py @@ -92,6 +92,7 @@ def test_without_display_txt_but_relevant_tag_instance( "permission_group": "APPLICANT", "id_persistent": user1.id_persistent, }, + "description": None, "curated": False, "hidden": False, "disabled": False, @@ -182,6 +183,7 @@ def test_contribution(contribution_instance_without_display_txt, display_txt_ord "id_persistent": cu.test_uuid1, "permission_group": "APPLICANT", }, + "description": None, "curated": False, "hidden": False, "disabled": False, @@ -208,6 +210,7 @@ def test_db_to_dict(tag_def): "curated": False, "hidden": False, "disabled": True, + "description": None, }, version_key="id", ) diff --git a/tests/user/api/integration/post_login_test.py b/tests/user/api/integration/post_login_test.py index 8a3b0363..1e20a4ac 100644 --- a/tests/user/api/integration/post_login_test.py +++ b/tests/user/api/integration/post_login_test.py @@ -1,4 +1,5 @@ # pylint: disable=missing-module-docstring, missing-function-docstring,redefined-outer-name,invalid-name,duplicate-code +import tests.edit_session.common as cs import tests.user.common as c from tests.user.api.integration.requests import post_login @@ -26,4 +27,16 @@ def test_valid_credentials(auth_server): "tag_definition_list": [], "id_persistent": c.test_uuid, "permission_group": "CONTRIBUTOR", + "edit_session": { + "id_persistent": cs.id_session_user, + "name": cs.name_session_user, + "owner": {"type_participant": "INTERNAL", "id_participant": c.test_uuid}, + "participant_list": [ + { + "id_participant": c.test_uuid, + "type_participant": "INTERNAL", + "name_participant": c.test_username, + } + ], + }, } diff --git a/tests/user/api/integration/post_refresh_test.py b/tests/user/api/integration/post_refresh_test.py index 6e79e1c5..462a9809 100644 --- a/tests/user/api/integration/post_refresh_test.py +++ b/tests/user/api/integration/post_refresh_test.py @@ -1,4 +1,5 @@ # pylint: disable=missing-module-docstring, missing-function-docstring,redefined-outer-name,invalid-name,duplicate-code +import tests.edit_session.common as cs import tests.user.common as c from tests.user.api.integration.requests import get_refresh @@ -21,4 +22,16 @@ def test_logged_in(auth_server): "tag_definition_list": [], "id_persistent": c.test_uuid, "permission_group": "CONTRIBUTOR", + "edit_session": { + "id_persistent": cs.id_session_user, + "name": cs.name_session_user, + "owner": {"type_participant": "INTERNAL", "id_participant": c.test_uuid}, + "participant_list": [ + { + "id_participant": c.test_uuid, + "type_participant": "INTERNAL", + "name_participant": c.test_username, + } + ], + }, } diff --git a/tests/user/api/integration/post_register_test.py b/tests/user/api/integration/post_register_test.py index 1d48ace8..9b65e2dc 100644 --- a/tests/user/api/integration/post_register_test.py +++ b/tests/user/api/integration/post_register_test.py @@ -61,6 +61,12 @@ def test_same_names(auth_server): "tag_definition_list": [], "id_persistent": str(uuid), "permission_group": "APPLICANT", + "edit_session": { + "id_persistent": str(uuid), + "name": "Default Edit Session", + "owner": {"type_participant": "INTERNAL", "id_participant": str(uuid)}, + "participant_list": [], + }, } diff --git a/tests/user/api/integration/requests.py b/tests/user/api/integration/requests.py index 55b02eaa..03b70145 100644 --- a/tests/user/api/integration/requests.py +++ b/tests/user/api/integration/requests.py @@ -64,3 +64,12 @@ def put_permission_group(url, id_user_persistent, permission_group, cookies=None cookies=cookies, timeout=900, ) + + +def post_edit_session(url, id_edit_session_persistent, cookies=None): + return requests.post( + url + "/vran/api/user/edit_session", + json={"id_edit_session_persistent": id_edit_session_persistent}, + cookies=cookies, + timeout=900, + ) diff --git a/tests/user/api/integration/set_edit_session_test.py b/tests/user/api/integration/set_edit_session_test.py new file mode 100644 index 00000000..d09c1993 --- /dev/null +++ b/tests/user/api/integration/set_edit_session_test.py @@ -0,0 +1,65 @@ +# pylint: disable=missing-module-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements +from unittest.mock import MagicMock, patch + +import tests.edit_session.common as ce +import tests.user.api.integration.requests as req +import tests.user.common as cu +from vran.exception import NotAuthenticatedException +from vran.util import VranUser + + +def test_unknown_user(auth_server): + "Test response, when user can not be authenticated" + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + server, cookies = auth_server + with patch("vran.user.api.check_user", mock): + rsp = req.post_edit_session( + server.url, ce.id_session_user_changed, cookies=cookies + ) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server): + "Test response when cookies are missing" + server, _cookies = auth_server + rsp = req.post_edit_session(server.url, ce.id_session_user_changed) + assert rsp.status_code == 401 + + +def test_not_owner(auth_server_commissioner, other_session): + "Test response when setting session where the user is not the owner" + server, cookies = auth_server_commissioner + rsp = req.post_edit_session(server.url, ce.id_session_user_changed, cookies=cookies) + assert rsp.status_code == 403 + + +def test_no_session(auth_server): + "Test response when session does not exist" + server, cookies = auth_server + rsp = req.post_edit_session(server.url, ce.id_session_user_changed, cookies=cookies) + assert rsp.status_code == 404 + + +def test_can_change_session(auth_server, other_session): + "Test that session is changed correctly" + server, cookies = auth_server + rsp = req.post_edit_session(server.url, ce.id_session_user_changed, cookies=cookies) + user = VranUser.objects.filter(id_persistent=cu.test_uuid).get() + assert user.edit_session_id == other_session.id_persistent + assert rsp.status_code == 200 + assert rsp.json() == { + "name": ce.name_session_user_changed, + "id_persistent": ce.id_session_user_changed, + "owner": { + "id_participant": user.id_persistent, + "type_participant": "INTERNAL", + }, + "participant_list": [ + { + "id_participant": user.id_persistent, + "type_participant": "INTERNAL", + "name_participant": user.username, + } + ], + } diff --git a/tests/user/conftest.py b/tests/user/conftest.py index fe1386d9..d78e5706 100644 --- a/tests/user/conftest.py +++ b/tests/user/conftest.py @@ -1,7 +1,9 @@ # pylint: disable=missing-module-docstring,missing-function-docstring,no-member,redefined-outer-name import pytest +import tests.edit_session.common as cs import tests.user.common as c +from vran.edit_session.models_django import EditSession from vran.tag.models_django import TagDefinition, TagDefinitionHistory @@ -72,3 +74,12 @@ def user_with_tag_defs( ] user.save() return user + + +@pytest.fixture() +def other_session(user): + return EditSession.create( + id_persistent=cs.id_session_user_changed, + name=cs.name_session_user_changed, + user=user, + ) diff --git a/vran/edit_session/api.py b/vran/edit_session/api.py new file mode 100644 index 00000000..5e8f8f93 --- /dev/null +++ b/vran/edit_session/api.py @@ -0,0 +1,348 @@ +"API models for edit sessions" +from typing import List +from uuid import uuid4 + +from django.http import HttpRequest +from ninja import Router, Schema + +from vran.edit_session.models_django import EditSession as EditSessionDb +from vran.edit_session.models_django import ( + EditSessionParticipant as EditSessionParticipantDb, +) +from vran.exception import ApiError, NotAuthenticatedException +from vran.util import VranUser +from vran.util.auth import check_user + +router = Router() + + +class EditSessionParticipant(Schema): + # pylint: disable=too-few-public-methods + "API model for edit session participants" + type_participant: str + id_participant: str + + +class EditSessionParticipantWithName(EditSessionParticipant): + # pylint: disable=too-few-public-methods + "Edit session participant with name." + name_participant: str + + +class EditSession(Schema): + # pylint: disable=too-few-public-methods + "API model for edit sessions." + id_persistent: str + owner: EditSessionParticipant + participant_list: List[EditSessionParticipantWithName] + name: str + + +class EditSessionList(Schema): + # pylint: disable=too-few-public-methods + "API model for multiple edit sessions." + edit_session_list: List[EditSession] + + +class EditSessionPatch(Schema): + "API model for changing edit sessions." + # pylint: disable=too-few-public-methods + name: str | None = None + + +class EditSessionPut(Schema): + "API model for creating edit sessions" + # pylint: disable=too-few-public-methods + name: str | None = None + + +class ParticipantSearchPost(Schema): + "API model for searching participants" + search_term: str + + +class ParticipantSearchResponse(Schema): + "API model for participant search results" + search_result_list: List[EditSessionParticipantWithName] + + +@router.post( + "search", + response={ + 200: ParticipantSearchResponse, + 401: ApiError, + 403: ApiError, + 500: ApiError, + }, +) +def search_participants(request: HttpRequest, request_data: ParticipantSearchPost): + "API method for searching for edit session participants" + try: + user = check_user(request) + except NotAuthenticatedException: + return 401, ApiError(msg="Not authenticated") + if user.permission_group == VranUser.APPLICANT: + return 403, ApiError(msg="Insufficient permissions.") + try: + users = VranUser.search_username(request_data.search_term) + return 200, ParticipantSearchResponse( + search_result_list=[ + EditSessionParticipantWithName( + type_participant="INTERNAL", + id_participant=user_db.id_persistent, + name_participant=user_db.username, + ) + for user_db in users + ] + ) + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not get possible contributors.") + + +@router.get( + "owner", + response={200: EditSessionList, 401: ApiError, 403: ApiError, 500: ApiError}, +) +def get_edit_sessions_owner(request: HttpRequest): + "API method for retrieving all edit sessions a user owns." + try: + user = check_user(request) + except NotAuthenticatedException: + return 401, ApiError(msg="Not authenticated") + try: + if user.permission_group in {VranUser.APPLICANT, VranUser.READER}: + return 403, ApiError(msg="Insufficient permissions") + sessions_db = EditSessionDb.owned_by_user(user) + return 200, EditSessionList( + edit_session_list=[ + edit_session_db_to_api(session) for session in sessions_db + ] + ) + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not get edit sessions.") + + +@router.get( + "participant", + response={200: EditSessionList, 401: ApiError, 403: ApiError, 500: ApiError}, +) +def get_edit_sessions_participant(request: HttpRequest): + "API method for retrieving all edit sessions a user participates in." + try: + user = check_user(request) + except NotAuthenticatedException: + return 401, ApiError(msg="Not authenticated") + try: + if user.permission_group in {VranUser.APPLICANT, VranUser.READER}: + return 403, ApiError(msg="Insufficient permissions") + sessions_db = EditSessionDb.user_participates(user) + return 200, EditSessionList( + edit_session_list=[ + edit_session_db_to_api(session) for session in sessions_db + ] + ) + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not get edit sessions.") + + +@router.put( + "", + response={ + 200: EditSession, + 400: ApiError, + 401: ApiError, + 403: ApiError, + 500: ApiError, + }, +) +def put_edit_session(request: HttpRequest, body: EditSessionPut): + "API method for creating a new edit session" + try: + user = check_user(request) + except NotAuthenticatedException: + return 401, ApiError(msg="Not authenticated") + if user.permission_group in {VranUser.APPLICANT, VranUser.READER}: + return 403, ApiError(msg="Insufficient permissions") + name = body.name + if name is None: + name = ( + "Edit Session " + f"{len(EditSessionDb.objects.filter(id_owner_persistent=user.id_persistent))}" + ) + try: + id_session = str(uuid4()) + session_db = EditSessionDb.create( + id_persistent=id_session, + name=name, + user=user, + ) + user.set_current_edit_session(session_db) + return 200, edit_session_db_to_api(session_db) + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not create edit session") + + +def check_session(request, id_edit_session_persistent, allow_participant=False): + "Check whether the user of a request owns a session." + try: + user = check_user(request) + except NotAuthenticatedException: + return None, (401, ApiError(msg="Not authenticated")) + if user.permission_group == VranUser.APPLICANT: + return None, (403, ApiError(msg="Insufficient permissions")) + try: + session = EditSessionDb.objects.filter( + id_persistent=id_edit_session_persistent + ).get() + if session.id_owner_persistent != user.id_persistent: + if ( + allow_participant + and len( + EditSessionParticipantDb.objects.filter( + edit_session_id=id_edit_session_persistent, + id_participant=user.id_persistent, + type_participant=EditSessionParticipantDb.INTERNAL, + ) + ) + > 0 + ): + return session, None + return None, ( + 403, + ApiError(msg="You are not the owner of this edit session."), + ) + return session, None + except EditSessionDb.DoesNotExist: + return None, (404, ApiError(msg="Edit session not found")) + + +@router.patch( + "{id_edit_session_persistent}", + response={ + 200: EditSession, + 401: ApiError, + 403: ApiError, + 404: ApiError, + 500: ApiError, + }, +) +def patch_edit_session( + request: HttpRequest, id_edit_session_persistent: str, patch_info: EditSessionPatch +): + "API method for changing an edit session." + try: + session, err = check_session(request, id_edit_session_persistent) + if err is not None: + return err + if patch_info.name is not None: + session.name = patch_info.name + session.save() + return 200, edit_session_db_to_api(session) + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not change edit session") + + +@router.put( + "{id_edit_session_persistent}/participants", + response={ + 200: EditSessionParticipantWithName, + 400: ApiError, + 401: ApiError, + 403: ApiError, + 500: ApiError, + }, +) +def put_participant( + request: HttpRequest, + id_edit_session_persistent: str, + put_body: EditSessionParticipantWithName, +): + "API method for adding a participant to an edit session." + try: + session, err = check_session(request, id_edit_session_persistent) + if err is not None: + return err + type_participant = PARTICIPANT_TYPE_API_TO_DB_DICT[put_body.type_participant] + participant = EditSessionParticipantDb.add( + type_participant=type_participant, + id_participant=put_body.id_participant, + name_participant=put_body.name_participant, + id_session_persistent=session.id_persistent, + ) + return 200, edit_session_participant_db_to_api(participant) + except KeyError: + return 400, ApiError(msg="Participant type not known.") + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not add contributor") + + +@router.delete( + "{id_edit_session_persistent}/participants", + response={200: None, 400: ApiError, 401: ApiError, 403: ApiError, 500: ApiError}, +) +def delete_participant( + request: HttpRequest, + id_edit_session_persistent: str, + delete_body: EditSessionParticipant, +): + "API method for adding a participant to an edit session." + try: + session, err = check_session( + request, id_edit_session_persistent, allow_participant=True + ) + if err is not None: + return err + type_participant = PARTICIPANT_TYPE_API_TO_DB_DICT[delete_body.type_participant] + if ( + type_participant == EditSessionParticipantDb.INTERNAL + and session.id_owner_persistent == delete_body.id_participant + ): + return 400, ApiError( + msg="You can not remove yourself from a session you own." + ) + EditSessionParticipantDb.objects.filter( + type_participant=type_participant, + id_participant=delete_body.id_participant, + edit_session=session, + ).delete() + return 200, None + except KeyError: + return 400, ApiError(msg="Participant type not known.") + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not add contributor") + + +PARTICIPANT_TYPE_DB_TO_API_DICT = { + EditSessionParticipantDb.INTERNAL: "INTERNAL", + EditSessionParticipantDb.ORCID: "ORCID", +} + +PARTICIPANT_TYPE_API_TO_DB_DICT = { + "INTERNAL": EditSessionParticipantDb.INTERNAL, + "ORCID": EditSessionParticipantDb.ORCID, +} + + +def edit_session_participant_db_to_api(participant: EditSessionParticipantDb): + "Transform and edit session participant from DB to API model" + return EditSessionParticipantWithName( + id_participant=participant.id_participant, + type_participant=PARTICIPANT_TYPE_DB_TO_API_DICT[participant.type_participant], + name_participant=participant.name_participant, + ) + + +def edit_session_db_to_api(edit_session: EditSessionDb): + "Convert an edit session from db to API format." + return EditSession( + name=edit_session.name, + id_persistent=edit_session.id_persistent, + owner=EditSessionParticipant( + id_participant=edit_session.id_owner_persistent, + type_participant="INTERNAL", + ), + participant_list=[ + edit_session_participant_db_to_api(participant) + for participant in edit_session.editsessionparticipant_set.all() + ], + ) diff --git a/vran/edit_session/models_django.py b/vran/edit_session/models_django.py new file mode 100644 index 00000000..b0b124a2 --- /dev/null +++ b/vran/edit_session/models_django.py @@ -0,0 +1,100 @@ +"Django ORM models for handling edit sessions" +from django.db import models +from django.db.utils import IntegrityError + + +class EditSession(models.Model): + "Django ORM model for edit sessions" + + id_persistent = models.CharField(max_length=36, primary_key=True) + id_owner_persistent = models.CharField(max_length=36) + name = models.TextField() + + @classmethod + def owned_by_user(cls, user): + "Get all edit sessions owned by a user" + return cls.objects.filter(id_owner_persistent=user.id_persistent).annotate( + type_participant=models.Value( + EditSessionParticipant.INTERNAL, + output_field=models.CharField(max_length=3), + ) + ) + + @classmethod + def user_participates(cls, user): + "Get all edit sessions where a user participates." + return cls.objects.annotate( + type_participant=models.Subquery( + EditSessionParticipant.objects.filter( + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user.id_persistent, + edit_session_id=models.OuterRef("id_persistent"), + ).values("type_participant") + ) + ).filter( + ~models.Q(id_owner_persistent=user.id_persistent), + type_participant__isnull=False, + ) + + @classmethod + def create(cls, id_persistent, name, user): + "Create a new edit session and add the owner as user" + session = cls.objects.create( + id_persistent=id_persistent, + id_owner_persistent=user.id_persistent, + name=name, + ) + participant = EditSessionParticipant.objects.create( + edit_session=session, + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user.id_persistent, + name_participant=user.username, + ) + session.editsessionparticipantset = {participant} + return session + + +class EditSessionParticipant(models.Model): + "Django ORM model for edit session participants" + edit_session = models.ForeignKey(EditSession, on_delete=models.CASCADE) + INTERNAL = "INT" + ORCID = "ORC" + type_participant = models.CharField( + max_length=3, choices=[(INTERNAL, "internal"), (ORCID, "ORCID")] + ) + id_participant = models.CharField(max_length=36) + name_participant = models.TextField() + + class Meta: + "Meta model for edit session participants." + constraints = [ + models.UniqueConstraint( + fields=["edit_session", "id_participant"], + name="Unique Edit Session Membership", + ) + ] + + @classmethod + def add( + cls, + id_session_persistent, + type_participant, + id_participant, + name_participant, + ): + "Add a participant to an edit session" + try: + return cls.objects.create( + edit_session_id=id_session_persistent, + type_participant=type_participant, + id_participant=id_participant, + name_participant=name_participant, + ) + except IntegrityError as exc: + # Return membership already exists + try: + return cls.objects.filter( + edit_session_id=id_session_persistent, id_participant=id_participant + ).get() + except Exception: # pylint: disable=broad-except + raise exc from exc diff --git a/vran/entity/queue.py b/vran/entity/queue.py index 81d64460..757486f8 100644 --- a/vran/entity/queue.py +++ b/vran/entity/queue.py @@ -78,6 +78,7 @@ def tag_def_db_to_dict(tag_definition): "type": tag_definition.type, "owner": user_db_to_public_user_info_dict(tag_definition.owner), "curated": tag_definition.curated, + "description": tag_definition.description, "hidden": tag_definition.hidden, "disabled": tag_definition.disabled, } diff --git a/vran/migrations/0010_editsession_vranuser_edit_session_and_more.py b/vran/migrations/0010_editsession_vranuser_edit_session_and_more.py new file mode 100644 index 00000000..021e09cc --- /dev/null +++ b/vran/migrations/0010_editsession_vranuser_edit_session_and_more.py @@ -0,0 +1,70 @@ +# Generated by Django 5.0.4 on 2024-07-30 08:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("vran", "0009_contributioncandidate_justification"), + ] + + operations = [ + migrations.CreateModel( + name="EditSession", + fields=[ + ( + "id_persistent", + models.CharField(max_length=36, primary_key=True, serialize=False), + ), + ("id_owner_persistent", models.CharField(max_length=36)), + ("name", models.TextField()), + ], + ), + migrations.AddField( + model_name="vranuser", + name="edit_session", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.RESTRICT, + to="vran.editsession", + ), + ), + migrations.CreateModel( + name="EditSessionParticipant", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "type_participant", + models.CharField( + choices=[("INT", "internal"), ("ORC", "ORCID")], max_length=3 + ), + ), + ("id_participant", models.CharField(max_length=36)), + ("name_participant", models.TextField()), + ( + "edit_session", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="vran.editsession", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="editsessionparticipant", + constraint=models.UniqueConstraint( + fields=("edit_session", "id_participant"), + name="Unique Edit Session Membership", + ), + ), + ] diff --git a/vran/migrations/0011_default_edit_session.py b/vran/migrations/0011_default_edit_session.py new file mode 100644 index 00000000..aeb0d7c3 --- /dev/null +++ b/vran/migrations/0011_default_edit_session.py @@ -0,0 +1,44 @@ +# Generated by Django 5.0.4 on 2024-07-04 11:30 + +from uuid import uuid4 + +from django.db import migrations + + +def create_and_set_initial_edit_session(apps, schema_editor): + "Create default edit sessions" + user_model = apps.get_model("vran", "vranuser") + session_model = apps.get_model("vran", "editsession") + participant_model = apps.get_model("vran", "editsessionparticipant") + db_alias = schema_editor.connection.alias + for user in user_model.objects.using(db_alias).filter(is_superuser=False): + session = session_model.objects.using(db_alias).create( + id_persistent=str(uuid4()), + id_owner_persistent=user.id_persistent, + name="Default Edit Session", + ) + user.edit_session = session + session.save() + user.save() + participant = participant_model.objects.using(db_alias).create( + edit_session_id=session.id_persistent, + type_participant="INT", + id_participant=user.id_persistent, + name_participant=user.username, + ) + participant.save() + + +def reverse(app, schema_editor): # pylint: disable=unused-argument + "empty reverse migration" + + +class Migration(migrations.Migration): + + dependencies = [ + ("vran", "0010_editsession_vranuser_edit_session_and_more"), + ] + + operations = [ + migrations.RunPython(create_and_set_initial_edit_session, reverse_code=reverse) + ] diff --git a/vran/models.py b/vran/models.py index a26b43c5..33c31cb3 100644 --- a/vran/models.py +++ b/vran/models.py @@ -8,6 +8,7 @@ TagDefinitionContribution, TagInstanceContribution, ) +from vran.edit_session.models_django import EditSession, EditSessionParticipant from vran.entity.models_django import Entity, EntityJustification from vran.management.models_django import ConfigValue from vran.merge_request.entity.models_django import ( diff --git a/vran/urls.py b/vran/urls.py index 0737ade4..ef6a1303 100644 --- a/vran/urls.py +++ b/vran/urls.py @@ -12,6 +12,7 @@ from vran.comments.api import router as comment_router from vran.contribution.api import router as contribution_router +from vran.edit_session.api import router as edit_session_router from vran.management import router as management_router from vran.merge_request.router import router from vran.person.api import router as person_router @@ -43,6 +44,7 @@ class JsonRendererWithDateTime(JSONRenderer): ninja_api.add_router("merge_requests", router, auth=vran_auth) ninja_api.add_router("manage", management_router, auth=vran_auth) ninja_api.add_router("comments", comment_router, auth=vran_auth) +ninja_api.add_router("edit_sessions", edit_session_router, auth=vran_auth) class LoginRequest(Schema): diff --git a/vran/user/api.py b/vran/user/api.py index 20de8e00..2dc4e95a 100644 --- a/vran/user/api.py +++ b/vran/user/api.py @@ -7,11 +7,13 @@ from django.conf import settings from django.contrib.auth import authenticate, login, logout from django.contrib.auth.models import AnonymousUser, Group -from django.db import DatabaseError, IntegrityError +from django.db import DatabaseError, IntegrityError, transaction from django.http import HttpRequest from ninja import Router, Schema from ninja.constants import NOT_SET +from vran.edit_session.api import EditSession, edit_session_db_to_api +from vran.edit_session.models_django import EditSession as EditSessionDb from vran.exception import ApiError, NotAuthenticatedException from vran.tag.api.models_conversion import tag_definition_db_to_api from vran.tag.models_django import TagDefinition as TagDefinitionDb @@ -36,6 +38,12 @@ class PutGroupRequest(Schema): permission_group: str +class SetEditSessionRequest(Schema): + # pylint: disable=too-few-public-methods + "Request body for setting the current edit session." + id_edit_session_persistent: str + + router = Router() @@ -81,18 +89,26 @@ def register_post( if not (settings.DEBUG or settings.IS_UNITTEST): if len(VranUser.objects.exclude(is_superuser=True)) == 0: permission_group = VranUser.COMMISSIONER - user = VranUser.objects.create_user( - username=registration_info.username, - email=registration_info.email, - password=registration_info.password, - first_name=registration_info.names_personal, - id_persistent=uuid4(), - permission_group=permission_group, - ) - if user.last_name and user.last_name != "": - user.last_name = registration_info.names_family - user.groups.set([Group.objects.get(name=str(VranGroup.APPLICANT))]) - user.save() + id_user = uuid4() + with transaction.atomic(): + session = EditSessionDb.objects.create( + id_persistent=str(uuid4()), + id_owner_persistent=str(id_user), + name="Default Edit Session", + ) + user = VranUser.objects.create_user( + username=registration_info.username, + email=registration_info.email, + password=registration_info.password, + first_name=registration_info.names_personal, + id_persistent=id_user, + permission_group=permission_group, + edit_session=session, + ) + if user.last_name and user.last_name != "": + user.last_name = registration_info.names_family + user.groups.set([Group.objects.get(name=str(VranGroup.APPLICANT))]) + user.save() return 200, user_db_to_login_response(user) except IntegrityError as exc: error_msg = exc.args[0] @@ -105,6 +121,39 @@ def register_post( return 500, ApiError(msg="Could not create user.") +@router.post( + "/edit_session", + response={ + 200: EditSession, + 400: ApiError, + 401: ApiError, + 403: ApiError, + 404: ApiError, + 500: ApiError, + }, +) +def set_edit_session(request: HttpRequest, body: SetEditSessionRequest): + "API method for setting the current edit session of a user." + try: + user = check_user(request) + except NotAuthenticatedException: + return 401, ApiError(msg="Not authenticated") + if user.permission_group == VranUser.APPLICANT: + return 403, ApiError(msg="Insufficient permissions") + try: + session = EditSessionDb.objects.filter( + id_persistent=body.id_edit_session_persistent + ).get() + if session.id_owner_persistent != user.id_persistent: + return 403, ApiError(msg="You do not own this session.") + user.set_current_edit_session(session) + return 200, edit_session_db_to_api(session) + except EditSessionDb.DoesNotExist: + return 404, ApiError(msg="Session does not exist.") + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not set edit session") + + @router.post( "/tag_definitions/append/{id_tag_definition_persistent}", response={200: None, 400: ApiError, 401: ApiError, 500: ApiError}, @@ -321,4 +370,5 @@ def user_db_to_login_response(user: VranUser): email=user.email, tag_definition_list=tag_definitions, permission_group=permission_group_db_to_api[user.permission_group], + edit_session=edit_session_db_to_api(user.edit_session), ) diff --git a/vran/user/models_api/login.py b/vran/user/models_api/login.py index 71f4efc8..d3fdc63b 100644 --- a/vran/user/models_api/login.py +++ b/vran/user/models_api/login.py @@ -4,6 +4,7 @@ from ninja import Schema from pydantic import Field +from vran.edit_session.api import EditSession from vran.tag.api.models_api import TagDefinitionResponse from vran.user.models_api.public import PublicUserInfo @@ -28,6 +29,7 @@ class LoginResponse(Schema): email: str tag_definition_list: List[TagDefinitionResponse] permission_group: str + edit_session: EditSession class LoginResponseList(Schema): diff --git a/vran/util/__init__.py b/vran/util/__init__.py index 37d69704..8147bc04 100644 --- a/vran/util/__init__.py +++ b/vran/util/__init__.py @@ -33,6 +33,9 @@ class VranUser(AbstractUser): permission_group = models.TextField( choices=PERMISSION_GROUP_CHOICES, default=APPLICANT, max_length=4 ) + edit_session = models.ForeignKey( + "editsession", null=True, on_delete=models.RESTRICT + ) @classmethod def search_username(cls, search_term: str): @@ -89,6 +92,11 @@ def has_elevated_rights(self): "Method for checking if a user has elevated rights." return self.permission_group in {VranUser.EDITOR, VranUser.COMMISSIONER} + def set_current_edit_session(self, edit_session): + "Set the current edit session for a user." + self.edit_session = edit_session + self.save() + def timestamp(): "Create a timezone aware timestamp"