diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e4924294bb2daafc499aade9225bf197b1c006c6..466b89d0ce227c432730110288373a8c2c50ab4e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,6 +34,7 @@ "i18next-browser-languagedetector": "^6.1.2", "i18next-http-backend": "^1.3.1", "libphonenumber-js": "^1.9.35", + "lodash": "^4.17.21", "react": "^17.0.2", "react-datepicker": "^4.2.1", "react-dom": "^17.0.2", @@ -47,6 +48,7 @@ "web-vitals": "^1.1.2" }, "devDependencies": { + "@types/lodash": "^4.14.175", "@typescript-eslint/eslint-plugin": "^4.31.1", "@typescript-eslint/parser": "^4.31.1", "eslint": "^7.32.0", @@ -57,6 +59,7 @@ "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-react": "^7.25.1", "eslint-plugin-react-hooks": "^4.2.0", + "jest-fetch-mock": "^3.0.3", "jest-junit": "^12.2.0" } }, @@ -4788,6 +4791,12 @@ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=" }, + "node_modules/@types/lodash": { + "version": "4.14.175", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.175.tgz", + "integrity": "sha512-XmdEOrKQ8a1Y/yxQFOMbC47G/V2VDO1GvMRnl4O75M4GW/abC5tnfzadQYkqEveqRM1dEJGFFegfPNA2vvx2iw==", + "dev": true + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -13388,6 +13397,16 @@ "node": ">=8" } }, + "node_modules/jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "dependencies": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "node_modules/jest-get-type": { "version": "26.3.0", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", @@ -18929,6 +18948,12 @@ "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=" }, + "node_modules/promise-polyfill": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.2.1.tgz", + "integrity": "sha512-3p9zj0cEHbp7NVUxEYUWjQlffXqnXaZIMPkAO7HhFh8u5636xLRDHOUo2vpWSK0T2mqm6fKLXYn1KP6PAZ2gKg==", + "dev": true + }, "node_modules/prompts": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.0.tgz", @@ -28398,6 +28423,12 @@ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=" }, + "@types/lodash": { + "version": "4.14.175", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.175.tgz", + "integrity": "sha512-XmdEOrKQ8a1Y/yxQFOMbC47G/V2VDO1GvMRnl4O75M4GW/abC5tnfzadQYkqEveqRM1dEJGFFegfPNA2vvx2iw==", + "dev": true + }, "@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -35008,6 +35039,16 @@ } } }, + "jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "requires": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "jest-get-type": { "version": "26.3.0", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", @@ -39280,6 +39321,12 @@ "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=" }, + "promise-polyfill": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.2.1.tgz", + "integrity": "sha512-3p9zj0cEHbp7NVUxEYUWjQlffXqnXaZIMPkAO7HhFh8u5636xLRDHOUo2vpWSK0T2mqm6fKLXYn1KP6PAZ2gKg==", + "dev": true + }, "prompts": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1aef0771b8c64246a3e85a30633ee48971f7e995..64f83285b922eebc84efddf35d64cb7452590edb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "i18next-browser-languagedetector": "^6.1.2", "i18next-http-backend": "^1.3.1", "libphonenumber-js": "^1.9.35", + "lodash": "^4.17.21", "react": "^17.0.2", "react-datepicker": "^4.2.1", "react-dom": "^17.0.2", @@ -74,6 +75,7 @@ ] }, "devDependencies": { + "@types/lodash": "^4.14.175", "@typescript-eslint/eslint-plugin": "^4.31.1", "@typescript-eslint/parser": "^4.31.1", "eslint": "^7.32.0", diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 72dc93f99c41110ed0bff661a0a2f503dc954921..06e4febf51b28aca1862859d25620c884fe789fd 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -34,6 +34,12 @@ "endNow": "End role", "overviewGuest": "Guest overview" }, + "register": { + "registerHeading": "Register new guest", + "registerText": "Please search for e-mail or phone number before registering a new guest to prevent duplicates.", + "registerButtonText": "Register new guest" + }, + "loading": "Loading...", "termsHeader": "Terms", "staging": "Staging", diff --git a/frontend/public/locales/nb/common.json b/frontend/public/locales/nb/common.json index 4a8c036881fb149cf71cd602d25c1fa14cb81c58..0a715f9e268d974c3d151c401c6233c4550d87aa 100644 --- a/frontend/public/locales/nb/common.json +++ b/frontend/public/locales/nb/common.json @@ -34,6 +34,11 @@ "endNow": "Avslutt rolle", "overviewGuest": "Oversikt over gjest" }, + "register": { + "registerHeading": "Registrer ny gjest", + "registerText": "Søk etter e-post eller mobilnummer før du registrerer ny gjest for å unngå dobbeltoppføringer.", + "registerButtonText": "Registrer ny gjest" + }, "loading": "Laster...", "termsHeader": "Vilkår", "staging": "Staging", diff --git a/frontend/public/locales/nn/common.json b/frontend/public/locales/nn/common.json index f58c9dd1c25ee44e92497fdf7ab74cbb4ce6e923..7cfe9c12f4ece559cdff4a4fc9e35824ef0bb2aa 100644 --- a/frontend/public/locales/nn/common.json +++ b/frontend/public/locales/nn/common.json @@ -35,6 +35,11 @@ "endNow": "Avslutt rolle", "overviewGuest": "Oversikt over gjest" }, + "register": { + "registerHeading": "Registrer ny gjest", + "registerText": "Søk etter e-post eller mobilnummer før du registrerer ny gjest for å unngå dobbeltoppføringer.", + "registerButtonText": "Registrer ny gjest" + }, "loading": "Lastar...", "termsHeader": "Vilkår", "staging": "Staging", diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 8bf895596e6ecffc99e3336e4ffc7f4fd932e876..9dad342987b651d1e214c55b6aa0159d8dccf4fd 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -1,34 +1,43 @@ export type Guest = { - id: string pid: string - name: string + first: string + last: string email: string mobile: string fnr: string - role_nb: string - role_en: string - max_days: number - start_date: Date - end_date: Date active: boolean - ou_nb: string - ou_en: string + roles: Role[] } export interface FetchedGuest { - id: string pid: string first: string last: string email: string mobile: string fnr: string - role_nb: string - role_en: string + active: boolean + roles: FetchedRole[] +} + +export type Role = { + id: string + name_nb: string + name_en: string + ou_nb: string + ou_en: string + start_date: Date + end_date: Date max_days: number - start_date: string - end_date: string +} + +export type FetchedRole = { + id: string + name_nb: string + name_en: string ou_nb: string ou_en: string - active: boolean + start_date: string + end_date: string + max_days: number } diff --git a/frontend/src/routes/sponsor/frontpage/index.tsx b/frontend/src/routes/sponsor/frontpage/index.tsx index c7d8ef85218e6105071f55f28c82eb2109a6b379..3d2a10a52df14bc04392158ee467121478680c82 100644 --- a/frontend/src/routes/sponsor/frontpage/index.tsx +++ b/frontend/src/routes/sponsor/frontpage/index.tsx @@ -17,7 +17,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import Page from 'components/page' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' -import { Guest } from 'interfaces' +import { Guest, Role } from 'interfaces' import format from 'date-fns/format' import SponsorGuestButtons from '../../components/sponsorGuestButtons' @@ -26,28 +26,29 @@ interface GuestProps { } interface PersonLineProps { person: Guest + role: Role } -const PersonLine = ({ person }: PersonLineProps) => { +const PersonLine = ({ person, role }: PersonLineProps) => { const [t, i18n] = useTranslation(['common']) return ( <TableRow - key={person.name} + key={`${person.first} ${person.last}`} sx={{ '&:last-child td, &:last-child th': { border: 0 } }} > <TableCell component="th" scope="row"> - {person.name} + {`${person.first} ${person.last}`} </TableCell> <TableCell align="left"> - {i18n.language === 'en' ? person.role_en : person.role_nb} + {i18n.language === 'en' ? role.name_en : role.name_nb} </TableCell> <TableCell align="left"> - {person.start_date ? format(person.start_date, 'yyyy-MM-dd') : null} -{' '} - {format(person.end_date, 'yyyy-MM-dd')} + {role.start_date ? format(role.start_date, 'yyyy-MM-dd') : null} -{' '} + {format(role.end_date, 'yyyy-MM-dd')} </TableCell> <TableCell align="left"> - {i18n.language === 'en' ? person.ou_en : person.ou_nb} + {i18n.language === 'en' ? role.ou_en : role.ou_nb} </TableCell> <TableCell align="left"> <Button @@ -95,9 +96,19 @@ const ActiveGuests = ({ persons }: GuestProps) => { </TableRow> </TableHead> <TableBody> - {guests.map((person) => ( - <PersonLine person={person} /> - ))} + {guests.length > 0 ? ( + guests.map((person) => + person.roles ? ( + person.roles.map((role) => ( + <PersonLine role={role} person={person} /> + )) + ) : ( + <></> + ) + ) + ) : ( + <></> + )} <TableRow> <TableCell> @@ -148,9 +159,19 @@ const WaitingGuests = ({ persons }: GuestProps) => { </TableRow> </TableHead> <TableBody> - {guests.map((person) => ( - <PersonLine person={person} /> - ))} + {guests.length > 0 ? ( + guests.map((person) => + person.roles ? ( + person.roles.map((role) => ( + <PersonLine role={role} person={person} /> + )) + ) : ( + <></> + ) + ) + ) : ( + <></> + )} <TableRow> <TableCell> {guests.length > 0 ? '' : t('common:noWaitingGuests')} diff --git a/frontend/src/routes/sponsor/guestInfo/index.tsx b/frontend/src/routes/sponsor/guestInfo/index.tsx index d65301202da844d5a8946c785d96342383fa1b60..0e46c0799d6437f6554eec76fe5732d1bae6513c 100644 --- a/frontend/src/routes/sponsor/guestInfo/index.tsx +++ b/frontend/src/routes/sponsor/guestInfo/index.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import { Link, useParams } from 'react-router-dom' import Page from 'components/page' @@ -13,39 +13,41 @@ import { TableRow, Paper, } from '@mui/material' -import { Guest } from 'interfaces' +import { Guest, Role, FetchedRole } from 'interfaces' import SponsorInfoButtons from 'routes/components/sponsorInfoButtons' -import format from 'date-fns/format' +import { format } from 'date-fns' +import { parseRole } from 'utils' type GuestInfoParams = { pid: string } interface RoleLineProps { - guest: Guest + role: Role + pid: string } -const RoleLine = ({ guest }: RoleLineProps) => { +const RoleLine = ({ role, pid }: RoleLineProps) => { const [t, i18n] = useTranslation('common') return ( <TableRow - key={guest.id} + key={role.id} sx={{ '&:last-child td, &:last-child th': { border: 0 } }} > <TableCell align="left"> - {i18n.language === 'en' ? guest.role_en : guest.role_nb} + {i18n.language === 'en' ? role.name_en : role.name_nb} </TableCell> <TableCell component="th" scope="row"> - {guest.start_date ? format(guest.start_date, 'yyyy-MM-dd') : null} -{' '} - {format(guest.end_date, 'yyyy-MM-dd')} + {role.start_date ? format(role.start_date, 'yyyy-MM-dd') : null} -{' '} + {format(role.end_date, 'yyyy-MM-dd')} </TableCell> <TableCell align="left"> - {i18n.language === 'en' ? guest.ou_en : guest.ou_nb} + {i18n.language === 'en' ? role.ou_en : role.ou_nb} </TableCell> <TableCell> <Button variant="contained" component={Link} - to={`/sponsor/guest/${guest.pid}/roles/${guest.id}`} + to={`/sponsor/guest/${pid}/roles/${role.id}`} > {t('sponsor.choose')} </Button> @@ -54,16 +56,46 @@ const RoleLine = ({ guest }: RoleLineProps) => { ) } -interface GuestInfoProps { - guests: Guest[] -} - -export default function GuestInfo({ guests }: GuestInfoProps) { +export default function GuestInfo() { const { pid } = useParams<GuestInfoParams>() const [t] = useTranslation(['common']) + const [guestInfo, setGuest] = useState<Guest>({ + pid: '', + first: '', + last: '', + email: '', + fnr: '', + mobile: '', + active: false, + roles: [], + }) + const [roles, setRoles] = useState<Role[]>([]) + + const getPerson = async (id: string) => { + try { + const response = await fetch(`/api/ui/v1/person/${id}`) + const rjson = await response.json() + if (response.ok) { + setGuest({ + pid: rjson.pid, + first: rjson.first, + last: rjson.last, + email: rjson.email, + mobile: rjson.mobile, + fnr: rjson.fnr, + active: rjson.active, + roles: rjson.roles, + }) + setRoles(rjson.roles.map((role: FetchedRole) => parseRole(role))) + } + } catch (error) { + console.error(error) + } + } - const roles = guests.filter((guest) => guest.pid.toString() === pid) - const guestInfo = roles[0] + useEffect(() => { + getPerson(pid) + }, []) return ( <Page> @@ -80,7 +112,9 @@ export default function GuestInfo({ guests }: GuestInfoProps) { <TableBody> <TableRow> <TableCell align="left">{t('input.fullName')}</TableCell> - <TableCell align="left">{guestInfo.name}</TableCell> + <TableCell align="left"> + {`${guestInfo.first} ${guestInfo.last}`} + </TableCell> </TableRow> <TableRow> <TableCell align="left">{t('input.email')}</TableCell> @@ -109,8 +143,8 @@ export default function GuestInfo({ guests }: GuestInfoProps) { </TableRow> </TableHead> <TableBody> - {roles.map((guest) => ( - <RoleLine guest={guest} /> + {roles.map((role) => ( + <RoleLine pid={pid} role={role} /> ))} </TableBody> </Table> diff --git a/frontend/src/routes/sponsor/guestRoleInfo/index.tsx b/frontend/src/routes/sponsor/guestRoleInfo/index.tsx index c135f7f7dfb70bf0a57c1aaa816e7e744d03de13..fed60946e6e9cb66f1af5518f2e2f2d1582609d4 100644 --- a/frontend/src/routes/sponsor/guestRoleInfo/index.tsx +++ b/frontend/src/routes/sponsor/guestRoleInfo/index.tsx @@ -61,15 +61,18 @@ export default function GuestRoleInfo({ guests }: GuestRoleInfoProps) { const [t, i18n] = useTranslation('common') // Find the role info relevant for this page - const role = guests.filter((guest) => guest.id.toString() === id)[0] + const guestInfo = guests.filter((guest) => guest.pid.toString() === pid)[0] + const roleInfo = guestInfo.roles.filter( + (role) => role.id.toString() === id + )[0] // Prepare min and max date values const today = new Date() - const todayPlusMaxDays = addDays(role.max_days)(today) + const todayPlusMaxDays = addDays(roleInfo.max_days)(today) // Make a function for use with onClick of the end role button const endPeriod = () => () => { - role.end_date = today + roleInfo.end_date = today endPeriodPost(id, { end_date: today }) } @@ -123,7 +126,7 @@ export default function GuestRoleInfo({ guests }: GuestRoleInfoProps) { <TableRow> <TableCell align="left">{t('common:role')}</TableCell> <TableCell> - {i18n.language === 'en' ? role.role_en : role.role_nb} + {i18n.language === 'en' ? roleInfo.name_en : roleInfo.name_nb} </TableCell> </TableRow> <TableRow> @@ -132,11 +135,13 @@ export default function GuestRoleInfo({ guests }: GuestRoleInfoProps) { <Controller name="start_date" control={control} - defaultValue={role.start_date} + defaultValue={roleInfo.start_date} render={({ field: { onChange, value } }) => ( <DatePicker mask="____-__-__" - disabled={role.start_date.getDate() <= today.getDate()} + disabled={ + roleInfo.start_date.getDate() <= today.getDate() + } label={t('input.roleStartDate')} value={value} minDate={today} @@ -152,12 +157,12 @@ export default function GuestRoleInfo({ guests }: GuestRoleInfoProps) { <Controller name="end_date" control={control} - defaultValue={role.end_date} + defaultValue={roleInfo.end_date} render={({ field: { onChange, value } }) => ( <DatePicker mask="____-__-__" label={t('input.roleEndDate')} - disabled={role.end_date.getDate() < today.getDate()} + disabled={roleInfo.end_date.getDate() < today.getDate()} minDate={today} maxDate={todayPlusMaxDays} value={value} @@ -175,7 +180,7 @@ export default function GuestRoleInfo({ guests }: GuestRoleInfoProps) { <TableRow> <TableCell align="left">{t('common:ou')}</TableCell> <TableCell align="left"> - {i18n.language === 'en' ? role.ou_en : role.ou_nb} + {i18n.language === 'en' ? roleInfo.ou_en : roleInfo.ou_nb} </TableCell> <TableCell align="left" /> </TableRow> diff --git a/frontend/src/routes/sponsor/index.tsx b/frontend/src/routes/sponsor/index.tsx index 7e44ab00f1e1b9fd1bf4c2bb19e3a2261d107989..e097b1b0881e35efcb447c5c4da83dd9e66ddde6 100644 --- a/frontend/src/routes/sponsor/index.tsx +++ b/frontend/src/routes/sponsor/index.tsx @@ -5,6 +5,7 @@ import FrontPage from 'routes/sponsor/frontpage' import GuestInfo from 'routes/sponsor/guestInfo' import GuestRoleInfo from 'routes/sponsor/guestRoleInfo' import { FetchedGuest, Guest } from 'interfaces' +import { parseRole } from 'utils' function Sponsor() { const [guests, setGuests] = useState<Guest[]>([]) @@ -14,27 +15,21 @@ function Sponsor() { const response = await fetch('/api/ui/v1/guests/?format=json') const jsonResponse = await response.json() if (response.ok) { - const roles = await jsonResponse.roles + const persons = await jsonResponse.persons setGuests( - roles.map((person: FetchedGuest) => ({ - id: person.id, - pid: person.pid, - name: `${person.first} ${person.last}`, - email: person.email, - mobile: person.mobile, - fnr: person.fnr, - role_nb: person.role_nb, - role_en: person.role_en, - max_days: person.max_days, - start_date: new Date(Date.parse(person.start_date)), - end_date: new Date(Date.parse(person.end_date)), - active: person.active, - ou_nb: person.ou_nb, - ou_en: person.ou_en, - })) + persons.map( + (person: FetchedGuest): Guest => ({ + pid: person.pid, + first: person.first, + last: person.last, + email: person.email, + mobile: person.mobile, + fnr: person.fnr, + active: person.active, + roles: person.roles.map((role) => parseRole(role)), + }) + ) ) - } else { - setGuests([]) } } catch (error) { setGuests([]) @@ -51,7 +46,7 @@ function Sponsor() { <GuestRoleInfo guests={guests} /> </Route> <Route exact path="/sponsor/guest/:pid"> - <GuestInfo guests={guests} /> + <GuestInfo /> </Route> <Route exact path="/sponsor"> <FrontPage guests={guests} /> diff --git a/frontend/src/routes/sponsor/register/frontPage.tsx b/frontend/src/routes/sponsor/register/frontPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..297e6abbba6db139f8d9bb05dd59d08f1840a37f --- /dev/null +++ b/frontend/src/routes/sponsor/register/frontPage.tsx @@ -0,0 +1,71 @@ +import { Button, InputAdornment, MenuItem, TextField } from '@mui/material' +import Page from 'components/page' +import { Link } from 'react-router-dom' +import SponsorGuestButtons from 'routes/components/sponsorGuestButtons' +import SearchIcon from '@mui/icons-material/Search' +import { useTranslation } from 'react-i18next' +import { debounce } from 'lodash' +import React, { useState } from 'react' + +type Guest = { + first: string + last: string + pid: string + value: string +} + +function FrontPage() { + const [t] = useTranslation('common') + const [guests, setGuests] = useState<Guest[]>([]) + + const getGuests = async (event: React.ChangeEvent<HTMLInputElement>) => { + if (event.target.value) { + console.log('searching') + const response = await fetch( + `/api/ui/v1/person/search/${event.target.value}` + ) + const repjson = await response.json() + console.log(repjson) + if (response.ok) { + setGuests(repjson.persons) + } + } + } + return ( + <Page> + <SponsorGuestButtons registerNewGuestActive /> + <h4>{t('register.registerHeading')}</h4> + <p>{t('register.registerText')}</p> + <TextField + InputProps={{ + endAdornment: ( + <InputAdornment position="end"> + <SearchIcon /> + </InputAdornment> + ), + }} + fullWidth + placeholder="Mobile phone, e-mail" + onChange={debounce(getGuests, 600)} + /> + {guests ? ( + guests.map((guest) => { + const guestTo = `/sponsor/guest/${guest.pid}` + return ( + <MenuItem component={Link} to={guestTo}> + {guest.first} {guest.last} + <br /> + {guest.value} + </MenuItem> + ) + }) + ) : ( + <></> + )} + <Button variant="contained" component={Link} to="register/new"> + {t('register.registerButtonText')} + </Button> + </Page> + ) +} +export default FrontPage diff --git a/frontend/src/routes/sponsor/register/index.test.tsx b/frontend/src/routes/sponsor/register/index.test.tsx index 9dbbc897e7a17086950531c91034710fcfe54a7e..4d699b4f5553db156fb04195321f346c8ec6231a 100644 --- a/frontend/src/routes/sponsor/register/index.test.tsx +++ b/frontend/src/routes/sponsor/register/index.test.tsx @@ -3,12 +3,12 @@ import { render, waitFor, screen } from 'test-utils' import userEvent from '@testing-library/user-event' import AdapterDateFns from '@mui/lab/AdapterDateFns' import { LocalizationProvider } from '@mui/lab' -import Register from './index' +import StepRegistration from './stepRegistration' test('Validation message showing if last name is missing', async () => { render( <LocalizationProvider dateAdapter={AdapterDateFns}> - <Register /> + <StepRegistration /> </LocalizationProvider> ) diff --git a/frontend/src/routes/sponsor/register/index.tsx b/frontend/src/routes/sponsor/register/index.tsx index a93b29c9d16d52c50aa965f9de4edbcd4a8769a3..c53b35efb1d991310e97b429c6b490ebf6d50e32 100644 --- a/frontend/src/routes/sponsor/register/index.tsx +++ b/frontend/src/routes/sponsor/register/index.tsx @@ -1,191 +1,17 @@ -import React, { useState, useRef } from 'react' -import { useTranslation } from 'react-i18next' - -import { Box, Button } from '@mui/material' -import Page from 'components/page' - -import { useHistory } from 'react-router-dom' -import format from 'date-fns/format' -import { RegisterFormData } from './formData' -import StepSummary from './stepSummary' -import StepPersonForm from './stepPersonForm' -import { PersonFormMethods } from './personFormMethods' -import SubmitState from './submitState' -import SponsorGuestButtons from '../../components/sponsorGuestButtons' -import { submitJsonOpts } from '../../../utils' -import StepSubmitSuccess from './stepSubmitSuccess' - -enum Steps { - RegisterStep = 0, - SummaryStep = 1, - SuccessStep = 2, -} - -/** - * - * This component controls the invite process where the sponsor - * enters the initial information about a guest. - * - */ -export default function StepRegistration() { - const { t } = useTranslation(['common']) - const [formData, setFormData] = useState<RegisterFormData>({ - first_name: undefined, - last_name: undefined, - role_type: undefined, - role_start: undefined, - role_end: undefined, - comment: undefined, - ou_id: undefined, - email: undefined, - }) - const history = useHistory() - - const [activeStep, setActiveStep] = useState(0) - const personFormRef = useRef<PersonFormMethods>(null) - const [submitState, setSubmitState] = useState(SubmitState.NotSubmitted) - - const handleNext = () => { - if (activeStep === 0) { - if (personFormRef.current) { - personFormRef.current.doSubmit() - } - } else { - setActiveStep((prevActiveStep) => prevActiveStep + 1) - } - } - - const registerGuest = () => { - const payload = { - first_name: formData.first_name, - last_name: formData.last_name, - email: formData.email, - role: { - type: formData.role_type, - start_date: - formData.role_start === null - ? null - : format(formData.role_start as Date, 'yyyy-MM-dd'), - end_date: - formData.role_end === null - ? null - : format(formData.role_end as Date, 'yyyy-MM-dd'), - comments: formData.comment, - orgunit_id: formData.ou_id, - }, - } - - console.log('submitting', JSON.stringify(payload)) - fetch('/api/ui/v1/invite/', submitJsonOpts('POST', payload)) - .then((res) => { - if (!res.ok) { - setSubmitState(SubmitState.SubmitFailure) - return null - } - return res.text() - }) - .then((result) => { - if (result !== null) { - console.log('result', result) - setSubmitState(SubmitState.SubmitSuccess) - setActiveStep(Steps.SuccessStep) - } - }) - .catch((error) => { - console.log('error', error) - setSubmitState(SubmitState.SubmitFailure) - }) - } - - const handleBack = () => { - setActiveStep((prevActiveStep) => prevActiveStep - 1) - } - - const handleForwardFromRegister = ( - updateFormData: RegisterFormData - ): void => { - setFormData(updateFormData) - setActiveStep((prevActiveStep) => prevActiveStep + 1) - } - - const handleCancel = () => { - history.push('/') - } +import { Route } from 'react-router-dom' +import FrontPage from './frontPage' +import StepRegistration from './stepRegistration' +function Register() { return ( - <Page> - <SponsorGuestButtons registerNewGuestActive /> - {/* Current page in wizard */} - <Box sx={{ width: '100%' }}> - {activeStep === Steps.RegisterStep && ( - <StepPersonForm - nextHandler={handleForwardFromRegister} - formData={formData} - ref={personFormRef} - /> - )} - {activeStep === Steps.SummaryStep && ( - <StepSummary formData={formData} /> - )} - </Box> - - <Box - sx={{ - display: 'flex', - flexDirection: 'row', - pt: 2, - color: 'primary.main', - paddingBottom: '1rem', - }} - > - {activeStep === Steps.RegisterStep && ( - <Button - data-testid="button-next" - sx={{ color: 'theme.palette.secondary', mr: 1 }} - onClick={handleNext} - > - {t('button.next')} - </Button> - )} - - {activeStep === Steps.SummaryStep && ( - <> - <Button - onClick={handleBack} - disabled={submitState === SubmitState.SubmitSuccess} - sx={{ mr: 1 }} - > - {t('button.back')} - </Button> - - <Button - onClick={registerGuest} - disabled={submitState === SubmitState.SubmitSuccess} - sx={{ mr: 1 }} - > - {t('button.save')} - </Button> - </> - )} - - {activeStep !== Steps.SuccessStep && ( - <Button - onClick={handleCancel} - disabled={submitState === SubmitState.SubmitSuccess} - > - {t('button.cancel')} - </Button> - )} - </Box> - - {activeStep === Steps.SuccessStep && <StepSubmitSuccess />} - - {/* TODO For now just showing a heading to give the user some feedback */} - {submitState === SubmitState.SubmitFailure && ( - <Box> - <h2>Submit failure</h2> - </Box> - )} - </Page> + <> + <Route path="/register/new"> + <StepRegistration /> + </Route> + <Route exact path="/register"> + <FrontPage /> + </Route> + </> ) } +export default Register diff --git a/frontend/src/routes/sponsor/register/stepRegistration.tsx b/frontend/src/routes/sponsor/register/stepRegistration.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a93b29c9d16d52c50aa965f9de4edbcd4a8769a3 --- /dev/null +++ b/frontend/src/routes/sponsor/register/stepRegistration.tsx @@ -0,0 +1,191 @@ +import React, { useState, useRef } from 'react' +import { useTranslation } from 'react-i18next' + +import { Box, Button } from '@mui/material' +import Page from 'components/page' + +import { useHistory } from 'react-router-dom' +import format from 'date-fns/format' +import { RegisterFormData } from './formData' +import StepSummary from './stepSummary' +import StepPersonForm from './stepPersonForm' +import { PersonFormMethods } from './personFormMethods' +import SubmitState from './submitState' +import SponsorGuestButtons from '../../components/sponsorGuestButtons' +import { submitJsonOpts } from '../../../utils' +import StepSubmitSuccess from './stepSubmitSuccess' + +enum Steps { + RegisterStep = 0, + SummaryStep = 1, + SuccessStep = 2, +} + +/** + * + * This component controls the invite process where the sponsor + * enters the initial information about a guest. + * + */ +export default function StepRegistration() { + const { t } = useTranslation(['common']) + const [formData, setFormData] = useState<RegisterFormData>({ + first_name: undefined, + last_name: undefined, + role_type: undefined, + role_start: undefined, + role_end: undefined, + comment: undefined, + ou_id: undefined, + email: undefined, + }) + const history = useHistory() + + const [activeStep, setActiveStep] = useState(0) + const personFormRef = useRef<PersonFormMethods>(null) + const [submitState, setSubmitState] = useState(SubmitState.NotSubmitted) + + const handleNext = () => { + if (activeStep === 0) { + if (personFormRef.current) { + personFormRef.current.doSubmit() + } + } else { + setActiveStep((prevActiveStep) => prevActiveStep + 1) + } + } + + const registerGuest = () => { + const payload = { + first_name: formData.first_name, + last_name: formData.last_name, + email: formData.email, + role: { + type: formData.role_type, + start_date: + formData.role_start === null + ? null + : format(formData.role_start as Date, 'yyyy-MM-dd'), + end_date: + formData.role_end === null + ? null + : format(formData.role_end as Date, 'yyyy-MM-dd'), + comments: formData.comment, + orgunit_id: formData.ou_id, + }, + } + + console.log('submitting', JSON.stringify(payload)) + fetch('/api/ui/v1/invite/', submitJsonOpts('POST', payload)) + .then((res) => { + if (!res.ok) { + setSubmitState(SubmitState.SubmitFailure) + return null + } + return res.text() + }) + .then((result) => { + if (result !== null) { + console.log('result', result) + setSubmitState(SubmitState.SubmitSuccess) + setActiveStep(Steps.SuccessStep) + } + }) + .catch((error) => { + console.log('error', error) + setSubmitState(SubmitState.SubmitFailure) + }) + } + + const handleBack = () => { + setActiveStep((prevActiveStep) => prevActiveStep - 1) + } + + const handleForwardFromRegister = ( + updateFormData: RegisterFormData + ): void => { + setFormData(updateFormData) + setActiveStep((prevActiveStep) => prevActiveStep + 1) + } + + const handleCancel = () => { + history.push('/') + } + + return ( + <Page> + <SponsorGuestButtons registerNewGuestActive /> + {/* Current page in wizard */} + <Box sx={{ width: '100%' }}> + {activeStep === Steps.RegisterStep && ( + <StepPersonForm + nextHandler={handleForwardFromRegister} + formData={formData} + ref={personFormRef} + /> + )} + {activeStep === Steps.SummaryStep && ( + <StepSummary formData={formData} /> + )} + </Box> + + <Box + sx={{ + display: 'flex', + flexDirection: 'row', + pt: 2, + color: 'primary.main', + paddingBottom: '1rem', + }} + > + {activeStep === Steps.RegisterStep && ( + <Button + data-testid="button-next" + sx={{ color: 'theme.palette.secondary', mr: 1 }} + onClick={handleNext} + > + {t('button.next')} + </Button> + )} + + {activeStep === Steps.SummaryStep && ( + <> + <Button + onClick={handleBack} + disabled={submitState === SubmitState.SubmitSuccess} + sx={{ mr: 1 }} + > + {t('button.back')} + </Button> + + <Button + onClick={registerGuest} + disabled={submitState === SubmitState.SubmitSuccess} + sx={{ mr: 1 }} + > + {t('button.save')} + </Button> + </> + )} + + {activeStep !== Steps.SuccessStep && ( + <Button + onClick={handleCancel} + disabled={submitState === SubmitState.SubmitSuccess} + > + {t('button.cancel')} + </Button> + )} + </Box> + + {activeStep === Steps.SuccessStep && <StepSubmitSuccess />} + + {/* TODO For now just showing a heading to give the user some feedback */} + {submitState === SubmitState.SubmitFailure && ( + <Box> + <h2>Submit failure</h2> + </Box> + )} + </Page> + ) +} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index fcb7c0da28e37afd12aa65ae24e6ef183cccc783..116236660180cbf20095854333cba435dd940f72 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,5 +1,7 @@ import validator from '@navikt/fnrvalidator' +import { parseISO } from 'date-fns' import i18n from 'i18next' +import { FetchedRole, Role } from 'interfaces' import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js' const validEmailRegex = @@ -97,3 +99,16 @@ export function splitPhoneNumber(phoneNumber: string): [string, string] { parsedNumber.nationalNumber.toString(), ] } + +export function parseRole(role: FetchedRole): Role { + return { + id: role.id, + name_nb: role.name_nb, + name_en: role.name_en, + ou_nb: role.ou_nb, + ou_en: role.ou_en, + start_date: parseISO(role.start_date), + end_date: parseISO(role.end_date), + max_days: role.max_days, + } +} diff --git a/gregui/api/urls.py b/gregui/api/urls.py index e5b04c50770d4f49878d8cae700d0d4fd677d102..db1d56dbedfee7dafec148cde4f959575ef5d864 100644 --- a/gregui/api/urls.py +++ b/gregui/api/urls.py @@ -7,6 +7,7 @@ from gregui.api.views.invitation import ( CreateInvitationView, InvitedGuestView, ) +from gregui.api.views.person import PersonSearchView, PersonView from gregui.api.views.roletypes import RoleTypeViewSet from gregui.api.views.unit import UnitsViewSet @@ -19,4 +20,8 @@ urlpatterns += [ path("invited/", InvitedGuestView.as_view(), name="invited-info"), path("invited/<uuid>", CheckInvitationView.as_view(), name="invite-verify"), path("invite/", CreateInvitationView.as_view(), name="invite-create"), + path("person/<int:id>", PersonView.as_view(), name="person-get"), + path( + "person/search/<searchstring>", PersonSearchView.as_view(), name="person-search" + ), ] diff --git a/gregui/api/views/person.py b/gregui/api/views/person.py new file mode 100644 index 0000000000000000000000000000000000000000..edf0239f1ba71026ae331db29b29a04b5e6c9e10 --- /dev/null +++ b/gregui/api/views/person.py @@ -0,0 +1,75 @@ +from django.http.response import JsonResponse +from rest_framework.authentication import SessionAuthentication, BasicAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView + +from greg.models import Identity, Person +from greg.permissions import IsSponsor + + +class PersonView(APIView): + """ + Fetch person info for any guest as long as you are a sponsor + + This is required for the functionality where a sponsor wants to add a guest role to + an already existing person that is not already their guest. + + Returns enough information to fill a profile page in the frontend + """ + + authentication_classes = [SessionAuthentication, BasicAuthentication] + permission_classes = [IsAuthenticated, IsSponsor] + + def get(self, request, id): + person = Person.objects.get(id=id) + response = { + "pid": person.id, + "first": person.first_name, + "last": person.last_name, + "email": person.private_email and person.private_email.value, + "mobile": person.private_mobile and person.private_mobile.value, + "fnr": person.fnr and "".join((person.fnr.value[:-5], "*****")), + "roles": [ + { + "id": role.id, + "name_nb": role.type.name_nb, + "name_en": role.type.name_en, + "ou_nb": role.orgunit_id.name_nb, + "ou_en": role.orgunit_id.name_en, + "start_date": role.start_date, + "end_date": role.end_date, + "max_days": role.type.max_days, + } + for role in person.roles.all() + ], + } + return JsonResponse(response) + + +class PersonSearchView(APIView): + """Search for persons using email or phone number""" + + authentication_classes = [SessionAuthentication, BasicAuthentication] + permission_classes = [IsAuthenticated, IsSponsor] + + def get(self, requests, searchstring): + search = Identity.objects.filter( + value__icontains=searchstring, # icontains to include wrong case emails + type__in=[ + Identity.IdentityType.PRIVATE_EMAIL, + Identity.IdentityType.PRIVATE_MOBILE_NUMBER, + ], + )[:10] + response = { + "persons": [ + { + "pid": i.person.id, + "first": i.person.first_name, + "last": i.person.last_name, + "value": i.value, + "type": i.type, + } + for i in search + ] + } + return JsonResponse(response) diff --git a/gregui/views.py b/gregui/views.py index 19ef21e57f59d1fbe835ad04b3b6197ab35d9f3a..b2be0d591e74caddb26178d6b9985d17c41afea9 100644 --- a/gregui/views.py +++ b/gregui/views.py @@ -6,7 +6,7 @@ from rest_framework.authentication import SessionAuthentication, BasicAuthentica from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView -from greg.models import Role, Sponsor +from greg.models import Person, Sponsor from greg.permissions import IsSponsor from gregui import mailutils from gregui.models import GregUserProfile @@ -85,6 +85,8 @@ class OusView(APIView): class GuestInfoView(APIView): + """Fetch all the sponsors guests""" + authentication_classes = [SessionAuthentication, BasicAuthentication] permission_classes = [IsAuthenticated, IsSponsor] @@ -92,30 +94,35 @@ class GuestInfoView(APIView): # pylint: disable=W0622 def get(request, format=None): user = GregUserProfile.objects.get(user=request.user) + return JsonResponse( { - "roles": [ + "persons": [ { - "id": i.id, - "pid": i.person.id, - "first": i.person.first_name, - "last": i.person.last_name, - "email": i.person.private_email - and i.person.private_email.value, - "mobile": i.person.private_mobile - and i.person.private_mobile.value, - "fnr": i.person.fnr - and "".join((i.person.fnr.value[:-5], "*****")), - "role_nb": i.type.name_nb, - "role_en": i.type.name_en, - "max_days": i.type.max_days, - "start_date": i.start_date and i.start_date.isoformat(), - "end_date": i.end_date.isoformat(), - "ou_nb": i.orgunit_id.name_nb, - "ou_en": i.orgunit_id.name_en, - "active": i.person.is_registered and i.person.is_verified, + "pid": person.id, + "first": person.first_name, + "last": person.last_name, + "email": person.private_email and person.private_email.value, + "mobile": person.private_mobile and person.private_mobile.value, + "fnr": person.fnr and "".join((person.fnr.value[:-5], "*****")), + "active": person.is_registered and person.is_verified, + "roles": [ + { + "id": role.id, + "name_nb": role.type.name_nb, + "name_en": role.type.name_en, + "ou_nb": role.orgunit_id.name_nb, + "ou_en": role.orgunit_id.name_en, + "start_date": role.start_date, + "end_date": role.end_date, + "max_days": role.type.max_days, + } + for role in person.roles.all() + ], } - for i in Role.objects.filter(sponsor_id=user.sponsor) + for person in Person.objects.filter( + roles__sponsor_id=user.sponsor + ).distinct() ] } )