From 8c58ba9054e3415fa6eee1d24fe0f520175da71f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emilia=20M=C3=A4kel=C3=A4?= Date: Mon, 18 Sep 2023 17:23:51 +0300 Subject: [PATCH] Implement a full-fledged plot search form section editor The editor allows the user to specify all data necessary for setting up a customisable, but structurally coherent form for any specific plot search. This includes automatic identifier specification as well as protection of specific operationally important fields and sections containing said fields. --- src/application/types.js | 14 +- src/components/button/IconButton.js | 41 +- src/components/collapse/Collapse.js | 50 +- src/components/form/FieldTypeMultiSelect.js | 2 +- src/components/form/FormField.js | 1 + src/components/icons/MoveDownIcon.js | 18 + src/components/icons/MoveUpIcon.js | 17 + src/components/icons/TrashIcon.js | 2 +- src/components/icons/_icons.scss | 12 + src/components/multi-select/MultiSelect.js | 2 +- src/enums.js | 5 + src/plotSearch/actions.js | 12 + .../application/ApplicationEdit.js | 14 +- .../EditPlotApplicationSectionFieldForm.js | 786 ++++++++++++++++++ .../EditPlotApplicationSectionForm.js | 652 ++++++++++++--- .../EditPlotApplicationSectionModal.js | 5 + .../application/SectionField.js | 106 --- .../application/_application.scss | 73 +- src/plotSearch/constants.js | 91 ++ src/plotSearch/helpers.js | 240 +++++- src/plotSearch/reducer.js | 13 + src/plotSearch/selectors.js | 3 + src/plotSearch/spec.js | 1 + src/plotSearch/types.js | 13 + 24 files changed, 1898 insertions(+), 275 deletions(-) create mode 100644 src/components/icons/MoveDownIcon.js create mode 100644 src/components/icons/MoveUpIcon.js create mode 100644 src/plotSearch/components/plotSearchSections/application/EditPlotApplicationSectionFieldForm.js delete mode 100644 src/plotSearch/components/plotSearchSections/application/SectionField.js diff --git a/src/application/types.js b/src/application/types.js index 7ee0ba774..ca275675b 100644 --- a/src/application/types.js +++ b/src/application/types.js @@ -33,6 +33,9 @@ export type FormSection = { id: number; identifier: string; title: string; + title_fi: string; + title_en: string | null; + title_sv: string | null; visible: boolean; sort_order: number; add_new_allowed: boolean; @@ -50,7 +53,13 @@ export type FormField = { identifier: string; type: number; label: string; - hint_text?: string; + label_fi: string; + label_en: string | null; + label_sv: string | null; + hint_text: string | null; + hint_text_fi: string | null; + hint_text_en: string | null; + hint_text_sv: string | null; enabled: boolean; required: boolean; validation?: string | null; @@ -64,6 +73,9 @@ export type FormField = { export type FormFieldChoice = { id: number; text: string; + text_fi: string; + text_en: string | null; + text_sv: string | null; value: string; action?: string | null; has_text_input: boolean; diff --git a/src/components/button/IconButton.js b/src/components/button/IconButton.js index 4264ea159..e8a6b9b9e 100644 --- a/src/components/button/IconButton.js +++ b/src/components/button/IconButton.js @@ -10,21 +10,34 @@ type Props = { style?: Object, title?: string, type?: string, + id?: string, } -const IconButton = ({children, className, disabled, onClick, style, title, type = 'button'}: Props) => { - return ( - - ); -}; +const IconButton = (React.forwardRef( + ({ + children, + className, + disabled, + onClick, + style, + title, + id, + type = 'button', + }: Props, ref) => { + return ( + + ); + }): React$AbstractComponent); export default IconButton; diff --git a/src/components/collapse/Collapse.js b/src/components/collapse/Collapse.js index 4192e8d84..f7222fdc4 100644 --- a/src/components/collapse/Collapse.js +++ b/src/components/collapse/Collapse.js @@ -32,6 +32,7 @@ type Props = { showTitleOnOpen?: boolean, tooltipStyle?: Object, uiDataKey?: ?string, + isOpen?: boolean, } type State = { @@ -53,10 +54,13 @@ class Collapse extends PureComponent { }; state: State = { - contentHeight: this.props.defaultOpen ? null : 0, + contentHeight: (this.props.isOpen !== undefined + ? this.props.isOpen + : this.props.defaultOpen + ) ? null : 0, isCollapsing: false, isExpanding: false, - isOpen: this.props.defaultOpen, + isOpen: this.props.isOpen !== undefined ? this.props.isOpen : this.props.defaultOpen, }; setComponentRef: (any) => void = (el) => { @@ -76,6 +80,10 @@ class Collapse extends PureComponent { } componentDidUpdate(prevProps: Object, prevState: Object) { + if (this.props.isOpen !== undefined && this.props.isOpen !== this.state.isOpen) { + this.handleToggleStateChange(this.props.isOpen); + } + if ((this.state.isOpen && !this.state.contentHeight) || (this.state.isOpen !== prevState.isOpen)) { this.calculateHeight(); @@ -101,35 +109,43 @@ class Collapse extends PureComponent { } handleToggle: (SyntheticEvent) => void = (e) => { - const {onToggle} = this.props; + const {onToggle, isOpen: externalIsOpen} = this.props; const {isOpen} = this.state; const target = e.currentTarget; const tooltipEl = ReactDOM.findDOMNode(this.tooltip); + const isExternallyControlled = externalIsOpen !== undefined; + if (!tooltipEl || (tooltipEl && target !== tooltipEl && !tooltipEl.contains(target))) { - if(isOpen) { - this.setState({ - isCollapsing: true, - isExpanding: false, - isOpen: false, - }); - } else { - this.setState({ - isCollapsing: false, - isExpanding: true, - isOpen: true, - }); + if (!isExternallyControlled) { + this.handleToggleStateChange(!isOpen); } - if(onToggle) { + if (onToggle) { onToggle(!isOpen); } } }; + handleToggleStateChange: (boolean) => void = (newIsOpen) => { + if (newIsOpen) { + this.setState({ + isCollapsing: false, + isExpanding: true, + isOpen: true, + }); + } else { + this.setState({ + isCollapsing: true, + isExpanding: false, + isOpen: false, + }); + } + } + handleKeyDown: (SyntheticKeyboardEvent) => void = (e) => { - if(e.keyCode === 13) { + if (e.keyCode === 13) { e.preventDefault(); this.handleToggle(e); } diff --git a/src/components/form/FieldTypeMultiSelect.js b/src/components/form/FieldTypeMultiSelect.js index f8546dd35..a03547312 100644 --- a/src/components/form/FieldTypeMultiSelect.js +++ b/src/components/form/FieldTypeMultiSelect.js @@ -32,7 +32,7 @@ const FieldTypeMultiSelect = ({ options={options} onBlur={handleBlur} onSelectedChanged={onChange} - selected={value} + selected={value instanceof Array ? value : []} disabled={disabled} isLoading={isLoading} /> diff --git a/src/components/form/FormField.js b/src/components/form/FormField.js index 450bb44c2..8f2fee7a5 100644 --- a/src/components/form/FormField.js +++ b/src/components/form/FormField.js @@ -283,6 +283,7 @@ type Props = { filterOption?: Function, invisibleLabel?: boolean, isLoading?: boolean, + isMulti?: boolean, language?: string, name: string, onBlur?: Function, diff --git a/src/components/icons/MoveDownIcon.js b/src/components/icons/MoveDownIcon.js new file mode 100644 index 000000000..69876939f --- /dev/null +++ b/src/components/icons/MoveDownIcon.js @@ -0,0 +1,18 @@ +// @flow +import React from 'react'; +import classNames from 'classnames'; + +type Props = { + className?: string, +} + +const MoveDownIcon = ({className}: Props): React$Element<'svg'> => + + Siirrä alas + + + + ; + + +export default MoveDownIcon; diff --git a/src/components/icons/MoveUpIcon.js b/src/components/icons/MoveUpIcon.js new file mode 100644 index 000000000..b104895b0 --- /dev/null +++ b/src/components/icons/MoveUpIcon.js @@ -0,0 +1,17 @@ +// @flow +import React from 'react'; +import classNames from 'classnames'; + +type Props = { + className?: string, +} + +const MoveUpIcon = ({className}: Props): React$Element<'svg'> => + + Siirrä ylös + + + + ; + +export default MoveUpIcon; diff --git a/src/components/icons/TrashIcon.js b/src/components/icons/TrashIcon.js index d0076225d..b66d4053e 100644 --- a/src/components/icons/TrashIcon.js +++ b/src/components/icons/TrashIcon.js @@ -6,7 +6,7 @@ type Props = { className?: string, } -const TrashIcon = ({className}: Props) => +const TrashIcon = ({className}: Props): React$Element<'svg'> => Poista diff --git a/src/components/icons/_icons.scss b/src/components/icons/_icons.scss index 95170bca4..3e21be881 100644 --- a/src/components/icons/_icons.scss +++ b/src/components/icons/_icons.scss @@ -43,6 +43,18 @@ stroke: none !important; } + &.icons__move-up, + &.icons__move-down { + width: rem-calc(16px); + + &.icon-medium { + width: rem-calc(12px); + } + &.icon-small { + width: rem-calc(9px); + } + } + &.icon-medium { height: rem-calc(20px); width: rem-calc(20px); diff --git a/src/components/multi-select/MultiSelect.js b/src/components/multi-select/MultiSelect.js index 4f7032299..d99574f1c 100644 --- a/src/components/multi-select/MultiSelect.js +++ b/src/components/multi-select/MultiSelect.js @@ -43,7 +43,7 @@ const MultiSelect = ({ hasSelectAll = true, onSelectedChanged, valueRenderer, -}: Props) => { +}: Props): React$Node => { const getSelectedText = () => { const selectedOptions = selected .map(s => options.find(o => o.value === s)); diff --git a/src/enums.js b/src/enums.js index 650cca571..e9407cdb6 100644 --- a/src/enums.js +++ b/src/enums.js @@ -386,6 +386,11 @@ export const ConfirmationModalTexts = { LABEL: 'Haluatko varmasti poistaa kentän?', TITLE: 'Poista kenttä', }, + DELETE_SECTION_SUBSECTION: { + BUTTON: 'Poista aliosio', + LABEL: 'Haluatko varmasti poistaa aliosion?', + TITLE: 'Poista aliosio', + }, DELETE_APPLICATION_TARGET_PROPOSED_MANAGEMENT: { BUTTON: DELETE_MODAL_BUTTON_TEXT, LABEL: 'Haluatko varmasti poistaa hallinta- ja rahoitusmuotoehdotuksen?', diff --git a/src/plotSearch/actions.js b/src/plotSearch/actions.js index adeb80957..63b089cb5 100644 --- a/src/plotSearch/actions.js +++ b/src/plotSearch/actions.js @@ -66,6 +66,9 @@ import type { ResetPlanUnitDecisionsAction, ShowEditModeAction, TemplateFormsNotFoundAction, + ClearSectionEditorCollapseStatesAction, + SetSectionEditorCollapseStateAction, + InitializeSectionEditorCollapseStatesAction, } from '$src/plotSearch/types'; export const fetchAttributes = (): FetchAttributesAction => @@ -238,3 +241,12 @@ export const directReservationLinkCreated = (): DirectReservationLinkCreatedActi export const directReservationLinkCreationFailed = (payload: any): DirectReservationLinkCreationFailedAction => createAction('mvj/plotSearch/DIRECT_RESERVATION_LINK_CREATION_FAILED')(payload); + +export const clearSectionEditorCollapseStates = (): ClearSectionEditorCollapseStatesAction => + createAction('mvj/plotSearch/CLEAR_SECTION_EDITOR_COLLAPSE_STATES')(); + +export const setSectionEditorCollapseState = (key: string, isOpen: boolean): SetSectionEditorCollapseStateAction => + createAction('mvj/plotSearch/SET_SECTION_EDITOR_COLLAPSE_STATE')({key, state: isOpen}); + +export const initializeSectionEditorCollapseStates = (states: {[key: string]: boolean}): InitializeSectionEditorCollapseStatesAction => + createAction('mvj/plotSearch/INITIALIZE_SECTION_EDITOR_COLLAPSE_STATES')(states); diff --git a/src/plotSearch/components/plotSearchSections/application/ApplicationEdit.js b/src/plotSearch/components/plotSearchSections/application/ApplicationEdit.js index af10951fb..4c3c39d21 100644 --- a/src/plotSearch/components/plotSearchSections/application/ApplicationEdit.js +++ b/src/plotSearch/components/plotSearchSections/application/ApplicationEdit.js @@ -80,7 +80,7 @@ type State = { class ApplicationEdit extends PureComponent { state = { isModalOpen: false, - modalSectionIndex: 0, + modalSectionIndex: -1, } componentDidMount() { @@ -271,18 +271,6 @@ class ApplicationEdit extends PureComponent { uiDataKey={getUiDataLeaseKey(ApplicationFieldPaths.NAME)} /> - {/* - */} {formData.sections.map((section, index) => diff --git a/src/plotSearch/components/plotSearchSections/application/EditPlotApplicationSectionFieldForm.js b/src/plotSearch/components/plotSearchSections/application/EditPlotApplicationSectionFieldForm.js new file mode 100644 index 000000000..29725afd5 --- /dev/null +++ b/src/plotSearch/components/plotSearchSections/application/EditPlotApplicationSectionFieldForm.js @@ -0,0 +1,786 @@ +// @flow +import React, {Fragment, useEffect, useMemo, useRef, useState} from 'react'; +import {connect} from 'react-redux'; +import {Column, Row} from 'react-foundation'; +import get from 'lodash/get'; +import {change, FieldArray, formValueSelector} from 'redux-form'; + +import FormField from '$components/form/FormField'; +import InfoIcon from '$components/icons/InfoIcon'; +import Tooltip from '$components/tooltip/Tooltip'; +import {FieldTypes, FormNames} from '$src/enums'; +import TooltipToggleButton from '$components/tooltip/TooltipToggleButton'; +import TooltipWrapper from '$components/tooltip/TooltipWrapper'; +import {getFieldTypeMapping, getFormAttributes} from '$src/application/selectors'; +import SubTitle from '$components/content/SubTitle'; +import {generateFieldIdentifierFromName} from '$src/plotSearch/helpers'; +import {FIELD_TYPE_FEATURES_BY_FIELD_TYPE_NAME, FieldTypeFeatures, FieldTypeLabels} from '$src/plotSearch/constants'; +import IconButton from '$components/button/IconButton'; +import TrashIcon from '$components/icons/TrashIcon'; +import AddIcon from '$components/icons/AddIcon'; +import EditIcon from '$components/icons/EditIcon'; +import MoveUpIcon from '$components/icons/MoveUpIcon'; +import MoveDownIcon from '$components/icons/MoveDownIcon'; + +import type {Attributes} from '$src/types'; +import type {Fields} from 'redux-form/lib/FieldArrayProps.types'; + +type ChoiceProps = { + attributes: Attributes, + disabled: boolean, + field: string, + fields: Fields, + change: Function, + onChoiceValuesChanged: Function, + onChoiceDeleted: Function, + autoFillValues: boolean, + protectedValues: Array, +} + +const EditPlotApplicationSectionFieldChoice = ({ + fields, + attributes, + disabled, + change, + onChoiceValuesChanged, + autoFillValues, + protectedValues, +}: ChoiceProps): React$Node => { + const choiceRefs = useRef({}); + + const getDataMapBase = (): {[key: string]: any} => fields.getAll().reduce((acc, item) => ({ + ...acc, + [item.value]: item.value, + }), {}); + + useEffect(() => { + if (autoFillValues) { + const dataMap = getDataMapBase(); + + fields.forEach((choiceField, choiceIndex) => { + const autoValue = (choiceIndex + 1).toString(); + const currentValue = fields.get(choiceIndex).value; + + if (currentValue !== autoValue) { + dataMap[currentValue] = autoValue; + change( + FormNames.PLOT_SEARCH_APPLICATION_SECTION_STAGING, + `${choiceField}.value`, + autoValue, + ); + } + }); + + onChoiceValuesChanged(dataMap); + } + }, [autoFillValues]); + + const setOtherChoicesTextInputOff = (changedField: string): void => { + fields.forEach((choiceField) => { + if (choiceField !== changedField) { + change( + FormNames.PLOT_SEARCH_APPLICATION_SECTION_STAGING, + `${choiceField}.has_text_input`, + false + ); + } + }); + }; + + const handleRemove = (index: number) => { + if (autoFillValues) { + const dataMap = getDataMapBase(); + const deletedValue = fields.get(index).value; + dataMap[deletedValue] = null; + + fields.forEach((choiceField, choiceIndex) => { + if (index >= choiceIndex) { + return; + } + + const autoValue = choiceIndex.toString(); + const currentValue = fields.get(choiceIndex).value; + if (currentValue !== autoValue) { + change( + FormNames.PLOT_SEARCH_APPLICATION_SECTION_STAGING, + `${choiceField}.value`, + autoValue, + ); + dataMap[currentValue] = autoValue; + } + }); + + onChoiceValuesChanged(dataMap); + } + + fields.remove(index); + }; + + const handleAdd = () => { + const dataMap = getDataMapBase(); + const initialValue = autoFillValues ? String(fields.length + 1) : ''; + + fields.push({ + text: '', + text_fi: '', + text_en: '', + text_sv: '', + value: initialValue, + has_text_input: false, + protected_values: [], + }); + + dataMap[initialValue] = initialValue; + onChoiceValuesChanged(dataMap); + }; + + const handleMoveUp = (index: number) => { + if (autoFillValues) { + handleSwapAutoValues(index, index - 1); + } + fields.move(index, index - 1); + setImmediate(() => { + + if (index - 1 !== 0) { + choiceRefs.current[`SectionEditorMoveUpButton_Choice_${index - 1}`]?.focus(); + } else { + choiceRefs.current[`SectionEditorMoveDownButton_Choice_${index - 1}`]?.focus(); + } + }); + }; + + const handleMoveDown = (index: number) => { + if (autoFillValues) { + handleSwapAutoValues(index, index + 1); + } + fields.move(index, index + 1); + setImmediate(() => { + if (index + 1 < fields.length - 1) { + choiceRefs.current[`SectionEditorMoveDownButton_Choice_${index + 1}`]?.focus(); + } else { + choiceRefs.current[`SectionEditorMoveUpButton_Choice_${index + 1}`]?.focus(); + } + }); + }; + + const setRef = (index, el) => { + choiceRefs.current[index] = el; + }; + + const handleSwapAutoValues = (index1: number, index2: number) => { + const dataMap = getDataMapBase(); + dataMap[(index2 + 1).toString()] = (index1 + 1).toString(); + dataMap[(index1 + 1).toString()] = (index2 + 1).toString(); + onChoiceValuesChanged(dataMap); + + change( + FormNames.PLOT_SEARCH_APPLICATION_SECTION_STAGING, + `${fields.name}[${index1}].value`, + (index2 + 1).toString(), + ); + change( + FormNames.PLOT_SEARCH_APPLICATION_SECTION_STAGING, + `${fields.name}[${index2}].value`, + (index1 + 1).toString(), + ); + }; + + const handleSingleValueChange = (index: number, value: any) => { + const dataMap = getDataMapBase(); + const currentValue = fields.get(index).value; + dataMap[currentValue] = value; + + onChoiceValuesChanged(dataMap); + }; + + return <> + {fields.map((field, i) => { + const isProtected = protectedValues.includes(fields.get(i).value); + + return + + + #{i + 1} + + + + + + + + + + + + + + + + handleRemove(i)} disabled={isProtected}> + + + handleMoveUp(i)} + disabled={i === 0} + id={`SectionEditorMoveUpButton_Choice_${i}`} + ref={(el) => setRef(`SectionEditorMoveUpButton_Choice_${i}`, el)} + > + + + handleMoveDown(i)} + disabled={(i + 1) >= fields.length} + id={`SectionEditorMoveDownButton_Choice_${i}`} + ref={(el) => setRef(`SectionEditorMoveDownButton_Choice_${i}`, el)} + > + + + + + + + + + + handleSingleValueChange(i, newValue)} + /> + + + setOtherChoicesTextInputOff(field)} + /> + + + + + + ; + })} + + Lisää vaihtoehto + + ; +}; + +type OwnProps = { + disabled: boolean, + field: any, + collapseStates: {[key: string]: boolean}, + setSectionEditorCollapseState: Function, +}; + +type Props = { + ...OwnProps, + attributes: Attributes, + fieldValues: Object, + fieldTypeMapping: {[number]: string}, + change: Function, + fieldIdentifiers: Array, + index: number, + onDelete: Function, + onMoveUp: Function | null, + onMoveDown: Function | null, + fieldRefs: any, +} + +const EditPlotApplicationSectionFieldForm = ({ + disabled, + field, + attributes, + fieldValues, + fieldTypeMapping, + change, + fieldIdentifiers, + onDelete, + onMoveUp, + onMoveDown, + collapseStates, + setSectionEditorCollapseState, + fieldRefs, +}: Props) => { + const [isHintPopupOpen, setIsHintPopupOpen] = useState(false); + const id = fieldValues.id ?? fieldValues.temporary_id; + const upButtonId = `SectionEditorMoveUpButton_Field_${id}`; + const downButtonId = `SectionEditorMoveDownButton_Field_${id}`; + + const isOpen = collapseStates[`field-${id}`]; + + useEffect(() => { + if (isOpen === undefined) { + setSectionEditorCollapseState(`field-${id}`, !fieldValues.id || !fieldValues.type); + } + }, []); + + const type = fieldTypeMapping[fieldValues.type]; + + const typeChoices = useMemo>(() => { + return get(attributes, 'sections.child.children.fields.child.children.type.choices')?.map((type) => ({ + value: type.value, + label: FieldTypeLabels[type.display_name] || type.display_name, + })); + }, [fieldTypeMapping]); + + const fieldFeatures = useMemo>(() => { + return FIELD_TYPE_FEATURES_BY_FIELD_TYPE_NAME[type] || []; + }, [fieldValues.type]); + + const listSelectionDefaultValueType = useMemo(() => { + if (fieldFeatures.includes(FieldTypeFeatures.MULTIPLE_SELECTION_OPTIONS)) { + if (fieldValues.choices.length === 0) { + return FieldTypes.BOOLEAN; + } else { + return FieldTypes.MULTISELECT; + } + } + + return FieldTypes.CHOICE; + }, [ + fieldValues.type, + fieldValues.choices, + ]); + + const updateAutoIdentifier = (shouldChange: boolean, newName: string): void => { + if (shouldChange && !fieldValues.is_protected) { + change( + FormNames.PLOT_SEARCH_APPLICATION_SECTION_STAGING, + `${field}.identifier`, + generateFieldIdentifierFromName(newName, fieldIdentifiers) + ); + } + }; + + const handleTypeChanged = (newType: number): void => { + const newFieldFeatures = FIELD_TYPE_FEATURES_BY_FIELD_TYPE_NAME[fieldTypeMapping[newType]] || []; + + const prevIsMultiSelect = fieldFeatures.includes(FieldTypeFeatures.MULTIPLE_SELECTION_OPTIONS); + const newIsMultiSelect = newFieldFeatures.includes(FieldTypeFeatures.MULTIPLE_SELECTION_OPTIONS); + + if (prevIsMultiSelect && !newIsMultiSelect) { + change( + FormNames.PLOT_SEARCH_APPLICATION_SECTION_STAGING, + `${field}.default_value`, + fieldValues.default_value?.[0] || '' + ); + } else if (newIsMultiSelect && !prevIsMultiSelect) { + if (fieldValues.choices.length > 0) { + change( + FormNames.PLOT_SEARCH_APPLICATION_SECTION_STAGING, + `${field}.default_value`, + fieldValues.default_value ? [fieldValues.default_value] : [] + ); + } else { + change( + FormNames.PLOT_SEARCH_APPLICATION_SECTION_STAGING, + `${field}.default_value`, + false + ); + } + } + }; + + const handleChoiceValuesChanged = (dataMap: {[string]: string}) => { + if (fieldFeatures.includes(FieldTypeFeatures.MULTIPLE_SELECTION_OPTIONS)) { + if (Object.values(dataMap).filter((choiceValue) => choiceValue !== null).length === 0) { + change( + FormNames.PLOT_SEARCH_APPLICATION_SECTION_STAGING, + `${field}.default_value`, + false, + ); + } else { + if (fieldValues.default_value instanceof Array) { + change( + FormNames.PLOT_SEARCH_APPLICATION_SECTION_STAGING, + `${field}.default_value`, + fieldValues.default_value.map((choiceValue) => dataMap[choiceValue]) + .filter((choiceValue) => choiceValue !== null) || [], + ); + } else { + change( + FormNames.PLOT_SEARCH_APPLICATION_SECTION_STAGING, + `${field}.default_value`, + [], + ); + } + } + } else { + if (dataMap[fieldValues.default_value] !== fieldValues.default_value) { + change( + FormNames.PLOT_SEARCH_APPLICATION_SECTION_STAGING, + `${field}.default_value`, + dataMap[fieldValues.default_value], + ); + } + } + }; + + const optionChoices = useMemo(() => { + const options = fieldValues.choices?.map((option) => ({ + label: option.text, + value: option.value, + })); + + if (fieldFeatures.includes(FieldTypeFeatures.SINGLE_SELECTION_OPTIONS)) { + options.unshift({ + label: '', + value: null, + }); + } + return options; + }, [ + fieldValues.choices, + fieldValues.type, + ]); + + const setRef = (id, el) => { + fieldRefs.current[id] = el; + }; + + return ( + + + + + + {fieldValues.hint_text && + + setIsHintPopupOpen(true)}> + + + setIsHintPopupOpen(false)}> + {fieldValues.hint_text} + + + } + + + + + + setSectionEditorCollapseState(`field-${id}`, !isOpen)}> + + + + + + onMoveUp?.(id)} + id={upButtonId} + ref={(el) => setRef(upButtonId, el)} + > + + + onMoveDown?.(id)} + id={downButtonId} + ref={(el) => setRef(downButtonId, el)} + > + + + + + {isOpen && + + + + + updateAutoIdentifier( + fieldValues.auto_fill_identifier, + newName, + )} + /> + + + + + + + + + + + + + + + { + if (value) { + updateAutoIdentifier( + true, + fieldValues.label, + ); + } + }} + /> + + + + + + + + + + + + + + {( + fieldFeatures.includes(FieldTypeFeatures.FREEFORM_DEFAULT_VALUE) || + fieldFeatures.includes(FieldTypeFeatures.LIST_SELECTION_DEFAULT_VALUE) + ) && ( + + Kentän arvot + + )} + + {fieldFeatures.includes(FieldTypeFeatures.FREEFORM_DEFAULT_VALUE) && + + } + {fieldFeatures.includes(FieldTypeFeatures.LIST_SELECTION_DEFAULT_VALUE) && + + } + + {( + fieldFeatures.includes(FieldTypeFeatures.SINGLE_SELECTION_OPTIONS) || + fieldFeatures.includes(FieldTypeFeatures.MULTIPLE_SELECTION_OPTIONS) + ) && <> + + + Vaihtoehdot + + + + + 0} + invisibleLabel + overrideValues={{ + options: [{ + label: 'Täytä arvot automaattisesti', + value: true, + }], + }} + /> + + + + + + + + } + + + } + + ); +}; + +const selector = formValueSelector(FormNames.PLOT_SEARCH_APPLICATION_SECTION_STAGING); + +export default (connect( + (state, props: OwnProps) => { + return { + attributes: getFormAttributes(state), + fieldValues: selector(state, props.field), + fieldTypeMapping: getFieldTypeMapping(state), + }; + }, { + change, + } +)(EditPlotApplicationSectionFieldForm): React$ComponentType); diff --git a/src/plotSearch/components/plotSearchSections/application/EditPlotApplicationSectionForm.js b/src/plotSearch/components/plotSearchSections/application/EditPlotApplicationSectionForm.js index bf4da2b4a..4d8d714d5 100644 --- a/src/plotSearch/components/plotSearchSections/application/EditPlotApplicationSectionForm.js +++ b/src/plotSearch/components/plotSearchSections/application/EditPlotApplicationSectionForm.js @@ -1,7 +1,7 @@ // @flow -import React, {Component} from 'react'; +import React, {Component, useEffect, useRef} from 'react'; import {connect} from 'react-redux'; -import {formValueSelector, reduxForm, getFormValues} from 'redux-form'; +import {formValueSelector, reduxForm, getFormValues, change} from 'redux-form'; import {Row, Column} from 'react-foundation'; import flowRight from 'lodash/flowRight'; import get from 'lodash/get'; @@ -11,68 +11,223 @@ import {FieldArray} from 'redux-form'; import Button from '$components/button/Button'; import FormField from '$components/form/FormField'; import ModalButtonWrapper from '$components/modal/ModalButtonWrapper'; -import {FormNames} from '$src/enums'; +import {ConfirmationModalTexts, FieldTypes, FormNames} from '$src/enums'; import {ButtonColors} from '$components/enums'; -import SectionField from '$src/plotSearch/components/plotSearchSections/application/SectionField'; +import EditPlotApplicationSectionFieldForm from '$src/plotSearch/components/plotSearchSections/application/EditPlotApplicationSectionFieldForm'; import Collapse from '$src/components/collapse/Collapse'; import SubTitle from '$src/components/content/SubTitle'; -import {getFormAttributes} from '$src/application/selectors'; +import {getFieldTypeMapping, getFormAttributes} from '$src/application/selectors'; +import {ActionTypes, AppConsumer} from '$src/app/AppContext'; +import IconButton from '$components/button/IconButton'; +import TrashIcon from '$components/icons/TrashIcon'; +import MoveUpIcon from '$components/icons/MoveUpIcon'; +import MoveDownIcon from '$components/icons/MoveDownIcon'; import type {Attributes} from '$src/types'; import type {FormSection} from '$src/application/types'; +import { + generateSectionIdentifierFromName, + getDefaultNewFormField, + getDefaultNewFormSection, + getInitialFormSectionEditorData, + transformCommittedFormSectionEditorData, +} from '$src/plotSearch/helpers'; +import {APPLICANT_SECTION_IDENTIFIER} from '$src/application/constants'; +import {getSectionEditorCollapseStates} from '$src/plotSearch/selectors'; +import { + clearSectionEditorCollapseStates, + initializeSectionEditorCollapseStates, + setSectionEditorCollapseState, +} from '$src/plotSearch/actions'; +import {uniq} from 'lodash/array'; +import ErrorBlock from '$components/form/ErrorBlock'; type SectionFieldProps = { disabled: boolean, fields: any, formName: string, isSaveClicked: Boolean, - form: string + form: string, + fieldIdentifiers: Array, + dispatch: Function, + collapseStates: {[key: string]: boolean}, + setSectionEditorCollapseState: Function, + meta: Object, } const EditPlotApplicationSectionFormSectionFields = ({ disabled, fields, form, + fieldIdentifiers, + dispatch, + collapseStates, + setSectionEditorCollapseState, + meta, // usersPermissions, -}: SectionFieldProps): React$Element<*> => { +}: SectionFieldProps): React$Node => { + const fieldRefs = useRef({}); + const handleRemove = (index: number) => { + dispatch({ + type: ActionTypes.SHOW_CONFIRMATION_MODAL, + confirmationFunction: () => { + fields.remove(index); + }, + confirmationModalButtonClassName: ButtonColors.ALERT, + confirmationModalButtonText: ConfirmationModalTexts.DELETE_SECTION_FIELD.BUTTON, + confirmationModalLabel: ConfirmationModalTexts.DELETE_SECTION_FIELD.LABEL, + confirmationModalTitle: ConfirmationModalTexts.DELETE_SECTION_FIELD.TITLE, + }); + }; + return (
{!!fields.length && fields.map((field, index) => { - return { + fields.move(index, index - 1); + setImmediate(() => { + + if (index - 1 !== 0) { + fieldRefs.current[`SectionEditorMoveUpButton_Field_${id}`]?.focus(); + } else { + fieldRefs.current[`SectionEditorMoveDownButton_Field_${id}`]?.focus(); + } + }); + }; + + const handleMoveDown = (id) => { + fields.move(index, index + 1); + setImmediate(() => { + if (index + 1 < fields.length - 1) { + fieldRefs.current[`SectionEditorMoveDownButton_Field_${id}`]?.focus(); + } else { + fieldRefs.current[`SectionEditorMoveUpButton_Field_${id}`]?.focus(); + } + }); + }; + return index !== i)} + onDelete={() => handleRemove(index)} + onMoveUp={(index > 0) ? handleMoveUp : null} + onMoveDown={(index < (fields.length - 1)) ? handleMoveDown : null} + collapseStates={collapseStates} + setSectionEditorCollapseState={setSectionEditorCollapseState} + fieldRefs={fieldRefs} />; })} + + {meta.error && } + +
); }; type SectionSubsectionProps = { fields: any, + sectionPath: string, form: string, attributes: Attributes, level: number, - stagedSectionValues: Object + stagedSectionValues: Object, + change: Function, + dispatch: Function, + collapseStates: {[key: string]: boolean}, + setSectionEditorCollapseState: Function, + meta: Object, } const EditPlotApplicationSectionFormSectionSubsections = ({ fields, + sectionPath, form, attributes, level, stagedSectionValues, -}: SectionSubsectionProps): React$Element<*> => { - return fields.map( - (ss, i) => ); + change, + dispatch, + collapseStates, + setSectionEditorCollapseState, + meta, +}: SectionSubsectionProps): React$Node => { + const handleRemove = (index: number) => { + dispatch({ + type: ActionTypes.SHOW_CONFIRMATION_MODAL, + confirmationFunction: () => { + fields.remove(index); + }, + confirmationModalButtonClassName: ButtonColors.ALERT, + confirmationModalButtonText: ConfirmationModalTexts.DELETE_SECTION_SUBSECTION.BUTTON, + confirmationModalLabel: ConfirmationModalTexts.DELETE_SECTION_SUBSECTION.LABEL, + confirmationModalTitle: ConfirmationModalTexts.DELETE_SECTION_SUBSECTION.TITLE, + }); + }; + const sectionRefs = useRef({}); + + const section = get(stagedSectionValues, sectionPath); + const subsectionIdentifiers = section.subsections.map((subsection) => subsection.identifier); + + return <> + {fields.map( + (ss, index) => { + const handleMoveUp = (id) => { + fields.move(index, index - 1); + setImmediate(() => { + if (index - 1 !== 0) { + sectionRefs.current[`SectionEditorMoveUpButton_Section_${id}`]?.focus(); + } else { + sectionRefs.current[`SectionEditorMoveDownButton_Section_${id}`]?.focus(); + } + }); + }; + + const handleMoveDown = (id) => { + fields.move(index, index + 1); + setImmediate(() => { + if (index + 1 < fields.length - 1) { + sectionRefs.current[`SectionEditorMoveDownButton_Section_${id}`]?.focus(); + } else { + sectionRefs.current[`SectionEditorMoveUpButton_Section_${id}`]?.focus(); + } + }); + }; + + return index !== i)} + onDelete={() => handleRemove(index)} + onMoveUp={(index > 0) ? handleMoveUp : null} + onMoveDown={(index < (fields.length - 1)) ? handleMoveDown : null} + dispatch={dispatch} + collapseStates={collapseStates} + setSectionEditorCollapseState={setSectionEditorCollapseState} + sectionRefs={sectionRefs} + />; + })} +
+
+ {meta.error && } +
+
+ ; }; type SubsectionProps = { @@ -81,6 +236,15 @@ type SubsectionProps = { sectionPath: string, form: string, stagedSectionValues: Object, + change: Function, + peerSectionIdentifiers: Array, + onMoveUp: ?Function, + onMoveDown: ?Function, + onDelete: ?Function, + dispatch: Function, + collapseStates: {[key: string]: boolean}, + setSectionEditorCollapseState: Function, + sectionRefs?: any, } type SubsectionWrapperProps = { @@ -89,6 +253,16 @@ type SubsectionWrapperProps = { sectionPath: string, subsection: FormSection, children: React$Node, + stagedSectionValues: Object, + peerSectionIdentifiers: Array, + change: Function, + onMoveUp: Function, + onMoveDown: Function, + onDelete: Function, + isApplicantSecondLevelSubsection: boolean, + collapseStates: {[key: string]: boolean}, + setSectionEditorCollapseState: Function, + sectionRefs: any, } const EditPlotApplicationSectionFormSubsectionFirstLevelWrapper = ({children, level}: SubsectionWrapperProps) =>
- - {subsection.show_duplication_check ? :
} -
} - className={classNames( - 'edit-plot-application-section-form__section', - `edit-plot-application-section-form__section--level-${level}`, - { - 'collapse__secondary': level === 2, - 'collapse__third': level > 2, + stagedSectionValues, + peerSectionIdentifiers, + change, + onDelete, + onMoveUp, + onMoveDown, + isApplicantSecondLevelSubsection, + collapseStates, + setSectionEditorCollapseState, + sectionRefs, +}: SubsectionWrapperProps) => { + const autoIdentifier = get(stagedSectionValues, `${sectionPath}.auto_fill_identifier`); + const isProtected = get(stagedSectionValues, `${sectionPath}.is_protected`); + const id = get(stagedSectionValues, `${sectionPath}.id`) ?? get(stagedSectionValues, `${sectionPath}.temporary_id`); + const upButtonId = `SectionEditorMoveUpButton_Section_${id}`; + const downButtonId = `SectionEditorMoveDownButton_Section_${id}`; + + const isOpen = collapseStates[`section-${id}`]; + + useEffect(() => { + if (isOpen === undefined) { + setSectionEditorCollapseState(`section-${id}`, true); } - )} ->{children}
; + }, []); + + const updateAutoIdentifier = (shouldChange: boolean, sectionPath: string, newName: string): void => { + if (shouldChange && !isProtected) { + change( + `${sectionPath}.identifier`, + generateSectionIdentifierFromName(newName, peerSectionIdentifiers) + ); + } + }; + + const handleMoveUp = () => { + onMoveUp(id); + }; + + const handleMoveDown = () => { + onMoveDown(id); + }; + + const setRef = (id, el) => { + if (sectionRefs) { + sectionRefs.current[id] = el; + } + }; + + const sectionValuesColumnWidth = isApplicantSecondLevelSubsection ? 4 : 3; + + return setSectionEditorCollapseState(`section-${id}`, newIsOpen)} + className={classNames( + 'edit-plot-application-section-form__section', + `edit-plot-application-section-form__section--level-${level}`, + { + 'collapse__secondary': level === 2, + 'collapse__third': level > 2, + }, + )} + headerTitle={subsection.title || '-'} + headerExtras={
+ + {subsection.show_duplication_check ? :
} + + + + setRef(upButtonId, el)} + > + + + setRef(downButtonId, el)} + > + + +
} + > + + + updateAutoIdentifier( + autoIdentifier, + sectionPath, + newName, + )} + /> + + + + + + + + + + { + if (value) { + updateAutoIdentifier( + true, + sectionPath, + get(stagedSectionValues, `${sectionPath}.title`), + ); + } + }} + /> + + {isApplicantSecondLevelSubsection && + + } + + {children} + ; +}; const EditPlotApplicationSectionFormSubsection: React$ComponentType = ({ sectionPath, @@ -146,19 +474,59 @@ const EditPlotApplicationSectionFormSubsection: React$ComponentType { const subsection = get(stagedSectionValues, sectionPath); + if (Object.keys(subsection || {}).length === 0) { + return null; + } + + const fieldIdentifiers = subsection.fields.map((field) => field.identifier); + const Wrapper = (level > 1) ? EditPlotApplicationSectionFormSubsectionSecondLevelWrapper : EditPlotApplicationSectionFormSubsectionFirstLevelWrapper; - return + return { + if (value && uniq(value.map((v) => v.identifier)).length < value.length) { + return 'Kahdella kentällä ei saa olla samaa sisäistä tunnusta!'; + } + }} /> { + if (value && uniq(value.map((v) => v.identifier)).length < value.length) { + return 'Kahdella osiolla ei saa olla samaa sisäistä tunnusta!'; + } + }} /> ; }; @@ -176,6 +554,7 @@ type OwnProps = { onClose: Function, onSubmit: Function, sectionIndex: number, + isOpen: boolean, }; type Props = { @@ -187,7 +566,13 @@ type Props = { parentFormSection: Object, initialize: Function, form: string, - stagedSectionValues: Object + stagedSectionValues: Object, + change: Function, + collapseStates: {[key: string]: boolean}, + setSectionEditorCollapseState: Function, + clearSectionEditorCollapseStates: Function, + initializeSectionEditorCollapseStates: Function, + fieldTypeMapping: Object, } class EditPlotApplicationSectionForm extends Component { @@ -198,44 +583,56 @@ class EditPlotApplicationSectionForm extends Component { } setFocus = (): void => { - if(this.firstField) { + if (this.firstField) { this.firstField.focus(); } } componentDidMount(): void { - const { - sectionIndex, - parentFormSection, - initialize, - } = this.props; + const {sectionIndex} = this.props; + if (sectionIndex !== undefined) { - initialize({ - section: parentFormSection, - }); + this.initializeData(); } } componentDidUpdate(prevProps: Props): void { const { sectionIndex, - parentFormSection, - initialize, + isOpen, } = this.props; - if (sectionIndex !== prevProps.sectionIndex) { - initialize({ - section: parentFormSection, - }); + if (sectionIndex !== prevProps.sectionIndex || (isOpen && !prevProps.isOpen)) { + this.initializeData(); } } + initializeData = (): void => { + const { + parentFormSection, + initialize, + clearSectionEditorCollapseStates, + initializeSectionEditorCollapseStates, + fieldTypeMapping, + } = this.props; + + const {sectionData, collapseInitialState} = getInitialFormSectionEditorData(fieldTypeMapping, parentFormSection); + + clearSectionEditorCollapseStates(); + initialize({ + section: sectionData, + identifier: parentFormSection.identifier, + }); + + initializeSectionEditorCollapseStates(collapseInitialState); + }; + handleSubmit = (): void => { const { onSubmit, onClose, stagedSectionValues, } = this.props; - onSubmit(stagedSectionValues.section); + onSubmit(transformCommittedFormSectionEditorData(stagedSectionValues.section)); onClose(); }; @@ -247,6 +644,9 @@ class EditPlotApplicationSectionForm extends Component { parentFormSection, stagedSectionValues, form, + change, + collapseStates, + setSectionEditorCollapseState, } = this.props; if (!parentFormSection) { @@ -254,50 +654,63 @@ class EditPlotApplicationSectionForm extends Component { } return ( -
- - {parentFormSection.title} - - - - + {({dispatch}) => + + {parentFormSection.title} + + + + + + + + + + + +