-
-
Save tekacs/d7f961479bd15cc238ce35dfb11fa403 to your computer and use it in GitHub Desktop.
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
// 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()} | |
/> | |
}) | |
} |
- 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
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:
provides
for the same purpose as Reactcontext
. What do you have againstcontext
?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 forthis
in an instance of a class, even if it has a huge hierarchy.