diff --git a/.eslintrc.js b/.eslintrc.js index be39a82dd..a2bfbdf29 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -28,7 +28,7 @@ module.exports = { 'object-curly-spacing': ['warn', 'always'], 'react/jsx-props-no-spreading': 'off', 'import/prefer-default-export': 'off', - 'react-hooks/exhaustive-deps': 'off', + 'react-hooks/exhaustive-deps': 'warn', 'no-underscore-dangle': ['error', { allow: ['__typename', '_env_'] }], 'no-param-reassign': [ 'error', diff --git a/.gitignore b/.gitignore index d64d59e6f..aa727b817 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,10 @@ package-lock.json public/env-config.js public/test-env-config.js +# Generated Excel-sheet with translations and it's cache file +locale_export.xlsx +~$locale_export.xlsx + npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/README.md b/README.md index 394a1352b..c0daadf4b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Haitaton UI +Haitaton is a service owned by the city of Helsinki that supports the management and prediction of the adverse +effects of projects taking place within the urban area. + ## Requirements - Node 16.x @@ -29,7 +32,7 @@ with `scripts/update-runtime-env.ts`, which contains the actual used variables w App is not using create-react-app's default `process.env` way to refer of variables but `window._env_` object. -### 'yarn e2e' +### `yarn e2e` Runs E2E cypress tests @@ -82,7 +85,20 @@ the identification method in the local environment, edit the `.env` -file. Then either rebuild the docker container or run `yarn update-runtime-env` as discussed above. -All cloud instances use Helsinki AD identification for now. +In the cloud instances, dev uses Helsinki AD identification while others use Suomi.fi. + +## Excel for translations + +You can export an Excel-file with current translations. This can then be sent to translators. + +1. In the repository root, run the export script with `yarn locales:export`. +2. The translations are written to `locale_export.xlsx`. + +After the translations are added to the Excel file, they can be imported back. + +1. Place the translated file inside repository root. It needs to be named `locale_export.xlsx`. +2. Run the import script: `yarn locales:import`. +3. The translations in `/src/locales` are updated. ## API mocking diff --git a/cypress/integration/E2E.spec.ts b/cypress/integration/E2E.spec.ts index ce16eb681..5687ac30a 100644 --- a/cypress/integration/E2E.spec.ts +++ b/cypress/integration/E2E.spec.ts @@ -9,7 +9,6 @@ import { HankeDataDraft, HankeIndexData, HANKE_INDEX_TYPE, - HANKE_SUUNNITTELUVAIHE, } from '../../src/domain/types/hanke'; import { createHankeFromUI } from '../utils/formFiller'; import { validateIndexes } from '../utils/indexValidator'; @@ -68,7 +67,6 @@ const hankeMock: HankeDataDraft = { ], tyomaaKatuosoite: 'Mannerheimintie 14', vaihe: HANKE_VAIHE.SUUNNITTELU, - suunnitteluVaihe: HANKE_SUUNNITTELUVAIHE.KATUSUUNNITTELU_TAI_ALUEVARAUS, omistajat: [ { id: null, // not used but types require it @@ -76,7 +74,7 @@ const hankeMock: HankeDataDraft = { email: 'harri.hanketest@hankekatu.foo', puhelinnumero: '12341234', tyyppi: 'YKSITYISHENKILO', - ytunnusTaiHetu: 'tunnus', + ytunnus: 'tunnus', }, ], }; @@ -87,7 +85,8 @@ const hankeMockIndex: Partial = { tyyppi: HANKE_INDEX_TYPE.PERUSINDEKSI, }, pyorailyIndeksi: 3, - joukkoliikenneIndeksi: 4, + raitiovaunuIndeksi: 4, + linjaautoIndeksi: 3, perusIndeksi: 4.8, }; diff --git a/cypress/utils/formFiller.ts b/cypress/utils/formFiller.ts index 10ebd3ff7..49b68006d 100644 --- a/cypress/utils/formFiller.ts +++ b/cypress/utils/formFiller.ts @@ -13,15 +13,10 @@ import { HANKE_TARINAHAITTA_KEY, HANKE_VAIHE, HankeDataDraft, - HANKE_SUUNNITTELUVAIHE, HANKE_VAIHE_KEY, - HANKE_SUUNNITTELUVAIHE_KEY, } from '../../src/domain/types/hanke'; -export const selectHankeVaihe = ( - vaihe: HANKE_VAIHE_KEY, - suunnitteluVaihe?: HANKE_SUUNNITTELUVAIHE_KEY -) => { +export const selectHankeVaihe = (vaihe: HANKE_VAIHE_KEY) => { cy.get('#vaihe-toggle-button').click(); switch (vaihe) { case HANKE_VAIHE.OHJELMOINTI: @@ -29,27 +24,6 @@ export const selectHankeVaihe = ( break; case HANKE_VAIHE.SUUNNITTELU: cy.get('#vaihe-item-1').click(); - if (suunnitteluVaihe) { - cy.get('#suunnitteluVaihe-toggle-button').click(); - switch (suunnitteluVaihe) { - case HANKE_SUUNNITTELUVAIHE.YLEIS_TAI_HANKE: - cy.get('#suunnitteluVaihe-item-0').click(); - break; - case HANKE_SUUNNITTELUVAIHE.KATUSUUNNITTELU_TAI_ALUEVARAUS: - cy.get('#suunnitteluVaihe-item-1').click(); - break; - case HANKE_SUUNNITTELUVAIHE.RAKENNUS_TAI_TOTEUTUS: - cy.get('#suunnitteluVaihe-item-2').click(); - break; - case HANKE_SUUNNITTELUVAIHE.TYOMAAN_TAI_HANKKEEN_AIKAINEN: - cy.get('#suunnitteluVaihe-item-3').click(); - break; - default: - break; - } - } else { - throw new Error('Tämä testin vaihe tarvitsee suunnitteluvaiheen'); - } break; case HANKE_VAIHE.RAKENTAMINEN: cy.get('#vaihe-item-2').click(); @@ -69,10 +43,7 @@ export const fillForm0 = (hankeData: HankeDataDraft) => { } cy.get('input[data-testid=nimi]').click(); - selectHankeVaihe( - hankeData.vaihe, - hankeData.suunnitteluVaihe ? hankeData.suunnitteluVaihe : undefined - ); + selectHankeVaihe(hankeData.vaihe); if (hankeData.onYKTHanke) { cy.get('input[data-testid=onYKTHanke]').click(); diff --git a/cypress/utils/indexValidator.ts b/cypress/utils/indexValidator.ts index efa756462..949b404d6 100644 --- a/cypress/utils/indexValidator.ts +++ b/cypress/utils/indexValidator.ts @@ -5,17 +5,18 @@ export const validateIndexes = (hankeIndexData: Partial) => { if (hankeIndexData.liikennehaittaIndeksi && hankeIndexData.liikennehaittaIndeksi.indeksi) { cy.get('[data-testid=test-liikennehaittaIndeksi]').should('not.be.empty'); cy.get('[data-testid=test-liikennehaittaIndeksi]').contains( - hankeIndexData.liikennehaittaIndeksi.indeksi + hankeIndexData.liikennehaittaIndeksi.indeksi, ); } if (hankeIndexData.pyorailyIndeksi) { cy.get('[data-testid=test-pyorailyIndeksi]').should('not.be.empty'); cy.get('[data-testid=test-pyorailyIndeksi]').contains(hankeIndexData.pyorailyIndeksi); } - if (hankeIndexData.joukkoliikenneIndeksi) { - cy.get('[data-testid=test-joukkoliikenneIndeksi]').contains( - hankeIndexData.joukkoliikenneIndeksi - ); + if (hankeIndexData.raitiovaunuIndeksi) { + cy.get('[data-testid=test-raitiovaunuIndeksi]').contains(hankeIndexData.raitiovaunuIndeksi); + } + if (hankeIndexData.linjaautoIndeksi) { + cy.get('[data-testid=test-linjaautoIndeksi]').contains(hankeIndexData.linjaautoIndeksi); } if (hankeIndexData.perusIndeksi) { cy.get('[data-testid=test-ruuhkautumisIndeksi]').contains(hankeIndexData.perusIndeksi); diff --git a/package.json b/package.json index d90f051d6..564eaa83e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build-and-serve": "yarn run build && yarn run serve", "update-runtime-env": "ts-node -P ./scripts/tsconfig.json --files scripts/update-runtime-env.ts", "test": "TEST=true yarn run update-runtime-env && DEBUG_PRINT_LIMIT=50000 react-scripts test", - "testCI": "CI=true react-scripts test", + "testCI": "TEST=true yarn run update-runtime-env && CI=TRUE DEBUG_PRINT_LIMIT=50000 react-scripts test", "eject": "react-scripts eject", "type-check": "tsc --pretty --noEmit", "format": "prettier --write", @@ -79,7 +79,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-helmet": "^6.1.0", - "react-hook-form": "^7.35.0", + "react-hook-form": "^7.47.0", "react-i18next": "^12.0.0", "react-query": "^3.39.2", "react-redux": "^8.0.5", diff --git a/public/helsinki.png b/public/helsinki.png new file mode 100644 index 000000000..e247c5af8 Binary files /dev/null and b/public/helsinki.png differ diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js index 8b1525b56..799b96754 100644 --- a/public/mockServiceWorker.js +++ b/public/mockServiceWorker.js @@ -120,7 +120,7 @@ self.addEventListener('fetch', function (event) { console.warn( '[MSW] Successfully emulated a network error for the "%s %s" request.', request.method, - request.url + request.url, ); return; } @@ -131,9 +131,9 @@ self.addEventListener('fetch', function (event) { [MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, request.method, request.url, - `${error.name}: ${error.message}` + `${error.name}: ${error.message}`, ); - }) + }), ); }); diff --git a/src/common/components/app/App.tsx b/src/common/components/app/App.tsx index 05e4a60b7..ba2f0573f 100644 --- a/src/common/components/app/App.tsx +++ b/src/common/components/app/App.tsx @@ -10,6 +10,7 @@ import theme from './theme'; import { GlobalNotificationProvider } from '../globalNotification/GlobalNotificationContext'; import GlobalNotification from '../globalNotification/GlobalNotification'; import { FeatureFlagsProvider } from '../featureFlags/FeatureFlagsContext'; +import ScrollToTop from '../scrollToTop/ScrollToTop'; import './app.scss'; import '../../../assets/styles/reset.css'; @@ -17,6 +18,7 @@ const queryClient = new QueryClient(); const App: React.FC> = () => ( + diff --git a/src/common/components/footer/Footer.tsx b/src/common/components/footer/Footer.tsx index b36c53a4d..058933a29 100644 --- a/src/common/components/footer/Footer.tsx +++ b/src/common/components/footer/Footer.tsx @@ -19,7 +19,7 @@ function HaitatonFooter() { - + ); } diff --git a/src/common/components/radiobutton/BooleanRadioButton.tsx b/src/common/components/radiobutton/BooleanRadioButton.tsx index 98cc93e34..478c2be32 100644 --- a/src/common/components/radiobutton/BooleanRadioButton.tsx +++ b/src/common/components/radiobutton/BooleanRadioButton.tsx @@ -15,7 +15,7 @@ type Props = { */ const BooleanRadioButton = ({ name, id, label, value }: Props) => { const { - field: { onChange, onBlur, value: inputValue }, + field: { onChange, onBlur, value: inputValue, ref }, } = useController({ name }); return ( @@ -30,6 +30,7 @@ const BooleanRadioButton = ({ name, id, label, value }: P value={value.toString()} checked={value === inputValue} data-testid={id} + ref={ref} /> ); }; diff --git a/src/common/components/scrollToTop/ScrollToTop.tsx b/src/common/components/scrollToTop/ScrollToTop.tsx new file mode 100644 index 000000000..679019ff1 --- /dev/null +++ b/src/common/components/scrollToTop/ScrollToTop.tsx @@ -0,0 +1,15 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +/** + * Scroll to top of document when URL pathname changes + */ +export default function ScrollToTop() { + const { pathname } = useLocation(); + + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + + return null; +} diff --git a/src/common/components/textArea/TextArea.tsx b/src/common/components/textArea/TextArea.tsx index fcb9aa79d..338abdc78 100644 --- a/src/common/components/textArea/TextArea.tsx +++ b/src/common/components/textArea/TextArea.tsx @@ -36,7 +36,7 @@ const TextArea: React.FC = ({ control={control} defaultValue="" shouldUnregister={shouldUnregister} - render={({ field: { onChange, onBlur, value }, fieldState: { error } }) => { + render={({ field: { onChange, onBlur, value, ref }, fieldState: { error } }) => { return ( = ({ required={required} disabled={disabled} errorText={getInputErrorText(t, error)} + ref={ref} /> ); }} diff --git a/src/common/components/textInput/TextInput.tsx b/src/common/components/textInput/TextInput.tsx index 2471c4e01..a33823ec3 100644 --- a/src/common/components/textInput/TextInput.tsx +++ b/src/common/components/textInput/TextInput.tsx @@ -9,6 +9,7 @@ import { getInputErrorText } from '../../utils/form'; type PropTypes = { name: string; label?: string; + maxLength?: number | undefined; disabled?: boolean; required?: boolean; readOnly?: boolean; @@ -23,6 +24,7 @@ type PropTypes = { const TextInput: React.FC> = ({ name, label, + maxLength = undefined, disabled, tooltip, required, @@ -48,6 +50,7 @@ const TextInput: React.FC> = ({ className={className} label={label || t(`hankeForm:labels:${name}`)} value={value || ''} + maxLength={maxLength} helperText={helperText} placeholder={placeholder} errorText={getInputErrorText(t, error)} diff --git a/src/common/routes/LocaleRoutes.tsx b/src/common/routes/LocaleRoutes.tsx index 2a9e5750c..b72f17577 100644 --- a/src/common/routes/LocaleRoutes.tsx +++ b/src/common/routes/LocaleRoutes.tsx @@ -22,6 +22,7 @@ import EditJohtoselvitysPage from '../../pages/EditJohtoselvitysPage'; import NotFoundPage from '../../pages/staticPages/404Page'; import ManualPage from '../../pages/staticPages/ManualPage'; import AccessRightsPage from '../../pages/AccessRightsPage'; +import UserIdentify from '../../domain/auth/components/UserIdentify'; const LocaleRoutes = () => { const { t } = useTranslation(); @@ -74,6 +75,7 @@ const LocaleRoutes = () => { } /> } /> } /> + } /> } /> ); diff --git a/src/domain/application/applicationView/ApplicationView.test.tsx b/src/domain/application/applicationView/ApplicationView.test.tsx index f445694a2..4bb709ad0 100644 --- a/src/domain/application/applicationView/ApplicationView.test.tsx +++ b/src/domain/application/applicationView/ApplicationView.test.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { rest } from 'msw'; -import { render, screen } from '../../../testUtils/render'; +import { render, screen, waitFor } from '../../../testUtils/render'; import ApplicationViewContainer from './ApplicationViewContainer'; import { waitForLoadingToFinish } from '../../../testUtils/helperFunctions'; import { server } from '../../mocks/test-server'; +import { SignedInUser } from '../../hanke/hankeUsers/hankeUser'; +import * as applicationApi from '../utils'; test('Correct information about application should be displayed', async () => { render(); @@ -56,7 +58,9 @@ test('Should show error notification if loading application fails', async () => test('Should be able to go editing application when editing is possible', async () => { const { user } = render(); - await waitForLoadingToFinish(); + await waitFor(() => screen.findByRole('button', { name: 'Muokkaa hakemusta' }), { + timeout: 4000, + }); await user.click(screen.getByRole('button', { name: 'Muokkaa hakemusta' })); expect(window.location.pathname).toBe('/fi/johtoselvityshakemus/4/muokkaa'); @@ -73,8 +77,7 @@ test('Application edit button should not be displayed when editing is not possib test('Should be able to cancel application if it is possible', async () => { const { user } = render(); - await waitForLoadingToFinish(); - + await waitFor(() => screen.findByRole('button', { name: 'Peru hakemus' }), { timeout: 4000 }); await user.click(screen.getByRole('button', { name: 'Peru hakemus' })); await user.click(screen.getByRole('button', { name: 'Vahvista' })); @@ -90,3 +93,50 @@ test('Should not be able to cancel application if it has moved to handling in Al expect(screen.queryByRole('button', { name: 'Peru hakemus' })).not.toBeInTheDocument(); }); + +test('Should not show Edit application and Cancel application buttons if user does not have EDIT_APPLICATIONS permission', async () => { + server.use( + rest.get('/api/hankkeet/:hankeTunnus/whoami', async (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + hankeKayttajaId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + kayttooikeustaso: 'KATSELUOIKEUS', + kayttooikeudet: ['VIEW'], + }), + ); + }), + ); + + render(); + + await waitForLoadingToFinish(); + + expect(screen.queryByRole('button', { name: 'Muokkaa hakemusta' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Peru hakemus' })).not.toBeInTheDocument(); +}); + +test('Should not send multiple requests if clicking application cancel confirm button many times', async () => { + server.use( + rest.delete('/api/hakemukset/:id', async (req, res, ctx) => { + return res(ctx.delay(200), ctx.status(200)); + }), + ); + + const cancelApplication = jest.spyOn(applicationApi, 'cancelApplication'); + const { user } = render(); + + await waitForLoadingToFinish(); + await waitFor(() => screen.findByRole('button', { name: 'Peru hakemus' }), { timeout: 4000 }); + + await user.click(screen.getByRole('button', { name: 'Peru hakemus' })); + const confirmCancelButton = screen.getByRole('button', { name: 'Vahvista' }); + await user.click(confirmCancelButton); + await user.click(confirmCancelButton); + await user.click(confirmCancelButton); + await screen.findByText('Hakemus peruttiin onnistuneesti'); + + expect(cancelApplication).toHaveBeenCalledTimes(1); + + cancelApplication.mockRestore(); +}); diff --git a/src/domain/application/applicationView/ApplicationView.tsx b/src/domain/application/applicationView/ApplicationView.tsx index 2066e6717..542c89730 100644 --- a/src/domain/application/applicationView/ApplicationView.tsx +++ b/src/domain/application/applicationView/ApplicationView.tsx @@ -38,6 +38,7 @@ import { ApplicationCancel } from '../components/ApplicationCancel'; import AttachmentSummary from '../components/AttachmentSummary'; import useAttachments from '../hooks/useAttachments'; import FeatureFlags from '../../../common/components/featureFlags/FeatureFlags'; +import UserRightsCheck from '../../hanke/hankeUsers/UserRightsCheck'; type Props = { application: Application; @@ -119,21 +120,25 @@ function ApplicationView({ application, hanke, onEditApplication }: Props) { {isPending ? ( - + + + ) : null} {hanke ? ( - } - /> + + } + /> + ) : null} diff --git a/src/domain/application/components/ApplicationCancel.test.tsx b/src/domain/application/components/ApplicationCancel.test.tsx index 0f76574d0..7b3215d58 100644 --- a/src/domain/application/components/ApplicationCancel.test.tsx +++ b/src/domain/application/components/ApplicationCancel.test.tsx @@ -1,8 +1,10 @@ import React from 'react'; +import { rest } from 'msw'; import { IconCross } from 'hds-react'; import { render, screen } from '../../../testUtils/render'; import { ApplicationCancel } from './ApplicationCancel'; import mockApplications from '../../mocks/data/hakemukset-data'; +import { server } from '../../mocks/test-server'; test('Cancel application when it has not been saved', async () => { const { user } = render( @@ -11,7 +13,7 @@ test('Cancel application when it has not been saved', async () => { alluStatus={null} hankeTunnus="HAI22-2" buttonIcon={} - /> + />, ); await user.click(screen.getByRole('button', { name: 'Peru hakemus' })); @@ -32,7 +34,7 @@ test('Cancel application when it has been saved, but not sent to Allu', async () alluStatus={application.alluStatus} hankeTunnus="HAI22-2" buttonIcon={} - /> + />, ); await user.click(screen.getByRole('button', { name: 'Peru hakemus' })); @@ -44,6 +46,29 @@ test('Cancel application when it has been saved, but not sent to Allu', async () expect(screen.queryByText('Hakemus peruttiin onnistuneesti')).toBeInTheDocument(); }); +test('Cancel application when generated hanke is also deleted, directs to hanke list', async () => { + server.use( + rest.delete('/api/hakemukset/:id', async (req, res, ctx) => { + return res(ctx.status(200), ctx.json({ hankeDeleted: true })); + }), + ); + const application = mockApplications[0]; + const { user } = render( + } + />, + ); + + await user.click(screen.getByRole('button', { name: 'Peru hakemus' })); + await user.click(screen.getByRole('button', { name: 'Vahvista' })); + + expect(window.location.pathname).toBe('/fi/hankesalkku'); + expect(screen.queryByText('Hakemus peruttiin onnistuneesti')).toBeInTheDocument(); +}); + test('Cancel application when it has been saved and sent to Allu but is still pending', async () => { const application = mockApplications[1]; @@ -53,7 +78,7 @@ test('Cancel application when it has been saved and sent to Allu but is still pe alluStatus={application.alluStatus} hankeTunnus="HAI22-2" buttonIcon={} - /> + />, ); await user.click(screen.getByRole('button', { name: 'Peru hakemus' })); @@ -74,7 +99,7 @@ test('Canceling application is not possible when it in handling in Allu', () => alluStatus={application.alluStatus} hankeTunnus="HAI22-2" buttonIcon={} - /> + />, ); expect(screen.queryByRole('button', { name: 'Peru hakemus' })).not.toBeInTheDocument(); diff --git a/src/domain/application/components/ApplicationCancel.tsx b/src/domain/application/components/ApplicationCancel.tsx index 257527969..0c7155261 100644 --- a/src/domain/application/components/ApplicationCancel.tsx +++ b/src/domain/application/components/ApplicationCancel.tsx @@ -7,7 +7,10 @@ import { AlluStatusStrings } from '../types/application'; import { isApplicationPending, cancelApplication } from '../utils'; import ConfirmationDialog from '../../../common/components/HDSConfirmationDialog/ConfirmationDialog'; import { useGlobalNotification } from '../../../common/components/globalNotification/GlobalNotificationContext'; -import useNavigateToApplicationList from '../../hanke/hooks/useNavigateToApplicationList'; +import { + useNavigateToApplicationList, + useNavigateToHankeList, +} from '../../hanke/hooks/useNavigateToApplicationList'; type Props = { applicationId: number | null; @@ -28,6 +31,7 @@ export const ApplicationCancel: React.FC = ({ }) => { const { t } = useTranslation(); const navigateToApplicationList = useNavigateToApplicationList(hankeTunnus); + const navigateToHankeList = useNavigateToHankeList(); const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(''); @@ -44,7 +48,7 @@ export const ApplicationCancel: React.FC = ({ } setErrorMessage(message); }, - onSuccess() { + onSuccess(data) { const closeButtonLabelText = t('common:components:notification:closeButtonLabelText'); setNotification(true, { label: t('hakemus:notifications:cancelSuccessLabel'), @@ -55,7 +59,7 @@ export const ApplicationCancel: React.FC = ({ autoClose: true, autoCloseDuration: 7000, }); - navigateToApplicationList(); + data?.hankeDeleted ? navigateToHankeList() : navigateToApplicationList(); }, }); @@ -87,6 +91,7 @@ export const ApplicationCancel: React.FC = ({ mainBtnLabel={t('common:confirmationDialog:confirmButton')} variant="danger" errorMsg={errorMessage} + isLoading={applicationCancelMutation.isLoading} />