xaviergonz / mobx-keystone

A MobX powered state management solution based on data trees with first class support for Typescript, support for snapshots, patches and much more
https://mobx-keystone.js.org
MIT License
555 stars 25 forks source link

[Feature] Generic `setAttribute` method on models #128

Closed rseyferth closed 4 years ago

rseyferth commented 4 years ago

When using models with a lot of attributes in forms it can become kind of tedious to create all the setters for each attribute. It would be nice if there were a generic setter method, that would allow you to set any attribute.

Currently I use the following workaround, through snapshots:

import { AnyModel, getSnapshot, applySnapshot } from 'mobx-keystone'
import inflection from 'inflection'

export function setAttribute(record: AnyModel, attribute: string, value: any, castValue: boolean = false): void {
  // Cast is first?
  let valueToSet = value
  if (castValue) {
    // @Todo 
  }

  // Is there a custom setter
  const methodName = `set${inflection.classify(attribute)}`
  const method: Function | undefined = record[methodName]
  if (typeof method === 'function') {
    method.apply(record, [valueToSet])
    return
  }

  // Create a snapshot and replace attribute
  const snap = {
    ...getSnapshot(record),
    ...{ [attribute]: valueToSet }
  }

  // Apply it back
  try {
    applySnapshot(record, snap)
  } catch (error) {
    throw `Could not set "${attribute}" on ${record.constructor.name}. Does the attribute exist? Should it be cast properly first?`
  }

Or is there another way or approach to do this that I'm not seeing?

xaviergonz commented 4 years ago

Would something like this work for you?

function setModelAttribute<M extends AnyModel>(
  this: M,
  key: keyof ModelInstanceData<M>,
  value: ModelInstanceData<M>[typeof key]
): void {
  ;(this as any)[key] = value
}

@model("P2")
export class P2 extends Model({
  y: prop(0),
}) {
  @modelAction
  addY = (n: number) => {
    this.y += n
    return this.y
  }

  @modelAction
  set = setModelAttribute
}

test("set", () => {
  const p2 = new P2({})
  p2.set("y", 5) // strongly typed
  expect(p2.y).toBe(5)
})
xaviergonz commented 4 years ago

Alternatively I think I could make it so prop / tProp can have a parameter to make assignations be under an action automatically for that particular property, something like:

@model("P2")
export class P2 extends Model({
  y: prop(0, { setAction: true }),
}) {
}

so p2.y = 5 is already under an action automatically

Would need to think how to make it so arrays support it too with push, etc. and objects too though.

rseyferth commented 4 years ago

Yes, that first approach is exactly what I need, brilliant!

Another thing I came across is that you can't instantiate an 'empty' record, to use in a 'new user'-form for example, without making all the attributes maybeNull or something similar. I really like the Draft flow, but would it be possible to add something like a "Concept" option, that functions similar to Partial<Model> in typescript, so all attributes are automatically allowed to be undefined inside the ConceptDraft, until you try to commit it.

Anyway, thanks for the quick response on the setAttribute thing!

xaviergonz commented 4 years ago

https://github.com/xaviergonz/mobx-keystone/pull/129

That PR includes a applySet/applyDelete/applyCall that should also help if you don't want to put a "set" function into every model. Also it includes a setterAction as model property option to automatically create setters.