From 591b7578d57b5ff54d988a12c8e314f825374da6 Mon Sep 17 00:00:00 2001 From: Santtu Tuovinen Date: Wed, 10 Jun 2020 12:34:52 +0300 Subject: [PATCH 01/48] Adding additional addresses is now possible. --- src/common/test/myProfileQueryData.ts | 21 +++ src/graphql/generatedTypes.ts | 36 ++++ src/i18n/en.json | 6 +- src/i18n/fi.json | 6 +- src/i18n/sv.json | 7 +- .../components/editProfile/EditProfile.tsx | 10 +- .../EditProfileForm.module.css | 4 + .../editProfileForm/EditProfileForm.tsx | 177 ++++++++++++++---- src/profile/constants/formConstants.ts | 34 ++++ src/profile/graphql/MyProfileQuery.graphql | 15 ++ src/profile/helpers/getAddressesFromNode.ts | 25 +++ .../helpers/updateMutationVariables.ts | 92 ++++++--- 12 files changed, 355 insertions(+), 78 deletions(-) create mode 100644 src/profile/constants/formConstants.ts create mode 100644 src/profile/helpers/getAddressesFromNode.ts diff --git a/src/common/test/myProfileQueryData.ts b/src/common/test/myProfileQueryData.ts index 53e5072b1..d6cc874ba 100644 --- a/src/common/test/myProfileQueryData.ts +++ b/src/common/test/myProfileQueryData.ts @@ -1,4 +1,5 @@ import { + AddressType, EmailType, Language, MyProfileQuery, @@ -20,10 +21,12 @@ export const myProfile: MyProfileQuery = { }, primaryAddress: { id: '123', + primary: true, address: 'Testikatu 55', city: 'Helsinki', countryCode: 'FI', postalCode: '00100', + addressType: AddressType.OTHER, __typename: 'AddressNode', }, primaryPhone: { @@ -31,6 +34,24 @@ export const myProfile: MyProfileQuery = { phone: '0501234567', __typename: 'PhoneNode', }, + addresses: { + edges: [ + { + node: { + id: '234', + address: 'Katu', + city: 'Kaupunki', + countryCode: 'FI', + postalCode: '12345', + primary: false, + addressType: AddressType.OTHER, + __typename: 'AddressNode', + }, + __typename: 'AddressNodeEdge', + }, + ], + __typename: 'AddressNodeConnection', + }, emails: { edges: [ { diff --git a/src/graphql/generatedTypes.ts b/src/graphql/generatedTypes.ts index aa9d9ebe9..e192b1270 100644 --- a/src/graphql/generatedTypes.ts +++ b/src/graphql/generatedTypes.ts @@ -113,10 +113,42 @@ export interface MyProfileQuery_myProfile_primaryAddress { * The ID of the object. */ readonly id: string; + readonly primary: boolean; + readonly address: string; + readonly postalCode: string; + readonly city: string; + readonly countryCode: string; + readonly addressType: AddressType | null; +} + +export interface MyProfileQuery_myProfile_addresses_edges_node { + readonly __typename: "AddressNode"; + readonly primary: boolean; + /** + * The ID of the object. + */ + readonly id: string; readonly address: string; readonly postalCode: string; readonly city: string; readonly countryCode: string; + readonly addressType: AddressType | null; +} + +export interface MyProfileQuery_myProfile_addresses_edges { + readonly __typename: "AddressNodeEdge"; + /** + * The item at the end of the edge + */ + readonly node: MyProfileQuery_myProfile_addresses_edges_node | null; +} + +export interface MyProfileQuery_myProfile_addresses { + readonly __typename: "AddressNodeConnection"; + /** + * Contains the nodes in this connection. + */ + readonly edges: ReadonlyArray<(MyProfileQuery_myProfile_addresses_edges | null)>; } export interface MyProfileQuery_myProfile_primaryEmail { @@ -180,6 +212,10 @@ export interface MyProfileQuery_myProfile { * Convenience field for the address which is marked as primary. */ readonly primaryAddress: MyProfileQuery_myProfile_primaryAddress | null; + /** + * List of addresses of the profile. + */ + readonly addresses: MyProfileQuery_myProfile_addresses | null; /** * Convenience field for the email which is marked as primary. */ diff --git a/src/i18n/en.json b/src/i18n/en.json index d1aa549d4..13ba4b551 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -61,9 +61,9 @@ }, "loading": "Loading...", "login": { - "description": "Registering gives you access to new services with one easy login. A single account makes the everyday life of a citizen easier.", + "description": "By signing in to the new Helsinki city services, a Helsinki profile is created for you. Here you can easily find your profile information and see all the services you have signed in with it. ", "login": "Log in", - "title": "Many opportunities with one account" + "title": "Your profile information in one address!" }, "nav": { "information": "Information", @@ -89,6 +89,7 @@ "title": "Your profile has been successfully removed!" }, "profileForm": { + "addAnotherAddress": "Add another address", "addAnotherEmail": "Add another email address", "additionalInfo": "Additional Information", "address": "Home address", @@ -101,6 +102,7 @@ "firstName": "First name", "language": "Profile language", "lastName": "Last name", + "makeAddressPrimary": "Change to your primary address", "makeEmailPrimary": "Change to your primary email address", "phone": "Phone", "postalCode": "Postal code", diff --git a/src/i18n/fi.json b/src/i18n/fi.json index 6930c5137..6d9d8426c 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -61,9 +61,9 @@ }, "loading": "Ladataan...", "login": { - "description": "Rekisteröitymällä pääset käyttämään uusia palveluita yhdellä helpolla kirjautumisella. Yksi yhtenäinen tunnus tekee kaupunkilaisen arjesta helpompaa.", + "description": "Rekisteröitymällä Helsingin kaupungin uusiin palveluihin, sinulle luodaan Helsinki-profiili, jonka kautta näet vaivattomasti tietosi, joita sinusta keräämme sekä palvelut joihin olet tunnistautunut. ", "login": "Kirjaudu sisään", - "title": "Yhdellä tunnuksella monta mahdollisuutta" + "title": "Profiilitietosi yhdessä osoitteessa! " }, "nav": { "information": "Omat tiedot", @@ -89,6 +89,7 @@ "title": "Profiilisi on poistettu onnistuneesti!" }, "profileForm": { + "addAnotherAddress": "Lisää toinen osoite", "addAnotherEmail": "Lisää toinen sähköpostiosoite", "additionalInfo": "Lisätiedot", "address": "Kotiosoite", @@ -101,6 +102,7 @@ "firstName": "Etunimi", "language": "Profiilin kieli", "lastName": "Sukunimi", + "makeAddressPrimary": "Muuta ensisijaiseksi osoitteeksi", "makeEmailPrimary": "Muuta ensisijaiseksi sähköpostiosoitteeksi", "phone": "Puhelinnumero", "postalCode": "Postinumero", diff --git a/src/i18n/sv.json b/src/i18n/sv.json index 5fb4dc142..4ae4f875b 100644 --- a/src/i18n/sv.json +++ b/src/i18n/sv.json @@ -61,10 +61,11 @@ }, "loading": "Laddar...", "login": { - "description": "Registrering ger dig tillgång till nya tjänster med en enkel inloggning. Ett enda konto gör en medborgares vardagsliv enklare.", + "description": "Genom att logga in på de nya Helsingfors stadstjänster skapas en Helsingfors-profil för dig. Här kan du enkelt hitta din profilinformation och se alla tjänster du har loggat in med den.", "login": "Logga in", - "title": "Många möjligheter med ett konto" + "title": "" }, + "Din profilinformation på en adress!": "", "nav": { "information": "Mina uppgifter", "menuButtonLabel": "Profilmeny", @@ -89,6 +90,7 @@ "title": "Din profil har tagits bort!" }, "profileForm": { + "addAnotherAddress": "Lägg till en annan adress", "addAnotherEmail": "Lägg till en annan e-postadress", "additionalInfo": "Ytterligare information", "address": "Hemadress", @@ -101,6 +103,7 @@ "firstName": "Förnamn", "language": "Profilspråk", "lastName": "Efternamn", + "makeAddressPrimary": "Ändra till din primära adress", "makeEmailPrimary": "Byt till din primära e-postadress", "phone": "Telefonnummer", "postalCode": "Postnummer", diff --git a/src/profile/components/editProfile/EditProfile.tsx b/src/profile/components/editProfile/EditProfile.tsx index a2dd25078..ab0d3504e 100644 --- a/src/profile/components/editProfile/EditProfile.tsx +++ b/src/profile/components/editProfile/EditProfile.tsx @@ -11,6 +11,7 @@ import { Language, MyProfileQuery, MyProfileQuery_myProfile_primaryEmail as PrimaryEmail, + MyProfileQuery_myProfile_primaryAddress as PrimaryAddress, ServiceConnectionsQuery, UpdateMyProfile as UpdateMyProfileData, UpdateMyProfileVariables, @@ -18,6 +19,7 @@ import { import NotificationComponent from '../../../common/notification/NotificationComponent'; import ProfileSection from '../../../common/profileSection/ProfileSection'; import getEmailsFromNode from '../../helpers/getEmailsFromNode'; +import getAddressesFromNode from '../../helpers/getAddressesFromNode'; import { updateMutationVariables } from '../../helpers/updateMutationVariables'; const UPDATE_PROFILE = loader('../../graphql/UpdateMyProfile.graphql'); @@ -90,12 +92,10 @@ function EditProfile(props: Props) { profileLanguage: profileData?.myProfile?.language || Language.FINNISH, primaryEmail: profileData?.myProfile?.primaryEmail || ({} as PrimaryEmail), + primaryAddress: + profileData?.myProfile?.primaryAddress || ({} as PrimaryAddress), phone: profileData?.myProfile?.primaryPhone?.phone || '', - address: profileData?.myProfile?.primaryAddress?.address || '', - city: profileData?.myProfile?.primaryAddress?.city || '', - postalCode: profileData?.myProfile?.primaryAddress?.postalCode || '', - countryCode: - profileData?.myProfile?.primaryAddress?.countryCode || 'FI', + addresses: getAddressesFromNode(profileData), emails: getEmailsFromNode(profileData), }} isSubmitting={loading} diff --git a/src/profile/components/editProfileForm/EditProfileForm.module.css b/src/profile/components/editProfileForm/EditProfileForm.module.css index c31b41f5a..8f37a2638 100644 --- a/src/profile/components/editProfileForm/EditProfileForm.module.css +++ b/src/profile/components/editProfileForm/EditProfileForm.module.css @@ -23,6 +23,10 @@ margin-top: var(--spacing-l); } +.multipleAddresses { + margin-bottom: var(--spacing-l); +} + .buttonRow { margin-top: 50px; margin-bottom: 30px; diff --git a/src/profile/components/editProfileForm/EditProfileForm.tsx b/src/profile/components/editProfileForm/EditProfileForm.tsx index fa90dd330..09d30d023 100644 --- a/src/profile/components/editProfileForm/EditProfileForm.tsx +++ b/src/profile/components/editProfileForm/EditProfileForm.tsx @@ -2,7 +2,6 @@ import React, { Fragment, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, IconPlusCircle, TextInput } from 'hds-react'; import { - ArrayHelpers, Field, FieldArray, FieldArrayRenderProps, @@ -15,14 +14,16 @@ import countries from 'i18n-iso-countries'; import classNames from 'classnames'; import validator from 'validator'; +import { formConstants } from '../../constants/formConstants'; import getLanguageCode from '../../../common/helpers/getLanguageCode'; import { getError, getIsInvalid } from '../../helpers/formik'; import Select from '../../../common/select/Select'; import styles from './EditProfileForm.module.css'; import { - EmailType, Language, + MyProfileQuery_myProfile_addresses_edges_node as Address, MyProfileQuery_myProfile_emails_edges_node as Email, + MyProfileQuery_myProfile_primaryAddress as PrimaryAddress, MyProfileQuery_myProfile_primaryEmail as PrimaryEmail, ServiceConnectionsQuery, } from '../../../graphql/generatedTypes'; @@ -55,12 +56,10 @@ export type FormValues = { firstName: string; lastName: string; primaryEmail: PrimaryEmail; + primaryAddress: PrimaryAddress; phone: string; - address: string; - postalCode: string; - city: string; profileLanguage: Language; - countryCode: string; + addresses: Address[]; emails: Email[]; }; @@ -97,28 +96,49 @@ function EditProfileForm(props: Props) { return getError(formikProps, fieldName, renderError); }; - const changePrimaryEmail = ( + const changePrimary = ( formProps: FormikProps, - arrayHelpers: ArrayHelpers, - index: number + arrayHelpers: FieldArrayRenderProps, + index: number, + primary: 'primaryEmail' | 'primaryAddress' ) => { - const oldPrimary = { ...formProps.values.primaryEmail, primary: false }; - const newPrimary = { ...formProps.values.emails[index], primary: true }; + const arrayName: 'emails' | 'addresses' = + arrayHelpers.name === 'emails' ? 'emails' : 'addresses'; + const oldPrimary = { ...formProps.values[primary], primary: false }; + const newPrimary = { ...formProps.values[arrayName][index], primary: true }; - formProps.setFieldValue('primaryEmail', newPrimary); + formProps.setFieldValue(primary, newPrimary); arrayHelpers.remove(index); arrayHelpers.push(oldPrimary); }; + const addNewValueToArray = ( + formProps: FormikProps, + fieldName: keyof FormValues + ) => { + const previous: (Email | Address)[] = formProps.getFieldProps(fieldName) + .value; + + previous.push(formConstants.EMPTY_VALUES[fieldName]); + + formProps.setFieldValue(fieldName, previous); + }; + // TODO FIX CHANGING UNCONTROLLED aka spreat values return ( { props.onValues({ ...values, emails: [...values.emails, values.primaryEmail], + addresses: [...values.addresses, values.primaryAddress], }); }} validationSchema={schema} @@ -196,8 +216,8 @@ function EditProfileForm(props: Props) { > - changePrimaryEmail( + changePrimary( formikProps, arrayHelpers, - index + index, + 'primaryEmail' ) } > @@ -303,26 +324,108 @@ function EditProfileForm(props: Props) { ) )} + + )} + /> + + ( + + {formikProps?.values?.addresses.map( + (address: Address, index: number) => ( +
+
+ -
- + + + + + +
+
+ + + {' | '} + +
+
+ ) + )}
)} /> + {/* Add additional field buttons */} + + + {/* Form control buttons */}
+ {canBeMadePrimary && ( + + {' | '} + + + )} +
+ ); +}; + +export default AdditionalInformationActions; diff --git a/src/profile/components/editProfileForm/EditProfileForm.tsx b/src/profile/components/editProfileForm/EditProfileForm.tsx index 09d30d023..85c924082 100644 --- a/src/profile/components/editProfileForm/EditProfileForm.tsx +++ b/src/profile/components/editProfileForm/EditProfileForm.tsx @@ -29,6 +29,7 @@ import { } from '../../../graphql/generatedTypes'; import profileConstants from '../../constants/profileConstants'; import ConfirmationModal from '../modals/confirmationModal/ConfirmationModal'; +import AdditionalInformationActions from './AdditionalInformationActions'; const schema = yup.object().shape({ firstName: yup.string().max(255, 'validation.maxLength'), @@ -71,6 +72,8 @@ type Props = { services?: ServiceConnectionsQuery; }; +export type Primary = 'primaryEmail' | 'primaryAddress'; + function EditProfileForm(props: Props) { const { t, i18n } = useTranslation(); const [confirmationDialog, setConfirmationDialog] = useState(false); @@ -100,7 +103,7 @@ function EditProfileForm(props: Props) { formProps: FormikProps, arrayHelpers: FieldArrayRenderProps, index: number, - primary: 'primaryEmail' | 'primaryAddress' + primary: Primary ) => { const arrayName: 'emails' | 'addresses' = arrayHelpers.name === 'emails' ? 'emails' : 'addresses'; @@ -123,13 +126,16 @@ function EditProfileForm(props: Props) { formProps.setFieldValue(fieldName, previous); }; - // TODO FIX CHANGING UNCONTROLLED aka spreat values + return ( {formikProps => ( - +
-
- - {email.id && ( - - {' | '} - - - )} -
+ { + changePrimary( + formikProps, + arrayHelpers, + index, + 'primaryEmail' + ); + }} + />
) )} @@ -374,33 +365,21 @@ function EditProfileForm(props: Props) { labelText={t('profileForm.country')} /> -
- - - {' | '} - -
+ { + changePrimary( + formikProps, + arrayHelpers, + index, + 'primaryAddress' + ); + }} + /> ) )} @@ -460,7 +439,7 @@ function EditProfileForm(props: Props) { modalText={t('confirmationModal.saveMessage')} actionButtonText={t('confirmationModal.save')} /> - + )} ); From 31c93c0a4ae5a3d1024d9016b97cec63685e2f79 Mon Sep 17 00:00:00 2001 From: Santtu Tuovinen Date: Wed, 10 Jun 2020 13:22:24 +0300 Subject: [PATCH 03/48] Validation for additional addresses --- .../editProfileForm/AdditionalInformationActions.tsx | 2 +- .../components/editProfileForm/EditProfileForm.tsx | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/profile/components/editProfileForm/AdditionalInformationActions.tsx b/src/profile/components/editProfileForm/AdditionalInformationActions.tsx index c7b4a2661..16411dbb0 100644 --- a/src/profile/components/editProfileForm/AdditionalInformationActions.tsx +++ b/src/profile/components/editProfileForm/AdditionalInformationActions.tsx @@ -1,8 +1,8 @@ import React, { Fragment } from 'react'; import { useTranslation } from 'react-i18next'; +import { FieldArrayRenderProps } from 'formik'; import styles from './EditProfileForm.module.css'; -import { FieldArrayRenderProps } from 'formik'; type Props = { tDelete: string; diff --git a/src/profile/components/editProfileForm/EditProfileForm.tsx b/src/profile/components/editProfileForm/EditProfileForm.tsx index 85c924082..ca3d566ff 100644 --- a/src/profile/components/editProfileForm/EditProfileForm.tsx +++ b/src/profile/components/editProfileForm/EditProfileForm.tsx @@ -31,6 +31,12 @@ import profileConstants from '../../constants/profileConstants'; import ConfirmationModal from '../modals/confirmationModal/ConfirmationModal'; import AdditionalInformationActions from './AdditionalInformationActions'; +const address = yup.object().shape({ + address: yup.string().max(128, 'validation.maxLength'), + city: yup.string().max(64, 'validation.maxLength'), + postalCode: yup.string().max(5, 'validation.maxLength'), +}); + const schema = yup.object().shape({ firstName: yup.string().max(255, 'validation.maxLength'), lastName: yup.string().max(255, 'validation.maxLength'), @@ -39,9 +45,8 @@ const schema = yup.object().shape({ .string() .min(6, 'validation.phoneMin') .max(255, 'validation.maxLength'), - address: yup.string().max(128, 'validation.maxLength'), - city: yup.string().max(64, 'validation.maxLength'), - postalCode: yup.string().max(5, 'validation.maxLength'), + primaryAddress: address, + addresses: yup.array().of(address), emails: yup.array().of( yup.object().shape({ email: yup.mixed().test('isValidEmail', 'validation.email', function() { From c518b700e9f428cde3289c5a5cddd670aa56e4f5 Mon Sep 17 00:00:00 2001 From: Santtu Tuovinen Date: Wed, 10 Jun 2020 13:45:00 +0300 Subject: [PATCH 04/48] Show additional addresses. --- .../profileInformation/ProfileInformation.tsx | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/profile/components/profileInformation/ProfileInformation.tsx b/src/profile/components/profileInformation/ProfileInformation.tsx index be946c55d..a2eb48863 100644 --- a/src/profile/components/profileInformation/ProfileInformation.tsx +++ b/src/profile/components/profileInformation/ProfileInformation.tsx @@ -1,19 +1,23 @@ import React, { Fragment } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, IconPenLine } from 'hds-react'; +import countries from 'i18n-iso-countries'; import DeleteProfile from '../deleteProfile/DeleteProfile'; import LabeledValue from '../../../common/labeledValue/LabeledValue'; import DownloadData from '../downloadData/DownloadData'; import styles from './ProfileInformation.module.css'; -import getName from '../../helpers/getName'; import getAddress from '../../helpers/getAddress'; +import getLanguageCode from '../../../common/helpers/getLanguageCode'; +import getName from '../../helpers/getName'; import { MyProfileQuery, + MyProfileQuery_myProfile_addresses_edges_node as Address, MyProfileQuery_myProfile_emails_edges_node as Email, } from '../../../graphql/generatedTypes'; import ProfileSection from '../../../common/profileSection/ProfileSection'; import getEmailsFromNode from '../../helpers/getEmailsFromNode'; +import getAddressesFromNode from '../../helpers/getAddressesFromNode'; type Props = { loading: boolean; @@ -27,9 +31,20 @@ function ProfileInformation(props: Props) { const { isEditing, setEditing, loading, data } = props; const emails = getEmailsFromNode(data); + const addresses = getAddressesFromNode(data); + + const getAdditionalAddresses = (address: Address) => { + const country = countries.getName( + address.countryCode || 'FI', + getLanguageCode(getLanguageCode(i18n.languages[0])) + ); + return [address.address, address.city, address.postalCode, country] + .filter(addressPart => addressPart) + .join(', '); + }; // Add checks for multiple addresses & phones later - const showAdditionalInformation = emails.length > 0; + const showAdditionalInformation = emails.length > 0 || addresses.length > 0; return ( )} + {addresses.length > 0 && ( +
+ {addresses.map((address: Address, index: number) => ( + + ))} +
+ )}
)} From 6041ff9f4357e2bb851ad7bf0540b198e7d0af8d Mon Sep 17 00:00:00 2001 From: Santtu Tuovinen Date: Thu, 11 Jun 2020 06:13:28 +0300 Subject: [PATCH 05/48] Tests for getAddress part. --- src/common/test/myProfileQueryData.ts | 4 +- .../__tests__/updateMutationVariables.test.ts | 87 ++++++++++++++++++- .../helpers/updateMutationVariables.ts | 2 +- 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/common/test/myProfileQueryData.ts b/src/common/test/myProfileQueryData.ts index d6cc874ba..44f5446dc 100644 --- a/src/common/test/myProfileQueryData.ts +++ b/src/common/test/myProfileQueryData.ts @@ -39,8 +39,8 @@ export const myProfile: MyProfileQuery = { { node: { id: '234', - address: 'Katu', - city: 'Kaupunki', + address: 'Muokkauskatu 55', + city: 'Helsinki', countryCode: 'FI', postalCode: '12345', primary: false, diff --git a/src/profile/helpers/__tests__/updateMutationVariables.test.ts b/src/profile/helpers/__tests__/updateMutationVariables.test.ts index a4f1189a5..f091cdb98 100644 --- a/src/profile/helpers/__tests__/updateMutationVariables.test.ts +++ b/src/profile/helpers/__tests__/updateMutationVariables.test.ts @@ -1,9 +1,11 @@ import { + AddressType, EmailType, MyProfileQuery, + MyProfileQuery_myProfile_addresses_edges_node as Address, MyProfileQuery_myProfile_emails_edges_node as Email, } from '../../../graphql/generatedTypes'; -import { getEmail } from '../updateMutationVariables'; +import { getEmail, getAddress } from '../updateMutationVariables'; import { myProfile } from '../../../common/test/myProfileQueryData'; // TODO add tests for getAddress & getEmail after support for multiple entities is added @@ -31,6 +33,39 @@ const emails: Email[] = [ } as Email, ]; +const addresses: Address[] = [ + { + id: '123', + primary: true, + address: 'Testikatu 55', + city: 'Helsinki', + countryCode: 'FI', + postalCode: '00100', + addressType: AddressType.OTHER, + __typename: 'AddressNode', + }, + { + id: '', + address: 'Testikatu 66', + city: 'Helsinki', + countryCode: 'FI', + postalCode: '00000', + primary: false, + addressType: AddressType.OTHER, + __typename: 'AddressNode', + }, + { + id: '234', + address: 'Muokkauskatu 66', + city: 'Helsinki', + countryCode: 'FI', + postalCode: '12345', + primary: false, + addressType: AddressType.OTHER, + __typename: 'AddressNode', + }, +]; + describe('test getEmails function', () => { test('add array is formed correctly', () => { const emailObj = getEmail(emails); @@ -91,3 +126,53 @@ describe('test getEmails function', () => { expect(emailObj.removeEmails).toEqual(['123', '234']); }); }); + +describe('tests for getAddress function', () => { + test('', () => { + const addressObj = getAddress(addresses); + + expect(addressObj.addAddresses.length).toEqual(1); + }); + + test('add array is null', () => { + const addressObj = getAddress([{ ...addresses[0] }]); + + expect(addressObj.addAddresses).toEqual([]); + }); + + test('update array is formed correctly', () => { + const addressObj = getAddress(addresses, myProfile); + + expect(addressObj.updateAddresses).toEqual([ + { + id: '234', + address: 'Muokkauskatu 66', + postalCode: '12345', + city: 'Helsinki', + countryCode: 'FI', + primary: false, + addressType: 'OTHER', + }, + ]); + }); + + test('update array is emty', () => { + const addressesAreSame: Address[] = [ + { ...addresses[0] }, + { ...addresses[1] }, + { ...addresses[2], address: 'Muokkauskatu 55' }, + ]; + const addressObj = getAddress(addressesAreSame, myProfile); + expect(addressObj.updateAddresses).toEqual([]); + }); + + test('removeAddress field doesnt exist', () => { + const addressObj = getAddress(addresses, myProfile); + expect(addressObj.removeAddresses).toBeFalsy(); + }); + + test('removeAddress exists', () => { + const addressObj = getAddress([], myProfile); + expect(addressObj.removeAddresses).toEqual(['123', '234']); + }); +}); diff --git a/src/profile/helpers/updateMutationVariables.ts b/src/profile/helpers/updateMutationVariables.ts index 980aae341..95fc93623 100644 --- a/src/profile/helpers/updateMutationVariables.ts +++ b/src/profile/helpers/updateMutationVariables.ts @@ -28,7 +28,7 @@ type AddressInputs = { removeAddresses?: (string | null)[] | null | undefined; }; -const getAddress = (addresses: Address[], profile?: MyProfileQuery) => { +export const getAddress = (addresses: Address[], profile?: MyProfileQuery) => { const profileAddresses: Address[] = [ profile?.myProfile?.primaryAddress as Address, ...getAddressesFromNode(profile), From 5244e2802ba376947210905fea46d3aad90d12b8 Mon Sep 17 00:00:00 2001 From: Santtu Tuovinen Date: Thu, 11 Jun 2020 12:53:27 +0300 Subject: [PATCH 06/48] Added additional phones to View & Form. --- src/common/test/myProfileQueryData.ts | 18 +++ src/graphql/generatedTypes.ts | 33 +++++ .../components/editProfile/EditProfile.tsx | 6 +- .../editProfileForm/EditProfileForm.tsx | 115 ++++++++++++++---- .../profileInformation/ProfileInformation.tsx | 17 ++- src/profile/constants/formConstants.ts | 15 ++- src/profile/graphql/MyProfileQuery.graphql | 12 ++ src/profile/helpers/getPhonesFromNode.ts | 22 ++++ .../helpers/updateMutationVariables.ts | 5 +- 9 files changed, 214 insertions(+), 29 deletions(-) create mode 100644 src/profile/helpers/getPhonesFromNode.ts diff --git a/src/common/test/myProfileQueryData.ts b/src/common/test/myProfileQueryData.ts index 44f5446dc..102f93b68 100644 --- a/src/common/test/myProfileQueryData.ts +++ b/src/common/test/myProfileQueryData.ts @@ -3,6 +3,7 @@ import { EmailType, Language, MyProfileQuery, + PhoneType, } from '../../graphql/generatedTypes'; export const myProfile: MyProfileQuery = { @@ -32,6 +33,8 @@ export const myProfile: MyProfileQuery = { primaryPhone: { id: '123', phone: '0501234567', + phoneType: PhoneType.OTHER, + primary: true, __typename: 'PhoneNode', }, addresses: { @@ -67,6 +70,21 @@ export const myProfile: MyProfileQuery = { ], __typename: 'EmailNodeConnection', }, + phones: { + edges: [ + { + node: { + id: '234', + phone: '0501234567', + phoneType: PhoneType.OTHER, + primary: false, + __typename: 'PhoneNode', + }, + __typename: 'PhoneNodeEdge', + }, + ], + __typename: 'PhoneNodeConnection', + }, __typename: 'ProfileNode', }, }; diff --git a/src/graphql/generatedTypes.ts b/src/graphql/generatedTypes.ts index e192b1270..1f1ac5bc8 100644 --- a/src/graphql/generatedTypes.ts +++ b/src/graphql/generatedTypes.ts @@ -196,6 +196,35 @@ export interface MyProfileQuery_myProfile_primaryPhone { */ readonly id: string; readonly phone: string | null; + readonly primary: boolean; + readonly phoneType: PhoneType | null; +} + +export interface MyProfileQuery_myProfile_phones_edges_node { + readonly __typename: "PhoneNode"; + readonly primary: boolean; + /** + * The ID of the object. + */ + readonly id: string; + readonly phone: string | null; + readonly phoneType: PhoneType | null; +} + +export interface MyProfileQuery_myProfile_phones_edges { + readonly __typename: "PhoneNodeEdge"; + /** + * The item at the end of the edge + */ + readonly node: MyProfileQuery_myProfile_phones_edges_node | null; +} + +export interface MyProfileQuery_myProfile_phones { + readonly __typename: "PhoneNodeConnection"; + /** + * Contains the nodes in this connection. + */ + readonly edges: ReadonlyArray<(MyProfileQuery_myProfile_phones_edges | null)>; } export interface MyProfileQuery_myProfile { @@ -228,6 +257,10 @@ export interface MyProfileQuery_myProfile { * Convenience field for the phone which is marked as primary. */ readonly primaryPhone: MyProfileQuery_myProfile_primaryPhone | null; + /** + * List of phone numbers of the profile. + */ + readonly phones: MyProfileQuery_myProfile_phones | null; } export interface MyProfileQuery { diff --git a/src/profile/components/editProfile/EditProfile.tsx b/src/profile/components/editProfile/EditProfile.tsx index ab0d3504e..04a25def4 100644 --- a/src/profile/components/editProfile/EditProfile.tsx +++ b/src/profile/components/editProfile/EditProfile.tsx @@ -12,6 +12,7 @@ import { MyProfileQuery, MyProfileQuery_myProfile_primaryEmail as PrimaryEmail, MyProfileQuery_myProfile_primaryAddress as PrimaryAddress, + MyProfileQuery_myProfile_primaryPhone as PrimaryPhone, ServiceConnectionsQuery, UpdateMyProfile as UpdateMyProfileData, UpdateMyProfileVariables, @@ -21,6 +22,7 @@ import ProfileSection from '../../../common/profileSection/ProfileSection'; import getEmailsFromNode from '../../helpers/getEmailsFromNode'; import getAddressesFromNode from '../../helpers/getAddressesFromNode'; import { updateMutationVariables } from '../../helpers/updateMutationVariables'; +import getPhonesFromNode from '../../helpers/getPhonesFromNode'; const UPDATE_PROFILE = loader('../../graphql/UpdateMyProfile.graphql'); const SERVICE_CONNECTIONS = loader( @@ -94,7 +96,9 @@ function EditProfile(props: Props) { profileData?.myProfile?.primaryEmail || ({} as PrimaryEmail), primaryAddress: profileData?.myProfile?.primaryAddress || ({} as PrimaryAddress), - phone: profileData?.myProfile?.primaryPhone?.phone || '', + primaryPhone: + profileData?.myProfile?.primaryPhone || ({} as PrimaryPhone), + phones: getPhonesFromNode(profileData), addresses: getAddressesFromNode(profileData), emails: getEmailsFromNode(profileData), }} diff --git a/src/profile/components/editProfileForm/EditProfileForm.tsx b/src/profile/components/editProfileForm/EditProfileForm.tsx index ca3d566ff..1caae9aba 100644 --- a/src/profile/components/editProfileForm/EditProfileForm.tsx +++ b/src/profile/components/editProfileForm/EditProfileForm.tsx @@ -23,8 +23,10 @@ import { Language, MyProfileQuery_myProfile_addresses_edges_node as Address, MyProfileQuery_myProfile_emails_edges_node as Email, + MyProfileQuery_myProfile_phones_edges_node as Phone, MyProfileQuery_myProfile_primaryAddress as PrimaryAddress, MyProfileQuery_myProfile_primaryEmail as PrimaryEmail, + MyProfileQuery_myProfile_primaryPhone as PrimaryPhone, ServiceConnectionsQuery, } from '../../../graphql/generatedTypes'; import profileConstants from '../../constants/profileConstants'; @@ -37,14 +39,18 @@ const address = yup.object().shape({ postalCode: yup.string().max(5, 'validation.maxLength'), }); -const schema = yup.object().shape({ - firstName: yup.string().max(255, 'validation.maxLength'), - lastName: yup.string().max(255, 'validation.maxLength'), - language: yup.string(), +const phone = yup.object().shape({ phone: yup .string() .min(6, 'validation.phoneMin') .max(255, 'validation.maxLength'), +}); + +const schema = yup.object().shape({ + firstName: yup.string().max(255, 'validation.maxLength'), + lastName: yup.string().max(255, 'validation.maxLength'), + language: yup.string(), + primaryPhone: phone, primaryAddress: address, addresses: yup.array().of(address), emails: yup.array().of( @@ -56,6 +62,7 @@ const schema = yup.object().shape({ }), }) ), + phones: yup.array().of(phone), }); export type FormValues = { @@ -63,10 +70,11 @@ export type FormValues = { lastName: string; primaryEmail: PrimaryEmail; primaryAddress: PrimaryAddress; - phone: string; + primaryPhone: PrimaryPhone; profileLanguage: Language; addresses: Address[]; emails: Email[]; + phones: Phone[]; }; type Props = { @@ -77,7 +85,7 @@ type Props = { services?: ServiceConnectionsQuery; }; -export type Primary = 'primaryEmail' | 'primaryAddress'; +export type Primary = 'primaryEmail' | 'primaryAddress' | 'primaryPhone'; function EditProfileForm(props: Props) { const { t, i18n } = useTranslation(); @@ -124,8 +132,9 @@ function EditProfileForm(props: Props) { formProps: FormikProps, fieldName: keyof FormValues ) => { - const previous: (Email | Address)[] = formProps.getFieldProps(fieldName) - .value; + const previous: (Email | Address | Phone)[] = formProps.getFieldProps( + fieldName + ).value; previous.push(formConstants.EMPTY_VALUES[fieldName]); @@ -144,17 +153,22 @@ function EditProfileForm(props: Props) { primary: props.profile.primaryAddress.primary || true, countryCode: props.profile.primaryAddress.countryCode || 'FI', }, + primaryPhone: { + ...props.profile.primaryPhone, + phone: props.profile.primaryPhone.phone || '', + }, }} onSubmit={values => { props.onValues({ ...values, emails: [...values.emails, values.primaryEmail], addresses: [...values.addresses, values.primaryAddress], + phones: [...values.phones, values.primaryPhone], }); }} validationSchema={schema} > - {formikProps => ( + {(formikProps: FormikProps) => (
@@ -200,14 +214,14 @@ function EditProfileForm(props: Props) { @@ -244,10 +262,14 @@ function EditProfileForm(props: Props) { id="primaryAddress.postalCode" maxLength="5" as={TextInput} - invalid={getIsInvalid(formikProps, 'postalCode')} - helperText={getFieldError(formikProps, 'postalCode', { - max: 5, - })} + invalid={getIsInvalid(formikProps, 'primaryAddress.postalCode')} + helperText={getFieldError( + formikProps, + 'primaryAddress.postalCode', + { + max: 5, + } + )} labelText={t('profileForm.postalCode')} /> @@ -257,8 +279,8 @@ function EditProfileForm(props: Props) { id="primaryAddress.city" maxLength="255" as={TextInput} - invalid={getIsInvalid(formikProps, 'city')} - helperText={getFieldError(formikProps, 'city', { + invalid={getIsInvalid(formikProps, 'primaryAddress.city')} + helperText={getFieldError(formikProps, 'primaryAddress.city', { max: 255, })} labelText={t('profileForm.city')} @@ -324,6 +346,43 @@ function EditProfileForm(props: Props) { )} /> + ( + +
+ {formikProps.values.phones.map( + (phone: Phone, index: number) => ( +
+ + + changePrimary( + formikProps, + arrayHelpers, + index, + 'primaryPhone' + ) + } + /> +
+ ) + )} +
+
+ )} + /> ( @@ -400,6 +459,16 @@ function EditProfileForm(props: Props) { > {t('profileForm.addAnotherEmail')} + + + diff --git a/src/profile/components/editProfileForm/EditProfileForm.tsx b/src/profile/components/editProfileForm/EditProfileForm.tsx index 13b957d94..103d84d49 100644 --- a/src/profile/components/editProfileForm/EditProfileForm.tsx +++ b/src/profile/components/editProfileForm/EditProfileForm.tsx @@ -541,9 +541,7 @@ function EditProfileForm(props: Props) {
diff --git a/src/profile/components/editProfileForm/EditProfileForm.tsx b/src/profile/components/editProfileForm/EditProfileForm.tsx index 103d84d49..e626e4100 100644 --- a/src/profile/components/editProfileForm/EditProfileForm.tsx +++ b/src/profile/components/editProfileForm/EditProfileForm.tsx @@ -166,7 +166,7 @@ function EditProfileForm(props: Props) { __typename: props.profile.primaryPhone.__typename || 'PhoneNode', }, }} - onSubmit={values => { + onSubmit={async values => { props.onValues({ ...values, emails: [...values.emails, values.primaryEmail], @@ -541,7 +541,9 @@ function EditProfileForm(props: Props) {
- - setShowNotification(false)} - /> ); } diff --git a/src/profile/components/profileInformation/ProfileInformation.tsx b/src/profile/components/profileInformation/ProfileInformation.tsx index 72db734ed..47182564d 100644 --- a/src/profile/components/profileInformation/ProfileInformation.tsx +++ b/src/profile/components/profileInformation/ProfileInformation.tsx @@ -1,7 +1,12 @@ -import React, { Fragment } from 'react'; +import React, { Fragment, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, IconPenLine } from 'hds-react'; import countries from 'i18n-iso-countries'; +import { loader } from 'graphql.macro'; +import * as Sentry from '@sentry/browser'; +import FileSaver from 'file-saver'; +import { useHistory } from 'react-router'; +import { useMatomo } from '@datapunt/matomo-tracker-react'; import DeleteProfile from '../deleteProfile/DeleteProfile'; import LabeledValue from '../../../common/labeledValue/LabeledValue'; @@ -11,6 +16,7 @@ import getAddress from '../../helpers/getAddress'; import getLanguageCode from '../../../common/helpers/getLanguageCode'; import getName from '../../helpers/getName'; import { + DownloadMyProfileQuery, MyProfileQuery, MyProfileQuery_myProfile_addresses_edges_node as Address, MyProfileQuery_myProfile_emails_edges_node as Email, @@ -20,6 +26,13 @@ import ProfileSection from '../../../common/profileSection/ProfileSection'; import getEmailsFromNode from '../../helpers/getEmailsFromNode'; import getAddressesFromNode from '../../helpers/getAddressesFromNode'; import getPhonesFromNode from '../../helpers/getPhonesFromNode'; +import NotificationComponent from '../../../common/notification/NotificationComponent'; +import useDownloadProfile from '../../../gdprApi/useDownloadProfile'; +import useDeleteProfile from '../../../gdprApi/useDeleteProfile'; +import checkBerthError from '../../helpers/checkBerthError'; +import BerthErrorModal from '../modals/berthError/BerthErrorModal'; + +const ALL_DATA = loader('../../graphql/DownloadMyProfileQuery.graphql'); type Props = { loading: boolean; @@ -29,7 +42,50 @@ type Props = { }; function ProfileInformation(props: Props) { + const history = useHistory(); const { t, i18n } = useTranslation(); + const { trackEvent } = useMatomo(); + const [showNotification, setShowNotification] = useState(false); + const [berthError, setBerthError] = useState(false); + + // useDownloadProfile and useDeleteProfile need to be mounted when + // the page they are on is first rendered. That's why it's sensible to + // manage them in a component that makes the root of a route. + const [downloadProfileData, downloadQueryResult] = useDownloadProfile< + DownloadMyProfileQuery + >(ALL_DATA, { + onCompleted: data => { + const blob = new Blob([data.downloadMyProfile], { + type: 'application/json', + }); + FileSaver.saveAs(blob, 'helsinkiprofile_data.json'); + }, + onError: (error: Error) => { + Sentry.captureException(error); + setShowNotification(true); + }, + fetchPolicy: 'network-only', + }); + const [deleteProfile, deleteProfileResult] = useDeleteProfile({ + onCompleted: data => { + if (data) { + trackEvent({ category: 'action', action: 'Delete profile' }); + history.push('/profile-deleted'); + } + }, + onError: error => { + if (checkBerthError(error.graphQLErrors)) { + setBerthError(true); + } else { + Sentry.captureException(error); + setShowNotification(true); + } + }, + }); + + const isDownloadingProfile = downloadQueryResult.loading; + const isDeletingProfile = deleteProfileResult.loading; + const { isEditing, setEditing, loading, data } = props; const emails = getEmailsFromNode(data); @@ -133,8 +189,23 @@ function ProfileInformation(props: Props) { )} - - + + + setShowNotification(false)} + /> + setBerthError(prevState => !prevState)} + /> ); } diff --git a/src/profile/graphql/DeleteMyProfile.graphql b/src/profile/graphql/DeleteMyProfile.graphql deleted file mode 100644 index f912b2e69..000000000 --- a/src/profile/graphql/DeleteMyProfile.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation DeleteMyProfile($input:DeleteMyProfileMutationInput!) { - deleteMyProfile(input: $input) { - clientMutationId - } -} diff --git a/src/profile/graphql/DownloadMyProfile.graphql b/src/profile/graphql/DownloadMyProfile.graphql deleted file mode 100644 index 237645587..000000000 --- a/src/profile/graphql/DownloadMyProfile.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query DownloadMyProfile { - downloadMyProfile -} diff --git a/src/profile/graphql/DownloadMyProfileQuery.graphql b/src/profile/graphql/DownloadMyProfileQuery.graphql new file mode 100644 index 000000000..c81de9c6d --- /dev/null +++ b/src/profile/graphql/DownloadMyProfileQuery.graphql @@ -0,0 +1,3 @@ +query DownloadMyProfileQuery($authorizationCode: String!) { + downloadMyProfile(authorizationCode: $authorizationCode) +} diff --git a/src/profile/helpers/checkBerthError.ts b/src/profile/helpers/checkBerthError.ts index 2171ebdac..6b5940829 100644 --- a/src/profile/helpers/checkBerthError.ts +++ b/src/profile/helpers/checkBerthError.ts @@ -2,7 +2,7 @@ import { GraphQLError } from 'graphql'; import profileConstants from '../constants/profileConstants'; -export default function checkBerthError(errors: Array) { +export default function checkBerthError(errors: Readonly>) { if (!errors) return false; return errors.find( diff --git a/yarn.lock b/yarn.lock index 7eb2a8f47..c98d0c683 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1559,6 +1559,11 @@ version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" +"@types/uuid@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.0.0.tgz#165aae4819ad2174a17476dbe66feebd549556c0" + integrity sha512-xSQfNcvOiE5f9dyd4Kzxbof1aTrLobL278pGLKOZI6esGfZ7ts9Ka16CzIN6Y8hFHE1C7jIBZokULhK1bOgjRw== + "@types/validator@^13.0.0": version "13.0.0" resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.0.0.tgz#365f1bf936aeaddd0856fc41aa1d6f82d88ee5b3" @@ -10709,6 +10714,11 @@ uuid@^3.0.1, uuid@^3.3.2: version "3.3.3" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866" +uuid@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.1.0.tgz#6f1536eb43249f473abc6bd58ff983da1ca30d8d" + integrity sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg== + v8-compile-cache@^2.0.3: version "2.1.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e" From c68c7588c52d4e7fc46d9ca43305138658e8e707 Mon Sep 17 00:00:00 2001 From: Jaakko Jokinen Date: Fri, 26 Jun 2020 08:29:48 +0300 Subject: [PATCH 30/48] Add documentation for GDPR auth code This was sourced from the PR description. --- README.md | 2 + docs/gdpr-api-authorization.md | 80 ++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 docs/gdpr-api-authorization.md diff --git a/README.md b/README.md index 07aa5bdd7..b642d966d 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,8 @@ The graphql-backend for development is located at https://profiili-api.test.kuva ## Learn More +To learn more about specific choices in this repository, you can browse the [docs](/docs). + You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). To learn React, check out the [React documentation](https://reactjs.org/). diff --git a/docs/gdpr-api-authorization.md b/docs/gdpr-api-authorization.md new file mode 100644 index 000000000..69347b12b --- /dev/null +++ b/docs/gdpr-api-authorization.md @@ -0,0 +1,80 @@ +# GDPR API compatibility + +The GDPR API requires the user to allow actions on their data. Let's take downloading profile data as an example. + +1) User clicks the download button +2) User is redirected to Tunnistamo which, if necessary, renders an UI the user can use to allow a set of permissions +3) User is redirected back to Helsinki profile UI and the download action is completed + +In essence, we need to request the authorization code in the UI, because the user flow may contain a step that requires user input. Once this code is generated, it must be provided within the download profile query and delete profile mutation when these requests are sent to the profile backend. The backend can then use this code to make requests to all the other services for data or deletion. + +## Technical explanation + +This flow introduces one difficult step--the exit and re-entry into the profile UI application. This makes the download and deletion code flows more difficult to handle. In comparison, these actions were previously completed with callbacks and promises--which make use of the fact that the "same SPA session" is retained throughout the user action. After we transition into fetching the authorization code, this assumption no longer holds, but instead the application is "hard refreshed" at least once. + +Within this application, this behaviour has been managed with the help of `GdprAuthorizationCodeManager`, `useActionResumer` and `useAuthorizationCode`. + +### `GdprAuthorizationCodeManager` + +This class is responsible for compliance with the `OpenID` protocol. It's responsible for handling the authorization flow. In this capacity it: +* Stores the application state so that it can be reused when authorization is complete +* Creates the authorization url +* Navigates to authorization url +* Interjects the authorization callback +* Saves code for use +* Reloads application state +* Deletes code and application state from store when it is no longer needed + +### `useActionResumer` + +This hook is an abstraction which seeks to bridge the "gap" that forms when the user is redirected to Tunnistamo and then finally back into our application. It allows other code within the application to complete actions that span a page refresh while being relatively agnostic about the method by which the application knows what action to resume when the redirection is done. + +**Parameters** + +| Name | Description | +| ------------- | ------------- | +| **`deferredAction`** | Name of action that get _deferred_ until the redirect back into the UI. This is used as an ID which tells `useActionResumer` whether it should run the `callback` parameter. | +| **`onActionInitialization`** | `useActionResumer` begins the action flow by calling this function. | +| **`callback`** | `useActionResumer`** calls this function when the action is resumed. | + +**Humanized explanation** +`useActionResumer` provides its user with the `startAction` function. This function can be used to invoke an action that persists over page reloads. `useActionResumer` listens with a `useEffect` in order to notice when it should complete an action. When it determines that it's a suitable time, it invokes `callback`. + +**Note:** +Currently `useActionResumer` relies on a search parameter to know whether it should invoke a `callback`. The `useEffect` that it uses to determine when an action should be completed is not hooked up to listen to changes in location. This way `useActionResumer` won't work unless the search parameter invoking it is present already when the component calling it is mounted. If the search parameter becomes available after component mount, the callback won't be invoked. Again from another perspective: `useActionResumer` uses the global `location` object to determine whether a search parameter is present. This means that it won't react to location changes completed through react-router for instance. + +Using the global location is a sort of anti-pattern which would make it more risky to transition this application into a server rendered application for instance. + +### `useAuthorizationCode` +Combines `GdprAuthorizationCodeManager` and `useActionResumer` into a single API that's easier to consume. Code that needs access to an authorization code can hook up to one with a call like this: + +``` + const [ + startFetchingAuthorizationCode, + isAuthorizing + ] = useAuthorizationCode( + 'useDownloadProfile', + handleAuthorizationCodeCallback + ); +``` + +## Technical flow + +Here I've explained how the application should act in more technical detail. Developers can make use of this explanation to get a better sense of how the features relying on `authorization code` should work. I'll take the download flow as an example, but the delete flow is mostly the same. + +1) User logs in +2) User expands panel for downloading user profile +3) User clicks download button + 1) Download button is disabled and its label is changed + 1) Current url and the download action are saved into local storage under `kuvaGdprAuthManager` prefix that's tailed by a random UUID + 1) Tunnistamo authorize URL is built + 1) User is redirected to Tunnistamo +6) User allows access to personal information in Tunnistamo +7) User is redirected back into the application into address `/gdpr-callback` + 1) It calls `GdprAuthorizationCodeManager.authorizationTokenFetchCallback` + 1) Token is saved into localStorage ready for consumption. + 1) Previous app state is restored. User is redirected to the page the invoked download on and a special search parameter is added to tell `useActionResumer` instances that the one with this id should fire its callback. + 1) Previous app state is cleared +8) User lands back on the profile index page based on the redirect. + 1) The code is consumed--it's requested from `GdprAuthorizationCodeManager` which then clears it from its memory (localStorage). + 1) The code is used to call `downloadProfile` From ecf3fd98a0df283c43949b64cc400d85446d7735 Mon Sep 17 00:00:00 2001 From: Jaakko Jokinen Date: Fri, 26 Jun 2020 08:34:53 +0300 Subject: [PATCH 31/48] Add changelog --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..1bee9aa25 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), + +## [Unreleased] +### Added +- Support for authorization code generation for GDPR API related calls (profile download and deletion) [#108](https://github.com/City-of-Helsinki/open-city-profile-ui/pull/108) + +## [1.0.0-rc.1] From e3ab5f9abf520315ae2440e8756daf7e51ece30e Mon Sep 17 00:00:00 2001 From: Jaakko Jokinen Date: Fri, 26 Jun 2020 09:12:43 +0300 Subject: [PATCH 32/48] Fix focus indicator being partially hidden --- .../expandingPanel/ExpandingPanel.module.css | 2 -- src/common/expandingPanel/ExpandingPanel.tsx | 29 ++++++++++++------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/common/expandingPanel/ExpandingPanel.module.css b/src/common/expandingPanel/ExpandingPanel.module.css index 596ae4bcb..6b2e31e38 100644 --- a/src/common/expandingPanel/ExpandingPanel.module.css +++ b/src/common/expandingPanel/ExpandingPanel.module.css @@ -67,8 +67,6 @@ /* title in order to avoid focus outline obstructing content. */ margin-top: calc(-1 * var(--ep-vertical-whitespace)); background: var(--ep-background); - position: relative; - z-index: 1; } @media (max-width: 400px) { diff --git a/src/common/expandingPanel/ExpandingPanel.tsx b/src/common/expandingPanel/ExpandingPanel.tsx index c545e6df1..f357721eb 100644 --- a/src/common/expandingPanel/ExpandingPanel.tsx +++ b/src/common/expandingPanel/ExpandingPanel.tsx @@ -44,16 +44,21 @@ function ExpandingPanel({ container.current = ref; }; + const handleContentClick = (event: React.SyntheticEvent) => { + event.stopPropagation(); + }; + return ( -
-
+
+

{title}

{showInformationText && ( @@ -71,7 +76,11 @@ function ExpandingPanel({ />
- {expanded &&
{children}
} + {expanded && ( +
+ {children} +
+ )}
); } From fbde5ca42b4808eec3759c6daddda3dbe9c37bd9 Mon Sep 17 00:00:00 2001 From: Jaakko Jokinen Date: Fri, 26 Jun 2020 09:13:55 +0300 Subject: [PATCH 33/48] Update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bee9aa25..5163daf35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,4 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - Support for authorization code generation for GDPR API related calls (profile download and deletion) [#108](https://github.com/City-of-Helsinki/open-city-profile-ui/pull/108) +### Fixed +- Focus indicator being partially hidden with elements used for downloading and deleting profile + ## [1.0.0-rc.1] From 05354afb104fc64990e1cb82cf29fe1abeb92c03 Mon Sep 17 00:00:00 2001 From: Jaakko Jokinen Date: Fri, 26 Jun 2020 10:14:29 +0300 Subject: [PATCH 34/48] Fix missing login button in build The specificity of CSS rules changed between development build and production build. This caused HDS to override some styles in production, which caused the button to go missing. In this commit I fixed this issue by adding specificity into the selectors for our custom rules. I added a question into the HDS repo in an attempt to uncover a better fix. --- src/auth/components/login/Login.module.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/auth/components/login/Login.module.css b/src/auth/components/login/Login.module.css index 8211db0f8..c2511d9b0 100644 --- a/src/auth/components/login/Login.module.css +++ b/src/auth/components/login/Login.module.css @@ -24,13 +24,13 @@ background-color: var(--color-white); } -.button { +button.button { margin-top: 50px; background-color: var(--color-white); width: 100%; } -.button:hover { +button.button:hover { background-color: var(--color-background-button-secondary-hover); } @@ -44,7 +44,7 @@ } @media(min-width: 450px) { - .button { + button.button { width: 230px; } From 79e911d1dea51a24dbfad2e5f44e75d5898b8325 Mon Sep 17 00:00:00 2001 From: Jaakko Jokinen Date: Mon, 29 Jun 2020 15:29:16 +0300 Subject: [PATCH 35/48] Remove bottom margin from expanding panel With this change we don't have to synchronize margin values between components. Instead, the components can be agnostic about how sparsley they should be positioned within the page. Instead the page can, for instance with the help of a grid, assing and control whitespace. This way whitespace ends up being managed in fewer places, limiting the maintenance effort. --- .../expandingPanel/ExpandingPanel.module.css | 1 - .../ProfileInformation.module.css | 6 ++++++ .../profileInformation/ProfileInformation.tsx | 20 ++++++++++--------- .../ServiceConnections.module.css | 3 +++ 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/common/expandingPanel/ExpandingPanel.module.css b/src/common/expandingPanel/ExpandingPanel.module.css index 6b2e31e38..a8aac5649 100644 --- a/src/common/expandingPanel/ExpandingPanel.module.css +++ b/src/common/expandingPanel/ExpandingPanel.module.css @@ -5,7 +5,6 @@ --ep-accent-color: var(--color-bus); width: 100%; background: var(--ep-background); - margin: 0 0 var(--spacing-m) 0; } .title { diff --git a/src/profile/components/profileInformation/ProfileInformation.module.css b/src/profile/components/profileInformation/ProfileInformation.module.css index 31df2535f..4da47fbe8 100644 --- a/src/profile/components/profileInformation/ProfileInformation.module.css +++ b/src/profile/components/profileInformation/ProfileInformation.module.css @@ -19,4 +19,10 @@ .title { font-size: var(--fontsize-h-3); +} + +.boxGrid { + display: grid; + grid-row-gap: var(--spacing-m); + grid-template-rows: auto; } \ No newline at end of file diff --git a/src/profile/components/profileInformation/ProfileInformation.tsx b/src/profile/components/profileInformation/ProfileInformation.tsx index 47182564d..5911c89ff 100644 --- a/src/profile/components/profileInformation/ProfileInformation.tsx +++ b/src/profile/components/profileInformation/ProfileInformation.tsx @@ -189,15 +189,17 @@ function ProfileInformation(props: Props) { )} - - +
+ + +
setShowNotification(false)} diff --git a/src/profile/components/serviceConnections/ServiceConnections.module.css b/src/profile/components/serviceConnections/ServiceConnections.module.css index 8156ea184..65876a72e 100644 --- a/src/profile/components/serviceConnections/ServiceConnections.module.css +++ b/src/profile/components/serviceConnections/ServiceConnections.module.css @@ -34,6 +34,9 @@ } .panelContainer { + display: grid; + grid-row-gap: var(--spacing-m); + grid-template-rows: auto; margin: 50px 0; } From 44abfd02475b36e64ecff221b093b46d689552e3 Mon Sep 17 00:00:00 2001 From: Jaakko Jokinen Date: Mon, 29 Jun 2020 15:35:48 +0300 Subject: [PATCH 36/48] Add link to authentication method account management --- src/auth/useProfile.ts | 87 +++++++++++++++++++ src/config.ts | 35 ++++++++ src/i18n/en.json | 11 ++- src/i18n/fi.json | 11 ++- src/i18n/sv.json | 11 ++- .../profileInformation/ProfileInformation.tsx | 2 + ...rofileInformationAccountManagementLink.tsx | 47 ++++++++++ ...eInformationAccountManagementLink.test.jsx | 53 +++++++++++ ...rmationAccountManagementLink.test.jsx.snap | 66 ++++++++++++++ ...nformationAccountManagementLink.module.css | 52 +++++++++++ ...leInformationAccountManagementLinkUtils.ts | 45 ++++++++++ 11 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 src/auth/useProfile.ts create mode 100644 src/config.ts create mode 100644 src/profile/components/profileInformation/ProfileInformationAccountManagementLink.tsx create mode 100644 src/profile/components/profileInformation/__tests__/ProfileInformationAccountManagementLink.test.jsx create mode 100644 src/profile/components/profileInformation/__tests__/__snapshots__/ProfileInformationAccountManagementLink.test.jsx.snap create mode 100644 src/profile/components/profileInformation/profileInformationAccountManagementLink.module.css create mode 100644 src/profile/components/profileInformation/profileInformationAccountManagementLinkUtils.ts diff --git a/src/auth/useProfile.ts b/src/auth/useProfile.ts new file mode 100644 index 000000000..a5ef5adc9 --- /dev/null +++ b/src/auth/useProfile.ts @@ -0,0 +1,87 @@ +import React from 'react'; + +import getAuthenticatedUser from './getAuthenticatedUser'; +import config from '../config'; + +export type AMR = + | 'github' + | 'google' + | 'facebook' + | 'yle' + | typeof config.helsinkiAccountAMR; + +export type AMRStatic = + | 'github' + | 'google' + | 'facebook' + | 'yle' + | 'helsinkiAccount'; + +export interface Profile { + amr: AMR; + auth_time: number; + email: string; + email_verified: boolean; + family_name: string; + given_name: string; + name: string; + nickname: string; + sub: string; +} + +interface ProfileState { + profile: Profile | null; + loading: boolean; + error: Error | null; +} + +function useProfile(): ProfileState { + const [profile, setProfile] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + let ignore = false; + + function getUser() { + setIsLoading(true); + + getAuthenticatedUser() + .then(user => { + if (ignore) { + return; + } + + setProfile(user.profile); + }) + .catch(() => { + if (ignore) { + return; + } + + setError(Error('User was not found')); + }) + .finally(() => { + if (ignore) { + return; + } + + setIsLoading(false); + }); + } + + getUser(); + + return () => { + ignore = true; + }; + }, []); + + return { + profile, + loading: isLoading, + error, + }; +} + +export default useProfile; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 000000000..d4b7d5f82 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,35 @@ +import defaultTo from 'lodash/defaultTo'; + +export default { + clientId: process.env.REACT_APP_OIDC_CLIENT_ID, + environment: process.env.REACT_APP_ENVIRONMENT, + helsinkiAccountAMR: defaultTo( + process.env.REACT_APP_HELSINKI_ACCOUNT_AMR, + 'helusername' + ), + oidcAuthority: process.env.REACT_APP_OIDC_AUTHORITY, + oidcScope: process.env.REACT_APP_OIDC_SCOPE, + profileAudience: process.env.REACT_APP_PROFILE_AUDIENCE, + profileGraphQl: process.env.REACT_APP_PROFILE_GRAPHQL, + sentryDsn: process.env.REACT_APP_SENTRY_DSN, + identityProviderManagementUrlHelsinki: defaultTo( + process.env.REACT_APP_IPD_MANAGEMENT_URL_HELSINKI_ACCOUNT, + 'https://salasana.hel.ninja/auth/realms/helsinki-salasana/account' + ), + identityProviderManagementUrlGithub: defaultTo( + process.env.REACT_APP_IPD_MANAGEMENT_URL_GITHUB, + 'https://github.com/settings/profile' + ), + identityProviderManagementUrlGoogle: defaultTo( + process.env.REACT_APP_IPD_MANAGEMENT_URL_GOOGLE, + 'https://myaccount.google.com' + ), + identityProviderManagementUrlFacebook: defaultTo( + process.env.REACT_APP_IPD_MANAGEMENT_URL_FACEBOOK, + 'http://facebook.com/settings' + ), + identityProviderManagementUrlYle: defaultTo( + process.env.REACT_APP_IPD_MANAGEMENT_URL_YLE, + 'https://tunnus.yle.fi/#omat-tiedot' + ), +}; diff --git a/src/i18n/en.json b/src/i18n/en.json index 4e6e7ee99..0eca1fdfa 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -122,7 +122,9 @@ "personalData": "Personal data", "phone": "Phone", "title": "My information", - "visibility": "The following information about you is stored in Helsinki profile." + "visibility": "The following information about you is stored in Helsinki profile.", + "doGoToAccountManagement": "Go to account management", + "authenticationMethod": "Authentication method" }, "sanityCheck": "open-city-profile.en", "serviceConnections": { @@ -147,5 +149,12 @@ "maxLength": "This field can be max {{max}} characters.", "phoneMin": "The number must be at least {{min}} characters long.", "required": "This field is required." + }, + "identityProvider": { + "helsinkiAccount": "Helsinki account", + "github": "GitHub", + "google": "Google", + "facebook": "Facebook", + "yle": "Yle" } } \ No newline at end of file diff --git a/src/i18n/fi.json b/src/i18n/fi.json index 54cc38dbe..a11cc3c27 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -122,7 +122,9 @@ "personalData": "Henkilötiedot", "phone": "Puhelinnumero", "title": "Omat tiedot", - "visibility": "Helsinki-profiiliin on tallennettu sinusta alla olevat tiedot." + "visibility": "Helsinki-profiiliin on tallennettu sinusta alla olevat tiedot.", + "doGoToAccountManagement": "Siirry tunnuksen hallintaan", + "authenticationMethod": "Tunnistautumistapa" }, "sanityCheck": "open-city-profile.fi", "serviceConnections": { @@ -147,5 +149,12 @@ "maxLength": "Kentän maksimipituus on {{max}} merkkiä.", "phoneMin": "Puhelinnumeron pitää olla vähintään {{min}} merkkiä pitkä.", "required": "Tämä kenttä on pakollinen." + }, + "identityProvider": { + "helsinkiAccount": "Helsinki-tunnus", + "github": "GitHub", + "google": "Google", + "facebook": "Facebook", + "yle": "Yle" } } \ No newline at end of file diff --git a/src/i18n/sv.json b/src/i18n/sv.json index bf4b23fe6..a6ad08a1d 100644 --- a/src/i18n/sv.json +++ b/src/i18n/sv.json @@ -122,7 +122,9 @@ "personalData": "Personuppgifter", "phone": "Telefonnummer", "title": "Min information", - "visibility": "Följande information om dig lagras i Helsingfors profilen." + "visibility": "Följande information om dig lagras i Helsingfors profilen.", + "doGoToAccountManagement": "Gå till kontohantering", + "authenticationMethod": "Autentiseringsmetod" }, "sanityCheck": "open-city-profile.sv", "serviceConnections": { @@ -147,5 +149,12 @@ "maxLength": "Fältet får vara högst {{max}} tecken långt.", "phoneMin": "Telefonnumret måste vara mint {{min}} tecken långt.", "required": "Detta fält krävs." + }, + "identityProvider": { + "helsinkiAccount": "Helsinfors-kontot", + "github": "GitHub", + "google": "Google", + "facebook": "Facebook", + "yle": "Yle" } } \ No newline at end of file diff --git a/src/profile/components/profileInformation/ProfileInformation.tsx b/src/profile/components/profileInformation/ProfileInformation.tsx index 5911c89ff..619d5b954 100644 --- a/src/profile/components/profileInformation/ProfileInformation.tsx +++ b/src/profile/components/profileInformation/ProfileInformation.tsx @@ -31,6 +31,7 @@ import useDownloadProfile from '../../../gdprApi/useDownloadProfile'; import useDeleteProfile from '../../../gdprApi/useDeleteProfile'; import checkBerthError from '../../helpers/checkBerthError'; import BerthErrorModal from '../modals/berthError/BerthErrorModal'; +import ProfileInformationAccountManagementLink from './ProfileInformationAccountManagementLink'; const ALL_DATA = loader('../../graphql/DownloadMyProfileQuery.graphql'); @@ -190,6 +191,7 @@ function ProfileInformation(props: Props) { )} + ); +} + +export default ProfileInformationAccountManagementLink; diff --git a/src/profile/components/profileInformation/__tests__/ProfileInformationAccountManagementLink.test.jsx b/src/profile/components/profileInformation/__tests__/ProfileInformationAccountManagementLink.test.jsx new file mode 100644 index 000000000..17486b198 --- /dev/null +++ b/src/profile/components/profileInformation/__tests__/ProfileInformationAccountManagementLink.test.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import enzymeToJson from 'enzyme-to-json'; + +import ProfileInformationAuthenticationSourceBackLink from '../ProfileInformationAccountManagementLink'; + +jest.mock('../../../../config', () => ({ + identityProviderManagementUrlHelsinki: 'https://test-url', + helsinkiAccountAMR: 'helusername-test', +})); + +jest.mock('../../../../auth/useProfile', () => () => ({ + profile: { + amr: 'helusername-test', + // eslint-disable-next-line @typescript-eslint/camelcase + auth_time: 1593431180, + email: 'email@email.com', + // eslint-disable-next-line @typescript-eslint/camelcase + email_verified: false, + // eslint-disable-next-line @typescript-eslint/camelcase + family_name: 'Betty', + // eslint-disable-next-line @typescript-eslint/camelcase + given_name: 'Smith', + name: 'Betty Smith', + nickname: 'Betty', + sub: 'uuidvalue', + }, +})); + +describe('', () => { + const defaultProps = {}; + const getWrapper = props => + mount( + + ); + + beforeAll(() => { + process.env.REACT_APP_HELSINKI_ACCOUNT_AMR = 'helusername-test'; + }); + + afterAll(() => { + process.env.REACT_APP_HELSINKI_ACCOUNT_AMR = 'helusername'; + }); + + it('should render helsinki account link as expected based on config', () => { + const wrapper = getWrapper(); + + expect(enzymeToJson(wrapper)).toMatchSnapshot(); + }); +}); diff --git a/src/profile/components/profileInformation/__tests__/__snapshots__/ProfileInformationAccountManagementLink.test.jsx.snap b/src/profile/components/profileInformation/__tests__/__snapshots__/ProfileInformationAccountManagementLink.test.jsx.snap new file mode 100644 index 000000000..81d36df74 --- /dev/null +++ b/src/profile/components/profileInformation/__tests__/__snapshots__/ProfileInformationAccountManagementLink.test.jsx.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render helsinki account link as expected based on config 1`] = ` + +
+
+ +
+ + profileInformation.authenticationMethod + + + identityProvider.helsinkiAccount + +
+
+
+ +
+
+`; diff --git a/src/profile/components/profileInformation/profileInformationAccountManagementLink.module.css b/src/profile/components/profileInformation/profileInformationAccountManagementLink.module.css new file mode 100644 index 000000000..f6f8199cd --- /dev/null +++ b/src/profile/components/profileInformation/profileInformationAccountManagementLink.module.css @@ -0,0 +1,52 @@ +.container { + --padding-v: var(--spacing-m); + --padding-h: var(--spacing-m); + + display: flex; + flex-direction: column; + padding: var(--padding-v) var(--padding-h); + + background: var(--color-white); +} + +.labelSection { + flex-grow: 1; + margin-bottom: var(--spacing-m); +} +.labelSection > div { + margin-bottom: 0; +} + +.link { + display: flex; +} + +.link > a { + display: inline-flex; + align-items: center; + + font-size: var(--fontsize-body-default); + font-weight: bold; + color: var(--color-bus); + text-decoration: none; +} + +@media (min-width: 450px) { + .container { + flex-direction: row; + } + + .labelSection { + margin-bottom: 0; + } + + .link { + justify-content: flex-end; + } +} + +@media (min-width: 600px) { + .container { + --padding-h: var(--spacing-xl); + } +} \ No newline at end of file diff --git a/src/profile/components/profileInformation/profileInformationAccountManagementLinkUtils.ts b/src/profile/components/profileInformation/profileInformationAccountManagementLinkUtils.ts new file mode 100644 index 000000000..05978b15b --- /dev/null +++ b/src/profile/components/profileInformation/profileInformationAccountManagementLinkUtils.ts @@ -0,0 +1,45 @@ +import { Profile, AMRStatic } from '../../../auth/useProfile'; +import config from '../../../config'; + +export function getAmr(profile: Profile | null): AMRStatic | null { + const amr = profile?.amr; + + // If amr designates helsinki account, switch the value into a static + // value. This setup allows the amr for Helsinki account to be + // configured, while allowing a static reference we can use for + // translations for instance. + if (amr === config.helsinkiAccountAMR) { + return 'helsinkiAccount'; + } + + if ( + amr === 'github' || + amr === 'google' || + amr === 'facebook' || + amr === 'yle' + ) { + return amr; + } + + // If amr doesn't match any of our expectations, return null. + return null; +} + +export function getAmrUrl(authenticationMethodReference: AMRStatic): string { + switch (authenticationMethodReference) { + case 'helsinkiAccount': + return config.identityProviderManagementUrlHelsinki; + case 'github': + return config.identityProviderManagementUrlGithub; + case 'google': + return config.identityProviderManagementUrlGoogle; + case 'facebook': + return config.identityProviderManagementUrlFacebook; + case 'yle': + return config.identityProviderManagementUrlYle; + default: + throw Error( + `Unexpected authentication method reference "${authenticationMethodReference}"` + ); + } +} From 85a0c57a3ac8676a2cb8b2f13a35ded213e2366a Mon Sep 17 00:00:00 2001 From: Jaakko Jokinen Date: Mon, 29 Jun 2020 16:00:42 +0300 Subject: [PATCH 37/48] Update documentation --- CHANGELOG.md | 1 + README.md | 20 ++++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5163daf35..0768316e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### Added - Support for authorization code generation for GDPR API related calls (profile download and deletion) [#108](https://github.com/City-of-Helsinki/open-city-profile-ui/pull/108) +- Link to authentication method account management [#114](https://github.com/City-of-Helsinki/open-city-profile-ui/pull/114) ### Fixed - Focus indicator being partially hidden with elements used for downloading and deleting profile diff --git a/README.md b/README.md index b642d966d..ac1a246aa 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,20 @@ Since this app uses react-scripts (Create React App) the env-files work a bit di The following envs are used: -- REACT_APP_OIDC_AUTHORITY - this is the URL to tunnistamo -- REACT_APP_OIDC_CLIENT_ID - ID of the client that has to be configured in tunnistamo -- REACT_APP_PROFILE_AUDIENCE - name of the api-token that client uses profile-api with -- REACT_APP_PROFILE_GRAPHQL - URL to the profile graphql -- REACT_APP_OIDC_SCOPE - which scopes the app requires -- REACT_APP_SENTRY_DSN - sentry public dns-key +| Name | Description | +| --- | ------------- | +| `REACT_APP_HELSINKI_ACCOUNT_AMR` | Authentication method reference for Helsinki account.
**default:** `helusername` | +| `REACT_APP_IPD_MANAGEMENT_URL_HELSINKI_ACCOUNT` | Account management url for Helsinki account.
**default:** `https://salasana.hel.ninja/auth/realms/helsinki-salasana/account` | +| `REACT_APP_IPD_MANAGEMENT_URL_GITHUB` | Account management url for GitHub.
**default:** `https://github.com/settings/profile` | +| `REACT_APP_IPD_MANAGEMENT_URL_GOOGLE` | Account management url for Google.
**default:** `https://myaccount.google.com` | +| `REACT_APP_IPD_MANAGEMENT_URL_FACEBOOK` | Account management url for Facebook.
**default:** `http://facebook.com/settings` | +| `REACT_APP_IPD_MANAGEMENT_URL_YLE` | Account management url for Yle.
**default:** `https://tunnus.yle.fi/#omat-tiedot` | +| `REACT_APP_OIDC_AUTHORITY` | This is the URL to tunnistamo. | +| `REACT_APP_OIDC_CLIENT_ID` | ID of the client that has to be configured in tunnistamo. | +| `REACT_APP_OIDC_SCOPE` | Which scopes the app requires. | +| `REACT_APP_PROFILE_AUDIENCE` | Name of the api-token that client uses profile-api with. | +| `REACT_APP_PROFILE_GRAPHQL` | URL to the profile graphql. | +| `REACT_APP_SENTRY_DSN` | Sentry public dns-key. | ## Setting up local development environment with Docker From 98dd8f28c41571f22682195a066f32e002b543f8 Mon Sep 17 00:00:00 2001 From: Jaakko Jokinen Date: Mon, 29 Jun 2020 09:46:53 +0300 Subject: [PATCH 38/48] Refactor notifications After this refactor, multiple notification can be shown at the same time. Previously notifications would render on top of each other. When two identical notifications were rendered on top of each other, dismissing the first would reveal the next, creating the illusion that closing notification did not work. Because these notifications were used like toasts, and because other applications in the office have used this naming, I used the name toast. --- CHANGELOG.md | 1 + package.json | 2 +- public/index.html | 1 + src/App.tsx | 67 ++-- src/auth/authenticate.ts | 14 - src/auth/components/login/Login.tsx | 8 +- src/auth/logout.ts | 14 - src/auth/useAuthenticate.ts | 32 ++ .../FullscreenNavigation.tsx | 4 +- .../header/userDropdown/UserDropdown.tsx | 16 +- .../NotificationComponent.module.css | 3 - .../createProfile/CreateProfile.tsx | 12 +- .../deleteProfile/DeleteProfile.tsx | 11 +- .../components/editProfile/EditProfile.tsx | 14 +- src/profile/components/profile/Profile.tsx | 11 +- .../profileDeleted/ProfileDeleted.tsx | 5 +- .../profileInformation/ProfileInformation.tsx | 12 +- .../serviceConnections/ServiceConnections.tsx | 12 +- .../components/viewProfile/ViewProfile.tsx | 11 +- .../components/subsciptions/Subscriptions.tsx | 35 +- src/toast/Toast.tsx | 28 ++ src/toast/ToastContext.ts | 23 ++ src/toast/ToastProvider.tsx | 91 +++++ src/toast/__tests__/Toast.test.jsx | 58 +++ .../__snapshots__/Toast.test.jsx.snap | 332 ++++++++++++++++++ src/toast/toast.module.css | 12 + src/toast/toastActions.ts | 31 ++ src/toast/toastReducer.ts | 36 ++ src/toast/types.ts | 43 +++ src/toast/useToast.ts | 9 + yarn.lock | 8 +- 31 files changed, 790 insertions(+), 166 deletions(-) delete mode 100644 src/auth/authenticate.ts delete mode 100644 src/auth/logout.ts create mode 100644 src/auth/useAuthenticate.ts create mode 100644 src/toast/Toast.tsx create mode 100644 src/toast/ToastContext.ts create mode 100644 src/toast/ToastProvider.tsx create mode 100644 src/toast/__tests__/Toast.test.jsx create mode 100644 src/toast/__tests__/__snapshots__/Toast.test.jsx.snap create mode 100644 src/toast/toast.module.css create mode 100644 src/toast/toastActions.ts create mode 100644 src/toast/toastReducer.ts create mode 100644 src/toast/types.ts create mode 100644 src/toast/useToast.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0768316e4..289958b44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,5 +10,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed - Focus indicator being partially hidden with elements used for downloading and deleting profile +- Notifications rendering on top of each other ## [1.0.0-rc.1] diff --git a/package.json b/package.json index d85500412..5c843041b 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "date-fns": "^2.9.0", "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.2", - "enzyme-to-json": "^3.4.4", + "enzyme-to-json": "^3.5.0", "file-saver": "^2.0.2", "formik": "^2.0.4", "graphql": "^14.5.8", diff --git a/public/index.html b/public/index.html index 40be74d86..062d6a920 100644 --- a/public/index.html +++ b/public/index.html @@ -14,5 +14,6 @@
+
diff --git a/src/App.tsx b/src/App.tsx index f894aa9c9..793169aad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,10 +23,10 @@ import AccessibilityStatement from './accessibilityStatement/AccessibilityStatem import { MAIN_CONTENT_ID } from './common/constants'; import AccessibilityShortcuts from './common/accessibilityShortcuts/AccessibilityShortcuts'; import AppMeta from './AppMeta'; -import authenticate from './auth/authenticate'; -import logout from './auth/logout'; +import useAuthenticate from './auth/useAuthenticate'; import authConstants from './auth/constants/authConstants'; import GdprAuthorizationCodeManagerCallback from './gdprApi/GdprAuthorizationCodeManagerCallback'; +import ToastProvider from './toast/ToastProvider'; countries.registerLocale(fi); countries.registerLocale(en); @@ -58,6 +58,7 @@ type Props = {}; function App(props: Props) { const location = useLocation(); + const [authenticate, logout] = useAuthenticate(); if (location.pathname === '/loginsso') { authenticate(); @@ -83,36 +84,38 @@ function App(props: Props) { - - - {/* This should be the first focusable element */} - - - - - - - - - - - - - - - - - - - - - - 404 - not found - - + + + + {/* This should be the first focusable element */} + + + + + + + + + + + + + + + + + + + + + + 404 - not found + + + diff --git a/src/auth/authenticate.ts b/src/auth/authenticate.ts deleted file mode 100644 index 73383aef3..000000000 --- a/src/auth/authenticate.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as Sentry from '@sentry/browser'; - -import userManager from './userManager'; -import store from '../redux/store'; -import { apiError } from './redux'; - -export default function(): void { - userManager.signinRedirect().catch(error => { - if (error.message !== 'Network Error') { - Sentry.captureException(error); - } - store.dispatch(apiError(error.toString())); - }); -} diff --git a/src/auth/components/login/Login.tsx b/src/auth/components/login/Login.tsx index 00a9dd7b0..ba0519d74 100644 --- a/src/auth/components/login/Login.tsx +++ b/src/auth/components/login/Login.tsx @@ -8,9 +8,8 @@ import { RootState } from '../../../redux/rootReducer'; import { AuthState, resetApiError } from '../../redux'; import HelsinkiLogo from '../../../common/helsinkiLogo/HelsinkiLogo'; import styles from './Login.module.css'; -import authenticate from '../../authenticate'; import PageLayout from '../../../common/pageLayout/PageLayout'; -import NotificationComponent from '../../../common/notification/NotificationComponent'; +import useAuthenticate from '../../../auth/useAuthenticate'; type Props = { auth: AuthState; @@ -20,6 +19,7 @@ type Props = { function Home(props: Props) { const { t } = useTranslation(); const { trackEvent } = useMatomo(); + const [authenticate] = useAuthenticate(); return ( @@ -40,10 +40,6 @@ function Home(props: Props) {
- props.resetApiError()} - /> ); } diff --git a/src/auth/logout.ts b/src/auth/logout.ts deleted file mode 100644 index 36959b79e..000000000 --- a/src/auth/logout.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as Sentry from '@sentry/browser'; - -import authConstants from './constants/authConstants'; -import userManager from './userManager'; -import store from '../redux/store'; -import { apiError } from './redux'; - -export default function(): void { - window.localStorage.removeItem(authConstants.OIDC_KEY); - userManager.signoutRedirect().catch(error => { - Sentry.captureException(error); - store.dispatch(apiError(error.toString())); - }); -} diff --git a/src/auth/useAuthenticate.ts b/src/auth/useAuthenticate.ts new file mode 100644 index 000000000..d9b55aefc --- /dev/null +++ b/src/auth/useAuthenticate.ts @@ -0,0 +1,32 @@ +import React from 'react'; +import * as Sentry from '@sentry/browser'; + +import useToast from '../toast/useToast'; +import userManager from './userManager'; +import authConstants from './constants/authConstants'; + +function useAuthenticate() { + const { createToast } = useToast(); + + const authenticate = React.useCallback(() => { + userManager.signinRedirect().catch(error => { + if (error.message !== 'Network Error') { + Sentry.captureException(error); + } + + createToast({ type: 'error' }); + }); + }, [createToast]); + + const logout = React.useCallback(() => { + window.localStorage.removeItem(authConstants.OIDC_KEY); + userManager.signoutRedirect().catch(error => { + Sentry.captureException(error); + createToast({ type: 'error' }); + }); + }, [createToast]); + + return [authenticate, logout]; +} + +export default useAuthenticate; diff --git a/src/common/fullscreenNavigation/FullscreenNavigation.tsx b/src/common/fullscreenNavigation/FullscreenNavigation.tsx index 97ef3ac03..9d9b3bbd3 100644 --- a/src/common/fullscreenNavigation/FullscreenNavigation.tsx +++ b/src/common/fullscreenNavigation/FullscreenNavigation.tsx @@ -4,8 +4,7 @@ import { useTranslation } from 'react-i18next'; import { NavLink } from 'react-router-dom'; import { useSelector } from 'react-redux'; -import logout from '../../auth/logout'; -import authenticate from '../../auth/authenticate'; +import useAuthenticate from '../../auth/useAuthenticate'; import { isAuthenticatedSelector } from '../../auth/redux'; import { ReactComponent as HamburgerMenu } from '../svg/HamburgerMenu.svg'; import { ReactComponent as Close } from '../svg/Close.svg'; @@ -19,6 +18,7 @@ type Props = { function FullscreenNavigation(props: Props) { const { t } = useTranslation(); const [isNavOpen, setIsNavOpen] = useState(false); + const [authenticate, logout] = useAuthenticate(); const isAuthenticated = useSelector(isAuthenticatedSelector); diff --git a/src/common/header/userDropdown/UserDropdown.tsx b/src/common/header/userDropdown/UserDropdown.tsx index c9a30d0ed..b2d578bbf 100644 --- a/src/common/header/userDropdown/UserDropdown.tsx +++ b/src/common/header/userDropdown/UserDropdown.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import { useSelector } from 'react-redux'; import { useLazyQuery } from '@apollo/react-hooks'; import { useTranslation } from 'react-i18next'; @@ -8,25 +8,25 @@ import { useMatomo } from '@datapunt/matomo-tracker-react'; import PersonIcon from '../../svg/Person.svg'; import { NameQuery } from '../../../graphql/generatedTypes'; import Dropdown from '../../dropdown/Dropdown'; -import authenticate from '../../../auth/authenticate'; -import logout from '../../../auth/logout'; +import useAuthenticate from '../../../auth/useAuthenticate'; import { isAuthenticatedSelector } from '../../../auth/redux'; -import NotificationComponent from '../../notification/NotificationComponent'; +import useToast from '../../../toast/useToast'; const NAME_QUERY = loader('../../../profile/graphql/NameQuery.graphql'); type Props = {}; function UserDropdown(props: Props) { - const [showNotification, setShowNotification] = useState(false); + const { createToast } = useToast(); const [nameQuery, { data, loading }] = useLazyQuery(NAME_QUERY, { onError: () => { - setShowNotification(true); + createToast({ type: 'error' }); }, fetchPolicy: 'cache-only', }); const { t } = useTranslation(); const { trackEvent } = useMatomo(); + const [authenticate, logout] = useAuthenticate(); const isAuthenticated = useSelector(isAuthenticatedSelector); @@ -84,10 +84,6 @@ function UserDropdown(props: Props) { return ( - setShowNotification(false)} - /> ); } diff --git a/src/common/notification/NotificationComponent.module.css b/src/common/notification/NotificationComponent.module.css index 51096d061..6bcfaa1ed 100644 --- a/src/common/notification/NotificationComponent.module.css +++ b/src/common/notification/NotificationComponent.module.css @@ -1,8 +1,5 @@ .notification { width: 300px; - position: fixed; - top: 100px; - right: 0; } .notification p { diff --git a/src/profile/components/createProfile/CreateProfile.tsx b/src/profile/components/createProfile/CreateProfile.tsx index 12f1c19d2..37316b229 100644 --- a/src/profile/components/createProfile/CreateProfile.tsx +++ b/src/profile/components/createProfile/CreateProfile.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import { User } from 'oidc-client'; import { useTranslation } from 'react-i18next'; import { useMutation } from '@apollo/react-hooks'; @@ -19,8 +19,8 @@ import { Language, PhoneType, } from '../../../graphql/generatedTypes'; -import NotificationComponent from '../../../common/notification/NotificationComponent'; import ProfileSection from '../../../common/profileSection/ProfileSection'; +import useToast from '../../../toast/useToast'; const CREATE_PROFILE = loader('../../graphql/CreateMyProfile.graphql'); @@ -32,11 +32,11 @@ type Props = { function CreateProfile({ tunnistamoUser, onProfileCreated }: Props) { const { t } = useTranslation(); const { trackEvent } = useMatomo(); - const [showNotification, setShowNotification] = useState(false); const [createProfile, { loading }] = useMutation< CreateMyProfileData, CreateMyProfileVariables >(CREATE_PROFILE); + const { createToast } = useToast(); const handleOnValues = (formValues: FormValues) => { const variables: CreateMyProfileVariables = { @@ -74,7 +74,7 @@ function CreateProfile({ tunnistamoUser, onProfileCreated }: Props) { }) .catch((error: Error) => { Sentry.captureException(error); - setShowNotification(true); + createToast({ type: 'error' }); }); }; return ( @@ -102,10 +102,6 @@ function CreateProfile({ tunnistamoUser, onProfileCreated }: Props) { />
- setShowNotification(false)} - />
); } diff --git a/src/profile/components/deleteProfile/DeleteProfile.tsx b/src/profile/components/deleteProfile/DeleteProfile.tsx index f0551f825..057dcacca 100644 --- a/src/profile/components/deleteProfile/DeleteProfile.tsx +++ b/src/profile/components/deleteProfile/DeleteProfile.tsx @@ -6,10 +6,10 @@ import * as Sentry from '@sentry/browser'; import { Checkbox } from 'hds-react'; import ConfirmationModal from '../modals/confirmationModal/ConfirmationModal'; -import NotificationComponent from '../../../common/notification/NotificationComponent'; import ExpandingPanel from '../../../common/expandingPanel/ExpandingPanel'; import Button from '../../../common/button/Button'; import { ServiceConnectionsQuery } from '../../../graphql/generatedTypes'; +import useToast from '../../../toast/useToast'; import styles from './deleteProfile.module.css'; const SERVICE_CONNECTIONS = loader( @@ -24,7 +24,7 @@ type Props = { function DeleteProfile({ isOpenByDefault, onDelete }: Props) { const [deleteConfirmationModal, setDeleteConfirmationModal] = useState(false); const [deleteInstructions, setDeleteInstructions] = useState(false); - const [showNotification, setShowNotification] = useState(false); + const { createToast } = useToast(); const { t, i18n } = useTranslation(); @@ -33,7 +33,7 @@ function DeleteProfile({ isOpenByDefault, onDelete }: Props) { { onError: (error: Error) => { Sentry.captureException(error); - setShowNotification(true); + createToast({ type: 'error' }); }, } ); @@ -108,11 +108,6 @@ function DeleteProfile({ isOpenByDefault, onDelete }: Props) { } actionButtonText={t('deleteProfileModal.delete')} /> - - setShowNotification(false)} - /> ); } diff --git a/src/profile/components/editProfile/EditProfile.tsx b/src/profile/components/editProfile/EditProfile.tsx index 04a25def4..1e429c893 100644 --- a/src/profile/components/editProfile/EditProfile.tsx +++ b/src/profile/components/editProfile/EditProfile.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { useMutation, useQuery } from '@apollo/react-hooks'; import { loader } from 'graphql.macro'; import { useTranslation } from 'react-i18next'; @@ -17,12 +17,12 @@ import { UpdateMyProfile as UpdateMyProfileData, UpdateMyProfileVariables, } from '../../../graphql/generatedTypes'; -import NotificationComponent from '../../../common/notification/NotificationComponent'; import ProfileSection from '../../../common/profileSection/ProfileSection'; import getEmailsFromNode from '../../helpers/getEmailsFromNode'; import getAddressesFromNode from '../../helpers/getAddressesFromNode'; import { updateMutationVariables } from '../../helpers/updateMutationVariables'; import getPhonesFromNode from '../../helpers/getPhonesFromNode'; +import useToast from '../../../toast/useToast'; const UPDATE_PROFILE = loader('../../graphql/UpdateMyProfile.graphql'); const SERVICE_CONNECTIONS = loader( @@ -35,13 +35,13 @@ type Props = { }; function EditProfile(props: Props) { - const [showNotification, setShowNotification] = useState(false); + const { createToast } = useToast(); const { data, refetch } = useQuery( SERVICE_CONNECTIONS, { onError: (error: Error) => { Sentry.captureException(error); - setShowNotification(true); + createToast({ type: 'error' }); }, } ); @@ -76,7 +76,7 @@ function EditProfile(props: Props) { }) .catch((error: Error) => { Sentry.captureException(error); - setShowNotification(true); + createToast({ type: 'error' }); }); }; @@ -105,10 +105,6 @@ function EditProfile(props: Props) { isSubmitting={loading} onValues={handleOnValues} /> - setShowNotification(false)} - /> ); } diff --git a/src/profile/components/profile/Profile.tsx b/src/profile/components/profile/Profile.tsx index a47f8ca2c..e5b09a4f5 100644 --- a/src/profile/components/profile/Profile.tsx +++ b/src/profile/components/profile/Profile.tsx @@ -13,7 +13,7 @@ import ViewProfile from '../viewProfile/ViewProfile'; import Loading from '../../../common/loading/Loading'; import styles from './Profile.module.css'; import { ProfileExistsQuery } from '../../../graphql/generatedTypes'; -import NotificationComponent from '../../../common/notification/NotificationComponent'; +import useToast from '../../../toast/useToast'; const PROFILE_EXISTS = loader('../../graphql/ProfileExistsQuery.graphql'); @@ -23,6 +23,7 @@ function Profile(props: Props) { const { t } = useTranslation(); const history = useHistory(); const location = useLocation(); + const { createToast } = useToast(); const [checkProfileExists, { data, loading }] = useLazyQuery< ProfileExistsQuery @@ -30,12 +31,11 @@ function Profile(props: Props) { fetchPolicy: 'no-cache', onError: (error: Error) => { Sentry.captureException(error); - setShowNotification(true); + createToast({ type: 'error' }); }, }); const [isCheckingAuthState, setIsCheckingAuthState] = useState(true); const [tunnistamoUser, setTunnistamoUser] = useState(); - const [showNotification, setShowNotification] = useState(false); useEffect(() => { getAuthenticatedUser() @@ -83,11 +83,6 @@ function Profile(props: Props) { /> )} - - setShowNotification(false)} - /> ); } diff --git a/src/profile/components/profileDeleted/ProfileDeleted.tsx b/src/profile/components/profileDeleted/ProfileDeleted.tsx index 934421985..e7f5c9876 100644 --- a/src/profile/components/profileDeleted/ProfileDeleted.tsx +++ b/src/profile/components/profileDeleted/ProfileDeleted.tsx @@ -4,11 +4,12 @@ import { useTranslation } from 'react-i18next'; import styles from './ProfileDeleted.module.css'; import responsive from '../../../common/cssHelpers/responsive.module.css'; import PageLayout from '../../../common/pageLayout/PageLayout'; -import logout from '../../../auth/logout'; +import useAuthenticate from '../../../auth/useAuthenticate'; function ProfileDeleted() { const [timeUntilLogout, setTimeUntilLogout] = useState(10); const { t } = useTranslation(); + const [, logout] = useAuthenticate(); useEffect(() => { if (timeUntilLogout > 0) { @@ -17,7 +18,7 @@ function ProfileDeleted() { }, 1000); return () => clearInterval(interval); } else logout(); - }, [timeUntilLogout]); + }, [logout, timeUntilLogout]); return ( diff --git a/src/profile/components/profileInformation/ProfileInformation.tsx b/src/profile/components/profileInformation/ProfileInformation.tsx index 619d5b954..16ce6be81 100644 --- a/src/profile/components/profileInformation/ProfileInformation.tsx +++ b/src/profile/components/profileInformation/ProfileInformation.tsx @@ -26,12 +26,12 @@ import ProfileSection from '../../../common/profileSection/ProfileSection'; import getEmailsFromNode from '../../helpers/getEmailsFromNode'; import getAddressesFromNode from '../../helpers/getAddressesFromNode'; import getPhonesFromNode from '../../helpers/getPhonesFromNode'; -import NotificationComponent from '../../../common/notification/NotificationComponent'; import useDownloadProfile from '../../../gdprApi/useDownloadProfile'; import useDeleteProfile from '../../../gdprApi/useDeleteProfile'; import checkBerthError from '../../helpers/checkBerthError'; import BerthErrorModal from '../modals/berthError/BerthErrorModal'; import ProfileInformationAccountManagementLink from './ProfileInformationAccountManagementLink'; +import useToast from '../../../toast/useToast'; const ALL_DATA = loader('../../graphql/DownloadMyProfileQuery.graphql'); @@ -46,8 +46,8 @@ function ProfileInformation(props: Props) { const history = useHistory(); const { t, i18n } = useTranslation(); const { trackEvent } = useMatomo(); - const [showNotification, setShowNotification] = useState(false); const [berthError, setBerthError] = useState(false); + const { createToast } = useToast(); // useDownloadProfile and useDeleteProfile need to be mounted when // the page they are on is first rendered. That's why it's sensible to @@ -63,7 +63,7 @@ function ProfileInformation(props: Props) { }, onError: (error: Error) => { Sentry.captureException(error); - setShowNotification(true); + createToast({ type: 'error' }); }, fetchPolicy: 'network-only', }); @@ -79,7 +79,7 @@ function ProfileInformation(props: Props) { setBerthError(true); } else { Sentry.captureException(error); - setShowNotification(true); + createToast({ type: 'error' }); } }, }); @@ -202,10 +202,6 @@ function ProfileInformation(props: Props) { isOpenByDefault={isDeletingProfile} />
- setShowNotification(false)} - /> setBerthError(prevState => !prevState)} diff --git a/src/profile/components/serviceConnections/ServiceConnections.tsx b/src/profile/components/serviceConnections/ServiceConnections.tsx index ad9e79a38..9b5029ca5 100644 --- a/src/profile/components/serviceConnections/ServiceConnections.tsx +++ b/src/profile/components/serviceConnections/ServiceConnections.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { loader } from 'graphql.macro'; import { useQuery } from '@apollo/react-hooks'; @@ -13,7 +13,7 @@ import styles from './ServiceConnections.module.css'; import { ServiceConnectionsQuery } from '../../../graphql/generatedTypes'; import getServices from '../../helpers/getServices'; import getAllowedDataFieldsFromService from '../../helpers/getAllowedDataFieldsFromService'; -import NotificationComponent from '../../../common/notification/NotificationComponent'; +import useToast from '../../../toast/useToast'; const SERVICE_CONNECTIONS = loader( '../../graphql/ServiceConnectionsQuery.graphql' @@ -23,14 +23,14 @@ type Props = {}; function ServiceConnections(props: Props) { const { t, i18n } = useTranslation(); - const [showNotification, setShowNotification] = useState(false); + const { createToast } = useToast(); const { data, loading, refetch } = useQuery( SERVICE_CONNECTIONS, { onError: (error: Error) => { Sentry.captureException(error); - setShowNotification(true); + createToast({ type: 'error' }); }, } ); @@ -92,10 +92,6 @@ function ServiceConnections(props: Props) { ))} - setShowNotification(false)} - /> ); } diff --git a/src/profile/components/viewProfile/ViewProfile.tsx b/src/profile/components/viewProfile/ViewProfile.tsx index f889f644d..5256bac1f 100644 --- a/src/profile/components/viewProfile/ViewProfile.tsx +++ b/src/profile/components/viewProfile/ViewProfile.tsx @@ -15,20 +15,20 @@ import getNicknameOrName from '../../helpers/getNicknameOrName'; import ServiceConnections from '../serviceConnections/ServiceConnections'; import Subscriptions from '../../../subscriptions/components/subsciptions/Subscriptions'; import { MyProfileQuery } from '../../../graphql/generatedTypes'; -import NotificationComponent from '../../../common/notification/NotificationComponent'; import Explanation from '../../../common/explanation/Explanation'; +import useToast from '../../../toast/useToast'; const MY_PROFILE = loader('../../graphql/MyProfileQuery.graphql'); function ViewProfile() { const [isEditing, setEditing] = useState(false); - const [showNotification, setShowNotification] = useState(false); const { t } = useTranslation(); + const { createToast } = useToast(); const { data, loading } = useQuery(MY_PROFILE, { onError: (error: Error) => { Sentry.captureException(error); - setShowNotification(true); + createToast({ type: 'error' }); }, }); @@ -112,11 +112,6 @@ function ViewProfile() { )} - - setShowNotification(false)} - /> ); } diff --git a/src/subscriptions/components/subsciptions/Subscriptions.tsx b/src/subscriptions/components/subsciptions/Subscriptions.tsx index 7ac6a3018..2d6f2f4b8 100644 --- a/src/subscriptions/components/subsciptions/Subscriptions.tsx +++ b/src/subscriptions/components/subsciptions/Subscriptions.tsx @@ -7,7 +7,6 @@ import { Checkbox } from 'hds-react'; import Explanation from '../../../common/explanation/Explanation'; import Button from '../../../common/button/Button'; -import NotificationComponent from '../../../common/notification/NotificationComponent'; import Loading from '../../../common/loading/Loading'; import responsive from '../../../common/cssHelpers/responsive.module.css'; import styles from './Subscriptions.module.css'; @@ -19,6 +18,7 @@ import { SubscriptionInputType, } from '../../../graphql/generatedTypes'; import getSubscriptionsData from '../../helpers/getSubscriptionsData'; +import useToast from '../../../toast/useToast'; const QUERY_SUBSCRIPTIONS = loader('../../graphql/QuerySubscriptions.graphql'); const QUERY_MY_SUBSCRIPTIONS = loader( @@ -43,19 +43,18 @@ type SubscriptionData = { }; function Subscriptions() { - const [showNotification, setShowNotification] = useState(false); - const [saveSuccessful, setSaveSuccessful] = useState(false); const [subscriptionData, setSubscriptionData] = useState< SubscriptionData[] >(); const { t, i18n } = useTranslation(); + const { createToast } = useToast(); const { data, loading, refetch } = useQuery( QUERY_SUBSCRIPTIONS, { onError: (error: Error) => { Sentry.captureException(error); - setShowNotification(true); + createToast({ type: 'error' }); }, } ); @@ -65,7 +64,7 @@ function Subscriptions() { >(QUERY_MY_SUBSCRIPTIONS, { onError: (error: Error) => { Sentry.captureException(error); - setShowNotification(true); + createToast({ type: 'error' }); }, }); @@ -115,10 +114,18 @@ function Subscriptions() { }; updateSubscriptions({ variables }) - .then(results => setSaveSuccessful(!!results.data)) + .then(results => { + if (!!results.data) { + createToast({ + type: 'success', + title: t('subscriptions.saveSuccess'), + description: t('subscriptions.saveSuccessMessage'), + }); + } + }) .catch((error: Error) => { Sentry.captureException(error); - setShowNotification(true); + createToast({ type: 'error' }); }); }; @@ -215,20 +222,6 @@ function Subscriptions() { )} - - setShowNotification(false)} - /> - - setSaveSuccessful(false)} - > - {t('subscriptions.saveSuccessMessage')} - ); diff --git a/src/toast/Toast.tsx b/src/toast/Toast.tsx new file mode 100644 index 000000000..bff0e2de4 --- /dev/null +++ b/src/toast/Toast.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import Notification from '../common/notification/NotificationComponent'; +import { ToastTypes } from './types'; + +interface Props { + title?: string; + description?: string; + id: string; + type?: ToastTypes; + onClose: () => void; + hidden: boolean; +} + +function Toast({ title, type, onClose, hidden, description }: Props) { + return ( + + {description} + + ); +} + +export default Toast; diff --git a/src/toast/ToastContext.ts b/src/toast/ToastContext.ts new file mode 100644 index 000000000..0b64a187f --- /dev/null +++ b/src/toast/ToastContext.ts @@ -0,0 +1,23 @@ +import { createContext } from 'react'; + +import { LaxToast } from './types'; + +interface ToastContextType { + createToast: (toast?: LaxToast) => string; + hideToast: (toastId: string) => void; + deleteToast: (toastId: string) => void; +} + +const ToastContext = createContext({ + createToast: (toast?: LaxToast): string => { + return ''; + }, + hideToast: (toastId: string) => { + // pass + }, + deleteToast: (toastId: string) => { + // pass + }, +}); + +export default ToastContext; diff --git a/src/toast/ToastProvider.tsx b/src/toast/ToastProvider.tsx new file mode 100644 index 000000000..0f8ed2777 --- /dev/null +++ b/src/toast/ToastProvider.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { v4 as uuid } from 'uuid'; + +import ToastContext from './ToastContext'; +import toastReducer from './toastReducer'; +import Toast from './Toast'; +import { + pushToast, + deleteToast as deleteToastActionCreator, + hideToast as hideToastActionCreator, +} from './toastActions'; +import { Toast as ToastType, LaxToast } from './types'; +import styles from './toast.module.css'; + +const TOAST_ROOT_DOM_NODE_ID = 'toast-root'; + +interface Props { + children: React.ReactElement; +} + +function ToastProvider({ children }: Props) { + const [state, dispatch] = React.useReducer(toastReducer, { toasts: [] }); + + const createToast = React.useCallback((toast: LaxToast = {}): string => { + const id = uuid(); + const toastWithDefaults: ToastType = { + id: uuid(), + type: 'notification', + hidden: false, + ...toast, + }; + + dispatch(pushToast(toastWithDefaults)); + + return id; + }, []); + + function deleteToast(toastId: string) { + dispatch(deleteToastActionCreator(toastId)); + } + + function hideToast(toastId: string) { + dispatch(hideToastActionCreator(toastId)); + } + + function handleCloseToast(toastId: string) { + // Use timeout to make sure that the notification has time to set + // its own state before it's removed from the dom. Otherwise React + // will log a memory leak error. + setTimeout(() => { + deleteToast(toastId); + }); + } + + const toasts = state.toasts; + + const toastRoot = document.getElementById(TOAST_ROOT_DOM_NODE_ID); + + // After a logout, if the user goes back into /profile-deleted, the + // root element for toast may be missing. + if (!toastRoot) { + return children; + } + + return ( + + {ReactDOM.createPortal( +
+ {toasts.map(toast => ( + handleCloseToast(toast.id)} + /> + ))} +
, + toastRoot + )} + {children} +
+ ); +} + +export default ToastProvider; diff --git a/src/toast/__tests__/Toast.test.jsx b/src/toast/__tests__/Toast.test.jsx new file mode 100644 index 000000000..c4a0847bc --- /dev/null +++ b/src/toast/__tests__/Toast.test.jsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import enzymeToJson from 'enzyme-to-json'; + +import { updateWrapper } from '../../common/test/testUtils'; +import ToastProvider from '../ToastProvider'; +import useToast from '../useToast'; + +function TestInvoker({ toast }) { + const { createToast } = useToast(); + + const handleClick = () => { + createToast(toast); + }; + + return ( + + ); +} + +describe('Toast', () => { + const toast = { + title: 'test title', + type: 'error', + id: 'test-1', + }; + const defaultProps = { + toast, + }; + const getWrapper = props => + mount( + + + + ); + + beforeAll(() => { + const toastRoot = global.document.createElement('div'); + + toastRoot.setAttribute('id', 'toast-root'); + global.document.body.appendChild(toastRoot); + }); + + afterAll(() => { + global.document.getElementById('toast').remove(); + }); + + it('should render correct toast', async () => { + const wrapper = getWrapper(); + + wrapper.find('#create').simulate('click'); + await updateWrapper(wrapper); + + expect(enzymeToJson(wrapper)).toMatchSnapshot(); + }); +}); diff --git a/src/toast/__tests__/__snapshots__/Toast.test.jsx.snap b/src/toast/__tests__/__snapshots__/Toast.test.jsx.snap new file mode 100644 index 000000000..8a2bf8a1a --- /dev/null +++ b/src/toast/__tests__/__snapshots__/Toast.test.jsx.snap @@ -0,0 +1,332 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Toast should render correct toast 1`] = ` + + +
+
+
+
+
+ + + test title + +
+
+ +
+ notification.defaultErrorText +
+
+
+
+
+
+ + } + > +
+ +
+
+ + + +
+`; diff --git a/src/toast/toast.module.css b/src/toast/toast.module.css new file mode 100644 index 000000000..7e135196a --- /dev/null +++ b/src/toast/toast.module.css @@ -0,0 +1,12 @@ +.toastContainer { + /* 99 is the z-index for modals */ + --toast-z-index: calc(99 + 1); + --top-whitespace: var(--spacing-layout-xl); + + position: fixed; + top: var(--top-whitespace); + right: var(--spacing-layout-s); + z-index: var(--toast-z-index); + display: flex; + flex-direction: column; +} \ No newline at end of file diff --git a/src/toast/toastActions.ts b/src/toast/toastActions.ts new file mode 100644 index 000000000..2f67ae9de --- /dev/null +++ b/src/toast/toastActions.ts @@ -0,0 +1,31 @@ +import { + Toast, + ToastPushAction, + ToastDeleteAction, + ToastHideAction, +} from './types'; + +export const PUSH_TOAST = 'PUSH_TOAST'; +export const DELETE_TOAST = 'DELETE_TOAST'; +export const HIDE_TOAST = 'HIDE_TOAST'; + +export function pushToast(toast: Toast): ToastPushAction { + return { + type: PUSH_TOAST, + toast, + }; +} + +export function deleteToast(toastId: string): ToastDeleteAction { + return { + type: DELETE_TOAST, + toastId, + }; +} + +export function hideToast(toastId: string): ToastHideAction { + return { + type: HIDE_TOAST, + toastId, + }; +} diff --git a/src/toast/toastReducer.ts b/src/toast/toastReducer.ts new file mode 100644 index 000000000..b5770ead2 --- /dev/null +++ b/src/toast/toastReducer.ts @@ -0,0 +1,36 @@ +import { PUSH_TOAST, DELETE_TOAST, HIDE_TOAST } from './toastActions'; +import { ToastState, ToastActions } from './types'; + +function toastReducer(state: ToastState, action: ToastActions): ToastState { + switch (action.type) { + case PUSH_TOAST: + return { + toasts: [...state.toasts, action.toast], + }; + case DELETE_TOAST: + const deleteId = action.toastId; + + return { + toasts: state.toasts.filter(toast => toast.id !== deleteId), + }; + case HIDE_TOAST: + const hideId = action.toastId; + + return { + toasts: state.toasts.map(toast => { + if (toast.id !== hideId) { + return toast; + } + + return { + ...toast, + hidden: true, + }; + }), + }; + default: + throw new Error(); + } +} + +export default toastReducer; diff --git a/src/toast/types.ts b/src/toast/types.ts new file mode 100644 index 000000000..97471999a --- /dev/null +++ b/src/toast/types.ts @@ -0,0 +1,43 @@ +import { PUSH_TOAST, DELETE_TOAST, HIDE_TOAST } from './toastActions'; + +export type ToastTypes = 'error' | 'success' | 'warning' | 'notification'; + +export interface Toast { + title?: string; + description?: string; + id: string; + type: ToastTypes; + hidden: boolean; +} + +export interface LaxToast { + title?: string; + description?: string; + id?: string; + type?: ToastTypes; + hidden?: boolean; +} + +export interface ToastState { + toasts: Toast[]; +} + +export type ToastPushAction = { + type: typeof PUSH_TOAST; + toast: Toast; +}; + +export type ToastDeleteAction = { + type: typeof DELETE_TOAST; + toastId: string; +}; + +export type ToastHideAction = { + type: typeof HIDE_TOAST; + toastId: string; +}; + +export type ToastActions = + | ToastPushAction + | ToastDeleteAction + | ToastHideAction; diff --git a/src/toast/useToast.ts b/src/toast/useToast.ts new file mode 100644 index 000000000..752cf40d1 --- /dev/null +++ b/src/toast/useToast.ts @@ -0,0 +1,9 @@ +import React from 'react'; + +import ToastContext from './ToastContext'; + +function useToast() { + return React.useContext(ToastContext); +} + +export default useToast; diff --git a/yarn.lock b/yarn.lock index c98d0c683..6a1143ac4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4096,10 +4096,10 @@ enzyme-shallow-equal@^1.0.1: has "^1.0.3" object-is "^1.0.2" -enzyme-to-json@^3.4.4: - version "3.4.4" - resolved "https://registry.yarnpkg.com/enzyme-to-json/-/enzyme-to-json-3.4.4.tgz#b30726c59091d273521b6568c859e8831e94d00e" - integrity sha512-50LELP/SCPJJGic5rAARvU7pgE3m1YaNj7JLM+Qkhl5t7PAs6fiyc8xzc50RnkKPFQCv0EeFVjEWdIFRGPWMsA== +enzyme-to-json@^3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/enzyme-to-json/-/enzyme-to-json-3.5.0.tgz#3d536f1e8fb50d972360014fe2bd64e6a672f7dd" + integrity sha512-clusXRsiaQhG7+wtyc4t7MU8N3zCOgf4eY9+CeSenYzKlFST4lxerfOvnWd4SNaToKhkuba+w6m242YpQOS7eA== dependencies: lodash "^4.17.15" react-is "^16.12.0" From 6a211607b6324f3967349af04ab4edb371402bc4 Mon Sep 17 00:00:00 2001 From: Santtu Tuovinen Date: Wed, 1 Jul 2020 10:46:53 +0300 Subject: [PATCH 39/48] Replace custom select with HDS Dropdown component. --- src/common/select/Select.module.css | 56 --------------- src/common/select/Select.tsx | 61 ++++++++-------- .../createProfileForm/CreateProfileForm.tsx | 32 +++++---- .../editProfileForm/EditProfileForm.tsx | 69 +++++++++++++------ 4 files changed, 100 insertions(+), 118 deletions(-) delete mode 100644 src/common/select/Select.module.css diff --git a/src/common/select/Select.module.css b/src/common/select/Select.module.css deleted file mode 100644 index 780b22fba..000000000 --- a/src/common/select/Select.module.css +++ /dev/null @@ -1,56 +0,0 @@ -.select { - display: flex; - flex-direction: column; - width: 100%; -} - -.label { - font-size: var(--fontsize-body-small); - line-height: var(--lineheight-xl); - font-weight: bold; - margin-bottom: 0.2em; -} - -.select select { - display: block; - padding: 0.888em 0.72em; - width: 100%; - max-width: 100%; - box-sizing: border-box; - margin: 0; - font-size: var(--fontsize-h-5); - border: 2px solid var(--color-black-20); - border-radius: 0; - background-color: var(--color-white); - background-image: url("../svg/ChevronDown.svg"); - background-repeat: no-repeat, repeat; - background-position: right .7em top 50%, 0 0; - background-size: .65em auto, 100%; - appearance: none; -} - - -.select select::-ms-expand { - display: none; -} -.select select:hover { - border-color: var(--color-black-40); -} -.select select:focus { - border-color: var(--color-black); - box-shadow: 0 0 0 3px -moz-mac-focusring; - color: var(--color-black-80); - outline: none; -} -.select select option { - font-weight:normal; -} - -.select select:disabled, .select select[aria-disabled=true] { - color: graytext; - background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22graytext%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E'), - linear-gradient(to bottom, var(--color-white) 0%, var(--color-black-10) 100%); -} -.select select:disabled:hover, .select select[aria-disabled=true] { - border-color: var(--color-black-30); -} diff --git a/src/common/select/Select.tsx b/src/common/select/Select.tsx index d82a4b995..2c3103b45 100644 --- a/src/common/select/Select.tsx +++ b/src/common/select/Select.tsx @@ -1,43 +1,44 @@ import React from 'react'; -import classNames from 'classnames'; +import { Dropdown, DropdownProps } from 'hds-react'; +import { Field, FieldProps } from 'formik'; -import styles from './Select.module.css'; +import { Language } from '../../graphql/generatedTypes'; -type Option = { +type Props = { + name: string; + default: string | Language; +} & DropdownProps; + +export type OptionType = { value: string; label: string; }; -type Props = { - name: string; - id?: string; - options: Option[]; - className?: string; - labelText?: string; - onChange?: () => void; - value?: string; +export type HdsOptionType = { + //eslint-disable-next-line + [key: string]: any; }; -function Select(props: Props) { +function FormikDropdown(props: Props) { + // HDS Dropdown expects default value to be an object. Find correct option object from array. + const getSelectDefault = (options: OptionType[], value?: string) => { + return options.find((option: OptionType) => option.value === value); + }; + return ( -
- - -
+ + {(fieldProps: FieldProps) => ( + + )} + ); } -export default Select; +export default FormikDropdown; diff --git a/src/profile/components/createProfileForm/CreateProfileForm.tsx b/src/profile/components/createProfileForm/CreateProfileForm.tsx index fab337c46..eea4de7e8 100644 --- a/src/profile/components/createProfileForm/CreateProfileForm.tsx +++ b/src/profile/components/createProfileForm/CreateProfileForm.tsx @@ -5,7 +5,7 @@ import { Formik, Form, Field, FormikProps } from 'formik'; import * as yup from 'yup'; import { getIsInvalid, getError } from '../../helpers/formik'; -import Select from '../../../common/select/Select'; +import FormikDropdown, { HdsOptionType } from '../../../common/select/Select'; import Button from '../../../common/button/Button'; import styles from './CreateProfileForm.module.css'; import profileConstants from '../../constants/profileConstants'; @@ -52,6 +52,13 @@ function CreateProfileForm(props: Props) { return getError(formikProps, fieldName, renderError); }; + const profileLanguageOptions = profileConstants.LANGUAGES.map(language => { + return { + value: language, + label: t(`LANGUAGE_OPTIONS.${language}`), + }; + }); + return ( - { - return { - value: language, - label: t(`LANGUAGE_OPTIONS.${language}`), - }; - })} - labelText={t('profileForm.language')} - name="profileLanguage" - id="profileLanguage" + name={'profileLanguage'} + options={profileLanguageOptions} + default={formikProps.values.profileLanguage} + label={t('profileForm.language')} + // onChange option type needs to match HDS that's why its set to OptionType | OptionType. + onChange={(option: HdsOptionType | HdsOptionType[]) => + formikProps.setFieldValue( + 'profileLanguage', + !Array.isArray(option) && option.value + ) + } /> { + return { + value: language, + label: t(`LANGUAGE_OPTIONS.${language}`), + }; + } + ); return ( - { - return { - value: language, - label: t(`LANGUAGE_OPTIONS.${language}`), - }; - })} - labelText={t('profileForm.language')} + name={'profileLanguage'} + options={profileLanguageOptions} + default={formikProps.values.profileLanguage} + label={t('profileForm.language')} + // onChange option type needs to match HDS that's why its set to OptionType | OptionType. + onChange={(option: HdsOptionType | HdsOptionType[]) => + formikProps.setFieldValue( + 'profileLanguage', + !Array.isArray(option) && option.value + ) + } /> - - + formikProps.setFieldValue( + 'primaryAddress.countryCode' as 'primaryAddress', + !Array.isArray(option) && option.value + ) + } />
@@ -479,13 +497,24 @@ function EditProfileForm(props: Props) { )} /> - + formikProps.setFieldValue( + `addresses.${index}.countryCode` as 'addresses', + !Array.isArray(option) && option.value + ) + } />
Date: Wed, 1 Jul 2020 10:49:26 +0300 Subject: [PATCH 40/48] Rename component to better match its purpose --- .../{select/Select.tsx => formikDropdown/FormikDropdown.tsx} | 0 .../components/createProfileForm/CreateProfileForm.tsx | 4 +++- src/profile/components/editProfileForm/EditProfileForm.tsx | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) rename src/common/{select/Select.tsx => formikDropdown/FormikDropdown.tsx} (100%) diff --git a/src/common/select/Select.tsx b/src/common/formikDropdown/FormikDropdown.tsx similarity index 100% rename from src/common/select/Select.tsx rename to src/common/formikDropdown/FormikDropdown.tsx diff --git a/src/profile/components/createProfileForm/CreateProfileForm.tsx b/src/profile/components/createProfileForm/CreateProfileForm.tsx index eea4de7e8..705186019 100644 --- a/src/profile/components/createProfileForm/CreateProfileForm.tsx +++ b/src/profile/components/createProfileForm/CreateProfileForm.tsx @@ -5,7 +5,9 @@ import { Formik, Form, Field, FormikProps } from 'formik'; import * as yup from 'yup'; import { getIsInvalid, getError } from '../../helpers/formik'; -import FormikDropdown, { HdsOptionType } from '../../../common/select/Select'; +import FormikDropdown, { + HdsOptionType, +} from '../../../common/formikDropdown/FormikDropdown'; import Button from '../../../common/button/Button'; import styles from './CreateProfileForm.module.css'; import profileConstants from '../../constants/profileConstants'; diff --git a/src/profile/components/editProfileForm/EditProfileForm.tsx b/src/profile/components/editProfileForm/EditProfileForm.tsx index edcea0b1f..78e96f184 100644 --- a/src/profile/components/editProfileForm/EditProfileForm.tsx +++ b/src/profile/components/editProfileForm/EditProfileForm.tsx @@ -34,7 +34,7 @@ import AdditionalInformationActions from './AdditionalInformationActions'; import FormikDropdown, { OptionType, HdsOptionType, -} from '../../../common/select/Select'; +} from '../../../common/formikDropdown/FormikDropdown'; const address = yup.object().shape({ address: yup.string().max(128, 'validation.maxLength'), From cc6dd9783df44ea11544fe3fd50eba671fbe941c Mon Sep 17 00:00:00 2001 From: Santtu Tuovinen Date: Thu, 2 Jul 2020 06:14:21 +0300 Subject: [PATCH 41/48] onChange comment is now shorter. --- .../components/createProfileForm/CreateProfileForm.tsx | 2 +- src/profile/components/editProfileForm/EditProfileForm.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/profile/components/createProfileForm/CreateProfileForm.tsx b/src/profile/components/createProfileForm/CreateProfileForm.tsx index 705186019..30cf7a817 100644 --- a/src/profile/components/createProfileForm/CreateProfileForm.tsx +++ b/src/profile/components/createProfileForm/CreateProfileForm.tsx @@ -113,7 +113,7 @@ function CreateProfileForm(props: Props) { options={profileLanguageOptions} default={formikProps.values.profileLanguage} label={t('profileForm.language')} - // onChange option type needs to match HDS that's why its set to OptionType | OptionType. + // onChange is set to OptionType | OptionType[] in order to match hds typing onChange={(option: HdsOptionType | HdsOptionType[]) => formikProps.setFieldValue( 'profileLanguage', diff --git a/src/profile/components/editProfileForm/EditProfileForm.tsx b/src/profile/components/editProfileForm/EditProfileForm.tsx index 78e96f184..fae8057de 100644 --- a/src/profile/components/editProfileForm/EditProfileForm.tsx +++ b/src/profile/components/editProfileForm/EditProfileForm.tsx @@ -223,7 +223,7 @@ function EditProfileForm(props: Props) { options={profileLanguageOptions} default={formikProps.values.profileLanguage} label={t('profileForm.language')} - // onChange option type needs to match HDS that's why its set to OptionType | OptionType. + // onChange is set to OptionType | OptionType[] in order to match hds typing onChange={(option: HdsOptionType | HdsOptionType[]) => formikProps.setFieldValue( 'profileLanguage', @@ -312,7 +312,7 @@ function EditProfileForm(props: Props) { options={countryOptions} default={formikProps.values.primaryAddress.countryCode} label={t('profileForm.country')} - // onChange option type needs to match HDS that's why its set to OptionType | OptionType. + // onChange is set to OptionType | OptionType[] in order to match hds typing onChange={(option: HdsOptionType | HdsOptionType[]) => formikProps.setFieldValue( 'primaryAddress.countryCode' as 'primaryAddress', @@ -506,7 +506,7 @@ function EditProfileForm(props: Props) { default={ formikProps.values.addresses[index].countryCode } - // onChange option type needs to match HDS that's why its set to OptionType | OptionType. + // onChange is set to OptionType | OptionType[] in order to match hds typing onChange={( option: HdsOptionType | HdsOptionType[] ) => From b20ee8a4491d0dc3bb06a515783d900707de735e Mon Sep 17 00:00:00 2001 From: Santtu Tuovinen Date: Thu, 2 Jul 2020 13:40:57 +0300 Subject: [PATCH 42/48] Set dropdown multiselect to false. Improves typing + shortens onChange function. --- src/common/formikDropdown/FormikDropdown.tsx | 1 + .../createProfileForm/CreateProfileForm.tsx | 8 ++------ .../editProfileForm/EditProfileForm.tsx | 20 ++++++------------- 3 files changed, 9 insertions(+), 20 deletions(-) diff --git a/src/common/formikDropdown/FormikDropdown.tsx b/src/common/formikDropdown/FormikDropdown.tsx index 2c3103b45..210552770 100644 --- a/src/common/formikDropdown/FormikDropdown.tsx +++ b/src/common/formikDropdown/FormikDropdown.tsx @@ -35,6 +35,7 @@ function FormikDropdown(props: Props) { props.options as OptionType[], props.default )} + multiselect={false} /> )}
diff --git a/src/profile/components/createProfileForm/CreateProfileForm.tsx b/src/profile/components/createProfileForm/CreateProfileForm.tsx index 30cf7a817..1f076498f 100644 --- a/src/profile/components/createProfileForm/CreateProfileForm.tsx +++ b/src/profile/components/createProfileForm/CreateProfileForm.tsx @@ -113,12 +113,8 @@ function CreateProfileForm(props: Props) { options={profileLanguageOptions} default={formikProps.values.profileLanguage} label={t('profileForm.language')} - // onChange is set to OptionType | OptionType[] in order to match hds typing - onChange={(option: HdsOptionType | HdsOptionType[]) => - formikProps.setFieldValue( - 'profileLanguage', - !Array.isArray(option) && option.value - ) + onChange={(option: HdsOptionType) => + formikProps.setFieldValue('profileLanguage', option.value) } /> diff --git a/src/profile/components/editProfileForm/EditProfileForm.tsx b/src/profile/components/editProfileForm/EditProfileForm.tsx index fae8057de..d93894aa6 100644 --- a/src/profile/components/editProfileForm/EditProfileForm.tsx +++ b/src/profile/components/editProfileForm/EditProfileForm.tsx @@ -223,12 +223,8 @@ function EditProfileForm(props: Props) { options={profileLanguageOptions} default={formikProps.values.profileLanguage} label={t('profileForm.language')} - // onChange is set to OptionType | OptionType[] in order to match hds typing - onChange={(option: HdsOptionType | HdsOptionType[]) => - formikProps.setFieldValue( - 'profileLanguage', - !Array.isArray(option) && option.value - ) + onChange={(option: HdsOptionType) => + formikProps.setFieldValue('profileLanguage', option.value) } /> @@ -312,11 +308,10 @@ function EditProfileForm(props: Props) { options={countryOptions} default={formikProps.values.primaryAddress.countryCode} label={t('profileForm.country')} - // onChange is set to OptionType | OptionType[] in order to match hds typing - onChange={(option: HdsOptionType | HdsOptionType[]) => + onChange={(option: HdsOptionType) => formikProps.setFieldValue( 'primaryAddress.countryCode' as 'primaryAddress', - !Array.isArray(option) && option.value + option.value ) } /> @@ -506,13 +501,10 @@ function EditProfileForm(props: Props) { default={ formikProps.values.addresses[index].countryCode } - // onChange is set to OptionType | OptionType[] in order to match hds typing - onChange={( - option: HdsOptionType | HdsOptionType[] - ) => + onChange={(option: HdsOptionType) => formikProps.setFieldValue( `addresses.${index}.countryCode` as 'addresses', - !Array.isArray(option) && option.value + option.value ) } /> From 5259a5266d0846143e3ac8afb4812cc0bcd5c3b2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2020 15:01:49 +0000 Subject: [PATCH 43/48] Bump lodash from 4.17.15 to 4.17.19 Bumps [lodash](https://github.com/lodash/lodash) from 4.17.15 to 4.17.19. - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.15...4.17.19) Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 5c843041b..710076301 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "i18n-iso-countries": "^5.3.0", "i18next": "^17.3.0", "i18next-browser-languagedetector": "^4.0.1", - "lodash": "^4.17.15", + "lodash": "^4.17.19", "oidc-client": "^1.9.1", "react": "^16.11.0", "react-dom": "^16.11.0", diff --git a/yarn.lock b/yarn.lock index 6a1143ac4..69e871028 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6849,9 +6849,10 @@ lodash.xorby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.xorby/-/lodash.xorby-4.7.0.tgz#9c19a6f9f063a6eb53dd03c1b6871799801463d7" -"lodash@>=3.5 <5", lodash@^4.15.0, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.5, lodash@~4.17.10: - version "4.17.15" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" +"lodash@>=3.5 <5", lodash@^4.15.0, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.5, lodash@~4.17.10: + version "4.17.19" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" + integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== log-symbols@^1.0.2: version "1.0.2" From cd3fc35005a0a40d4a2db108883e1c5d1e90d41a Mon Sep 17 00:00:00 2001 From: Santtu Tuovinen Date: Mon, 27 Jul 2020 11:20:13 +0300 Subject: [PATCH 44/48] Unify footer area styles. --- src/common/footer/Footer.module.css | 3 +++ src/common/footerLinks/FooterLinks.tsx | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/common/footer/Footer.module.css b/src/common/footer/Footer.module.css index 820000ad5..1221e8cbc 100644 --- a/src/common/footer/Footer.module.css +++ b/src/common/footer/Footer.module.css @@ -36,6 +36,7 @@ font-weight: bold; color: var(--color-white); margin-top: 10px; + text-decoration: none; } @media (min-width: 1200px) { @@ -58,7 +59,9 @@ .feedback { margin-top: 0; } +} +@media (min-width: 1200px) { .logo { margin: 20px 0; } diff --git a/src/common/footerLinks/FooterLinks.tsx b/src/common/footerLinks/FooterLinks.tsx index ccaad5d88..16089dec7 100644 --- a/src/common/footerLinks/FooterLinks.tsx +++ b/src/common/footerLinks/FooterLinks.tsx @@ -12,7 +12,11 @@ function FooterLinks(props: Props) { return ( {' '} - + {t('footer.privacy')} {' '} | {t('footer.accessibility')} From 93c46678c637a0e04c34eb0c92eabdc8a1af02ef Mon Sep 17 00:00:00 2001 From: Santtu Tuovinen Date: Mon, 27 Jul 2020 11:20:25 +0300 Subject: [PATCH 45/48] Update feedback link text. --- src/i18n/en.json | 2 +- src/i18n/fi.json | 2 +- src/i18n/sv.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/i18n/en.json b/src/i18n/en.json index 0eca1fdfa..6c44910a6 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -41,7 +41,7 @@ "footer": { "accessibility": "Accessibility document", "copyright": "Copyright {{year}}", - "feedback": "GIVE FEEDBACK", + "feedback": "Give feedback", "feedbackLink": "https://www.hel.fi/helsinki/en/administration/participate/feedback", "privacy": "Privacy policy", "reserveRights": "All rights reserved" diff --git a/src/i18n/fi.json b/src/i18n/fi.json index a11cc3c27..281ed0130 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -41,7 +41,7 @@ "footer": { "accessibility": "Saavutettavuusseloste", "copyright": "Tekijänoikeus {{year}}", - "feedback": "ANNA PALAUTETTA", + "feedback": "Anna palautetta", "feedbackLink": "https://www.hel.fi/helsinki/fi/kaupunki-ja-hallinto/osallistu-ja-vaikuta/palaute/anna-palautetta", "privacy": "Rekisteriseloste", "reserveRights": "Kaikki oikeudet pidätetään" diff --git a/src/i18n/sv.json b/src/i18n/sv.json index a6ad08a1d..3cb44987f 100644 --- a/src/i18n/sv.json +++ b/src/i18n/sv.json @@ -41,7 +41,7 @@ "footer": { "accessibility": "Tillgänglighetsdokument", "copyright": "Upphovsrätt {{year}}", - "feedback": "GE FEEDBACK", + "feedback": "Ge feedback", "feedbackLink": "https://www.hel.fi/helsinki/sv/stad-och-forvaltning/delta/feedback", "privacy": "Integritetspolicy", "reserveRights": "Alla rättigheter förbehållna" From 8974f859c90899b65551b2d5e05b3b1b268fd053 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2020 01:05:05 +0000 Subject: [PATCH 46/48] Bump elliptic from 6.5.2 to 6.5.3 Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.2 to 6.5.3. - [Release notes](https://github.com/indutny/elliptic/releases) - [Commits](https://github.com/indutny/elliptic/compare/v6.5.2...v6.5.3) Signed-off-by: dependabot[bot] --- yarn.lock | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6a1143ac4..8e22b70a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2591,8 +2591,9 @@ bluebird@^3.5.5: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: - version "4.11.8" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" + version "4.11.9" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" + integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw== body-parser@1.19.0: version "1.19.0" @@ -2649,6 +2650,7 @@ braces@^2.3.1, braces@^2.3.2: brorand@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" + integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= browser-process-hrtime@^0.1.2: version "0.1.3" @@ -4005,8 +4007,9 @@ elegant-spinner@^1.0.1: resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" elliptic@^6.0.0: - version "6.5.2" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.2.tgz#05c5678d7173c049d8ca433552224a495d0e3762" + version "6.5.3" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6" + integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw== dependencies: bn.js "^4.4.0" brorand "^1.0.1" @@ -5241,6 +5244,7 @@ hash-base@^3.0.0: hash.js@^1.0.0, hash.js@^1.0.3: version "1.1.7" resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" + integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== dependencies: inherits "^2.0.3" minimalistic-assert "^1.0.1" @@ -5303,6 +5307,7 @@ history@^4.9.0: hmac-drbg@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" + integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= dependencies: hash.js "^1.0.3" minimalistic-assert "^1.0.0" @@ -5622,6 +5627,7 @@ inflight@^1.0.4: inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== inherits@2.0.1: version "2.0.1" @@ -7073,10 +7079,12 @@ mini-css-extract-plugin@0.8.0: minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" + integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= minimatch@3.0.4, minimatch@^3.0.4, minimatch@~3.0.2: version "3.0.4" From d5295386a3bf97d5b1c701983762a013daec30ea Mon Sep 17 00:00:00 2001 From: Kimmo Lempinen Date: Tue, 25 Aug 2020 15:38:36 +0300 Subject: [PATCH 47/48] OM-894 | Bump version number to 1.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4db5c6ac8..973708ac1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-city-profile-ui", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "private": true, "dependencies": { From 1c92b9be71c707e39ab477ce14fabcc0a924e529 Mon Sep 17 00:00:00 2001 From: Kimmo Lempinen Date: Tue, 25 Aug 2020 15:51:41 +0300 Subject: [PATCH 48/48] OM-894 | Update changelog --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 289958b44..d19ebbdf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] + +## [1.1.0] - 2020-08-25 ### Added - Support for authorization code generation for GDPR API related calls (profile download and deletion) [#108](https://github.com/City-of-Helsinki/open-city-profile-ui/pull/108) - Link to authentication method account management [#114](https://github.com/City-of-Helsinki/open-city-profile-ui/pull/114) +- Managing multiple addresses +- Managing multiple phone numbers +- Favicon + +### Changed +- Better postal code validation +- Removed drop shadows +- Replaced custom select boxes with HDS Dropdown ### Fixed - Focus indicator being partially hidden with elements used for downloading and deleting profile - Notifications rendering on top of each other +- Order and visibility of language options +- Several issues with layout, scaling etc +- Text fixes ## [1.0.0-rc.1]