diff --git a/.babelrc b/.babelrc index 4cec30d35..f044d9cca 100644 --- a/.babelrc +++ b/.babelrc @@ -21,10 +21,7 @@ "./src" ], "alias": { - "$src": "./src", - "$assets": "./assets", - "$components": "./src/components", - "$util": "./src/util" + "src": "./src" } } ] diff --git a/.flowconfig b/.flowconfig deleted file mode 100644 index b075da25a..000000000 --- a/.flowconfig +++ /dev/null @@ -1,22 +0,0 @@ -[include] -.*/src/ - -[ignore] -.*/node_modules/fbjs -.*/node_modules/editions/source -.*/node_modules/react-leaflet -.*/node_modules/react-select - -[libs] -./defs -./flow-typed - -[options] -module.name_mapper.extension='css' -> 'CSSModule' -module.name_mapper.extension='svg' -> 'EmptyModule' -module.name_mapper='^\$assets/\(.*\)$' -> '/assets/\1' -module.name_mapper='^\$components/\(.*\)$' -> '/src/components/\1' -module.name_mapper='^\$src/\(.*\)$' -> '/src/\1' -module.name_mapper='^\$util/\(.*\)$' -> '/src/util/\1' - -# suppress_comment= \\(.\\|\n\\)*\\$FlowFixMe diff --git a/config/paths.js b/config/paths.js index 35248ad10..fc0fe22d6 100644 --- a/config/paths.js +++ b/config/paths.js @@ -45,9 +45,9 @@ module.exports = { dotenv: resolveApp('.env'), appBuild: resolveApp('dist'), appHtml: resolveApp('src/index.html'), - appIndexJs: resolveApp('src/index.js'), + appIndexJs: resolveApp('src/index.ts'), appScss: resolveApp('src/main.scss'), - silentRenewJs: resolveApp('src/silent_renew.js'), + silentRenewJs: resolveApp('src/silent_renew.ts'), silentRenewHtml: resolveApp('src/silent_renew.html'), appPackageJson: resolveApp('package.json'), appSrc: resolveApp('src'), diff --git a/config/webpack.config.dev.js b/config/webpack.config.dev.js index fa421b5e1..85ab5f168 100644 --- a/config/webpack.config.dev.js +++ b/config/webpack.config.dev.js @@ -48,17 +48,30 @@ module.exports = { }, resolve: { alias: { - 'react-dom': '@hot-loader/react-dom', + 'react-dom': '@hot-loader/react-dom' }, modules: [ - path.join(__dirname, 'src'), + path.resolve(__dirname, '../src'), 'node_modules', ], - extensions: ['.web.js', '.js', '.json', '.web.jsx', '.jsx'], + extensions: ['.ts', '.tsx', '.web.js', '.js', '.json', '.web.jsx', '.jsx'], + // Some libraries import Node modules but don't use them in the browser. + // Tell Webpack to provide empty mocks for them so importing them works. + fallback: { + dgram: 'empty', + fs: 'empty', + net: 'empty', + tls: 'empty', + } }, module: { strictExportPresence: true, rules: [ + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/, + }, { test: /\.(js|jsx)$/, loader: 'babel-loader', @@ -138,16 +151,7 @@ module.exports = { contextRegExp: /moment$/, }), ], - // Some libraries import Node modules but don't use them in the browser. - // Tell Webpack to provide empty mocks for them so importing them works. - resolve: { - fallback: { - dgram: 'empty', - fs: 'empty', - net: 'empty', - tls: 'empty', - } - }, + // Turn off performance hints during development because we don't do any // splitting or minification in interest of speed. These warnings become // cumbersome. diff --git a/config/webpack.config.prod.js b/config/webpack.config.prod.js index 5c16c6667..157cea0f3 100644 --- a/config/webpack.config.prod.js +++ b/config/webpack.config.prod.js @@ -64,14 +64,27 @@ module.exports = { }, resolve: { modules: [ - path.join(__dirname, 'src'), + path.resolve(__dirname, '../src'), 'node_modules', ], - extensions: ['.web.js', '.js', '.json', '.web.jsx', '.jsx'], + extensions: ['.web.js', '.js', '.json', '.web.jsx', '.jsx', '.ts', '.tsx'], + // Some libraries import Node modules but don't use them in the browser. + // Tell Webpack to provide empty mocks for them so importing them works. + fallback: { + dgram: 'empty', + fs: 'empty', + net: 'empty', + tls: 'empty', + } }, module: { strictExportPresence: true, rules: [ + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/, + }, { test: /\.(js|jsx)$/, loader: 'babel-loader', @@ -206,15 +219,5 @@ module.exports = { }, extractComments: false, })], - }, - // Some libraries import Node modules but don't use them in the browser. - // Tell Webpack to provide empty mocks for them so importing them works. - resolve: { - fallback: { - dgram: 'empty', - fs: 'empty', - net: 'empty', - tls: 'empty', - } - }, + } }; diff --git a/jsconfig.json b/jsconfig.json index 639378075..a510ec626 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -2,10 +2,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "$assets/*": ["./assets/*"], - "$components/*": ["./src/components/*"], - "$src/*": ["./src/*"], - "$util/*": ["./src/util/*"] + "src/*": ["./src/*"], } } } diff --git a/package.json b/package.json index 4f7b68aa6..218c56030 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "name": "mvj-ui", "version": "1.0.0", "description": "City of Helsinki ground rent system UI", - "main": "src/index.js", + "main": "src/index.ts", "author": "Jori Lindell ", "license": "MIT", "nyc": { "require": [ "@babel/register", - "./src/test.js" + "./src/test.ts" ], "sourceMap": false, "instrument": false @@ -22,8 +22,8 @@ "compile": "node scripts/compile.js", "precommit": "npm-run-all flow lint", "prepush": "npm-run-all test", - "test": "mocha --require @babel/register --require src/test.js \"src/**/*spec.js\"", - "test:coverage": "cross-env NODE_ENV=test nyc --reporter=lcov --reporter=text mocha \"src/**/*spec.js\" --exit", + "test": "mocha --require @babel/register --require ts-node/register --require tsconfig-paths/register --require src/test.ts \"src/**/*spec.ts\"", + "test:coverage": "cross-env NODE_ENV=test nyc --reporter=lcov --reporter=text --require ts-node/register --require tsconfig-paths/register mocha \"src/**/*spec.ts\" --exit", "test:watch": "npm run test -- --watch" }, "dependencies": { @@ -69,6 +69,7 @@ "redux-form": "^8.2.5", "redux-oidc": "^3.1.4", "redux-saga": "^1.0.5", + "utility-types": "^3.11.0", "whatwg-fetch": "^3.0.0" }, "devDependencies": { @@ -81,6 +82,15 @@ "@babel/preset-react": "^7.23.3", "@babel/register": "^7.23.7", "@hot-loader/react-dom": "17.0.2", + "@istanbuljs/nyc-config-typescript": "^1.0.2", + "@types/chai": "^4.3.16", + "@types/history": "^5.0.0", + "@types/lodash": "^4.17.1", + "@types/mocha": "^10.0.6", + "@types/react": "^18.3.2", + "@types/react-dom": "^18.3.0", + "@types/redux-actions": "^2.6.5", + "@types/webpack-env": "^1.18.5", "babel-loader": "^9.1.3", "babel-plugin-istanbul": "^6.1.1", "babel-plugin-module-resolver": "^5.0.0", @@ -100,7 +110,6 @@ "eslint-plugin-react": "^7.14.3", "favicons-webpack-plugin": "^6.0.1", "file-loader": "^6.2.0", - "flow-bin": "^0.227.0", "flow-typed": "^3.9.0", "fs-extra": "^11.2.0", "html-webpack-plugin": "^5.6.0", @@ -118,6 +127,10 @@ "sinon-chai": "^3.3.0", "style-loader": "^3.3.4", "terser-webpack-plugin": "^5.3.10", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.4.5", "url": "^0.11.3", "url-loader": "^4.1.1", "webpack": "^5.9.0", diff --git a/scripts/start.js b/scripts/start.js index 7a6ecc36c..949e993f7 100644 --- a/scripts/start.js +++ b/scripts/start.js @@ -53,7 +53,7 @@ choosePort(HOST, DEFAULT_PORT) } const protocol = process.env.HTTPS === 'true' ? 'https' : 'http'; const appName = require(paths.appPackageJson).name; - const useTypeScript = false; + const useTypeScript = true; const urls = prepareUrls(protocol, HOST, port); const devSocket = { warnings: warnings => diff --git a/src/api/ApiErrorModal.js b/src/api/ApiErrorModal.js deleted file mode 100644 index cc4c03b10..000000000 --- a/src/api/ApiErrorModal.js +++ /dev/null @@ -1,67 +0,0 @@ -/* eslint-disable */ -import flowRight from 'lodash/flowRight'; -import React from 'react'; -import {Button} from 'react-foundation'; -import {reveal} from '../foundation/reveal'; -import {Sizes} from '../foundation/enums'; - -const ApiErrorModal = ({data, handleDismiss, isOpen}) => ( -
- {data ? : null} - -
-); - -const ApiErrorList = ({errors}) => { - const listObjectErrors = (obj: Object) => { - return ( -
    - {Object.keys(obj).map(function(key, index) { - if (typeof obj[key] === 'object') { - return
  • {key}{listObjectErrors(obj[key])}
  • - } - return
  • {key}: {obj[key]}
  • ; - })} -
- ); - } - - return ( -
-
Error list
- {listObjectErrors(errors)} -
- ); -} - -const ApiErrorStackTrace = ({trace}) => -
-
Trace
-
    - {trace.filter((item) => item.file).map((item, index) => ( -
  1. - - {item.file}({item.line}) - -   - - {item.class ? `${item.class}::${item.function}` : item.function} - -
  2. - ))} -
-
; - -const ApiErrorContent = ({data}) => { - return
-

Server error {data.exception}

-
{data.message}
-
{data.source}
- {data.errors ? : null} - {data.trace ? : null} -
; -}; - -export default flowRight( - reveal({name: 'apiError'}), -)(ApiErrorModal); diff --git a/src/api/ApiErrorModal.tsx b/src/api/ApiErrorModal.tsx new file mode 100644 index 000000000..8cd98db7f --- /dev/null +++ b/src/api/ApiErrorModal.tsx @@ -0,0 +1,69 @@ +/* eslint-disable */ +import flowRight from "lodash/flowRight"; +import React from "react"; +import { Button } from "react-foundation"; +import { reveal } from "../foundation/reveal"; +import { Sizes } from "../foundation/enums"; + +const ApiErrorModal = ({ + data, + handleDismiss, + isOpen +}) =>
+ {data ? : null} + +
; + +const ApiErrorList = ({ + errors +}) => { + const listObjectErrors = (obj: Record) => { + return
    + {Object.keys(obj).map(function (key, index) { + if (typeof obj[key] === 'object') { + return
  • {key}{listObjectErrors(obj[key])}
  • ; + } + + return
  • {key}: {obj[key]}
  • ; + })} +
; + }; + + return
+
Error list
+ {listObjectErrors(errors)} +
; +}; + +const ApiErrorStackTrace = ({ + trace +}) =>
+
Trace
+
    + {trace.filter(item => item.file).map((item, index) =>
  1. + + {item.file}({item.line}) + +   + + {item.class ? `${item.class}::${item.function}` : item.function} + +
  2. )} +
+
; + +const ApiErrorContent = ({ + data +}) => { + return
+

Server error {data.exception}

+
{data.message}
+
{data.source}
+ {data.errors ? : null} + {data.trace ? : null} +
; +}; + +export default flowRight(reveal({ + name: 'apiError' +}))(ApiErrorModal); \ No newline at end of file diff --git a/src/api/actions.js b/src/api/actions.js deleted file mode 100644 index ab9b58787..000000000 --- a/src/api/actions.js +++ /dev/null @@ -1,11 +0,0 @@ -// @flow - -import {createAction} from 'redux-actions'; - -import type {ApiError, ReceiveErrorAction, ClearErrorAction} from './types'; - -export const receiveError = (error: ApiError): ReceiveErrorAction => - createAction('mvj/api/RECEIVE_ERROR')(error); - -export const clearError = (): ClearErrorAction => - createAction('mvj/api/CLEAR_ERROR')(); diff --git a/src/api/actions.ts b/src/api/actions.ts new file mode 100644 index 000000000..3d07a9a9b --- /dev/null +++ b/src/api/actions.ts @@ -0,0 +1,4 @@ +import { createAction } from "redux-actions"; +import type { ApiError, ReceiveErrorAction, ClearErrorAction } from "./types"; +export const receiveError = (error: ApiError): ReceiveErrorAction => createAction('mvj/api/RECEIVE_ERROR')(error); +export const clearError = (): ClearErrorAction => createAction('mvj/api/CLEAR_ERROR')(); \ No newline at end of file diff --git a/src/api/callApi.js b/src/api/callApi.js deleted file mode 100644 index 5b54466ef..000000000 --- a/src/api/callApi.js +++ /dev/null @@ -1,37 +0,0 @@ -// @flow -import {call, select} from 'redux-saga/effects'; - -import {getApiToken} from '$src/auth/selectors'; -import {UI_ACCEPT_LANGUAGE_VALUE} from '$src/api/constants'; - -function* callApi(request: Request): Generator { - const apiToken = yield select(getApiToken); - - if (apiToken) { - request.headers.set('Authorization', `Bearer ${apiToken}`); - } - - request.headers.set( - 'Accept-Language', - UI_ACCEPT_LANGUAGE_VALUE - ); - - if (request.method === 'PATCH' || request.method === 'POST' || request.method === 'PUT') { - request.headers.set('Content-Type', 'application/json'); - } - - const response = yield call(fetch, request); - const status = response.status; - - switch(status) { - case 204: - return {response}; - case 500: - return {response, bodyAsJson: {exception: response.status, message: response.statusText}}; - } - - const bodyAsJson = yield call([response, response.json]); - return {response, bodyAsJson}; -} - -export default callApi; diff --git a/src/api/callApi.ts b/src/api/callApi.ts new file mode 100644 index 000000000..85ee74024 --- /dev/null +++ b/src/api/callApi.ts @@ -0,0 +1,44 @@ +import { call, select } from "redux-saga/effects"; +import { getApiToken } from "auth/selectors"; +import { UI_ACCEPT_LANGUAGE_VALUE } from "api/constants"; + +function* callApi(request: Request): Generator { + const apiToken = yield select(getApiToken); + + if (apiToken) { + request.headers.set('Authorization', `Bearer ${apiToken}`); + } + + request.headers.set('Accept-Language', UI_ACCEPT_LANGUAGE_VALUE); + + if (request.method === 'PATCH' || request.method === 'POST' || request.method === 'PUT') { + request.headers.set('Content-Type', 'application/json'); + } + + const response = yield call(fetch, request); + const status = response.status; + + switch (status) { + case 204: + return { + response + }; + + case 500: + return { + response, + bodyAsJson: { + exception: response.status, + message: response.statusText + } + }; + } + + const bodyAsJson = yield call([response, response.json]); + return { + response, + bodyAsJson + }; +} + +export default callApi; \ No newline at end of file diff --git a/src/api/callApiAsync.js b/src/api/callApiAsync.js deleted file mode 100644 index e74513277..000000000 --- a/src/api/callApiAsync.js +++ /dev/null @@ -1,27 +0,0 @@ -// @flow -import {store} from '$src/root/startApp'; -import {getApiToken} from '$src/auth/selectors'; -import {UI_ACCEPT_LANGUAGE_VALUE} from '$src/api/constants'; - -const callApiAsync = async (request: Request): Promise => { - const apiToken = await getApiToken(store.getState()); - - if (apiToken) { - request.headers.set('Authorization', `Bearer ${apiToken}`); - } - - request.headers.set( - 'Accept-Language', - UI_ACCEPT_LANGUAGE_VALUE - ); - - if (request.method === 'PATCH' || request.method === 'POST' || request.method === 'PUT') { - request.headers.set('Content-Type', 'application/json'); - } - - const response = await fetch(request); - const bodyAsJson = await response.json(); - return {response, bodyAsJson}; -}; - -export default callApiAsync; diff --git a/src/api/callApiAsync.ts b/src/api/callApiAsync.ts new file mode 100644 index 000000000..b41ea3d44 --- /dev/null +++ b/src/api/callApiAsync.ts @@ -0,0 +1,26 @@ +import { store } from "root/startApp"; +import { getApiToken } from "auth/selectors"; +import { UI_ACCEPT_LANGUAGE_VALUE } from "api/constants"; + +const callApiAsync = async (request: Request): Promise> => { + const apiToken = await getApiToken(store.getState()); + + if (apiToken) { + request.headers.set('Authorization', `Bearer ${apiToken}`); + } + + request.headers.set('Accept-Language', UI_ACCEPT_LANGUAGE_VALUE); + + if (request.method === 'PATCH' || request.method === 'POST' || request.method === 'PUT') { + request.headers.set('Content-Type', 'application/json'); + } + + const response = await fetch(request); + const bodyAsJson = await response.json(); + return { + response, + bodyAsJson + }; +}; + +export default callApiAsync; \ No newline at end of file diff --git a/src/api/callUploadRequest.js b/src/api/callUploadRequest.js deleted file mode 100644 index 7cd2eace1..000000000 --- a/src/api/callUploadRequest.js +++ /dev/null @@ -1,31 +0,0 @@ -// @flow - -import {call, put, select} from 'redux-saga/effects'; - -import {receiveError} from '$src/api/actions'; -import {getApiToken} from '$src/auth/selectors'; -import {UI_ACCEPT_LANGUAGE_VALUE} from '$src/api/constants'; - -function* callUploadRequest(request: Request): Generator { - const apiToken = yield select(getApiToken); - if (apiToken) { - request.headers.set('Authorization', `Bearer ${apiToken}`); - } - - request.headers.set( - 'Accept-Language', - UI_ACCEPT_LANGUAGE_VALUE - ); - - const response = yield call(fetch, request); - const status = response.status; - const bodyAsJson = yield call([response, response.json]); - - if (status === 500) { - yield put(receiveError(bodyAsJson)); - } - - return {response, bodyAsJson}; -} - -export default callUploadRequest; diff --git a/src/api/callUploadRequest.ts b/src/api/callUploadRequest.ts new file mode 100644 index 000000000..36c5e7d68 --- /dev/null +++ b/src/api/callUploadRequest.ts @@ -0,0 +1,28 @@ +import { call, put, select } from "redux-saga/effects"; +import { receiveError } from "api/actions"; +import { getApiToken } from "auth/selectors"; +import { UI_ACCEPT_LANGUAGE_VALUE } from "api/constants"; + +function* callUploadRequest(request: Request): Generator { + const apiToken = yield select(getApiToken); + + if (apiToken) { + request.headers.set('Authorization', `Bearer ${apiToken}`); + } + + request.headers.set('Accept-Language', UI_ACCEPT_LANGUAGE_VALUE); + const response = yield call(fetch, request); + const status = response.status; + const bodyAsJson = yield call([response, response.json]); + + if (status === 500) { + yield put(receiveError(bodyAsJson)); + } + + return { + response, + bodyAsJson + }; +} + +export default callUploadRequest; \ No newline at end of file diff --git a/src/api/constants.js b/src/api/constants.js deleted file mode 100644 index ca23e838a..000000000 --- a/src/api/constants.js +++ /dev/null @@ -1,3 +0,0 @@ -//@flow - -export const UI_ACCEPT_LANGUAGE_VALUE = 'fi,en;q=0.9'; diff --git a/src/api/constants.ts b/src/api/constants.ts new file mode 100644 index 000000000..56b5678bd --- /dev/null +++ b/src/api/constants.ts @@ -0,0 +1 @@ +export const UI_ACCEPT_LANGUAGE_VALUE = 'fi,en;q=0.9'; \ No newline at end of file diff --git a/src/api/createUrl.js b/src/api/createUrl.js deleted file mode 100644 index a742e1e29..000000000 --- a/src/api/createUrl.js +++ /dev/null @@ -1,20 +0,0 @@ -// @flow -import isArray from 'lodash/isArray'; - -export const stringifyQuery = (query: {[key: string]: any}): string => - Object - .keys(query) - .map((key) => [key, query[key]].map((v) => encodeURIComponent(v)).join('=')) - .join('&'); - -export const standardStringifyQuery = (query: {[key: string]: any}): string => - Object - .keys(query) - .map((key) => isArray(query[key]) - ? [key, query[key].map((v) => encodeURIComponent(v)).join(`&${key}=`)].join('=') - : [key, query[key]].map((v) => encodeURIComponent(v)).join('=') - ) - .join('&'); - -export default (url: string, params?: Object): string => - `${process.env.API_URL || ''}/${url}${params ? `?${stringifyQuery(params)}` : ''}`; diff --git a/src/api/createUrl.ts b/src/api/createUrl.ts new file mode 100644 index 000000000..2d57c79f1 --- /dev/null +++ b/src/api/createUrl.ts @@ -0,0 +1,4 @@ +import isArray from "lodash/isArray"; +export const stringifyQuery = (query: Record): string => Object.keys(query).map(key => [key, query[key]].map(v => encodeURIComponent(v)).join('=')).join('&'); +export const standardStringifyQuery = (query: Record): string => Object.keys(query).map(key => isArray(query[key]) ? [key, query[key].map(v => encodeURIComponent(v)).join(`&${key}=`)].join('=') : [key, query[key]].map(v => encodeURIComponent(v)).join('=')).join('&'); +export default ((url: string, params?: Record): string => `${process.env.API_URL || ''}/${url}${params ? `?${stringifyQuery(params)}` : ''}`); \ No newline at end of file diff --git a/src/api/createUrlWithoutVersionSuffix.js b/src/api/createUrlWithoutVersionSuffix.js deleted file mode 100644 index 881c15e70..000000000 --- a/src/api/createUrlWithoutVersionSuffix.js +++ /dev/null @@ -1,9 +0,0 @@ -// @flow -import {getApiUrlWithOutVersionSuffix} from '$src/util/helpers'; -import {stringifyQuery} from './createUrl'; - -export default (url: string, params?: Object) => { - const apiUrlWithOutVersionSuffix = getApiUrlWithOutVersionSuffix(); - - return `${apiUrlWithOutVersionSuffix}/${url}${params ? `?${stringifyQuery(params)}` : ''}`; -}; diff --git a/src/api/createUrlWithoutVersionSuffix.ts b/src/api/createUrlWithoutVersionSuffix.ts new file mode 100644 index 000000000..9f472b730 --- /dev/null +++ b/src/api/createUrlWithoutVersionSuffix.ts @@ -0,0 +1,6 @@ +import { getApiUrlWithOutVersionSuffix } from "util/helpers"; +import { stringifyQuery } from "./createUrl"; +export default ((url: string, params?: Record) => { + const apiUrlWithOutVersionSuffix = getApiUrlWithOutVersionSuffix(); + return `${apiUrlWithOutVersionSuffix}/${url}${params ? `?${stringifyQuery(params)}` : ''}`; +}); \ No newline at end of file diff --git a/src/api/reducer.js b/src/api/reducer.js deleted file mode 100644 index 4545e9663..000000000 --- a/src/api/reducer.js +++ /dev/null @@ -1,16 +0,0 @@ -// @flow - -import {combineReducers} from 'redux'; -import {handleActions} from 'redux-actions'; - -import type {Reducer} from '../types'; -import type {ApiError, ReceiveErrorAction} from './types'; - -export const errorReducer: Reducer = handleActions({ - ['mvj/api/RECEIVE_ERROR']: (state, {payload: error}: ReceiveErrorAction) => error, - ['mvj/api/CLEAR_ERROR']: () => null, -}, null); - -export default combineReducers({ - error: errorReducer, -}); diff --git a/src/api/reducer.ts b/src/api/reducer.ts new file mode 100644 index 000000000..bedae0a29 --- /dev/null +++ b/src/api/reducer.ts @@ -0,0 +1,13 @@ +import { combineReducers } from "redux"; +import { handleActions } from "redux-actions"; +import type { Reducer } from "../types"; +import type { ApiError, ReceiveErrorAction } from "./types"; +export const errorReducer: Reducer = handleActions({ + ['mvj/api/RECEIVE_ERROR']: (state, { + payload: error + }: ReceiveErrorAction) => error, + ['mvj/api/CLEAR_ERROR']: () => null +}, null); +export default combineReducers, any>({ + error: errorReducer +}); \ No newline at end of file diff --git a/src/api/selectors.js b/src/api/selectors.js deleted file mode 100644 index f2961e50e..000000000 --- a/src/api/selectors.js +++ /dev/null @@ -1,7 +0,0 @@ -// @flow - -import type {Selector} from '../types'; -import type {ApiError} from './types'; - -export const getError: Selector = (state) => - state.api.error; diff --git a/src/api/selectors.ts b/src/api/selectors.ts new file mode 100644 index 000000000..e417b985c --- /dev/null +++ b/src/api/selectors.ts @@ -0,0 +1,3 @@ +import type { Selector } from "../types"; +import type { ApiError } from "./types"; +export const getError: Selector = state => state.api.error; \ No newline at end of file diff --git a/src/api/spec.js b/src/api/spec.js deleted file mode 100644 index a82f1c665..000000000 --- a/src/api/spec.js +++ /dev/null @@ -1,44 +0,0 @@ -// @flow -import {expect} from 'chai'; - -import { - receiveError, - clearError, -} from './actions'; -import apiReducer from './reducer'; - -import type {ApiState} from './types'; - -const defaultState: ApiState = { - error: null, -}; - -// $FlowFixMe -describe('API', () => { - - // $FlowFixMe - describe('Reducer', () => { - - // $FlowFixMe - describe('apiReducer', () => { - - // $FlowFixMe - it('should update error', () => { - const dummyError = {'error': 'test'}; - const newState = {...defaultState, error: dummyError}; - - const state = apiReducer({}, receiveError(dummyError)); - expect(state).to.deep.equal(newState); - }); - - it('should clear error', () => { - const dummyError = {'error': 'test'}; - const newState = {...defaultState, error: null}; - - let state = apiReducer({}, receiveError(dummyError)); - state = apiReducer(state, clearError()); - expect(state).to.deep.equal(newState); - }); - }); - }); -}); diff --git a/src/api/spec.ts b/src/api/spec.ts new file mode 100644 index 000000000..33b497f54 --- /dev/null +++ b/src/api/spec.ts @@ -0,0 +1,35 @@ +import { expect } from "chai"; +import { receiveError, clearError } from "./actions"; +import apiReducer from "./reducer"; +import type { ApiState } from "./types"; +const defaultState: ApiState = { + error: null +}; + +describe('API', () => { + describe('Reducer', () => { + describe('apiReducer', () => { + it('should update error', () => { + const dummyError = { + 'error': 'test' + }; + const newState = { ...defaultState, + error: dummyError + }; + const state = apiReducer({}, receiveError(dummyError)); + expect(state).to.deep.equal(newState); + }); + it('should clear error', () => { + const dummyError = { + 'error': 'test' + }; + const newState = { ...defaultState, + error: null + }; + let state = apiReducer({}, receiveError(dummyError)); + state = apiReducer(state, clearError()); + expect(state).to.deep.equal(newState); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/api/types.js b/src/api/types.js deleted file mode 100644 index 89620fbbb..000000000 --- a/src/api/types.js +++ /dev/null @@ -1,12 +0,0 @@ -// @flow - -import type {Action} from '../types'; - -export type ApiError = Object | null; - -export type ReceiveErrorAction = Action<'mvj/api/RECEIVE_ERROR', ApiError>; -export type ClearErrorAction = Action<'mvj/api/CLEAR_ERROR', void>; - -export type ApiState = { - error: ApiError, -}; diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 000000000..4cb28724a --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,7 @@ +import type { Action } from "../types"; +export type ApiError = Record | null; +export type ReceiveErrorAction = Action; +export type ClearErrorAction = Action; +export type ApiState = { + error: ApiError; +}; \ No newline at end of file diff --git a/src/app/App.js b/src/app/App.js deleted file mode 100644 index 53be89a14..000000000 --- a/src/app/App.js +++ /dev/null @@ -1,337 +0,0 @@ -// @flow -import React, {Component} from 'react'; -import {connect} from 'react-redux'; -import ReduxToastr from 'react-redux-toastr'; -import {withRouter} from 'react-router'; -import flowRight from 'lodash/flowRight'; -import isEmpty from 'lodash/isEmpty'; -import get from 'lodash/get'; -import {Sizes} from '$src/foundation/enums'; -import {revealContext} from '$src/foundation/reveal'; - -import {ActionTypes, AppConsumer, AppProvider} from '$src/app/AppContext'; -import ApiErrorModal from '$src/api/ApiErrorModal'; -import ConfirmationModal from '$src/components/modal/ConfirmationModal'; -import Loader from '$components/loader/Loader'; -import LoginPage from '$src/auth/components/LoginPage'; -import SideMenu from '$components/sideMenu/SideMenu'; -import TopNavigation from '$components/topNavigation/TopNavigation'; -import userManager from '$src/auth/util/user-manager'; -import {Routes, getRouteById} from '$src/root/routes'; -import {clearError} from '$src/api/actions'; -import {clearApiToken, fetchApiToken} from '$src/auth/actions'; -import {getEpochTime} from '$util/helpers'; -import {getError} from '$src/api/selectors'; -import {getApiToken, getApiTokenExpires, getIsFetching, getLoggedInUser} from '$src/auth/selectors'; -import {getLinkUrl, getPageTitle, getShowSearch} from '$components/topNavigation/selectors'; -import {getUserGroups, getUserActiveServiceUnit, getUserServiceUnits} from '$src/usersPermissions/selectors'; -import {setRedirectUrlToSessionStorage} from '$util/storage'; - -import type {ApiError} from '../api/types'; -import type {ApiToken} from '../auth/types'; -import type {UserGroups, UserServiceUnit, UserServiceUnits} from '$src/usersPermissions/types'; -import type {RootState} from '../root/types'; - -const url = window.location.toString(); -const IS_DEVELOPMENT_URL = url.includes('ninja') || url.includes('localhost'); - -type OwnProps = { - children: React$Node, -}; - -type Props = { - ...OwnProps, - apiError: ApiError, - apiToken: ApiToken, - apiTokenExpires: number, - clearApiToken: Function, - clearError: typeof clearError, - closeReveal: Function, - fetchApiToken: Function, - history: Object, - isApiTokenFetching: boolean, - linkUrl: string, - location: Object, - pageTitle: string, - userActiveServiceUnit: UserServiceUnit, - userServiceUnits: UserServiceUnits, - showSearch: boolean, - user: Object, - userGroups: UserGroups, -}; - -type State = { - displaySideMenu: boolean, - loggedIn: boolean, -}; - -class App extends Component { - state = { - displaySideMenu: false, - loggedIn: false, - } - - timerID: any - - componentWillUnmount() { - this.stopApiTokenTimer(); - } - - startApiTokenTimer = () => { - this.timerID = setInterval( - () => this.checkApiToken(), - 5000 - ); - } - - stopApiTokenTimer = () => { - clearInterval(this.timerID); - } - - componentDidUpdate(prevProps: Props) { - const { - apiError, - apiToken, - clearApiToken, - fetchApiToken, - history, - isApiTokenFetching, - user, - } = this.props; - const {loggedIn} = this.state; - - if(apiError) { - return; - } - - // Fetch api token if user info is received but Api token is empty - if(!isApiTokenFetching && - user && - user.access_token && - (isEmpty(apiToken) || user.access_token !== get(prevProps, 'user.access_token')) - ) { - fetchApiToken(user.access_token); - this.startApiTokenTimer(); - return; - } - - if(apiToken && !prevProps.apiToken) { - this.setState({loggedIn: true}); - } - - // Clear API token when user has logged out - if(!user && !isEmpty(apiToken)) { - clearApiToken(); - this.stopApiTokenTimer(); - - // If user has pressed logout button move to lease list page - if(!loggedIn) { - history.push(getRouteById(Routes.LEASES)); - } - } - } - - handleLogin = (event: any) => { - const {location: {pathname, search}} = this.props; - - event.preventDefault(); - userManager.signinRedirect(); - - setRedirectUrlToSessionStorage(`${pathname}${search}` || getRouteById(Routes.LEASES)); - } - - logOut = () => { - this.setState({ - loggedIn: false, - }, () => { - userManager.removeUser(); - sessionStorage.clear(); - }); - - } - - checkApiToken () { - const {apiTokenExpires, fetchApiToken} = this.props; - - if((apiTokenExpires <= getEpochTime()) && get(this.props, 'user.access_token')) { - fetchApiToken(this.props.user.access_token); - } - } - - toggleSideMenu = () => { - return this.setState({ - displaySideMenu: !this.state.displaySideMenu, - }); - }; - - handleDismissErrorModal = () => { - this.props.closeReveal('apiError'); - this.props.clearError(); - }; - - render() { - const {apiError, - apiToken, - children, - isApiTokenFetching, - linkUrl, - location, - pageTitle, - showSearch, - user, - userGroups, - userActiveServiceUnit, - userServiceUnits, - } = this.props; - const {displaySideMenu} = this.state; - const appStyle = (IS_DEVELOPMENT_URL) ? 'app-dev' : 'app'; - - if (isEmpty(user) || isEmpty(apiToken)) { - return ( -
- - - - - - - - {location.pathname === getRouteById(Routes.CALLBACK) && children} -
- ); - } - - return ( - - - {({ - isConfirmationModalOpen, - confirmationFunction, - confirmationModalButtonClassName, - confirmationModalButtonText, - confirmationModalLabel, - confirmationModalTitle, - dispatch, - }) => { - const handleConfirmation = () => { - confirmationFunction?.(); - handleHideConfirmationModal(); - }; - - const handleHideConfirmationModal = () => { - dispatch({type: ActionTypes.HIDE_CONFIRMATION_MODAL}); - }; - - return ( -
- - - - - - - - -
- -
- {children} -
-
-
- ); - }} -
-
- ); - } -} - -const mapStateToProps = (state: RootState) => { - const user = getLoggedInUser(state); - - if (!user || user.expired) { - return { - apiToken: getApiToken(state), - pageTitle: getPageTitle(state), - showSearch: getShowSearch(state), - user: null, - }; - } - - return { - apiError: getError(state), - apiToken: getApiToken(state), - apiTokenExpires: getApiTokenExpires(state), - isApiTokenFetching: getIsFetching(state), - linkUrl: getLinkUrl(state), - pageTitle: getPageTitle(state), - showSearch: getShowSearch(state), - user, - userGroups: getUserGroups(state), - userServiceUnits: getUserServiceUnits(state), - userActiveServiceUnit: getUserActiveServiceUnit(state), - }; -}; - -export default (flowRight( - withRouter, - connect( - mapStateToProps, - { - clearError, - clearApiToken, - fetchApiToken, - }, - ), - revealContext(), -)(App): React$ComponentType); diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 000000000..f9efd44e4 --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,272 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import ReduxToastr from "react-redux-toastr"; +import { withRouter } from "react-router"; +import flowRight from "lodash/flowRight"; +import isEmpty from "lodash/isEmpty"; +import get from "lodash/get"; +import { Sizes } from "foundation/enums"; +import { revealContext } from "foundation/reveal"; +import { ActionTypes, AppConsumer, AppProvider } from "app/AppContext"; +import ApiErrorModal from "api/ApiErrorModal"; +import ConfirmationModal from "components/modal/ConfirmationModal"; +import Loader from "components/loader/Loader"; +import LoginPage from "auth/components/LoginPage"; +import SideMenu from "components/sideMenu/SideMenu"; +import TopNavigation from "components/topNavigation/TopNavigation"; +import userManager from "auth/util/user-manager"; +import { Routes, getRouteById } from "root/routes"; +import { clearError } from "api/actions"; +import { clearApiToken, fetchApiToken } from "auth/actions"; +import { getEpochTime } from "util/helpers"; +import { getError } from "api/selectors"; +import { getApiToken, getApiTokenExpires, getIsFetching, getLoggedInUser } from "auth/selectors"; +import { getLinkUrl, getPageTitle, getShowSearch } from "components/topNavigation/selectors"; +import { getUserGroups, getUserActiveServiceUnit, getUserServiceUnits } from "usersPermissions/selectors"; +import { setRedirectUrlToSessionStorage } from "util/storage"; +import type { ApiError } from "../api/types"; +import type { ApiToken } from "../auth/types"; +import type { UserGroups, UserServiceUnit, UserServiceUnits } from "usersPermissions/types"; +import type { RootState } from "../root/types"; +const url = window.location.toString(); +const IS_DEVELOPMENT_URL = url.includes('ninja') || url.includes('localhost'); +type OwnProps = { + children: React.ReactNode; +}; +type Props = OwnProps & { + apiError: ApiError; + apiToken: ApiToken; + apiTokenExpires: number; + clearApiToken: (...args: Array) => any; + clearError: typeof clearError; + closeReveal: (...args: Array) => any; + fetchApiToken: (...args: Array) => any; + history: Record; + isApiTokenFetching: boolean; + linkUrl: string; + location: Record; + pageTitle: string; + userActiveServiceUnit: UserServiceUnit; + userServiceUnits: UserServiceUnits; + showSearch: boolean; + user: Record; + userGroups: UserGroups; +}; +type State = { + displaySideMenu: boolean; + loggedIn: boolean; +}; + +class App extends Component { + state = { + displaySideMenu: false, + loggedIn: false + }; + timerID: any; + + componentWillUnmount() { + this.stopApiTokenTimer(); + } + + startApiTokenTimer = () => { + this.timerID = setInterval(() => this.checkApiToken(), 5000); + }; + stopApiTokenTimer = () => { + clearInterval(this.timerID); + }; + + componentDidUpdate(prevProps: Props) { + const { + apiError, + apiToken, + clearApiToken, + fetchApiToken, + history, + isApiTokenFetching, + user + } = this.props; + const { + loggedIn + } = this.state; + + if (apiError) { + return; + } + + // Fetch api token if user info is received but Api token is empty + if (!isApiTokenFetching && user && user.access_token && (isEmpty(apiToken) || user.access_token !== get(prevProps, 'user.access_token'))) { + fetchApiToken(user.access_token); + this.startApiTokenTimer(); + return; + } + + if (apiToken && !prevProps.apiToken) { + this.setState({ + loggedIn: true + }); + } + + // Clear API token when user has logged out + if (!user && !isEmpty(apiToken)) { + clearApiToken(); + this.stopApiTokenTimer(); + + // If user has pressed logout button move to lease list page + if (!loggedIn) { + history.push(getRouteById(Routes.LEASES)); + } + } + } + + handleLogin = (event: any) => { + const { + location: { + pathname, + search + } + } = this.props; + event.preventDefault(); + userManager.signinRedirect(); + setRedirectUrlToSessionStorage(`${pathname}${search}` || getRouteById(Routes.LEASES)); + }; + logOut = () => { + this.setState({ + loggedIn: false + }, () => { + userManager.removeUser(); + sessionStorage.clear(); + }); + }; + + checkApiToken() { + const { + apiTokenExpires, + fetchApiToken + } = this.props; + + if (apiTokenExpires <= getEpochTime() && get(this.props, 'user.access_token')) { + fetchApiToken(this.props.user.access_token); + } + } + + toggleSideMenu = () => { + return this.setState({ + displaySideMenu: !this.state.displaySideMenu + }); + }; + handleDismissErrorModal = () => { + this.props.closeReveal('apiError'); + this.props.clearError(); + }; + + render() { + const { + apiError, + apiToken, + children, + isApiTokenFetching, + linkUrl, + location, + pageTitle, + showSearch, + user, + userGroups, + userActiveServiceUnit, + userServiceUnits + } = this.props; + const { + displaySideMenu + } = this.state; + const appStyle = IS_DEVELOPMENT_URL ? 'app-dev' : 'app'; + + if (isEmpty(user) || isEmpty(apiToken)) { + return
+ + + {/** @ts-ignore: Property 'MEDIUM' does not exist on type */} + + + + + + {location.pathname === getRouteById(Routes.CALLBACK) && children} +
; + } + + return + + {({ + isConfirmationModalOpen, + confirmationFunction, + confirmationModalButtonClassName, + confirmationModalButtonText, + confirmationModalLabel, + confirmationModalTitle, + dispatch + }) => { + const handleConfirmation = () => { + confirmationFunction?.(); + handleHideConfirmationModal(); + }; + + const handleHideConfirmationModal = () => { + dispatch({ + type: ActionTypes.HIDE_CONFIRMATION_MODAL + }); + }; + + return
+ + + + + + + + +
+ +
+ {children} +
+
+
; + }} +
+
; + } + +} + +const mapStateToProps = (state: RootState) => { + const user = getLoggedInUser(state); + + if (!user || user.expired) { + return { + apiToken: getApiToken(state), + pageTitle: getPageTitle(state), + showSearch: getShowSearch(state), + user: null + }; + } + + return { + apiError: getError(state), + apiToken: getApiToken(state), + apiTokenExpires: getApiTokenExpires(state), + isApiTokenFetching: getIsFetching(state), + linkUrl: getLinkUrl(state), + pageTitle: getPageTitle(state), + showSearch: getShowSearch(state), + user, + userGroups: getUserGroups(state), + userServiceUnits: getUserServiceUnits(state), + userActiveServiceUnit: getUserActiveServiceUnit(state) + }; +}; + +export default (flowRight(withRouter, connect(mapStateToProps, { + clearError, + clearApiToken, + fetchApiToken +}), revealContext())(App) as React.ComponentType); \ No newline at end of file diff --git a/src/app/AppContext.js b/src/app/AppContext.js deleted file mode 100644 index 7c8d34d29..000000000 --- a/src/app/AppContext.js +++ /dev/null @@ -1,71 +0,0 @@ -// @flow - -import React from 'react'; - -const Context: React$Context<$Shape> = React.createContext({}); - -export const ActionTypes = { - HIDE_CONFIRMATION_MODAL: 'HIDE_CONFIRMATION_MODAL', - SHOW_CONFIRMATION_MODAL: 'SHOW_CONFIRMATION_MODAL', -}; - -const reducer = (state, action) => { - switch(action.type) { - case ActionTypes.HIDE_CONFIRMATION_MODAL: - return {...state, isConfirmationModalOpen: false}; - case ActionTypes.SHOW_CONFIRMATION_MODAL: - const { - confirmationFunction, - confirmationModalButtonClassName, - confirmationModalButtonText, - confirmationModalLabel, - confirmationModalTitle, - } = action; - - return { - ...state, - confirmationFunction: confirmationFunction, - confirmationModalButtonClassName: confirmationModalButtonClassName, - confirmationModalButtonText: confirmationModalButtonText, - confirmationModalLabel: confirmationModalLabel, - confirmationModalTitle: confirmationModalTitle, - isConfirmationModalOpen: true, - }; - } -}; - -type Props = { - children: any, -} - -type AppContextState = { - confirmationFunction: ?Function, - confirmationModalButtonClassName: ?string, - confirmationModalButtonText: ?string, - confirmationModalLabel: ?string, - confirmationModalTitle: ?string, - isConfirmationModalOpen: boolean, - dispatch: Function, -} - -export class AppProvider extends React.Component { - state: AppContextState = { - confirmationFunction: null, - confirmationModalButtonClassName: null, - confirmationModalButtonText: null, - confirmationModalLabel: null, - confirmationModalTitle: null, - isConfirmationModalOpen: false, - - dispatch: (action) => { - this.setState((state) => reducer(state, action)); - }, - }; - render(): React$Node { - const {state, props: {children}} = this; - - return {children}; - } -} - -export const AppConsumer = Context.Consumer; diff --git a/src/app/AppContext.tsx b/src/app/AppContext.tsx new file mode 100644 index 000000000..4b670db0d --- /dev/null +++ b/src/app/AppContext.tsx @@ -0,0 +1,71 @@ +import { $Shape } from "utility-types"; +import React from "react"; +const Context: React.Context<$Shape> = React.createContext({}); +export const ActionTypes = { + HIDE_CONFIRMATION_MODAL: 'HIDE_CONFIRMATION_MODAL', + SHOW_CONFIRMATION_MODAL: 'SHOW_CONFIRMATION_MODAL' +}; + +const reducer = (state, action) => { + switch (action.type) { + case ActionTypes.HIDE_CONFIRMATION_MODAL: + return { ...state, + isConfirmationModalOpen: false + }; + + case ActionTypes.SHOW_CONFIRMATION_MODAL: + const { + confirmationFunction, + confirmationModalButtonClassName, + confirmationModalButtonText, + confirmationModalLabel, + confirmationModalTitle + } = action; + return { ...state, + confirmationFunction: confirmationFunction, + confirmationModalButtonClassName: confirmationModalButtonClassName, + confirmationModalButtonText: confirmationModalButtonText, + confirmationModalLabel: confirmationModalLabel, + confirmationModalTitle: confirmationModalTitle, + isConfirmationModalOpen: true + }; + } +}; + +type Props = { + children: any; +}; +type AppContextState = { + confirmationFunction: ((...args: Array) => any) | null | undefined; + confirmationModalButtonClassName: string | null | undefined; + confirmationModalButtonText: string | null | undefined; + confirmationModalLabel: string | null | undefined; + confirmationModalTitle: string | null | undefined; + isConfirmationModalOpen: boolean; + dispatch: (...args: Array) => any; +}; +export class AppProvider extends React.Component { + state: AppContextState = { + confirmationFunction: null, + confirmationModalButtonClassName: null, + confirmationModalButtonText: null, + confirmationModalLabel: null, + confirmationModalTitle: null, + isConfirmationModalOpen: false, + dispatch: action => { + this.setState(state => reducer(state, action)); + } + }; + + render(): React.ReactNode { + const { + state, + props: { + children + } + } = this; + return {children}; + } + +} +export const AppConsumer = Context.Consumer; \ No newline at end of file diff --git a/src/application/actions.js b/src/application/actions.js deleted file mode 100644 index 2fbe73e87..000000000 --- a/src/application/actions.js +++ /dev/null @@ -1,82 +0,0 @@ -// @flow -import {createAction} from 'redux-actions'; - -import type { - ApplicantInfoCheckAttributesNotFoundAction, - ApplicationRelatedAttachmentsNotFoundAction, - AttachmentAttributesNotFoundAction, - AttributesNotFoundAction, - DeleteUploadAction, - FetchApplicantInfoCheckAttributesAction, - FetchApplicationRelatedAttachmentsAction, - FetchAttachmentAttributesAction, - FetchAttributesAction, - FetchFormAttributesAction, - FetchPendingUploadsAction, - FormAttributesNotFoundAction, - PendingUploadsNotFoundAction, - ReceiveApplicantInfoCheckAttributesAction, - ReceiveApplicationRelatedAttachmentsAction, - ReceiveAttachmentAttributesAction, - ReceiveAttachmentMethodsAction, - ReceiveAttributesAction, - ReceiveFileOperationFinishedAction, - ReceiveFormAttributesAction, - ReceiveMethodsAction, - ReceivePendingUploadsAction, - ReceiveUpdatedApplicantInfoCheckItemAction, - ReceiveUpdatedTargetInfoCheckItemAction, - UploadFileAction, -} from '$src/application/types'; -import type {Attributes, Methods} from '$src/types'; - -export const fetchAttributes = (): FetchAttributesAction => - createAction('mvj/application/FETCH_ATTRIBUTES')(); -export const receiveMethods = (methods: Methods): ReceiveMethodsAction => - createAction('mvj/application/RECEIVE_METHODS')(methods); -export const attributesNotFound = (): AttributesNotFoundAction => - createAction('mvj/application/ATTRIBUTES_NOT_FOUND')(); -export const receiveAttributes = (attributes: Attributes): ReceiveAttributesAction => - createAction('mvj/application/RECEIVE_ATTRIBUTES')(attributes); -export const fetchApplicantInfoCheckAttributes = (): FetchApplicantInfoCheckAttributesAction => - createAction('mvj/application/FETCH_APPLICANT_INFO_CHECK_ATTRIBUTES')(); -export const receiveApplicantInfoCheckAttributes = (payload: Object): ReceiveApplicantInfoCheckAttributesAction => - createAction('mvj/application/RECEIVE_APPLICANT_INFO_CHECK_ATTRIBUTES')(payload); -export const applicantInfoCheckAttributesNotFound = (): ApplicantInfoCheckAttributesNotFoundAction => - createAction('mvj/application/APPLICANT_INFO_CHECK_ATTRIBUTES_NOT_FOUND')(); -export const receiveUpdatedApplicantInfoCheckItem = (payload: Object): ReceiveUpdatedApplicantInfoCheckItemAction => - createAction('mvj/application/RECEIVE_UPDATED_APPLICANT_INFO_CHECK_ITEM')(payload); -export const receiveUpdatedTargetInfoCheckItem = (payload: Object): ReceiveUpdatedTargetInfoCheckItemAction => - createAction('mvj/application/RECEIVE_UPDATED_TARGET_INFO_CHECK_ITEM')(payload); -export const fetchFormAttributes = (payload: Object): FetchFormAttributesAction => - createAction('mvj/application/FETCH_FORM_ATTRIBUTES')(payload); -export const formAttributesNotFound = (): FormAttributesNotFoundAction => - createAction('mvj/application/FORM_ATTRIBUTES_NOT_FOUND')(); -export const receiveFormAttributes = (attributes: Attributes): ReceiveFormAttributesAction => - createAction('mvj/application/RECEIVE_FORM_ATTRIBUTES')(attributes); -export const fetchAttachmentAttributes = (): FetchAttachmentAttributesAction => - createAction('mvj/application/FETCH_ATTACHMENT_ATTRIBUTES')(); -export const receiveAttachmentAttributes = (payload: Object): ReceiveAttachmentAttributesAction => - createAction('mvj/application/RECEIVE_ATTACHMENT_ATTRIBUTES')(payload); -export const receiveAttachmentMethods = (payload: Object): ReceiveAttachmentMethodsAction => - createAction('mvj/application/RECEIVE_ATTACHMENT_METHODS')(payload); -export const attachmentAttributesNotFound = (): AttachmentAttributesNotFoundAction => - createAction('mvj/application/ATTACHMENT_ATTRIBUTES_NOT_FOUND')(); -export const fetchApplicationRelatedAttachments = (payload: Object): FetchApplicationRelatedAttachmentsAction => - createAction('mvj/application/FETCH_ATTACHMENTS')(payload); -export const receiveApplicationRelatedAttachments = (payload: Object): ReceiveApplicationRelatedAttachmentsAction => - createAction('mvj/application/RECEIVE_ATTACHMENTS')(payload); -export const applicationRelatedAttachmentsNotFound = (payload: Object): ApplicationRelatedAttachmentsNotFoundAction => - createAction('mvj/application/ATTACHMENTS_NOT_FOUND')(payload); -export const deleteUploadedAttachment = (payload: Object): DeleteUploadAction => - createAction('mvj/application/DELETE_UPLOAD')(payload); -export const uploadAttachment = (payload: Object): UploadFileAction => - createAction('mvj/application/UPLOAD_FILE')(payload); -export const fetchPendingUploads = (): FetchPendingUploadsAction => - createAction('mvj/application/FETCH_PENDING_UPLOADS')(); -export const receivePendingUploads = (payload: Object): ReceivePendingUploadsAction => - createAction('mvj/application/RECEIVE_PENDING_UPLOADS')(payload); -export const pendingUploadsNotFound = (): PendingUploadsNotFoundAction => - createAction('mvj/application/PENDING_UPLOADS_NOT_FOUND')(); -export const receiveFileOperationFinished = (): ReceiveFileOperationFinishedAction => - createAction('mvj/application/RECEIVE_FILE_OPERATION_FINISHED')(); diff --git a/src/application/actions.ts b/src/application/actions.ts new file mode 100644 index 000000000..d51e34c4f --- /dev/null +++ b/src/application/actions.ts @@ -0,0 +1,28 @@ +import { createAction } from "redux-actions"; +import type { ApplicantInfoCheckAttributesNotFoundAction, ApplicationRelatedAttachmentsNotFoundAction, AttachmentAttributesNotFoundAction, AttributesNotFoundAction, DeleteUploadAction, FetchApplicantInfoCheckAttributesAction, FetchApplicationRelatedAttachmentsAction, FetchAttachmentAttributesAction, FetchAttributesAction, FetchFormAttributesAction, FetchPendingUploadsAction, FormAttributesNotFoundAction, PendingUploadsNotFoundAction, ReceiveApplicantInfoCheckAttributesAction, ReceiveApplicationRelatedAttachmentsAction, ReceiveAttachmentAttributesAction, ReceiveAttachmentMethodsAction, ReceiveAttributesAction, ReceiveFileOperationFinishedAction, ReceiveFormAttributesAction, ReceiveMethodsAction, ReceivePendingUploadsAction, ReceiveUpdatedApplicantInfoCheckItemAction, ReceiveUpdatedTargetInfoCheckItemAction, UploadFileAction } from "application/types"; +import type { Attributes, Methods } from "types"; +export const fetchAttributes = (): FetchAttributesAction => createAction('mvj/application/FETCH_ATTRIBUTES')(); +export const receiveMethods = (methods: Methods): ReceiveMethodsAction => createAction('mvj/application/RECEIVE_METHODS')(methods); +export const attributesNotFound = (): AttributesNotFoundAction => createAction('mvj/application/ATTRIBUTES_NOT_FOUND')(); +export const receiveAttributes = (attributes: Attributes): ReceiveAttributesAction => createAction('mvj/application/RECEIVE_ATTRIBUTES')(attributes); +export const fetchApplicantInfoCheckAttributes = (): FetchApplicantInfoCheckAttributesAction => createAction('mvj/application/FETCH_APPLICANT_INFO_CHECK_ATTRIBUTES')(); +export const receiveApplicantInfoCheckAttributes = (payload: Record): ReceiveApplicantInfoCheckAttributesAction => createAction('mvj/application/RECEIVE_APPLICANT_INFO_CHECK_ATTRIBUTES')(payload); +export const applicantInfoCheckAttributesNotFound = (): ApplicantInfoCheckAttributesNotFoundAction => createAction('mvj/application/APPLICANT_INFO_CHECK_ATTRIBUTES_NOT_FOUND')(); +export const receiveUpdatedApplicantInfoCheckItem = (payload: Record): ReceiveUpdatedApplicantInfoCheckItemAction => createAction('mvj/application/RECEIVE_UPDATED_APPLICANT_INFO_CHECK_ITEM')(payload); +export const receiveUpdatedTargetInfoCheckItem = (payload: Record): ReceiveUpdatedTargetInfoCheckItemAction => createAction('mvj/application/RECEIVE_UPDATED_TARGET_INFO_CHECK_ITEM')(payload); +export const fetchFormAttributes = (payload: Record): FetchFormAttributesAction => createAction('mvj/application/FETCH_FORM_ATTRIBUTES')(payload); +export const formAttributesNotFound = (): FormAttributesNotFoundAction => createAction('mvj/application/FORM_ATTRIBUTES_NOT_FOUND')(); +export const receiveFormAttributes = (attributes: Attributes): ReceiveFormAttributesAction => createAction('mvj/application/RECEIVE_FORM_ATTRIBUTES')(attributes); +export const fetchAttachmentAttributes = (): FetchAttachmentAttributesAction => createAction('mvj/application/FETCH_ATTACHMENT_ATTRIBUTES')(); +export const receiveAttachmentAttributes = (payload: Record): ReceiveAttachmentAttributesAction => createAction('mvj/application/RECEIVE_ATTACHMENT_ATTRIBUTES')(payload); +export const receiveAttachmentMethods = (payload: Record): ReceiveAttachmentMethodsAction => createAction('mvj/application/RECEIVE_ATTACHMENT_METHODS')(payload); +export const attachmentAttributesNotFound = (): AttachmentAttributesNotFoundAction => createAction('mvj/application/ATTACHMENT_ATTRIBUTES_NOT_FOUND')(); +export const fetchApplicationRelatedAttachments = (payload: Record): FetchApplicationRelatedAttachmentsAction => createAction('mvj/application/FETCH_ATTACHMENTS')(payload); +export const receiveApplicationRelatedAttachments = (payload: Record): ReceiveApplicationRelatedAttachmentsAction => createAction('mvj/application/RECEIVE_ATTACHMENTS')(payload); +export const applicationRelatedAttachmentsNotFound = (payload?: Record): ApplicationRelatedAttachmentsNotFoundAction => createAction('mvj/application/ATTACHMENTS_NOT_FOUND')(payload); +export const deleteUploadedAttachment = (payload: Record): DeleteUploadAction => createAction('mvj/application/DELETE_UPLOAD')(payload); +export const uploadAttachment = (payload: Record): UploadFileAction => createAction('mvj/application/UPLOAD_FILE')(payload); +export const fetchPendingUploads = (): FetchPendingUploadsAction => createAction('mvj/application/FETCH_PENDING_UPLOADS')(); +export const receivePendingUploads = (payload: Record): ReceivePendingUploadsAction => createAction('mvj/application/RECEIVE_PENDING_UPLOADS')(payload); +export const pendingUploadsNotFound = (): PendingUploadsNotFoundAction => createAction('mvj/application/PENDING_UPLOADS_NOT_FOUND')(); +export const receiveFileOperationFinished = (): ReceiveFileOperationFinishedAction => createAction('mvj/application/RECEIVE_FILE_OPERATION_FINISHED')(); \ No newline at end of file diff --git a/src/application/components/ApplicationAnswersField.js b/src/application/components/ApplicationAnswersField.js deleted file mode 100644 index fdd587d0c..000000000 --- a/src/application/components/ApplicationAnswersField.js +++ /dev/null @@ -1,125 +0,0 @@ -// @flow - -import React from 'react'; -import {Column, Row} from 'react-foundation'; - -import FileDownloadLink from '$components/file/FileDownloadLink'; -import FormTextTitle from '$components/form/FormTextTitle'; -import FormText from '$components/form/FormText'; -import ApplicationAnswersSection from '$src/application/components/ApplicationAnswersSection'; -import {getApplicationAttachmentDownloadLink} from '$src/application/helpers'; - -import type { - FormSection, - SavedApplicationFormSection, - SectionExtraComponentProps, - UploadedFileMeta, -} from '$src/application/types'; - -type Props = { - section: FormSection, - answer: SavedApplicationFormSection, - topLevel: boolean, - fieldTypes: Array<{ value: string, display_name: string }>, - identifier: string, - sectionExtraComponent?: React$ComponentType, - sectionTitleTransformers?: Array<(string, FormSection, SavedApplicationFormSection) => string>, -}; - -const ApplicationAnswersField = ({ - section, - answer, - fieldTypes, - topLevel, - identifier, - sectionExtraComponent: SectionExtraComponent, - sectionTitleTransformers, -}: Props): React$Node => { - return <> - - {section.fields.filter((field) => field.enabled).map((field) => { - const fieldAnswer = answer.fields[field.identifier]; - const fieldType = fieldTypes?.find((fieldType) => fieldType.value === field.type)?.value; - - if (fieldType === 'hidden') { - return null; - } - - const getChoiceName = (id) => { - if (!id) { - return null; - } - - const choice = field.choices.find((choice) => choice.value === id || Number(choice.value) === id); - - let name = choice?.text || '(tuntematon vaihtoehto)'; - if (choice?.has_text_input) { - name += ` (${fieldAnswer.extra_value})`; - } - - return name; - }; - - let displayValue = fieldAnswer?.value; - if (displayValue !== undefined && displayValue !== null) { - switch (fieldType) { - case 'radiobutton': - case 'radiobuttoninline': - displayValue = getChoiceName(displayValue); - break; - case 'checkbox': - case 'dropdown': - if (Array.isArray(displayValue)) { - displayValue = displayValue.map(getChoiceName).join(', '); - } else { - if (field.choices.length > 0) { - displayValue = getChoiceName(displayValue); - } else { - displayValue = displayValue === true ? 'Kyllä' : 'Ei'; - } - } - break; - case 'uploadfiles': - // TODO: can this be cast in a cleaner way? - const files: Array = (displayValue: any); - displayValue = files.length > 0 ?
    {files.map((file) =>
  • - -
  • )}
: null; - break; - case 'hidden': - break; - } - } - - return - - {field.label} - - - {displayValue || '-'} - - ; - })} -
- {section.subsections.filter((section) => section.visible).map((subsection) => - )} - {SectionExtraComponent ? : null} - ; -}; - -export default ApplicationAnswersField; diff --git a/src/application/components/ApplicationAnswersField.tsx b/src/application/components/ApplicationAnswersField.tsx new file mode 100644 index 000000000..d5071af1c --- /dev/null +++ b/src/application/components/ApplicationAnswersField.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import { Column, Row } from "react-foundation"; +import FileDownloadLink from "components/file/FileDownloadLink"; +import FormTextTitle from "components/form/FormTextTitle"; +import FormText from "components/form/FormText"; +import ApplicationAnswersSection from "application/components/ApplicationAnswersSection"; +import { getApplicationAttachmentDownloadLink } from "application/helpers"; +import type { FormSection, SavedApplicationFormSection, SectionExtraComponentProps, UploadedFileMeta } from "application/types"; +type Props = { + section: FormSection; + answer: SavedApplicationFormSection; + topLevel: boolean; + fieldTypes: Array<{ + value: string; + display_name: string; + }>; + identifier: string; + sectionExtraComponent?: React.ComponentType; + sectionTitleTransformers?: Array<(arg0: string, arg1: FormSection, arg2: SavedApplicationFormSection) => string>; +}; + +const ApplicationAnswersField = ({ + section, + answer, + fieldTypes, + topLevel, + identifier, + sectionExtraComponent: SectionExtraComponent, + sectionTitleTransformers +}: Props): React.ReactNode => { + return <> + + {section.fields.filter(field => field.enabled).map(field => { + const fieldAnswer = answer.fields[field.identifier]; + const fieldType = fieldTypes?.find(fieldType => fieldType.value === field.type)?.value; + + if (fieldType === 'hidden') { + return null; + } + + const getChoiceName = id => { + if (!id) { + return null; + } + + const choice = field.choices.find(choice => choice.value === id || Number(choice.value) === id); + let name = choice?.text || '(tuntematon vaihtoehto)'; + + if (choice?.has_text_input) { + name += ` (${fieldAnswer.extra_value})`; + } + + return name; + }; + + let displayValue: any = fieldAnswer?.value; + + if (displayValue !== undefined && displayValue !== null) { + switch (fieldType) { + case 'radiobutton': + case 'radiobuttoninline': + displayValue = getChoiceName(displayValue); + break; + + case 'checkbox': + case 'dropdown': + if (Array.isArray(displayValue)) { + displayValue = displayValue.map(getChoiceName).join(', '); + } else { + if (field.choices.length > 0) { + displayValue = getChoiceName(displayValue); + } else { + displayValue = displayValue === true ? 'Kyllä' : 'Ei'; + } + } + + break; + + case 'uploadfiles': + // TODO: can this be cast in a cleaner way? + const files: Array = (displayValue as any); + displayValue = files.length > 0 ?
    {files.map(file =>
  • + +
  • )}
: null; + break; + + case 'hidden': + break; + } + } + + return + + {field.label} + + + {displayValue || '-'} + + ; + })} +
+ {section.subsections.filter(section => section.visible).map(subsection => )} + {SectionExtraComponent ? : null} + ; +}; + +export default ApplicationAnswersField; \ No newline at end of file diff --git a/src/application/components/ApplicationAnswersSection.js b/src/application/components/ApplicationAnswersSection.js deleted file mode 100644 index 0974e69d3..000000000 --- a/src/application/components/ApplicationAnswersSection.js +++ /dev/null @@ -1,78 +0,0 @@ -// @flow - -import React from 'react'; - -import Collapse from '$components/collapse/Collapse'; -import SubTitle from '$components/content/SubTitle'; -import ApplicationAnswersField from '$src/application/components/ApplicationAnswersField'; - -import type { - FormSection, - SavedApplicationFormSection, - SectionExtraComponentProps, -} from '$src/application/types'; - -type Props = { - section: FormSection, - answer: SavedApplicationFormSection | Array, - topLevel?: boolean, - fieldTypes: Array<{ value: string, display_name: string }>, - sectionExtraComponent?: React$ComponentType, - sectionTitleTransformers?: Array<(string, FormSection, SavedApplicationFormSection) => string>, -}; - -const ApplicationAnswersSection = ({ - section, - answer, - topLevel = false, - fieldTypes, - sectionExtraComponent, - sectionTitleTransformers, -}: Props): React$Node => { - if (!answer) { - return null; - } - - const title = section.title || '(tuntematon osio)'; - - const Wrapper = topLevel ? - ({children}) => {children} : ({children}) =>
- - {title} - - {children} -
; - return - {section.add_new_allowed - ? (answer instanceof Array ? answer : []).map((singleAnswer, i) => { - const subtitle: string = (sectionTitleTransformers || []).reduce( - (title, transformer) => transformer(title, section, singleAnswer), `#${i + 1}`); - - return - - ; - }) - : } - ; -}; - -export default ApplicationAnswersSection; diff --git a/src/application/components/ApplicationAnswersSection.tsx b/src/application/components/ApplicationAnswersSection.tsx new file mode 100644 index 000000000..eb135a3e6 --- /dev/null +++ b/src/application/components/ApplicationAnswersSection.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import Collapse from "components/collapse/Collapse"; +import SubTitle from "components/content/SubTitle"; +import ApplicationAnswersField from "application/components/ApplicationAnswersField"; +import type { FormSection, SavedApplicationFormSection, SectionExtraComponentProps } from "application/types"; +type Props = { + section: FormSection; + answer: SavedApplicationFormSection | Array; + topLevel?: boolean; + fieldTypes: any; + sectionExtraComponent?: React.ComponentType; + sectionTitleTransformers?: Array; + plotSearch?: any; + editMode?: boolean; +}; + +const ApplicationAnswersSection = ({ + section, + answer, + topLevel = false, + fieldTypes, + sectionExtraComponent, + sectionTitleTransformers +}: Props): React.ReactNode => { + if (!answer) { + return null; + } + + const title = section.title || '(tuntematon osio)'; + const Wrapper = topLevel ? ({ + children + }) => {children} : ({ + children + }) =>
+ + {title} + + {children} +
; + return + {section.add_new_allowed ? (answer instanceof Array ? answer : []).map((singleAnswer, i) => { + const subtitle: string = (sectionTitleTransformers || []).reduce((title, transformer) => transformer(title, section, singleAnswer), `#${i + 1}`); + return + + ; + }) : } + ; +}; + +export default ApplicationAnswersSection; \ No newline at end of file diff --git a/src/application/components/ApplicationSubsection.js b/src/application/components/ApplicationSubsection.js deleted file mode 100644 index 0172b0baf..000000000 --- a/src/application/components/ApplicationSubsection.js +++ /dev/null @@ -1,539 +0,0 @@ -// @flow -import React, {Fragment, useCallback} from 'react'; -import {change, FieldArray, formValueSelector} from 'redux-form'; -import {connect} from 'react-redux'; -import type {Fields} from 'redux-form/lib/FieldArrayProps.types'; -import {Column, Row} from 'react-foundation'; - -import AddButton from '$components/form/AddButton'; -import RemoveButton from '$components/form/RemoveButton'; -import FormField from '$components/form/FormField'; -import AddFileButton from '$components/form/AddFileButton'; -import FormTextTitle from '$components/form/FormTextTitle'; -import FormText from '$components/form/FormText'; -import Authorization from '$components/authorization/Authorization'; -import FileDownloadLink from '$components/file/FileDownloadLink'; -import LoadingIndicator from '$components/multi-select/LoadingIndicator'; -import {ButtonColors} from '$components/enums'; -import FormHintText from '$components/form/FormHintText'; -import {formatDate, isFieldAllowedToRead} from '$util/helpers'; -import {ConfirmationModalTexts} from '$src/enums'; -import {ActionTypes, AppConsumer} from '$src/app/AppContext'; -import {ApplicantTypes} from '$src/application/enums'; -import { - getApplicationAttachmentDownloadLink, - getFieldFileIds, - getSectionApplicantType, - getSectionTemplate, - valueToApplicantType, -} from '$src/application/helpers'; -import { - getAttachmentAttributes, - getAttachmentMethods, - getExistingUploads, - getFieldTypeMapping, - getIsFetchingApplicationRelatedAttachments, - getIsFetchingAttachmentAttributes, - getIsFetchingPendingUploads, - getIsPerformingFileOperation, - getPendingUploads, -} from '$src/application/selectors'; -import {ApplicationSectionKeys} from '$src/application/components/enums'; -import { - APPLICANT_MAIN_IDENTIFIERS, - APPLICANT_SECTION_IDENTIFIER, - APPLICANT_TYPE_FIELD_IDENTIFIER, - EMAIL_FIELD_IDENTIFIER, - TARGET_SECTION_IDENTIFIER, -} from '$src/application/constants'; -import { - deleteUploadedAttachment, - uploadAttachment, -} from '$src/application/actions'; - -import type { - FormSection, - PlotApplicationFormValue, - UploadedFileMeta, -} from '$src/application/types'; -import {companyIdentifierValidator, emailValidator, personalIdentifierValidator} from '$src/application/formValidation'; - -const ApplicationFormFileField = connect( - (state, props) => { - const {formName, formPath, field, fieldName} = props; - - return ({ - pendingUploads: getPendingUploads(state), - existingUploads: getExistingUploads(state, field.identifier), - attachmentMethods: getAttachmentMethods(state), - attachmentAttributes: getAttachmentAttributes(state), - isFetchingPendingUploads: getIsFetchingPendingUploads(state), - isFetchingAttachments: getIsFetchingApplicationRelatedAttachments(state), - isFetchingAttachmentAttributes: getIsFetchingAttachmentAttributes(state), - isPerformingFileOperation: getIsPerformingFileOperation(state), - fieldFileIds: getFieldFileIds(state, formName, fieldName), - attachmentIds: formValueSelector(formName)(state, (formPath ? `${formPath}.` : '') + 'attachments'), - }); - }, - { - uploadAttachment, - deleteUploadedAttachment, - change, - } -)(({ - uploadAttachment, - deleteUploadedAttachment, - pendingUploads, - existingUploads, - // TODO: Method authorization is disabled, since at this point in time, the backend returns false for all methods. - //attachmentMethods, - attachmentAttributes, - isFetchingPendingUploads, - isFetchingAttachments, - isFetchingAttachmentAttributes, - isPerformingFileOperation, - field, - fieldName, - change, - fieldFileIds, - attachmentIds, - formName, - formPath, - answerId, -}) => { - return ( - - {({dispatch}) => { - const isNew = !answerId; - const pathWithinForm = fieldName.split('.').slice(1).join('.'); - - const addId = (newId: number) => { - change(formName, `${fieldName}.value`, [...fieldFileIds, newId]); - change(formName, (formPath ? `${formPath}.` : '') + 'attachments', [...attachmentIds, newId]); - }; - - const removeId = (newId: number) => { - change(formName, `${fieldName}.value`, fieldFileIds.filter((id) => id !== newId)); - change(formName, (formPath ? `${formPath}.` : '') + 'attachments', attachmentIds.filter((id) => id !== newId)); - }; - - const submitFile = (fieldId, e) => { - uploadAttachment({ - fileData: { - field: fieldId, - file: e.target.files[0], - answer: answerId || undefined, - }, - callback: (path: string, uploadedFile: UploadedFileMeta) => { - addId(uploadedFile.id); - }, - path: pathWithinForm, - }); - }; - - const uploads = (isNew ? pendingUploads : existingUploads).filter((file) => fieldFileIds.includes(file.id)); - - const deleteFile = (file) => { - dispatch({ - type: ActionTypes.SHOW_CONFIRMATION_MODAL, - confirmationFunction: () => { - // Hard delete only if this is a new editor. Edit mode can be canceled, in which case changes to files - // are desirable to not also take place. Orphaned files not attached to an application anymore are - // planned to be removed by the backend periodically. - if (isNew) { - deleteUploadedAttachment(file); - } - - removeId(file.id); - }, - confirmationModalButtonClassName: ButtonColors.ALERT, - confirmationModalButtonText: ConfirmationModalTexts.DELETE_ATTACHMENT.BUTTON, - confirmationModalLabel: ConfirmationModalTexts.DELETE_ATTACHMENT.LABEL, - confirmationModalTitle: ConfirmationModalTexts.DELETE_ATTACHMENT.TITLE, - }); - }; - - const busy = isFetchingPendingUploads || isPerformingFileOperation || isFetchingAttachments || isFetchingAttachmentAttributes; - - return - {field.label} - {!isFetchingAttachmentAttributes && uploads.length > 0 && - - - - Tiedoston nimi - - - - - - - {'Ladattu'} - - - - } - {!isFetchingAttachmentAttributes && uploads.map((file, index) => { - return - - - - - - - - {formatDate(file.created_at) || '-'} - - - - - deleteFile(file)} - style={{right: 12}} - title="Poista liitetiedosto" - disabled={busy} - /> - - - ; - })} - {uploads.length === 0 && !busy && - - Ei lisättyjä liitteitä. - - } - - {busy - ? - : submitFile(field.id, e)} />} - - ;}} - - ); -}); - -const ApplicationFormSubsectionFields = connect( - (state, props) => ({ - sectionApplicantType: getSectionApplicantType(state, props.formName, props.section, props.identifier), - }), { - change, - } -)( - ({ - section, - identifier, - change, - sectionApplicantType, - formName, - formPath, - sectionTitleTransformers, - answerId, - }) => { - const renderField = useCallback((pathName, field) => { - if (!field.enabled) { - return null; - } - - const fieldName = [ - pathName, - ApplicationSectionKeys.Fields, - field.identifier, - ].join('.'); - const fieldType = field.type; - - // Special cases that use a different submission path and thus different props - if (fieldType === 'uploadfiles') { - return ; - } - - let extraAttributes = {}; - let fieldOverrides = {}; - let columnWidths = { - small: 12, - medium: 6, - large: 3, - }; - - switch (fieldType) { - case 'textbox': - case 'fractional': - break; - case 'textarea': - columnWidths = { - small: 12, - medium: 12, - large: 12, - }; - break; - case 'dropdown': - extraAttributes = { - choices: field.choices.map((option) => ({ - display_name: option.label, - value: option.value, - })), - }; - break; - case 'hidden': - if(field.identifier === APPLICANT_TYPE_FIELD_IDENTIFIER) { - change(formName, `${identifier}.metadata.applicantType`, valueToApplicantType(field.default_value)); - } - extraAttributes = { - type: 'hidden', - }; - break; - case 'checkbox': - fieldOverrides = { - options: field.choices.map((choice) => ({ - value: choice.value, - label: choice.has_text_input ? <> - {choice.text} - { ' ' } - - : choice.text, - })), - }; - columnWidths = { - small: 12, - medium: 12, - large: 12, - }; - break; - case 'radiobutton': - case 'radiobuttoninline': - extraAttributes = { - type: 'radio-with-field', - }; - fieldOverrides = { - options: field.choices.map((choice) => ({ - label: choice.text, - value: choice.value, - field: choice.has_text_input - ? - : null, - })), - }; - columnWidths = { - small: 12, - medium: 12, - large: 12, - }; - break; - default: - break; - } - - let validator; - switch (fieldName.substring(fieldName.lastIndexOf('.') + 1)) { - case APPLICANT_MAIN_IDENTIFIERS[ApplicantTypes.PERSON].IDENTIFIER_FIELD: - validator = personalIdentifierValidator; - break; - case APPLICANT_MAIN_IDENTIFIERS[ApplicantTypes.COMPANY].IDENTIFIER_FIELD: - validator = companyIdentifierValidator; - break; - case EMAIL_FIELD_IDENTIFIER: - validator = emailValidator; - break; - } - - return ( - - checkSpecialValues(field, newValue)} - validate={validator} - /> - - ); - }, []); - - const checkSpecialValues = useCallback((field: Object, newValue: PlotApplicationFormValue): void => { - if (field.identifier === APPLICANT_TYPE_FIELD_IDENTIFIER && section.identifier === APPLICANT_SECTION_IDENTIFIER) { - change(formName, `${identifier}.metadata.applicantType`, valueToApplicantType(newValue)); - } - }, []); - - return ( - <> - - {section.fields.map((field) => {renderField(identifier, field)})} - - {section.subsections.map((subsection) => ( - - ))} - - ); - } -); - -const ApplicationFormSubsectionFieldArray = ({ - fields, - section, - headerTag: HeaderTag, - formName, - formPath, - sectionTitleTransformers, - answerId, -}: { - fields: Fields, - section: Object, - headerTag: string | React$ComponentType<{ children?: React$Node }>, - formName: string, - formPath: string, - sectionTitleTransformers: Array<(string, FormSection, string) => string>, - answerId: number | null, -}): React$Node => { - return ( -
- {fields.map((identifier, i) => { - const subtitle: string = (sectionTitleTransformers || []).reduce( - (title, transformer) => transformer(title, section, identifier), `${section.title} (${i + 1})`); - - return ( -
-
- {fields.length > 1 && section.identifier !== TARGET_SECTION_IDENTIFIER && ( - fields.remove(i)} - style={{float: 'right'}} - /> - )} - - {subtitle} - - -
-
- ); - })} - {section.identifier !== TARGET_SECTION_IDENTIFIER && fields.push(getSectionTemplate(formName, formPath, section.identifier))} - label={section.add_new_text || 'Lisää uusi'} - />} -
- ); -}; - -const ApplicationSubsection = ({ - path, - section, - headerTag: HeaderTag = 'h3', - parentApplicantType, - formName, - formPath = '', - sectionTitleTransformers, - answerId, -}: { - path: Array, - section: Object, - headerTag?: string | React$ComponentType<{ children?: React$Node }>, - parentApplicantType?: string | null, - formName: string, - formPath: ?string, - sectionTitleTransformers: Array<(string, FormSection, string) => string>, - answerId: number | null, -}): React$Node => { - if (!section.visible) { - return null; - } - - if (parentApplicantType === ApplicantTypes.UNSELECTED) { - return null; - } - - if (parentApplicantType !== ApplicantTypes.NOT_APPLICABLE && ![ - ApplicantTypes.UNKNOWN, - ApplicantTypes.BOTH, - parentApplicantType, - ].includes(section.applicant_type)) { - return null; - } - - const isArray = section.add_new_allowed; - const pathName = [...path, section.identifier].join('.'); - - const title = section.title || '(tuntematon osio)'; - - return ( -
- {isArray ? ( - - ) : ( -
- {title} - -
- )} -
- ); -}; - -export default ApplicationSubsection; diff --git a/src/application/components/ApplicationSubsection.tsx b/src/application/components/ApplicationSubsection.tsx new file mode 100644 index 000000000..b352ac527 --- /dev/null +++ b/src/application/components/ApplicationSubsection.tsx @@ -0,0 +1,425 @@ +import React, { Fragment, useCallback } from "react"; +import { change, FieldArray, formValueSelector } from "redux-form"; +import { connect } from "react-redux"; +import { Column, Row } from "react-foundation"; +import AddButton from "components/form/AddButton"; +import RemoveButton from "components/form/RemoveButton"; +import FormField from "components/form/FormField"; +import AddFileButton from "components/form/AddFileButton"; +import FormTextTitle from "components/form/FormTextTitle"; +import FormText from "components/form/FormText"; +import Authorization from "components/authorization/Authorization"; +import FileDownloadLink from "components/file/FileDownloadLink"; +import LoadingIndicator from "components/multi-select/LoadingIndicator"; +import { ButtonColors } from "components/enums"; +import FormHintText from "components/form/FormHintText"; +import { formatDate, isFieldAllowedToRead } from "util/helpers"; +import { ConfirmationModalTexts } from "enums"; +import { ActionTypes, AppConsumer } from "app/AppContext"; +import { ApplicantTypes } from "application/enums"; +import { getApplicationAttachmentDownloadLink, getFieldFileIds, getSectionApplicantType, getSectionTemplate, valueToApplicantType } from "application/helpers"; +import { getAttachmentAttributes, getAttachmentMethods, getExistingUploads, getFieldTypeMapping, getIsFetchingApplicationRelatedAttachments, getIsFetchingAttachmentAttributes, getIsFetchingPendingUploads, getIsPerformingFileOperation, getPendingUploads } from "application/selectors"; +import { ApplicationSectionKeys } from "application/components/enums"; +import { APPLICANT_MAIN_IDENTIFIERS, APPLICANT_SECTION_IDENTIFIER, APPLICANT_TYPE_FIELD_IDENTIFIER, EMAIL_FIELD_IDENTIFIER, TARGET_SECTION_IDENTIFIER } from "application/constants"; +import { deleteUploadedAttachment, uploadAttachment } from "application/actions"; +import type { FormSection, PlotApplicationFormValue, UploadedFileMeta } from "application/types"; +import { companyIdentifierValidator, emailValidator, personalIdentifierValidator } from "application/formValidation"; +const ApplicationFormFileField = connect((state, props) => { + const { + formName, + formPath, + field, + fieldName + } = props; + return { + pendingUploads: getPendingUploads(state), + existingUploads: getExistingUploads(state, field.identifier), + attachmentMethods: getAttachmentMethods(state), + attachmentAttributes: getAttachmentAttributes(state), + isFetchingPendingUploads: getIsFetchingPendingUploads(state), + isFetchingAttachments: getIsFetchingApplicationRelatedAttachments(state), + isFetchingAttachmentAttributes: getIsFetchingAttachmentAttributes(state), + isPerformingFileOperation: getIsPerformingFileOperation(state), + fieldFileIds: getFieldFileIds(state, formName, fieldName), + attachmentIds: formValueSelector(formName)(state, (formPath ? `${formPath}.` : '') + 'attachments') + }; +}, { + uploadAttachment, + deleteUploadedAttachment, + change +})(({ + uploadAttachment, + deleteUploadedAttachment, + pendingUploads, + existingUploads, + // TODO: Method authorization is disabled, since at this point in time, the backend returns false for all methods. + //attachmentMethods, + attachmentAttributes, + isFetchingPendingUploads, + isFetchingAttachments, + isFetchingAttachmentAttributes, + isPerformingFileOperation, + field, + fieldName, + change, + fieldFileIds, + attachmentIds, + formName, + formPath, + answerId +}) => { + return + {({ + dispatch + }) => { + const isNew = !answerId; + const pathWithinForm = fieldName.split('.').slice(1).join('.'); + + const addId = (newId: number) => { + change(formName, `${fieldName}.value`, [...fieldFileIds, newId]); + change(formName, (formPath ? `${formPath}.` : '') + 'attachments', [...attachmentIds, newId]); + }; + + const removeId = (newId: number) => { + change(formName, `${fieldName}.value`, fieldFileIds.filter(id => id !== newId)); + change(formName, (formPath ? `${formPath}.` : '') + 'attachments', attachmentIds.filter(id => id !== newId)); + }; + + const submitFile = (fieldId, e) => { + uploadAttachment({ + fileData: { + field: fieldId, + file: e.target.files[0], + answer: answerId || undefined + }, + callback: (path: string, uploadedFile: UploadedFileMeta) => { + addId(uploadedFile.id); + }, + path: pathWithinForm + }); + }; + + const uploads = (isNew ? pendingUploads : existingUploads).filter(file => fieldFileIds.includes(file.id)); + + const deleteFile = file => { + dispatch({ + type: ActionTypes.SHOW_CONFIRMATION_MODAL, + confirmationFunction: () => { + // Hard delete only if this is a new editor. Edit mode can be canceled, in which case changes to files + // are desirable to not also take place. Orphaned files not attached to an application anymore are + // planned to be removed by the backend periodically. + if (isNew) { + deleteUploadedAttachment(file); + } + + removeId(file.id); + }, + confirmationModalButtonClassName: ButtonColors.ALERT, + confirmationModalButtonText: ConfirmationModalTexts.DELETE_ATTACHMENT.BUTTON, + confirmationModalLabel: ConfirmationModalTexts.DELETE_ATTACHMENT.LABEL, + confirmationModalTitle: ConfirmationModalTexts.DELETE_ATTACHMENT.TITLE + }); + }; + + const busy = isFetchingPendingUploads || isPerformingFileOperation || isFetchingAttachments || isFetchingAttachmentAttributes; + return + {field.label} + {!isFetchingAttachmentAttributes && uploads.length > 0 && + + + + Tiedoston nimi + + + + + + + {'Ladattu'} + + + + } + {!isFetchingAttachmentAttributes && uploads.map((file, index) => { + return + + + + + + + + {formatDate(file.created_at) || '-'} + + + + + deleteFile(file)} style={{ + right: 12 + }} title="Poista liitetiedosto" disabled={busy} /> + + + ; + })} + {uploads.length === 0 && !busy && + + Ei lisättyjä liitteitä. + + } + + {busy ? : submitFile(field.id, e)} />} + + ; + }} + ; +}); +const ApplicationFormSubsectionFields = connect((state, props) => ({ + sectionApplicantType: getSectionApplicantType(state, props.formName, props.section, props.identifier) +}), { + change +})(({ + section, + identifier, + change, + sectionApplicantType, + formName, + formPath, + sectionTitleTransformers, + answerId +}) => { + const renderField = useCallback((pathName, field) => { + if (!field.enabled) { + return null; + } + + const fieldName = [pathName, ApplicationSectionKeys.Fields, field.identifier].join('.'); + const fieldType = field.type; + + // Special cases that use a different submission path and thus different props + if (fieldType === 'uploadfiles') { + return ; + } + + let extraAttributes = {}; + let fieldOverrides = {}; + let columnWidths = { + small: 12, + medium: 6, + large: 3 + }; + + switch (fieldType) { + case 'textbox': + case 'fractional': + break; + + case 'textarea': + columnWidths = { + small: 12, + medium: 12, + large: 12 + }; + break; + + case 'dropdown': + extraAttributes = { + choices: field.choices.map(option => ({ + display_name: option.label, + value: option.value + })) + }; + break; + + case 'hidden': + if (field.identifier === APPLICANT_TYPE_FIELD_IDENTIFIER) { + change(formName, `${identifier}.metadata.applicantType`, valueToApplicantType(field.default_value)); + } + + extraAttributes = { + type: 'hidden' + }; + break; + + case 'checkbox': + fieldOverrides = { + options: field.choices.map(choice => ({ + value: choice.value, + label: choice.has_text_input ? <> + {choice.text} + {' '} + + : choice.text + })) + }; + columnWidths = { + small: 12, + medium: 12, + large: 12 + }; + break; + + case 'radiobutton': + case 'radiobuttoninline': + extraAttributes = { + type: 'radio-with-field' + }; + fieldOverrides = { + options: field.choices.map(choice => ({ + label: choice.text, + value: choice.value, + field: choice.has_text_input ? : null + })) + }; + columnWidths = { + small: 12, + medium: 12, + large: 12 + }; + break; + + default: + break; + } + + let validator; + + switch (fieldName.substring(fieldName.lastIndexOf('.') + 1)) { + case APPLICANT_MAIN_IDENTIFIERS[ApplicantTypes.PERSON].IDENTIFIER_FIELD: + validator = personalIdentifierValidator; + break; + + case APPLICANT_MAIN_IDENTIFIERS[ApplicantTypes.COMPANY].IDENTIFIER_FIELD: + validator = companyIdentifierValidator; + break; + + case EMAIL_FIELD_IDENTIFIER: + validator = emailValidator; + break; + } + + return + {/** @ts-ignore: No overload matches this call. */} + checkSpecialValues(field, newValue)} validate={validator} /> + ; + }, []); + const checkSpecialValues = useCallback((field: Record, newValue: PlotApplicationFormValue): void => { + if (field.identifier === APPLICANT_TYPE_FIELD_IDENTIFIER && section.identifier === APPLICANT_SECTION_IDENTIFIER) { + change(formName, `${identifier}.metadata.applicantType`, valueToApplicantType(newValue)); + } + }, []); + return <> + + {section.fields.map(field => {renderField(identifier, field)})} + + {section.subsections.map(subsection => )} + ; +}); + +const ApplicationFormSubsectionFieldArray = ({ + fields, + section, + headerTag: HeaderTag, + formName, + formPath, + sectionTitleTransformers, + answerId +}: { + fields: any; + section: any; + headerTag: string | React.ComponentType<{ + children?: React.ReactNode; + }>; + formName: string; + formPath: string; + sectionTitleTransformers: Array<(arg0: string, arg1: FormSection, arg2: string) => string>; + answerId: number | null; +}): React.ReactNode => { + return
+ {fields.map((identifier, i) => { + const subtitle: string = (sectionTitleTransformers || []).reduce((title, transformer) => transformer(title, section, identifier), `${section.title} (${i + 1})`); + return
+
+ {fields.length > 1 && section.identifier !== TARGET_SECTION_IDENTIFIER && fields.remove(i)} style={{ + float: 'right' + }} />} + + {subtitle} + + +
+
; + })} + {section.identifier !== TARGET_SECTION_IDENTIFIER && fields.push(getSectionTemplate(formName, formPath, section.identifier))} label={section.add_new_text || 'Lisää uusi'} />} +
; +}; + +const ApplicationSubsection = ({ + path, + section, + headerTag: HeaderTag = 'h3', + parentApplicantType, + formName, + formPath = '', + sectionTitleTransformers, + answerId +}: { + path: Array; + section: any; + headerTag?: string | React.ComponentType<{ + children?: React.ReactNode; + }>; + parentApplicantType?: string | null; + formName: string; + formPath: string | null | undefined; + sectionTitleTransformers: Array<(arg0: string, arg1: FormSection, arg2: string) => string>; + answerId: number | null; +}): React.ReactNode => { + if (!section.visible) { + return null; + } + + if (parentApplicantType === ApplicantTypes.UNSELECTED) { + return null; + } + + if (parentApplicantType !== ApplicantTypes.NOT_APPLICABLE && ![ApplicantTypes.UNKNOWN, ApplicantTypes.BOTH, parentApplicantType].includes(section.applicant_type)) { + return null; + } + + const isArray = section.add_new_allowed; + const pathName = [...path, section.identifier].join('.'); + const title = section.title || '(tuntematon osio)'; + return
+ {isArray ? :
+ {title} + +
} +
; +}; + +export default ApplicationSubsection; \ No newline at end of file diff --git a/src/application/components/enums.js b/src/application/components/enums.js deleted file mode 100644 index 96ed48457..000000000 --- a/src/application/components/enums.js +++ /dev/null @@ -1,5 +0,0 @@ -// @flow -export const ApplicationSectionKeys = { - Subsections: 'sections', - Fields: 'fields', -}; diff --git a/src/application/components/enums.ts b/src/application/components/enums.ts new file mode 100644 index 000000000..aae9c059b --- /dev/null +++ b/src/application/components/enums.ts @@ -0,0 +1,4 @@ +export const ApplicationSectionKeys = { + Subsections: 'sections', + Fields: 'fields' +}; \ No newline at end of file diff --git a/src/application/components/infoCheck/ApplicantInfoCheck.js b/src/application/components/infoCheck/ApplicantInfoCheck.js deleted file mode 100644 index 2accaa299..000000000 --- a/src/application/components/infoCheck/ApplicantInfoCheck.js +++ /dev/null @@ -1,42 +0,0 @@ -// @flow -import React, {Component} from 'react'; -import {Column, Row} from 'react-foundation'; - -import {getLabelOfOption} from '$util/helpers'; -import {getUserFullName} from '$src/users/helpers'; - -type Props = { - infoChecks: Object, - infoCheckStateOptions: Object, -}; - -class ApplicantInfoCheck extends Component { - render(): React$Node { - const { - infoChecks, - infoCheckStateOptions, - } = this.props; - - return ( - - {infoChecks.map((item) => { - const statusText = getLabelOfOption(infoCheckStateOptions, item.data.state); - - return - - - {item.kind.label} - - - {statusText} - {item.data.preparer && <>, {getUserFullName(item.data.preparer)}} - - - ; - })} - - ); - } -} - -export default ApplicantInfoCheck; diff --git a/src/application/components/infoCheck/ApplicantInfoCheck.tsx b/src/application/components/infoCheck/ApplicantInfoCheck.tsx new file mode 100644 index 000000000..ab637be46 --- /dev/null +++ b/src/application/components/infoCheck/ApplicantInfoCheck.tsx @@ -0,0 +1,36 @@ +import React, { Component } from "react"; +import { Column, Row } from "react-foundation"; +import { getLabelOfOption } from "util/helpers"; +import { getUserFullName } from "users/helpers"; +type Props = { + infoChecks: Record; + infoCheckStateOptions: any; +}; + +class ApplicantInfoCheck extends Component { + render(): React.ReactNode { + const { + infoChecks, + infoCheckStateOptions + } = this.props; + return + {infoChecks.map(item => { + const statusText = getLabelOfOption(infoCheckStateOptions, item.data.state); + return + + + {item.kind.label} + + + {statusText} + {item.data.preparer && <>, {getUserFullName(item.data.preparer)}} + + + ; + })} + ; + } + +} + +export default ApplicantInfoCheck; \ No newline at end of file diff --git a/src/application/components/infoCheck/ApplicantInfoCheckEdit.js b/src/application/components/infoCheck/ApplicantInfoCheckEdit.js deleted file mode 100644 index bc2591f55..000000000 --- a/src/application/components/infoCheck/ApplicantInfoCheckEdit.js +++ /dev/null @@ -1,167 +0,0 @@ -// @flow -import React, {Component} from 'react'; -import {Row} from 'react-foundation'; -import {connect} from 'react-redux'; -import {change} from 'redux-form'; - -import ApplicantInfoCheckModal from '$src/application/components/infoCheck/ApplicantInfoCheckModal'; -import {ApplicantInfoCheckFieldPaths, ApplicantInfoCheckFieldTitles, ApplicantTypes} from '$src/application/enums'; -import ApplicantInfoCheckEditItem from '$src/application/components/infoCheck/ApplicantInfoCheckEditItem'; -import FormText from '$components/form/FormText'; -import {getApplicantInfoCheckFormName} from '$src/application/helpers'; - -type OwnProps = { - infoCheckIds: Array, - answer: Object, - showMarkAll: boolean, - submissionErrors: Array<{ - id: number, - kind: ?Object, - error: ?Object | ?Array, - }>, -}; - -type Props = { - ...OwnProps, - change: typeof change, -}; - -type State = { - isModalOpen: boolean, - modalCheckItem: ?Object, - checkItemForm: ?string, - modalPage: number, -}; - -class ApplicantInfoCheckEdit extends Component { - state: State = { - isModalOpen: false, - modalCheckItem: null, - checkItemForm: null, - modalPage: 0, - }; - - openModal = (checkItem: Object, form: string, skipToForm: boolean): void => { - this.setState(() => ({ - isModalOpen: true, - modalCheckItem: checkItem, - checkItemForm: form, - modalPage: (!skipToForm && checkItem.kind.external) ? 1 : 2, - })); - }; - - closeModal = (): void => { - this.setState(() => ({ - isModalOpen: false, - modalCheckItem: null, - checkItemForm: '', - modalPage: 0, - })); - }; - - setPage = (page: number): void => { - this.setState(() => ({ - modalPage: page, - })); - } - - saveInfoCheck = (data: Object): void => { - const {change} = this.props; - const {checkItemForm} = this.state; - - if (checkItemForm) { - Object.keys(data).forEach((field) => { - change(checkItemForm, `data.${field}`, data[field]); - }); - - this.closeModal(); - } - } - - renderErrors(): React$Node { - const {submissionErrors} = this.props; - - if (submissionErrors.length === 0) { - return null; - } - - let content = []; - submissionErrors.map((infoCheckItem) => { - try { - if (infoCheckItem.error instanceof Array) { - content.push(
    - {infoCheckItem.error.map((error, i) =>
  • {error}
  • )} -
); - } else if (infoCheckItem.error instanceof Error) { - content.push(infoCheckItem.error.message); - } else if (typeof infoCheckItem.error === 'object') { - const errorObject: Object = infoCheckItem.error; - content.push(
    - {Object.keys(errorObject).map((key) => { - const fieldLabelKey = Object.keys(ApplicantInfoCheckFieldPaths).find( - (path) => ApplicantInfoCheckFieldPaths[path] === key); - - return
  • - {infoCheckItem.kind?.label} - {fieldLabelKey ? ApplicantInfoCheckFieldTitles[fieldLabelKey] : key}:{' '} - {errorObject[key].length !== undefined - ? errorObject[key].join(', ') - : errorObject[key]} -
  • ; - })} -
); - } else { - content.push(infoCheckItem.error); - } - } catch { - content.push(JSON.stringify(infoCheckItem.error)); - } - }); - - return - Tallennus ei onnistunut:{' '} - {content} - ; - } - - render(): React$Node { - const { - isModalOpen, - modalCheckItem, - modalPage, - } = this.state; - - const { - infoCheckIds, - answer, - submissionErrors, - showMarkAll = true, - } = this.props; - - const applicantType = answer?.metadata?.applicantType; - - return ( -
- - {infoCheckIds.map((id, index) => )} - - this.saveInfoCheck(data)} - infoCheck={modalCheckItem} - businessId={applicantType === ApplicantTypes.COMPANY ? answer.metadata.identifier : undefined} - personId={applicantType === ApplicantTypes.PERSON ? answer.metadata.identifier : undefined} - showMarkAll={showMarkAll} - /> - {submissionErrors && this.renderErrors()} -
- ); - } -} - -export default (connect(null, { - change, -})(ApplicantInfoCheckEdit): React$ComponentType); diff --git a/src/application/components/infoCheck/ApplicantInfoCheckEdit.tsx b/src/application/components/infoCheck/ApplicantInfoCheckEdit.tsx new file mode 100644 index 000000000..3ebd042b7 --- /dev/null +++ b/src/application/components/infoCheck/ApplicantInfoCheckEdit.tsx @@ -0,0 +1,142 @@ +import React, { Component } from "react"; +import { Row } from "react-foundation"; +import { connect } from "react-redux"; +import { change } from "redux-form"; +import ApplicantInfoCheckModal from "application/components/infoCheck/ApplicantInfoCheckModal"; +import { ApplicantInfoCheckFieldPaths, ApplicantInfoCheckFieldTitles, ApplicantTypes } from "application/enums"; +import ApplicantInfoCheckEditItem from "application/components/infoCheck/ApplicantInfoCheckEditItem"; +import FormText from "components/form/FormText"; +import { getApplicantInfoCheckFormName } from "application/helpers"; +type OwnProps = { + infoCheckIds: Array; + answer: Record; + showMarkAll?: boolean; + submissionErrors: Array<{ + id: number; + kind: Record | null | undefined; + error: any; + }>; +}; +type Props = OwnProps & { + change: typeof change; +}; +type State = { + isModalOpen: boolean; + modalCheckItem: Record | null | undefined; + checkItemForm: string | null | undefined; + modalPage: number; +}; + +class ApplicantInfoCheckEdit extends Component { + state: State = { + isModalOpen: false, + modalCheckItem: null, + checkItemForm: null, + modalPage: 0 + }; + openModal = (checkItem: Record, form: string, skipToForm: boolean): void => { + this.setState(() => ({ + isModalOpen: true, + modalCheckItem: checkItem, + checkItemForm: form, + modalPage: !skipToForm && checkItem.kind.external ? 1 : 2 + })); + }; + closeModal = (): void => { + this.setState(() => ({ + isModalOpen: false, + modalCheckItem: null, + checkItemForm: '', + modalPage: 0 + })); + }; + setPage = (page: number): void => { + this.setState(() => ({ + modalPage: page + })); + }; + saveInfoCheck = (data: Record): void => { + const { + change + } = this.props; + const { + checkItemForm + } = this.state; + + if (checkItemForm) { + Object.keys(data).forEach(field => { + change(checkItemForm, `data.${field}`, data[field]); + }); + this.closeModal(); + } + }; + + renderErrors(): React.ReactNode { + const { + submissionErrors + } = this.props; + + if (submissionErrors.length === 0) { + return null; + } + + let content = []; + submissionErrors.map(infoCheckItem => { + try { + if (infoCheckItem.error instanceof Array) { + content.push(
    + {infoCheckItem.error.map((error, i) =>
  • {error}
  • )} +
); + } else if (infoCheckItem.error instanceof Error) { + content.push(infoCheckItem.error.message); + } else if (typeof infoCheckItem.error === 'object') { + const errorObject: Record = infoCheckItem.error; + content.push(
    + {Object.keys(errorObject).map(key => { + const fieldLabelKey = Object.keys(ApplicantInfoCheckFieldPaths).find(path => ApplicantInfoCheckFieldPaths[path] === key); + return
  • + {infoCheckItem.kind?.label} - {fieldLabelKey ? ApplicantInfoCheckFieldTitles[fieldLabelKey] : key}:{' '} + {errorObject[key].length !== undefined ? errorObject[key].join(', ') : errorObject[key]} +
  • ; + })} +
); + } else { + content.push(infoCheckItem.error); + } + } catch { + content.push(JSON.stringify(infoCheckItem.error)); + } + }); + return + Tallennus ei onnistunut:{' '} + {content} + ; + } + + render(): React.ReactNode { + const { + isModalOpen, + modalCheckItem, + modalPage + } = this.state; + const { + infoCheckIds, + answer, + submissionErrors, + showMarkAll = true + } = this.props; + const applicantType = answer?.metadata?.applicantType; + return
+ + {infoCheckIds.map((id, index) => )} + + this.saveInfoCheck(data)} infoCheck={modalCheckItem} businessId={applicantType === ApplicantTypes.COMPANY ? answer.metadata.identifier : undefined} personId={applicantType === ApplicantTypes.PERSON ? answer.metadata.identifier : undefined} showMarkAll={showMarkAll} /> + {submissionErrors && this.renderErrors()} +
; + } + +} + +export default (connect(null, { + change +})(ApplicantInfoCheckEdit) as React.ComponentType); \ No newline at end of file diff --git a/src/application/components/infoCheck/ApplicantInfoCheckEditItem.js b/src/application/components/infoCheck/ApplicantInfoCheckEditItem.js deleted file mode 100644 index 9c0780117..000000000 --- a/src/application/components/infoCheck/ApplicantInfoCheckEditItem.js +++ /dev/null @@ -1,68 +0,0 @@ -// @flow - -import React from 'react'; -import flowRight from 'lodash/flowRight'; -import {connect} from 'react-redux'; -import {getFormValues, reduxForm} from 'redux-form'; -import {Column, Row} from 'react-foundation'; -import classNames from 'classnames'; - -import {getApplicantInfoCheckAttributes} from '$src/application/selectors'; -import {getFieldOptions, getLabelOfOption} from '$util/helpers'; -import {getUserFullName} from '$src/users/helpers'; - -import type {Attributes} from '$src/types'; - -type OwnProps = { - openModal: Function, - formName: string, -}; - -type Props = { - ...OwnProps, - dirty: boolean, - infoCheckAttributes: Attributes, - formValues: Object, -}; - -const ApplicantInfoCheckEditItem = (({formValues, dirty, openModal, formName, infoCheckAttributes}: Props) => { - const infoCheckStatusOptions = getFieldOptions(infoCheckAttributes, 'state'); - const statusText = getLabelOfOption(infoCheckStatusOptions, formValues.data.state); - - return - - - {formValues.kind.external && - openModal(formValues, formName, false)}> - {formValues.kind.label} - } - {!formValues.kind.external && {formValues.kind.label}} - - - {formValues.kind.external && !formValues.data.preparer && {statusText}} - {(!formValues.kind.external || formValues.data.preparer) && - openModal(formValues, formName, true)}> - {statusText} - {formValues.data.preparer && <>, {getUserFullName(formValues.data.preparer)}} - } - - - ; -}); - -export default (flowRight(connect((state, props) => { - const formValues = getFormValues(props.formName)(state); - - return { - formValues, - form: props.formName, - infoCheckAttributes: getApplicantInfoCheckAttributes(state), - }; -}), reduxForm({ - destroyOnUnmount: false, -}))(ApplicantInfoCheckEditItem): React$ComponentType); diff --git a/src/application/components/infoCheck/ApplicantInfoCheckEditItem.tsx b/src/application/components/infoCheck/ApplicantInfoCheckEditItem.tsx new file mode 100644 index 000000000..e8f8bfaef --- /dev/null +++ b/src/application/components/infoCheck/ApplicantInfoCheckEditItem.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import flowRight from "lodash/flowRight"; +import { connect } from "react-redux"; +import { getFormValues, reduxForm } from "redux-form"; +import { Column, Row } from "react-foundation"; +import classNames from "classnames"; +import { getApplicantInfoCheckAttributes } from "application/selectors"; +import { getFieldOptions, getLabelOfOption } from "util/helpers"; +import { getUserFullName } from "users/helpers"; +import type { Attributes } from "types"; +type OwnProps = { + openModal: (...args: Array) => any; + formName: string; +}; +type Props = OwnProps & { + dirty: boolean; + infoCheckAttributes: Attributes; + formValues: Record; +}; + +const ApplicantInfoCheckEditItem = ({ + formValues, + dirty, + openModal, + formName, + infoCheckAttributes +}: Props) => { + const infoCheckStatusOptions = getFieldOptions(infoCheckAttributes, 'state'); + const statusText = getLabelOfOption(infoCheckStatusOptions, formValues.data.state); + return + + + {formValues.kind.external && openModal(formValues, formName, false)}> + {formValues.kind.label} + } + {!formValues.kind.external && {formValues.kind.label}} + + + {formValues.kind.external && !formValues.data.preparer && {statusText}} + {(!formValues.kind.external || formValues.data.preparer) && openModal(formValues, formName, true)}> + {statusText} + {formValues.data.preparer && <>, {getUserFullName(formValues.data.preparer)}} + } + + + ; +}; + +export default (flowRight(connect((state, props) => { + const formValues = getFormValues(props.formName)(state); + return { + formValues, + form: props.formName, + infoCheckAttributes: getApplicantInfoCheckAttributes(state) + }; +}), reduxForm({ + destroyOnUnmount: false +}))(ApplicantInfoCheckEditItem) as React.ComponentType); \ No newline at end of file diff --git a/src/application/components/infoCheck/ApplicantInfoCheckForm.js b/src/application/components/infoCheck/ApplicantInfoCheckForm.js deleted file mode 100644 index 5c8ebd606..000000000 --- a/src/application/components/infoCheck/ApplicantInfoCheckForm.js +++ /dev/null @@ -1,164 +0,0 @@ -//@flow -import React, {Component} from 'react'; -import {getFormValues, reduxForm} from 'redux-form'; -import {Column, Row} from 'react-foundation'; -import get from 'lodash/get'; -import flowRight from 'lodash/flowRight'; -import {connect} from 'react-redux'; - -import {FieldTypes, FormNames} from '$src/enums'; -import {ButtonColors} from '$components/enums'; -import FormField from '$components/form/FormField'; -import ModalButtonWrapper from '$components/modal/ModalButtonWrapper'; -import Button from '$components/button/Button'; -import { - ApplicantInfoCheckFieldPaths, - ApplicantInfoCheckFieldTitles, -} from '$src/application/enums'; -import {getApplicantInfoCheckAttributes} from '$src/application/selectors'; - -import type {Attributes} from '$src/types'; - -type OwnProps = { - infoCheck: Object, - onClose: Function, - onSubmit: Function, -}; - -type Props = { - ...OwnProps, - initialize: Function, - attributes: Attributes, - valid: boolean, - formValues: Object, - isPreparerDirty: boolean, - showMarkAll: boolean, -}; - -class ApplicantInfoCheckForm extends Component { - firstField: ?HTMLInputElement - - componentDidMount(): void { - const infoCheck = this.props?.infoCheck; - - if (!infoCheck) { - return; - } - - const {preparer, ...rest} = infoCheck.data; - - this.props.initialize({ - ...rest, - preparer, - }); - } - - setRefForFirstField = (element: HTMLInputElement): void => { - this.firstField = element; - } - - setFocus = (): void => { - if (this.firstField) { - this.firstField.focus(); - } - } - - handleSave = (): void => { - const { - onSubmit, - formValues, - } = this.props; - - onSubmit(formValues); - }; - - render(): React$Node { - const { - attributes, - valid, - onClose, - showMarkAll, - } = this.props; - - return ( -
- - - - - - - - {showMarkAll === true && - - - - } - - - - - - - - -