diff --git a/frontend/src/AdminPage.js b/frontend/src/AdminPage.js index 2985793478..ce7f197a1b 100644 --- a/frontend/src/AdminPage.js +++ b/frontend/src/AdminPage.js @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import { Stack, Text, Select, useToast } from '@chakra-ui/core' +import { Stack, Text, Select, useToast, Icon, Divider } from '@chakra-ui/core' import { Trans, t } from '@lingui/macro' import { Layout } from './Layout' import AdminPanel from './AdminPanel' @@ -8,6 +8,8 @@ import { useQuery } from '@apollo/client' import { useUserState } from './UserState' import { ErrorFallbackMessage } from './ErrorFallbackMessage' import { LoadingMessage } from './LoadingMessage' +import { TrackerButton } from './TrackerButton' +import { Link as RouteLink } from 'react-router-dom' export default function AdminPage() { const { currentUser } = useUserState() @@ -90,7 +92,7 @@ export default function AdminPage() { Welcome, Admin - + Organization: @@ -105,6 +107,16 @@ export default function AdminPage() { > {options} + + + Create Organization + {options.length > 1 && orgDetails ? ( @@ -125,9 +137,23 @@ export default function AdminPage() { ) } else { return ( - - You do not have admin permissions in any organization - + + + + You do not have admin permissions in any organization + + + + + Create Organization + + + ) } } diff --git a/frontend/src/App.js b/frontend/src/App.js index 02fa6e4d3f..26f03577d3 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -36,6 +36,7 @@ const TwoFactorAuthenticatePage = lazy(() => import('./TwoFactorAuthenticatePage'), ) const EmailValidationPage = lazy(() => import('./EmailValidationPage')) +const CreateOrganizationPage = lazy(() => import('./CreateOrganizationPage')) export default function App() { // Hooks to be used with this functional component @@ -203,6 +204,13 @@ export default function App() { + + + + diff --git a/frontend/src/CreateOrganizationField.js b/frontend/src/CreateOrganizationField.js new file mode 100644 index 0000000000..f3111d43d9 --- /dev/null +++ b/frontend/src/CreateOrganizationField.js @@ -0,0 +1,49 @@ +import React from 'react' +import { elementType, func, oneOfType, shape, string } from 'prop-types' +import { + FormControl, + FormErrorMessage, + FormLabel, + Input, +} from '@chakra-ui/core' +import { useField } from 'formik' +import WithPseudoBox from './withPseudoBox' + +const OrganizationCreateField = WithPseudoBox(function OrganizationCreateField({ + name, + label, + language, + forwardedRef, + ...props +}) { + const [field, meta] = useField(name) + + return ( + + + {label} ({language}) + + + {meta.error} + + ) +}) + +OrganizationCreateField.propTypes = { + name: string.isRequired, + forwardedRef: oneOfType([func, shape({ current: elementType })]), +} + +const withForwardedRef = React.forwardRef((props, ref) => { + return +}) +withForwardedRef.displayName = 'OrganizationCreateField' + +export default withForwardedRef diff --git a/frontend/src/CreateOrganizationPage.js b/frontend/src/CreateOrganizationPage.js new file mode 100644 index 0000000000..b828a33d9b --- /dev/null +++ b/frontend/src/CreateOrganizationPage.js @@ -0,0 +1,287 @@ +import React from 'react' +import { Stack, useToast, Box, Button, Heading } from '@chakra-ui/core' +import { Trans, t } from '@lingui/macro' +import { CREATE_ORGANIZATION } from './graphql/mutations' +import { useMutation } from '@apollo/client' +import { useUserState } from './UserState' +import { LoadingMessage } from './LoadingMessage' +import { Formik } from 'formik' +import { useHistory, Link as RouteLink } from 'react-router-dom' +import { TrackerButton } from './TrackerButton' +import { object, string } from 'yup' +import { fieldRequirements } from './fieldRequirements' +import CreateOrganizationField from './CreateOrganizationField' +import { i18n } from '@lingui/core' + +export default function CreateOrganizationPage() { + const { currentUser } = useUserState() + const toast = useToast() + const history = useHistory() + + const validationSchema = object().shape({ + nameEN: string().required(i18n._(fieldRequirements.field.required.message)), + nameFR: string().required(i18n._(fieldRequirements.field.required.message)), + acronymEN: string() + .matches( + fieldRequirements.acronym.matches.regex, + i18n._(fieldRequirements.acronym.matches.message), + ) + .max( + fieldRequirements.acronym.max.maxLength, + i18n._(fieldRequirements.acronym.max.message), + ) + .required(i18n._(fieldRequirements.field.required.message)), + acronymFR: string() + .matches( + fieldRequirements.acronym.matches.regex, + i18n._(fieldRequirements.acronym.matches.message), + ) + .max( + fieldRequirements.acronym.max.maxLength, + i18n._(fieldRequirements.acronym.max.message), + ) + .required(i18n._(fieldRequirements.field.required.message)), + zoneEN: string().required(i18n._(fieldRequirements.field.required.message)), + zoneFR: string().required(i18n._(fieldRequirements.field.required.message)), + sectorEN: string().required( + i18n._(fieldRequirements.field.required.message), + ), + sectorFR: string().required( + i18n._(fieldRequirements.field.required.message), + ), + cityEN: string().required(i18n._(fieldRequirements.field.required.message)), + cityFR: string().required(i18n._(fieldRequirements.field.required.message)), + provinceEN: string().required( + i18n._(fieldRequirements.field.required.message), + ), + provinceFR: string().required( + i18n._(fieldRequirements.field.required.message), + ), + countryEN: string().required( + i18n._(fieldRequirements.field.required.message), + ), + countryFR: string().required( + i18n._(fieldRequirements.field.required.message), + ), + }) + + const [createOrganization, { loading }] = useMutation(CREATE_ORGANIZATION, { + context: { + headers: { + authorization: currentUser.jwt, + }, + }, + onError(error) { + toast({ + title: t`An error occurred.`, + description: error.message, + status: 'error', + duration: 9000, + isClosable: true, + position: 'top-left', + }) + }, + onCompleted({ createOrganization }) { + if (createOrganization.result.__typename === 'Organization') { + toast({ + title: t`Organization created`, + description: t`${createOrganization.result.name} was created`, + status: 'success', + duration: 9000, + isClosable: true, + position: 'top-left', + }) + history.push('/admin') + } else if (createOrganization.result.__typename === 'OrganizationError') { + toast({ + title: t`Unable to create new organization.`, + description: createOrganization.result.description, + status: 'error', + duration: 9000, + isClosable: true, + position: 'top-left', + }) + } else { + toast({ + title: t`Incorrect send method received.`, + description: t`Incorrect createOrganization.result typename.`, + status: 'error', + duration: 9000, + isClosable: true, + position: 'top-left', + }) + console.log('Incorrect createOrganization.result typename.') + } + }, + }) + + if (loading) return + + return ( + + { + createOrganization({ + variables: { + nameEN: values.nameEN, + nameFR: values.nameFR, + acronymEN: values.acronymEN, + acronymFR: values.acronymFR, + zoneEN: values.zoneEN, + zoneFR: values.zoneFR, + sectorEN: values.sectorEN, + sectorFR: values.sectorFR, + countryEN: values.countryEN, + countryFR: values.countryFR, + provinceEN: values.provinceEN, + provinceFR: values.provinceFR, + cityEN: values.cityEN, + cityFR: values.cityFR, + }, + }) + }} + > + {({ handleSubmit, isSubmitting }) => ( +
+ + + Create an organization by filling out the following info in both + English and French + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Create Organization + + + + +
+ )} +
+
+ ) +} diff --git a/frontend/src/__tests__/CreateOrganizationField.test.js b/frontend/src/__tests__/CreateOrganizationField.test.js new file mode 100644 index 0000000000..4463864023 --- /dev/null +++ b/frontend/src/__tests__/CreateOrganizationField.test.js @@ -0,0 +1,56 @@ +import React from 'react' +import { object, string } from 'yup' +import { waitFor, render, fireEvent } from '@testing-library/react' +import { ThemeProvider, theme } from '@chakra-ui/core' +import CreateOrganizationField from '../CreateOrganizationField' +import { Formik } from 'formik' +import { I18nProvider } from '@lingui/react' +import { setupI18n } from '@lingui/core' + +const i18n = setupI18n({ + locale: 'en', + messages: { + en: {}, + }, + localeData: { + en: {}, + }, +}) + +describe('', () => { + describe('when validation fails', () => { + it('displays an error message', async () => { + const validationSchema = object().shape({ + acronym: string().required('sadness'), + }) + + const { getByTestId, getByText } = render( + + + + {() => ( + + )} + + + , + ) + + const input = getByTestId('CreateOrganizationField') + fireEvent.blur(input) + + await waitFor(() => { + expect(getByText(/sadness/)).toBeInTheDocument() + }) + }) + }) +}) diff --git a/frontend/src/__tests__/CreateOrganizationPage.test.js b/frontend/src/__tests__/CreateOrganizationPage.test.js new file mode 100644 index 0000000000..98185584e4 --- /dev/null +++ b/frontend/src/__tests__/CreateOrganizationPage.test.js @@ -0,0 +1,128 @@ +import React from 'react' +import { ThemeProvider, theme } from '@chakra-ui/core' +import { MemoryRouter } from 'react-router-dom' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { MockedProvider } from '@apollo/client/testing' +import CreateOrganizationPage from '../CreateOrganizationPage' +import { CREATE_ORGANIZATION } from '../graphql/mutations' +import { I18nProvider } from '@lingui/react' +import { setupI18n } from '@lingui/core' +import { UserStateProvider } from '../UserState' + +const i18n = setupI18n({ + locale: 'en', + messages: { + en: {}, + }, + localeData: { + en: {}, + }, +}) + +const mocks = [ + { + request: { + query: CREATE_ORGANIZATION, + }, + result: { + data: { + organization: { + name: 'New Test Org', + }, + }, + }, + }, +] + +describe('', () => { + it('renders', async () => { + const { getByText } = render( + + + + + + + + + + + , + ) + + const welcomeMessage = /Create an organization by filling out the following info in both English and French/i + + await waitFor(() => expect(getByText(welcomeMessage)).toBeInTheDocument()) + }) + + describe('acronym fields', () => { + it('displays an error message when incorrect format is used', async () => { + const { container, getByText } = render( + + + + + + + + + + + , + ) + + const acronymEN = container.querySelector('#acronymEN') + const errorMessage = /Acronyms can only use upper case letters and underscores/i + + await waitFor(() => { + fireEvent.change(acronymEN, { target: { value: 'test' } }) + }) + + await waitFor(() => { + fireEvent.blur(acronymEN) + }) + + await waitFor(() => expect(getByText(errorMessage)).toBeInTheDocument()) + }) + + it('displays an error message when input is too large', async () => { + const { container, getByText } = render( + + + + + + + + + + + , + ) + + const acronymEN = container.querySelector('#acronymEN') + const errorMessage = /Acronyms must be at most 50 characters/i + + await waitFor(() => { + fireEvent.change(acronymEN, { + target: { + value: + 'THIS_ACRONYM_IS_OVER_FIFTY_CHARACTERS_WHICH_MAKES_IT_INVALID', + }, + }) + }) + + await waitFor(() => { + fireEvent.blur(acronymEN) + }) + + await waitFor(() => expect(getByText(errorMessage)).toBeInTheDocument()) + }) + }) +}) diff --git a/frontend/src/fieldRequirements.js b/frontend/src/fieldRequirements.js index 4ca8195f07..3e82b541d1 100644 --- a/frontend/src/fieldRequirements.js +++ b/frontend/src/fieldRequirements.js @@ -35,4 +35,17 @@ export const fieldRequirements = { }, required: { message: t`Phone number field must not be empty` }, }, + acronym: { + matches: { + regex: /^[A-Z]+(?:_[A-Z]+)*$/g, + message: t`Acronyms can only use upper case letters and underscores`, + }, + max: { + maxLength: 50, + message: t`Acronyms must be at most 50 characters`, + }, + }, + field: { + required: { message: t`This field cannot be empty` }, + }, } diff --git a/frontend/src/graphql/mutations.js b/frontend/src/graphql/mutations.js index 3ad5381d1b..f1a651ac6c 100644 --- a/frontend/src/graphql/mutations.js +++ b/frontend/src/graphql/mutations.js @@ -380,4 +380,52 @@ export const VERIFY_ACCOUNT = gql` } ` +export const CREATE_ORGANIZATION = gql` + mutation CreateOrganization( + $acronymEN: Acronym! + $acronymFR: Acronym! + $nameEN: String! + $nameFR: String! + $zoneEN: String! + $zoneFR: String! + $sectorEN: String! + $sectorFR: String! + $countryEN: String! + $countryFR: String! + $provinceEN: String! + $provinceFR: String! + $cityEN: String! + $cityFR: String! + ) { + createOrganization( + input: { + acronymEN: $acronymEN + acronymFR: $acronymFR + nameEN: $nameEN + nameFR: $nameFR + zoneEN: $zoneEN + zoneFR: $zoneFR + sectorEN: $sectorEN + sectorFR: $sectorFR + countryEN: $countryEN + countryFR: $countryFR + provinceEN: $provinceEN + provinceFR: $provinceFR + cityEN: $cityEN + cityFR: $cityFR + } + ) { + result { + ... on Organization { + name + } + ... on OrganizationError { + code + description + } + } + } + } +` + export default '' diff --git a/frontend/src/locales/en.po b/frontend/src/locales/en.po index 83a6159f8d..8025c92334 100644 --- a/frontend/src/locales/en.po +++ b/frontend/src/locales/en.po @@ -168,10 +168,11 @@ msgstr "An error occurred while verifying your phone number." #: src/AdminDomains.js:147 #: src/AdminDomains.js:199 #: src/AdminDomains.js:275 -#: src/TwoFactorAuthenticatePage.js:36 -#: src/UserList.js:153 -#: src/UserList.js:203 -#: src/UserList.js:246 +#: src/TwoFactorAuthenticatePage.js:34 +#: src/UserList.js:136 +#: src/UserList.js:184 +#: src/UserList.js:237 +#: src/CreateOrganizationPage.js:61 msgid "An error occurred." msgstr "An error occurred." @@ -980,10 +981,11 @@ msgstr "Incorrect resetPassword.result typename." #: src/EditableUserPhoneNumber.js:129 #: src/EditableUserTFAMethod.js:69 #: src/ResetPasswordPage.js:60 -#: src/SignInPage.js:99 -#: src/TwoFactorAuthenticatePage.js:83 -#: src/UserList.js:129 -#: src/UserList.js:182 +#: src/SignInPage.js:98 +#: src/TwoFactorAuthenticatePage.js:80 +#: src/UserList.js:114 +#: src/UserList.js:165 +#: src/CreateOrganizationPage.js: msgid "Incorrect send method received." msgstr "Incorrect send method received." @@ -2455,6 +2457,80 @@ msgstr "New user email" msgid "Edit Role" msgstr "Edit Role" +#: src/AdminPage.js:114 +#: src/AdminPage.js:149 +#: src/CreateOrganizationPage.js:367 +msgid "Create Organization" +msgstr "Create Organization" + +#: src/fieldRequirements.js:41 +msgid "Acronyms can only use upper case letters and underscores" +msgstr "Acronyms can only use upper case letters and underscores" + +#: src/fieldRequirements.js:45 +msgid "Acronyms must be at most 50 characters" +msgstr "Acronyms must be at most 50 characters" + +#: src/fieldRequirements.js:49 +msgid "This field cannot be empty" +msgstr "This field cannot be empty" + +#: src/CreateOrganizationPage.js:72 +msgid "Organization created" +msgstr "Organization created" + +#: src/CreateOrganizationPage.js:73 +msgid "{0} was created" +msgstr "{0} was created" + +#: src/CreateOrganizationPage.js:82 +msgid "Unable to create new organization." +msgstr "Unable to create new organization." + +#: src/CreateOrganizationPage.js:92 +msgid "Incorrect createOrganization.result typename." +msgstr "Incorrect createOrganization.result typename." + +#: src/CreateOrganizationPage.js:157 +msgid "Create an organization by filling out the following info in both English and French" +msgstr "Create an organization by filling out the following info in both English and French" + +#: src/CreateOrganizationPage.js:164 +msgid "Name" +msgstr "Name" + +#: src/CreateOrganizationPage.js:192 +msgid "Acronym" +msgstr "Acronym" + +#: src/CreateOrganizationPage.js:222 +msgid "Zone" +msgstr "Zone" + +#: src/CreateOrganizationPage.js:250 +msgid "Sector" +msgstr "Sector" + +#: src/CreateOrganizationPage.js:278 +msgid "City" +msgstr "City" + +#: src/CreateOrganizationPage.js:306 +msgid "Province" +msgstr "Province" + +#: src/CreateOrganizationPage.js:334 +msgid "Country" +msgstr "Country" + +#: src/CreateOrganizationPage.js:163 +msgid "English" +msgstr "English" + +#: src/CreateOrganizationPage.js:172 +msgid "French" +msgstr "French" + #: src/ScanCategoryDetails.js:44 msgid "Implementation:" msgstr "Implementation:" diff --git a/frontend/src/locales/fr.po b/frontend/src/locales/fr.po index d2f7037ab1..aa9173a221 100644 --- a/frontend/src/locales/fr.po +++ b/frontend/src/locales/fr.po @@ -2447,6 +2447,80 @@ msgstr "Courriel du nouvel utilisateur" msgid "Edit Role" msgstr "Rôle d'édition" +#: src/AdminPage.js:114 +#: src/AdminPage.js:149 +#: src/CreateOrganizationPage.js:367 +msgid "Create Organization" +msgstr "Créer une organisation" + +#: src/fieldRequirements.js:41 +msgid "Acronyms can only use upper case letters and underscores" +msgstr "Les acronymes ne peuvent utiliser que des lettres majuscules et des caractères de soulignement." + +#: src/fieldRequirements.js:45 +msgid "Acronyms must be at most 50 characters" +msgstr "Les acronymes doivent comporter au maximum 50 caractères." + +#: src/fieldRequirements.js:49 +msgid "This field cannot be empty" +msgstr "Ce champ ne peut pas être vide" + +#: src/CreateOrganizationPage.js:72 +msgid "Organization created" +msgstr "Organisation créée" + +#: src/CreateOrganizationPage.js:73 +msgid "{0} was created" +msgstr "{0} a été créée" + +#: src/CreateOrganizationPage.js:82 +msgid "Unable to create new organization." +msgstr "Impossible de créer une nouvelle organisation." + +#: src/CreateOrganizationPage.js:92 +msgid "Incorrect createOrganization.result typename." +msgstr "createOrganization.result incorrecte typename." + +#: src/CreateOrganizationPage.js:157 +msgid "Create an organization by filling out the following info in both English and French" +msgstr "Créez une organisation en remplissant les informations suivantes en anglais et en français" + +#: src/CreateOrganizationPage.js:164 +msgid "Name" +msgstr "Nom" + +#: src/CreateOrganizationPage.js:192 +msgid "Acronym" +msgstr "Acronyme" + +#: src/CreateOrganizationPage.js:222 +msgid "Zone" +msgstr "Zone" + +#: src/CreateOrganizationPage.js:250 +msgid "Sector" +msgstr "Secteur" + +#: src/CreateOrganizationPage.js:278 +msgid "City" +msgstr "Ville" + +#: src/CreateOrganizationPage.js:306 +msgid "Province" +msgstr "Province" + +#: src/CreateOrganizationPage.js:334 +msgid "Country" +msgstr "Pays" + +#: src/CreateOrganizationPage.js:163 +msgid "English" +msgstr "Anglais" + +#: src/CreateOrganizationPage.js:172 +msgid "French" +msgstr "Français" + #: src/ScanCategoryDetails.js:44 msgid "Implementation:" msgstr "Mise en œuvre:"