Skip to content

Instantly share code, notes, and snippets.

@tekacs
Created June 7, 2017 09:01
Show Gist options
  • Save tekacs/d7f961479bd15cc238ce35dfb11fa403 to your computer and use it in GitHub Desktop.
Save tekacs/d7f961479bd15cc238ce35dfb11fa403 to your computer and use it in GitHub Desktop.
// This is a fragment of a relevant file.
export abstract class FormTemplate<Props, T, TV, S, SV> extends ReactRx<Props & FormTemplateProps<T>> {
readonly provides: Provides
readonly apollo: ApolloInterface
queryRaw$: obs<any>
query$: obs<T>
refresh$ = obs.never<any>().startWith(null)
cell: SSCell<T>
data$ = new rx.BehaviorSubject<T>(undefined)
ready$ = this.data$.map(data => !_.isNil(data))
formCompleted$ = this.data$.map(_ => this.checkValidation())
get data() {
return this.data$.getValue()
}
abstract name: string
abstract get query(): Query
abstract get variables(): any
abstract get submit(): Mutation
abstract get submitVariables(): SV
abstract get mutations(): _.Dictionary<Mutation>
abstract get transforms(): _.Dictionary<FieldTransformer<any, any>>
abstract get validations(): _.Dictionary<FormValidator>
get extraChecks(): Array<DataValidator<T>> { return [] }
inputClasses: string = ''
abstract renderForm(...args: any[]): ReactElement<any>
setup(): void { return }
afterLoad(data: T): void { return }
afterSubmit(result: S): void { return }
fieldError(key: string, message: string): void {
this.provides.toaster.show({
intent: Intent.WARNING,
message: `${key}: ${message}`,
})
}
userError(message: string): void {
this.provides.toaster.show({
intent: Intent.DANGER,
message,
})
}
unrecoverableError(err: any): void {
if (__DEV__) throw err
this.props.history.push('/error')
}
makeValidators = (keys: string[], maker: (key: string) => FormValidator) => {
const result: _.Dictionary<FormValidator> = {}
keys.forEach(key => result[key] = maker(key))
return result
}
required = (...keys: string[]) => {
return this.makeValidators(keys, key => (value => _.isString(value) ? !_.isEmpty(value) : !_.isNil(value) || `${key} is required`))
}
checkEmail = (...keys: string[]) => {
return this.makeValidators(keys, key => value => /.+@.+\..+/.test(value) || `Invalid e-mail address`)
}
checkPhone = (...keys: string[]) => {
return this.makeValidators(keys, key => value => phone.validate(value) || `Invalid phone number`)
}
constructor(props: Props & FormTemplateProps<T>) {
super(props)
if (!props.provides) throw new Error("")
this.provides = props.provides
this.apollo = this.provides.apollo
this.setup()
this.bind()
}
componentWillMount() {
super.componentWillMount()
this.start()
}
bind = () => {
this.mutate = this.mutate.bind(this)
this.mutateRx = this.mutateRx.bind(this)
this.makeMutation = this.makeMutation.bind(this)
}
start = () => {
const { document, options } = this.query
const variables = this.variables
this.queryRaw$ = this.refresh$.switchMap(_ =>
this.apollo.watchQueryRx({ query: document, variables, ...options, fetchPolicy: 'network-only' })
)
this.query$ = this.queryRaw$
.map(next => this.fromData(next))
.do(next => this.afterLoad(next))
.do({ error: (err: ApolloError) => this.unrecoverableError(err) })
this.cell = new SSCell<T>(this.query$, undefined)
this.cell.source$.map(v => this.toData(v)).subscribe(this.data$)
}
makeMutation<M>(mutation: DocumentNode, variables: Object, options?: Partial<MutationOptions>) {
const res$ = this.apollo.mutateRx<M>({ mutation, variables, ...options })
const effects$ = res$.do({ error: (err: ApolloError) => this.userError(err.message) })
return effects$
}
mutateRx<M>(name: string, variables: Object, options?: MutationOptions) {
const mutation = this.mutations[name]
return this.makeMutation<M>(mutation.document, variables, _.merge({}, mutation.options, options))
}
mutate<M>(name: string, variables: Object, options?: MutationOptions) {
this.mutateRx(name, variables, options).subscribe()
}
runSubmit() {
if (!this.submitValidation()) return
this
.makeMutation(this.submit.document, this.submitVariables, this.submit.options)
.map((res: S) => this.afterSubmit(res))
.subscribe()
}
runTransforms = (next: _.Dictionary<any>, mapper: 'fromData' | 'toData'): any => {
const res = _.cloneDeep(next)
_.sortBy(_.keys(this.transforms), [(v: string) => v.split('.').length + v.split('[').length]).map(key => {
const transform = this.transforms[key]
const transformed = (transform[mapper] || identity)(_.get(next, key), res)
_.set(res, key, transformed)
})
return res
}
fromData(next: _.Dictionary<any>): T {
return this.runTransforms(next, 'fromData')
}
toData(next: _.Dictionary<any>): T {
return this.runTransforms(next, 'toData')
}
validatorFor = (key: string): FormValidator => {
return this.validations[key] || (($) => true)
}
validateKey = (key: string, handleValidation: ValidationHandler): void => {
const validator = this.validatorFor(key)
const data = _.get(this.data, key) as any
handleValidation(validator(data))
}
runValidations = (handleValidation: ValidationHandler, forKey?: string) => {
if (forKey) {
this.validateKey(forKey, handleValidation)
} else {
_.keys(this.validations).map(key => this.validateKey(key, handleValidation))
this.extraChecks.forEach(check => handleValidation(check(this.data)))
}
}
checkValidation = (forKey?: string): boolean => {
let okay = true
this.runValidations((res, $) => {
if (res !== true) okay = false
}, forKey)
return okay
}
submitValidation = (forKey?: string) => {
let okay = true
this.runValidations((res, key) => {
if (res === false) throw new Error("Validations should return true or a message.")
if (res !== true) {
key ? this.fieldError(key, res) : this.userError(res)
okay = false
}
}, forKey)
return okay
}
renderForm$(data$: obs<T>, refresh$: obs<any>): obs<ReactElement<any>> {
return obs.combineLatest(data$, refresh$).map(_ => this.renderForm())
}
render$ = this.ready$.switchMap(ready => ready ? this.renderForm$(this.data$, this.refresh$) : obs.of(<Loading />))
// These are components, used by subclasses in the form `<this.TextInput {...props} />`.
// Most of their logic is in the `Inner*` components, elsewhere.
TextInput = (props: TextInputProps) => {
return <InputExplanation {...props}>
<InnerTextInput {...props} cell={this.cell} disabled={this.props.disabled} className={props.className || this.inputClasses} />
</InputExplanation>
}
NumberInput = (props: TextInputProps) => {
return <InputExplanation {...props}>
<InnerTextInput {...props} cell={this.cell} disabled={this.props.disabled} numeric={true} className={props.className || this.inputClasses}/>
</InputExplanation>
}
SelectInput = (props: SelectInputProps) => {
return <InputExplanation {...props}>
<InnerSelectInput {...props} cell={this.cell} disabled={this.props.disabled} className={props.className || this.inputClasses} />
</InputExplanation>
}
RadioInput = (props: RadioInputProps) => {
return <InputExplanation {...props}>
<InnerRadioInput {...props} cell={this.cell} disabled={this.props.disabled} className={props.className || this.inputClasses} />
</InputExplanation>
}
BooleanInput = (props: BooleanInputProps) => {
return <InputExplanation {...props}>
<InnerBooleanInput {...props} cell={this.cell} disabled={this.props.disabled} className={props.className || this.inputClasses} />
</InputExplanation>
}
DateInput = (props: DateInputProps) => {
return <InputExplanation {...props}>
<InnerDateInput {...props} cell={this.cell} disabled={this.props.disabled} className={props.className || this.inputClasses} />
</InputExplanation>
}
SubmitButton = mr.observer((props: SubmitButtonProps) => {
return <InnerSubmitButton
{...props}
formCompleted={this.formCompleted$}
cell={this.cell}
disabled={this.props.disabled}
onClick={() => this.runSubmit()}
/>
})
}
@jedwards1211
Copy link

There are many ways to do things I guess, and it looks like this way is self-consistent, which is the most important thing. But I'll be honest, this is not my cup of tea at all anymore even though I used to be really into OO hierarchies back in the Java days, and wrote something that was much like RxJS in Java. It's hard to explain the value of "favor object composition over inheritance" without a lot of deep examples, but I've come to find it easier to use object composition than inheritance.

Some notes:

  • You're using provides for the same purpose as React context. What do you have against context?
  • Is bind actually necessary instead of making the functions it binds lambdas? On first thought I figured those functions were meant to be overridden in a derived class. On second thought, does that make a difference? There's only one value for this in an instance of a class, even if it has a huge hierarchy.

@tekacs
Copy link
Author

tekacs commented Feb 15, 2018

  • I'm super familiar with composition > inheritance - however TypeScript (certainly 8 months ago) didn't make composition very practical.
  • Provides is again, a type, passed down inside context.
  • bind is necessary for reasons not shown in this code fragment, again related to the way TypeScript works (the way in which it evaluates the class body).

TypeScript semantics are the reason for every such above case and the above was a reasonable case, given the constraints put down by the language.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment