Skip to content

Instantly share code, notes, and snippets.

@baransu
Created January 12, 2019 21:55
Show Gist options
  • Save baransu/4ff1086014693fe27c562d435c1ee461 to your computer and use it in GitHub Desktop.
Save baransu/4ff1086014693fe27c562d435c1ee461 to your computer and use it in GitHub Desktop.
re-formality complex dynamic form example
module Part = OfferPart;
module Button = OfferButton;
module Styles = OfferStyles;
module Form = OfferForm;
module PartButton = {
let component = ReasonReact.statelessComponent(__MODULE__);
let make =
(
~actions: array(Action.t),
~form: OfferForm.Form.interface,
~part: Part.t,
~button: Button.t,
_,
) => {
...component,
render: _ =>
<Card className=Styles.Single.card key={button.id}>
<div style=Styles.Single.buttonTypeSelectWrapper>
<Form.Select
form
label=messages##selectButtonType
placeholder=messages##selectButtonType
value={Button.contentToString(button.content)}
options=Button.options
field={ButtonContent(button.id)}
update={Form.Button.Content.update(button.id)}
/>
<CloseIcon onClick={_ => Form.removeButton(~part, ~button, ~form)} />
</div>
{
switch (button.content) {
| UrlButton({caption, url}) =>
<div>
<Form.Input
form
field={ButtonCaption(button.id)}
value=caption
placeholder=messages##caption
label=messages##caption
update={Form.Button.Caption.update(button.id)}
/>
<Form.Input
form
field={ButtonUrl(button.id)}
value=url
placeholder=messages##url
label=messages##url
update={Form.Button.Url.update(button.id)}
/>
</div>
| PluginButton({caption, pluginId, instanceId}) =>
<div>
<Form.Input
form
field={ButtonCaption(button.id)}
value=caption
placeholder=messages##caption
label=messages##caption
update={Form.Button.Caption.update(button.id)}
/>
<Form.Select
form
label=messages##selectPlugin
placeholder=I18n.Global.messages##required
value={Action.createKey(pluginId, instanceId)}
options={Action.toOptions(actions)}
field={ButtonPlugin(button.id)}
update={Form.Button.Plugin.update(button.id)}
/>
</div>
}
}
</Card>,
};
};
module PartTabHeader = {
let component = ReasonReact.statelessComponent(__MODULE__);
let make = (~title, ~form: Form.Form.interface, ~part: Part.t, _) => {
...component,
render: _ =>
<div style=Styles.Single.tab>
{ReasonReact.string(title)}
<Components.Button
style=Styles.Single.tabButton
type_=`ghost
text={/* REDACTED */}
size=`tiny
onClick={_ => Form.removePart(~part, ~form)}
/>
</div>,
};
};
open Belt;
module Config = OfferFormConfig;
module Name = {
open Config;
let update = (name, state) => {...state, name};
/*
Validation.validate is our custom function to reduce validation record boiler place
It accepts things like dependents and strategy as optional labeled arguments
It also allows us to pass multiple validators on the same value
(e.g required string and max string length) and return first validation error
*/
let validator = Validation.(validate(Name, state => state.name, [required]));
};
module Part = OfferFormPart;
module Button = OfferFormButton;
let initialPartId = OfferPart.generateId();
let initialState: Config.state = {
let buttonId = OfferButton.generateId();
let part = OfferPart.getEmpty(~id=initialPartId, ~buttonId, ());
let button = OfferButton.getEmpty(~id=buttonId, ());
{name: "", parts: [part], buttons: [button]};
};
/* Used to generate validators for Config.state received from the API as our form is used for both create and edit */
let generateValidators = (state: Config.state) =>
[Name.validator]
->List.concat(state.parts->List.map(part => Part.allValidators(part.id))->List.flatten)
->List.concat(
state.buttons->List.map(button => Button.allValidators(button.id))->List.flatten,
);
/* Our little wrapper to provide reusable components like Input/Select that are aware of Config.state and Config.field */
include Form.Create(Config);
/* Most comples change functions to limit logic in the view */
let removeButton = (~part: OfferPart.t, ~button: OfferButton.t, ~form: Form.interface) =>
form.change(
~validatorsToRemove=Button.allFields(button.id),
~field=PartButtons(part.id),
Button.remove(part.id, button.id),
);
let removePart = (~part: OfferPart.t, ~form: Form.interface) =>
form.change(
~validatorsToRemove=
part.buttons
->List.map(Button.allFields)
->List.flatten
->List.concat(Part.allFields(part.id)),
~field=Part,
Part.remove(part.id),
);
let addPart = (~form: Form.interface) => {
let partId = OfferPart.generateId();
let buttonId = OfferButton.generateId();
form.change(
~validatorsToAdd=List.concat(Part.allValidators(partId), Button.allValidators(buttonId)),
~field=Part,
Part.add(partId, buttonId),
);
};
let addButton = (~part: OfferPart.t, ~form: Form.interface) => {
let buttonId = OfferButton.generateId();
form.change(
~validatorsToAdd=Button.allValidators(buttonId),
~field=PartButtons(part.id),
Button.add(part.id, buttonId),
);
};
open Belt;
open OfferFormConfig;
let byId = (id, button: OfferButton.t) => button.id == id;
let updateButton = (~buttonId, fn, state) => {
...state,
buttons: ListExtra.update(state.buttons, byId(buttonId), fn),
};
let updateContent = (fn, button: OfferButton.t) => {...button, content: fn(button.content)};
let createValidator = (~buttonId, ~field, validate): validator => {
field,
strategy: Formality.Strategy.OnFirstSuccessOrFirstBlur,
dependents: None,
validate: state =>
state.buttons
->ListExtra.find(byId(buttonId))
->Option.flatMap(button => validate(button)->List.head)
->Option.getWithDefault(Validation.valid),
};
module Content = {
let update = (buttonId, wannabe) =>
updateButton(
~buttonId,
updateContent(content =>
switch (wannabe, content) {
| ("plugin_button", UrlButton({caption, _})) =>
PluginButton({caption, pluginId: "", instanceId: ""})
| ("url_button", PluginButton({caption, _})) => UrlButton({caption, url: ""})
| (_, content) => content
}
),
);
};
module Caption = {
let update = (buttonId, caption) =>
updateButton(
~buttonId,
updateContent(
fun
| UrlButton(content) => UrlButton({...content, caption})
| PluginButton(content) => PluginButton({...content, caption}),
),
);
let validator = buttonId =>
createValidator(
~buttonId,
~field=ButtonCaption(buttonId),
button => {
let content =
switch (button.content) {
| UrlButton({caption, _}) => caption
| PluginButton({caption, _}) => caption
};
[Validation.required(content)];
},
);
};
module Url = {
let update = (buttonId, url) =>
updateButton(
~buttonId,
updateContent(
fun
| UrlButton(content) => UrlButton({...content, url})
| content => content,
),
);
let validator = buttonId =>
createValidator(~buttonId, ~field=ButtonUrl(buttonId), button =>
switch (button.content) {
| UrlButton({url, _}) => [Validation.required(url)]
| PluginButton(_) => [Validation.valid]
}
);
};
module Plugin = {
let update = (buttonId, actionKey, state) =>
switch (Plugin.Action.splitKey(actionKey)) {
| Some((pluginId, instanceId)) =>
updateButton(
~buttonId,
updateContent(
fun
| PluginButton(content) => PluginButton({...content, pluginId, instanceId})
| content => content,
),
state,
)
| _ => state
};
let validator = buttonId =>
createValidator(~buttonId, ~field=ButtonPlugin(buttonId), button =>
switch (button.content) {
| PluginButton({pluginId, instanceId, _}) =>
switch (pluginId, instanceId) {
| ("", _)
| (_, "") => [Validation.required("")]
| _ => [Validation.valid]
}
| UrlButton(_) => [Validation.valid]
}
);
};
let remove = (partId, buttonId, state) =>
{...state, buttons: List.keep(state.buttons, button => button.id != buttonId)}
->OfferFormPart.Buttons.remove(partId, buttonId, _);
let add = (partId, buttonId, state) => {
let button = OfferButton.getEmpty(~id=buttonId, ());
{...state, buttons: List.add(state.buttons, button)}
->OfferFormPart.Buttons.add(partId, buttonId, _);
};
let allValidators = buttonId => [
Url.validator(buttonId),
Caption.validator(buttonId),
Plugin.validator(buttonId),
];
/* after change to validatorsToRemove signature we can remove that */
let allFields = buttonId => [
ButtonCaption(buttonId),
ButtonUrl(buttonId),
ButtonPlugin(buttonId),
];
type field =
| Name
| ButtonUrl(string)
| ButtonCaption(string)
| ButtonPlugin(string)
| ButtonContent(string)
/* it has no validator nor update connected to it */
| Part
| PartImage(string)
| PartTitle(string)
| PartSubtitle(string)
| PartButtons(string);
type state = {
name: string,
parts: list(OfferPart.t),
buttons: list(OfferButton.t),
};
type validator = Formality.validator(field, state, I18n.t);
module Part = OfferPart;
module Button = OfferButton;
module Api = OfferApi;
module Form = OfferForm;
module FormConfig = OfferFormConfig;
type currentId = option(string);
type action =
| Loading
| SetState(FormConfig.state)
| SetActiveTab(string)
| SetActions(array(Action.t))
| ResetState
| SetId(currentId);
type state = {
activeTab: string,
currentId,
actions: array(Action.t),
loading: bool,
validators: list(Form.Form.validator),
initialState: FormConfig.state,
};
let initialState = () => {
actions: [||],
activeTab: Form.initialPartId,
loading: false,
currentId: None,
validators: Form.generateValidators(Form.initialState),
initialState: Form.initialState,
};
let getRoutingAction = path =>
switch (path) {
| ["offer", "new"] => SetId(None)
| ["offer", id] => SetId(Some(id))
| _ => SetId(None)
};
let getOffer = (~baseUrl, ~id, {ReasonReact.send, _}) =>
Api.getOne(~baseUrl, ~id)
|> Repromise.Rejectable.wait(
fun
| Result.Ok(payload) => send(SetState(payload))
| Result.Error(_) => {
Notifications.error(/* REDACTED */);
send(ResetState);
},
);
let getActions = (~organizationId, ~resourceId, {ReasonReact.send, _}) =>
Action.getButtonActions(~organizationId, ~resourceId)
|> Repromise.Rejectable.wait(
fun
| Result.Ok(actions) => send(SetActions(actions))
| Result.Error(_) => (),
);
let formNotifications =
fun
| Result.Ok(_) as ok => {
Notifications.success(/* REDACTED */);
ok;
}
| Result.Error(_) as err => {
Notifications.error(/* REDACTED */);
err;
};
let component = ReasonReact.reducerComponent(__MODULE__);
let make = (~push, ~organizationId, ~resourceId, ~fileUploadUrl, ~baseUrl, _children) => {
...component,
initialState,
reducer: (action, state) =>
switch (action) {
| Loading => ReasonReact.Update({...state, loading: true})
| SetState(initialState) =>
ReasonReact.Update({
...state,
loading: false,
initialState,
validators: Form.generateValidators(initialState),
activeTab:
initialState.parts
->List.head
->Option.mapWithDefault(Form.initialPartId, part => part.id),
})
| SetActiveTab(activeTab) => ReasonReact.Update({...state, activeTab})
| SetActions(actions) => ReasonReact.Update({...state, actions})
| ResetState => ReasonReact.Update(initialState())
| SetId(currentId) => ReasonReact.Update({...state, currentId})
},
didMount: self => {
/* some logic to start fetching data from the API */
},
render: self => {
let%Epitath form = children =>
<Form.Form
validators={self.state.validators}
enableReinitialize=true
initialState={self.state.initialState}
onSubmit={
(state, form) => {
let resultPromise =
switch (self.state.currentId) {
| Some(id) => Api.save(~baseUrl, ~id, state)
| None => Api.create(~baseUrl, state)
};
resultPromise
|> Repromise.Rejectable.map(formNotifications)
|> Repromise.Rejectable.wait(
fun
| Result.Ok(_) => form.notifyOnSuccess(None);
| Result.Error(_) => form.notifyOnFailure([], None),
)
}
}>
...children
</Form.Form>;
<Section
loading={self.state.loading}
header={/* REDACTED */}
action={
<StickyButton
loading={form.submitting}
onClick={_ => form.submit()}
text={/* REDACTED */}
type_=`secondary
/>
}>
<Section header={/* REDACTED */}>
<Form.Input
form
field=Name
value={form.state.name}
placeholder=messages##namePlaceholder
label=messages##namePlaceholder
update=Form.Name.update
/>
</Section>
<Section header={/* REDACTED */}>
<Tabs
defaultActiveKey={self.state.activeTab}
activeKey={self.state.activeTab}
onChange={activeTab => SetActiveTab(activeTab) |> self.send}
extra={
<Components.Button
size=`small
text={/* REDACTED */}
onClick={_ => Form.addPart(~form)}
/>
}>
{
List.map(form.state.parts, part =>
<Tabs.Tab
tab={<OfferComponents.PartTabHeader title={part.title} part form />}
key={part.id}>
<div>
<Card>
<Form.Input
maxLength=Form.Part.Title.maxLength
form
field={PartTitle(part.id)}
value={part.title}
placeholder=messages##title
label=messages##title
update={Form.Part.Title.update(part.id)}
/>
<Form.Input
maxLength=Form.Part.Subtitle.maxLength
form
field={PartSubtitle(part.id)}
value={part.subtitle}
placeholder=messages##subtitle
label=messages##subtitle
update={Form.Part.Subtitle.update(part.id)}
/>
<FormItem touched=true label=messages##image>
<ImageUpload
uploadUrl={_ => fileUploadUrl}
fileId={part.image}
onFileUpload={
value =>
form.change(
~field=PartImage(part.id),
Form.Part.Image.update(part.id, value),
)
}
onFileRemove={
_ =>
form.change(
~field=PartImage(part.id),
Form.Part.Image.remove(part.id),
)
}
/>
</FormItem>
</Card>
{
List.map(
part.buttons,
buttonId => {
let button =
ListExtra.find(form.state.buttons, button => button.id == buttonId);
switch (button) {
| None => ReasonReact.null
| Some(button) =>
<OfferComponents.PartButton actions=[||] form part button />
};
},
)
->List.toArray
->ReasonReact.array
}
</div>
<Center>
<Error
/* hasError is our custom function to return bool from form.result */
hasError={form.result(PartButtons(part.id))->Validation.hasError}
error=messages##atLeastOneButtonRequired
size=`large
/>
<Components.Button
text={/* REDACTED */}
onClick={_ => Form.addButton(~part, ~form)}
/>
</Center>
</Tabs.Tab>
)
->List.toArray
->ReasonReact.array
}
</Tabs>
</Section>
</Section>;
},
};
open Belt;
open OfferFormConfig;
let byId = (partId, part: OfferPart.t) => part.id == partId;
let updatePart = (parts, partId, fn) => ListExtra.update(parts, byId(partId), fn);
let createValidator = (~partId, ~field, validate): validator => {
field,
strategy: Formality.Strategy.OnFirstSuccessOrFirstBlur,
dependents: None,
validate: state =>
state.parts
->ListExtra.find(byId(partId))
->Option.flatMap(part => validate(part)->List.head)
->Option.getWithDefault(Validation.valid),
};
module Title = {
let maxLength = 80;
let update = (partId, title, state) => {
...state,
parts: updatePart(state.parts, partId, part => {...part, title}),
};
let validator = partId =>
createValidator(~partId, ~field=PartTitle(partId), part =>
[Validation.required(part.title), Validation.maxLength(~maxLength, part.title)]
);
};
module Subtitle = {
let maxLength = 80;
let update = (partId, subtitle, state) => {
...state,
parts: updatePart(state.parts, partId, part => {...part, subtitle}),
};
let validator = partId =>
createValidator(~partId, ~field=PartSubtitle(partId), part =>
[Validation.maxLength(~maxLength, part.subtitle)]
);
};
module Image = {
let update = (partId, image, state) => {
...state,
parts: updatePart(state.parts, partId, part => {...part, image}),
};
let remove = (partId, state) => {
...state,
parts: updatePart(state.parts, partId, part => {...part, image: ""}),
};
};
module Buttons = {
let add = (partId, buttonId, state) => {
...state,
parts:
updatePart(state.parts, partId, part =>
{...part, buttons: List.concat(part.buttons, [buttonId])}
),
};
let remove = (partId, buttonId, state) => {
...state,
parts:
updatePart(state.parts, partId, part =>
{...part, buttons: List.keep(part.buttons, id => id != buttonId)}
),
};
let validator = partId =>
createValidator(~partId, ~field=PartButtons(partId), part =>
[
switch (part.buttons) {
| [] => Validation.invalid(I18n.Global.messages##required)
| _ => Validation.valid
},
]
);
};
let add = (partId, buttonId, state) => {
let part = OfferPart.getEmpty(~id=partId, ~buttonId, ());
let button = OfferButton.getEmpty(~id=buttonId, ());
{
...state,
parts: List.concat(state.parts, [part]),
buttons: List.add(state.buttons, button),
};
};
let remove = (partId, state) => {
let buttonsToRemove =
ListExtra.find(state.parts, byId(partId))->Option.mapWithDefault([], part => part.buttons);
{
...state,
parts: List.keep(state.parts, part => part.id != partId),
buttons: List.keep(state.buttons, button => !ListExtra.contains(buttonsToRemove, button.id)),
};
};
let allValidators = partId => [
Title.validator(partId),
Subtitle.validator(partId),
Buttons.validator(partId),
];
/* after change to validatorsToRemove signature we can remove that */
let allFields = partId => [PartTitle(partId), PartSubtitle(partId), PartButtons(partId)];
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment