Skip to content

Commit

Permalink
refactor(core/forms): Extract a controlled MapEditorInput component
Browse files Browse the repository at this point in the history
Retains the old API in MapEditor.tsx
  • Loading branch information
christopherthielen committed Aug 6, 2019
1 parent 29d470d commit 0c547a8
Show file tree
Hide file tree
Showing 3 changed files with 204 additions and 149 deletions.
2 changes: 2 additions & 0 deletions app/scripts/modules/core/src/forms/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './mapEditor/MapEditor';
export * from './mapEditor/MapEditorInput';
export * from './mapEditor/MapPair';
193 changes: 44 additions & 149 deletions app/scripts/modules/core/src/forms/mapEditor/MapEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import * as React from 'react';
import { isString, isFunction } from 'lodash';

import { IPipeline } from 'core/domain';
import { isString } from 'lodash';
import { IMapPair, MapPair } from './MapPair';
import { IValidationProps, IValidator } from 'core/presentation';

import { MapEditorInput, IMapEditorModel } from './MapEditorInput';

export interface IMapEditorProps {
addButtonLabel?: string;
Expand All @@ -10,160 +13,52 @@ export interface IMapEditorProps {
keyLabel?: string;
label?: string;
labelsLeft?: boolean;
model: string | { [key: string]: string };
model: string | IMapEditorModel;
valueLabel?: string;
onChange: (model: string | { [key: string]: string }, duplicateKeys: boolean) => void;
onChange: (model: string | IMapEditorModel, error: boolean) => void;
valueCanContainSpel?: boolean;
pipeline?: IPipeline;
}

export interface IMapEditorState {
backingModel: IMapPair[];
}

export class MapEditor extends React.Component<IMapEditorProps, IMapEditorState> {
public static defaultProps: Partial<IMapEditorProps> = {
addButtonLabel: 'Add Field',
allowEmpty: false,
hiddenKeys: [],
keyLabel: 'Key',
labelsLeft: false,
valueLabel: 'Value',
valueCanContainSpel: false,
};

constructor(props: IMapEditorProps) {
super(props);
const isParameterized = isString(props.model);

this.state = {
backingModel: !isParameterized ? this.mapModel(props.model as { [key: string]: string }) : null,
};
}

private mapModel(model: { [key: string]: string }): IMapPair[] {
return Object.keys(model).map(key => ({ key: key, value: model[key] }));
}

private reduceModel(backingModel: IMapPair[]): { [key: string]: string } {
return backingModel.reduce(
(acc, pair) => {
if (this.props.allowEmpty || pair.value) {
acc[pair.key] = pair.value;
}
return acc;
},
{} as any,
);
}

private validateUnique(model: IMapPair[]): boolean {
let error = false;

const usedKeys = new Set();

model.forEach(p => {
if (usedKeys.has(p.key)) {
p.error = 'Duplicate key';
error = true;
} else {
delete p.error;
}
usedKeys.add(p.key);
});

return error;
function doValidation(validator: IValidator, value: string | IMapEditorModel): IMapEditorModel {
if (isString(value)) {
return null;
}

private handleChanged() {
const error = this.validateUnique(this.state.backingModel);
const newModel = this.reduceModel(this.state.backingModel);
this.props.onChange(newModel, error);
}

private onChange = (newPair: IMapPair, index: number) => {
this.state.backingModel[index] = newPair;
this.handleChanged();
};

private onDelete = (index: number) => {
this.state.backingModel.splice(index, 1);
this.handleChanged();
};
const newErrors = isFunction(validator) ? validator(value) : null;
return (newErrors as any) as IMapEditorModel;
}

private onAdd = () => {
this.state.backingModel.push({ key: '', value: '' });
this.handleChanged();
// A component that adapts the MapEditorInput (a controlled component) to the previous API of MapEditor
// Handles validation and feeds it back into the MapEditorInput
export function MapEditor(mapEditorProps: IMapEditorProps) {
const { onChange, model: initialModel, ...props } = mapEditorProps;
const [model, setModel] = React.useState<string | IMapEditorModel>(initialModel);
const [validator, setValidator] = React.useState<IValidator>();
const [errors, setErrors] = React.useState<IMapEditorModel>();

React.useEffect(() => {
const newErrors = doValidation(validator, model);
const hasError = !!Object.keys(newErrors || {}).length;
setErrors(newErrors);
onChange(model, hasError);
}, [validator, model]);

const validation: IValidationProps = {
touched: true,
// Use setValidator(oldstate => newstate) overload
// Otherwise, react calls the validator function internally and stores the returned errors object
// https://reactjs.org/docs/hooks-reference.html#functional-updates
addValidator: newValidator => setValidator(() => newValidator),
removeValidator: () => setValidator(null),
};

public render() {
const {
addButtonLabel,
hiddenKeys,
keyLabel,
label,
labelsLeft,
model,
valueLabel,
valueCanContainSpel,
pipeline,
} = this.props;
const { backingModel } = this.state;

const rowProps = { keyLabel, valueLabel, labelsLeft };

const columnCount = this.props.labelsLeft ? 5 : 3;
const tableClass = this.props.label ? '' : 'no-border-top';
const isParameterized = isString(this.props.model);

return (
<div>
{label && (
<div className="sm-label-left">
<b>{label}</b>
</div>
)}

{isParameterized && <input className="form-control input-sm" value={model as string} />}
{!isParameterized && (
<table className={`table table-condensed packed tags ${tableClass}`}>
<thead>
{!labelsLeft && (
<tr>
<th>{keyLabel}</th>
<th>{valueLabel}</th>
<th />
</tr>
)}
</thead>
<tbody>
{backingModel
.filter(p => !hiddenKeys.includes(p.key))
.map((pair, index) => (
<MapPair
key={index}
{...rowProps}
onChange={value => this.onChange(value, index)}
onDelete={() => this.onDelete(index)}
pair={pair}
valueCanContainSpel={valueCanContainSpel}
pipeline={pipeline}
/>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={columnCount}>
<button type="button" className="btn btn-block btn-sm add-new" onClick={this.onAdd}>
<span className="glyphicon glyphicon-plus-sign" />
{addButtonLabel}
</button>
</td>
</tr>
</tfoot>
</table>
)}
</div>
);
}
return (
<MapEditorInput
{...props}
errors={errors}
value={model}
onChange={e => setModel(e.target.value)}
validation={validation}
/>
);
}
158 changes: 158 additions & 0 deletions app/scripts/modules/core/src/forms/mapEditor/MapEditorInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import * as React from 'react';
import { isNil, isFunction, isString } from 'lodash';

import { IPipeline } from 'core/domain';
import { createFakeReactSyntheticEvent, IFormInputProps, IValidator } from '../../presentation/forms';
import { IMapPair, MapPair } from './MapPair';

export interface IMapEditorInputProps extends IFormInputProps {
addButtonLabel?: string;
hiddenKeys?: string[];
keyLabel?: string;
label?: string;
labelsLeft?: boolean;
errors: IMapEditorModel;
valueLabel?: string;
valueCanContainSpel?: boolean;
pipeline?: IPipeline;
}

export interface IMapEditorModel {
[key: string]: string;
}

const duplicateKeyPattern = /^__MapEditorDuplicateKey__\d+__/;

// Convert the controlled component value (an object) to a list of IMapPairs
function objectToTuples(model: IMapEditorModel, errors: IMapEditorModel): IMapPair[] {
model = model || {};
errors = errors || {};
return Object.keys(model).map(key => {
const keyWithoutMagicString = key.split(duplicateKeyPattern).pop();
return { key: keyWithoutMagicString, value: model[key], error: errors[key] };
});
}

// Convert a list of IMapPairs to an object with keys and values
// Prepend any duplicate keys with a magic string
function tuplesToObject(pairs: IMapPair[]): IMapEditorModel {
return pairs.reduce(
(acc, pair, idx) => {
// Cannot have duplicate keys in an object, so prepend a magic string to the key
const key = isNil(acc[pair.key]) ? pair.key : `__MapEditorDuplicateKey__${idx}__${pair.key}`;
return { ...acc, [key]: pair.value };
},
{} as IMapEditorModel,
);
}

function validator(values: IMapEditorModel): IMapEditorModel {
return Object.keys(values || {}).reduce((acc, key) => {
return duplicateKeyPattern.exec(key) ? { ...acc, [key]: 'Duplicate key' } : acc;
}, {});
}

export function MapEditorInput({
addButtonLabel = 'Add Field',
errors = {},
hiddenKeys = [],
keyLabel = 'Key',
label,
labelsLeft = false,
name,
onChange,
value,
valueLabel = 'Value',
valueCanContainSpel = false,
validation,
pipeline,
}: IMapEditorInputProps) {
const rowProps = { keyLabel, valueLabel, labelsLeft };

const columnCount = labelsLeft ? 5 : 3;
const tableClass = label ? '' : 'no-border-top';
const isParameterized = isString(value);
const backingModel = !isString(value) ? objectToTuples(value, errors) : null;

// Register/unregister validator, if a validation prop was supplied
React.useEffect(() => {
if (validation && isFunction(validation.addValidator)) {
validation.addValidator((validator as any) as IValidator);
}

return () => {
if (validation && isFunction(validation.removeValidator)) {
validation.removeValidator((validator as any) as IValidator);
}
};
}, []);

const handleChanged = () => {
onChange(createFakeReactSyntheticEvent({ name, value: tuplesToObject(backingModel) }));
};

const handlePairChanged = (newPair: IMapPair, index: number) => {
backingModel[index] = newPair;
handleChanged();
};

const handleDeletePair = (index: number) => {
backingModel.splice(index, 1);
handleChanged();
};

const handleAddPair = () => {
backingModel.push({ key: '', value: '' });
handleChanged();
};

return (
<div>
{label && (
<div className="sm-label-left">
<b>{label}</b>
</div>
)}

{isParameterized && <input className="form-control input-sm" value={value as string} />}
{!isParameterized && (
<table className={`table table-condensed packed tags ${tableClass}`}>
<thead>
{!labelsLeft && (
<tr>
<th>{keyLabel}</th>
<th>{valueLabel}</th>
<th />
</tr>
)}
</thead>
<tbody>
{backingModel
.filter(p => !hiddenKeys.includes(p.key))
.map((pair, index) => (
<MapPair
key={index}
{...rowProps}
onChange={x => handlePairChanged(x, index)}
onDelete={() => handleDeletePair(index)}
pair={pair}
valueCanContainSpel={valueCanContainSpel}
pipeline={pipeline}
/>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={columnCount}>
<button type="button" className="btn btn-block btn-sm add-new" onClick={handleAddPair}>
<span className="glyphicon glyphicon-plus-sign" />
{addButtonLabel}
</button>
</td>
</tr>
</tfoot>
</table>
)}
</div>
);
}

0 comments on commit 0c547a8

Please sign in to comment.