blob: ce26cadf293ac5c0614826842247e190f5447183 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Button, ButtonGroup, FormGroup, Intent, NumericInput } from '@blueprintjs/core';
import React from 'react';
import { deepDelete, deepGet, deepSet } from '../../utils/object-change';
import { ArrayInput } from '../array-input/array-input';
import { FormGroupWithInfo } from '../form-group-with-info/form-group-with-info';
import { IntervalInput } from '../interval-input/interval-input';
import { JsonInput } from '../json-input/json-input';
import { PopoverText } from '../popover-text/popover-text';
import { SuggestibleInput, Suggestion } from '../suggestible-input/suggestible-input';
import './auto-form.scss';
export type Functor<M, R> = R | ((model: M) => R);
export interface Field<M> {
name: string;
label?: string;
info?: React.ReactNode;
type:
| 'number'
| 'size-bytes'
| 'string'
| 'duration'
| 'boolean'
| 'string-array'
| 'json'
| 'interval';
defaultValue?: any;
emptyValue?: any;
suggestions?: Functor<M, Suggestion[]>;
placeholder?: Functor<M, string>;
min?: number;
zeroMeansUndefined?: boolean;
height?: string;
disabled?: Functor<M, boolean>;
defined?: Functor<M, boolean>;
required?: Functor<M, boolean>;
adjustment?: (model: M) => M;
}
export interface AutoFormProps<M> {
fields: Field<M>[];
model: M | undefined;
onChange: (newModel: M) => void;
onFinalize?: () => void;
showCustom?: (model: M) => boolean;
large?: boolean;
globalAdjustment?: (model: M) => M;
}
export class AutoForm<T extends Record<string, any>> extends React.PureComponent<AutoFormProps<T>> {
static REQUIRED_INTENT = Intent.PRIMARY;
static makeLabelName(label: string): string {
let newLabel = label
.split(/(?=[A-Z])/)
.join(' ')
.toLowerCase()
.replace(/\./g, ' ');
newLabel = newLabel[0].toUpperCase() + newLabel.slice(1);
return newLabel;
}
static evaluateFunctor<M, R>(
functor: undefined | Functor<M, R>,
model: M | undefined,
defaultValue: R,
): R {
if (!model || functor == null) return defaultValue;
if (typeof functor === 'function') {
return (functor as any)(model);
} else {
return functor;
}
}
constructor(props: AutoFormProps<T>) {
super(props);
this.state = {};
}
private fieldChange = (field: Field<T>, newValue: any) => {
const { model } = this.props;
if (!model) return;
let newModel: T;
if (typeof newValue === 'undefined') {
if (typeof field.emptyValue === 'undefined') {
newModel = deepDelete(model, field.name);
} else {
newModel = deepSet(model, field.name, field.emptyValue);
}
} else {
newModel = deepSet(model, field.name, newValue);
}
if (field.adjustment) {
newModel = field.adjustment(newModel);
}
this.modelChange(newModel);
};
private modelChange = (newModel: T) => {
const { globalAdjustment, fields, onChange, model } = this.props;
// Delete things that are not defined now (but were defined prior to the change)
for (const someField of fields) {
if (
!AutoForm.evaluateFunctor(someField.defined, newModel, true) &&
AutoForm.evaluateFunctor(someField.defined, model, true)
) {
newModel = deepDelete(newModel, someField.name);
}
}
// Perform any global adjustments if needed
if (globalAdjustment) {
newModel = globalAdjustment(newModel);
}
onChange(newModel);
};
private renderNumberInput(field: Field<T>): JSX.Element {
const { model, large, onFinalize } = this.props;
let modelValue = deepGet(model as any, field.name);
if (typeof modelValue !== 'number') modelValue = field.defaultValue;
return (
<NumericInput
value={modelValue}
onValueChange={(valueAsNumber: number, valueAsString: string) => {
if (valueAsString === '' || isNaN(valueAsNumber)) return;
this.fieldChange(
field,
valueAsNumber === 0 && field.zeroMeansUndefined ? undefined : valueAsNumber,
);
}}
onBlur={e => {
if (e.target.value === '') {
this.fieldChange(field, undefined);
}
if (onFinalize) onFinalize();
}}
min={field.min || 0}
fill
large={large}
disabled={AutoForm.evaluateFunctor(field.disabled, model, false)}
placeholder={AutoForm.evaluateFunctor(field.placeholder, model, '')}
intent={
AutoForm.evaluateFunctor(field.required, model, false) && modelValue == null
? AutoForm.REQUIRED_INTENT
: undefined
}
/>
);
}
private renderSizeBytesInput(field: Field<T>): JSX.Element {
const { model, large, onFinalize } = this.props;
return (
<NumericInput
value={deepGet(model as any, field.name) || field.defaultValue}
onValueChange={(v: number) => {
if (isNaN(v)) return;
this.fieldChange(field, v);
}}
onBlur={() => {
if (onFinalize) onFinalize();
}}
min={0}
stepSize={1000}
majorStepSize={1000000}
fill
large={large}
disabled={AutoForm.evaluateFunctor(field.disabled, model, false)}
/>
);
}
private renderStringInput(field: Field<T>, sanitize?: (str: string) => string): JSX.Element {
const { model, large, onFinalize } = this.props;
const modelValue = deepGet(model as any, field.name);
return (
<SuggestibleInput
value={modelValue != null ? modelValue : field.defaultValue || ''}
onValueChange={v => {
if (sanitize && typeof v === 'string') v = sanitize(v);
this.fieldChange(field, v);
}}
onBlur={() => {
if (modelValue === '') this.fieldChange(field, undefined);
}}
onFinalize={onFinalize}
placeholder={AutoForm.evaluateFunctor(field.placeholder, model, '')}
suggestions={AutoForm.evaluateFunctor(field.suggestions, model, undefined)}
large={large}
disabled={AutoForm.evaluateFunctor(field.disabled, model, false)}
intent={
AutoForm.evaluateFunctor(field.required, model, false) && modelValue == null
? AutoForm.REQUIRED_INTENT
: undefined
}
/>
);
}
private renderBooleanInput(field: Field<T>): JSX.Element {
const { model, large, onFinalize } = this.props;
const modelValue = deepGet(model as any, field.name);
const shownValue = modelValue == null ? field.defaultValue : modelValue;
const disabled = AutoForm.evaluateFunctor(field.disabled, model, false);
const intent =
AutoForm.evaluateFunctor(field.required, model, false) && modelValue == null
? AutoForm.REQUIRED_INTENT
: undefined;
return (
<ButtonGroup large={large}>
<Button
intent={intent}
disabled={disabled}
active={shownValue === false}
onClick={() => {
this.fieldChange(field, false);
if (onFinalize) onFinalize();
}}
>
False
</Button>
<Button
intent={intent}
disabled={disabled}
active={shownValue === true}
onClick={() => {
this.fieldChange(field, true);
if (onFinalize) onFinalize();
}}
>
True
</Button>
</ButtonGroup>
);
}
private renderJsonInput(field: Field<T>): JSX.Element {
const { model } = this.props;
return (
<JsonInput
value={deepGet(model as any, field.name)}
onChange={(v: any) => this.fieldChange(field, v)}
placeholder={AutoForm.evaluateFunctor(field.placeholder, model, '')}
height={field.height}
/>
);
}
private renderStringArrayInput(field: Field<T>): JSX.Element {
const { model, large } = this.props;
const modelValue = deepGet(model as any, field.name);
return (
<ArrayInput
values={modelValue || []}
onChange={(v: any) => {
this.fieldChange(field, v);
}}
placeholder={AutoForm.evaluateFunctor(field.placeholder, model, '')}
large={large}
disabled={AutoForm.evaluateFunctor(field.disabled, model, false)}
intent={
AutoForm.evaluateFunctor(field.required, model, false) && modelValue == null
? AutoForm.REQUIRED_INTENT
: undefined
}
/>
);
}
private renderIntervalInput(field: Field<T>): JSX.Element {
const { model } = this.props;
const modelValue = deepGet(model as any, field.name);
return (
<IntervalInput
interval={modelValue != null ? modelValue : field.defaultValue || ''}
onValueChange={(v: any) => {
this.fieldChange(field, v);
}}
placeholder={AutoForm.evaluateFunctor(field.placeholder, model, '')}
intent={
AutoForm.evaluateFunctor(field.required, model, false) && modelValue == null
? AutoForm.REQUIRED_INTENT
: undefined
}
/>
);
}
renderFieldInput(field: Field<T>) {
switch (field.type) {
case 'number':
return this.renderNumberInput(field);
case 'size-bytes':
return this.renderSizeBytesInput(field);
case 'string':
return this.renderStringInput(field);
case 'duration':
return this.renderStringInput(field, (str: string) =>
str.toUpperCase().replace(/[^0-9PYMDTHS.,]/g, ''),
);
case 'boolean':
return this.renderBooleanInput(field);
case 'string-array':
return this.renderStringArrayInput(field);
case 'json':
return this.renderJsonInput(field);
case 'interval':
return this.renderIntervalInput(field);
default:
throw new Error(`unknown field type '${field.type}'`);
}
}
private renderField = (field: Field<T>) => {
const { model } = this.props;
if (!model) return;
if (!AutoForm.evaluateFunctor(field.defined, model, true)) return;
const label = field.label || AutoForm.makeLabelName(field.name);
return (
<FormGroupWithInfo
key={field.name}
label={label}
info={field.info ? <PopoverText>{field.info}</PopoverText> : undefined}
>
{this.renderFieldInput(field)}
</FormGroupWithInfo>
);
};
renderCustom() {
const { model } = this.props;
return (
<FormGroup label="Custom" key="custom">
<JsonInput value={model} onChange={this.modelChange} />
</FormGroup>
);
}
render(): JSX.Element {
const { fields, model, showCustom } = this.props;
return (
<div className="auto-form">
{model && fields.map(this.renderField)}
{model && showCustom && showCustom(model) && this.renderCustom()}
</div>
);
}
}