Created
January 12, 2019 21:55
-
-
Save baransu/4ff1086014693fe27c562d435c1ee461 to your computer and use it in GitHub Desktop.
re-formality complex dynamic form example
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>, | |
}; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | |
); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; | |
}, | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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