From ede9b6273a169ec9878f51d1ca4101e8d40c8e67 Mon Sep 17 00:00:00 2001 From: Javier Gonzalez Date: Fri, 7 Feb 2020 20:15:36 +0100 Subject: [PATCH 1/4] implicit property transforms --- CHANGELOG.md | 2 + packages/lib/src/model/BaseModel.ts | 89 ++++++++-- packages/lib/src/model/Model.ts | 145 ++++++++++++----- packages/lib/src/model/newModel.ts | 4 +- packages/lib/src/model/prop.ts | 51 ++++-- packages/lib/src/propTransform/asDate.ts | 31 ---- packages/lib/src/propTransform/index.ts | 6 +- .../lib/src/propTransform/propTransform.ts | 128 +++++++++++++-- .../src/propTransform/transformArrayAsSet.ts | 33 ++++ .../propTransform/transformStringAsDate.ts | 35 ++++ .../propTransform/transformTimestampAsDate.ts | 35 ++++ packages/lib/src/snapshot/SnapshotOf.ts | 6 +- packages/lib/src/tweaker/tweak.ts | 6 +- packages/lib/src/typeChecking/tProp.ts | 8 +- packages/lib/src/wrappers/arrayAsMap.ts | 2 +- packages/lib/src/wrappers/arrayAsSet.ts | 2 +- packages/lib/src/wrappers/objectAsMap.ts | 2 +- .../decorator/stringAsDate.test.ts | 111 +++++++++++++ .../timestampAsDate.test.ts} | 140 +++++----------- .../implicit/transformArrayAsSet.test.ts | 148 +++++++++++++++++ .../implicit/transformStringAsDate.test.ts | 115 +++++++++++++ .../implicit/transformTimestampAsDate.test.ts | 154 ++++++++++++++++++ packages/site/src/models.mdx | 9 +- 23 files changed, 1029 insertions(+), 233 deletions(-) delete mode 100644 packages/lib/src/propTransform/asDate.ts create mode 100644 packages/lib/src/propTransform/transformArrayAsSet.ts create mode 100644 packages/lib/src/propTransform/transformStringAsDate.ts create mode 100644 packages/lib/src/propTransform/transformTimestampAsDate.ts create mode 100644 packages/lib/test/propTransform/decorator/stringAsDate.test.ts rename packages/lib/test/propTransform/{propTransform.test.ts => decorator/timestampAsDate.test.ts} (50%) create mode 100644 packages/lib/test/propTransform/implicit/transformArrayAsSet.test.ts create mode 100644 packages/lib/test/propTransform/implicit/transformStringAsDate.test.ts create mode 100644 packages/lib/test/propTransform/implicit/transformTimestampAsDate.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0791bbd5..e826dadc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Change Log +- [BREAKING CHANGE] Some type helpers have been renamed: `ModelData` -> `ModelPropsData` / `ModelInstanceData`, `ModelCreationData` -> `ModelPropsCreationData` / `ModelInstanceCreationData`. +- New feature: "Implicit property transforms", which are preferred over the old decorator based property transforms, wrappers (`arrayAsSet`, `arrayAsMap`, `objectAsMap`) and collection models (`ArraySet`, `ObjectMap`). - Property transforms can now be used standalone. ## 0.39.0 diff --git a/packages/lib/src/model/BaseModel.ts b/packages/lib/src/model/BaseModel.ts index 49fc9771..9b283d1f 100644 --- a/packages/lib/src/model/BaseModel.ts +++ b/packages/lib/src/model/BaseModel.ts @@ -1,6 +1,8 @@ import { observable } from "mobx" import { O } from "ts-toolbelt" import { getGlobalConfig } from "../globalConfig" +import { PropTransform } from "../propTransform" +import { memoTransformCache } from "../propTransform/propTransform" import { getSnapshot } from "../snapshot/getSnapshot" import { SnapshotInOfModel, SnapshotInOfObject, SnapshotOutOfModel } from "../snapshot/SnapshotOf" import { typesModel } from "../typeChecking/model" @@ -12,8 +14,10 @@ import { modelInfoByClass } from "./modelInfo" import { internalNewModel } from "./newModel" import { assertIsModelClass } from "./utils" -declare const dataTypeSymbol: unique symbol -declare const creationDataTypeSymbol: unique symbol +declare const propsDataTypeSymbol: unique symbol +declare const propsCreationDataTypeSymbol: unique symbol +declare const instanceDataTypeSymbol: unique symbol +declare const instanceCreationDataTypeSymbol: unique symbol /** * @ignore @@ -30,12 +34,16 @@ export const modelInitializedSymbol = Symbol("modelInitialized") * @typeparam CreationData Creation data type. */ export abstract class BaseModel< - Data extends { [k: string]: any }, - CreationData extends { [k: string]: any } + PropsData extends { [k: string]: any }, + PropsCreationData extends { [k: string]: any }, + InstanceData extends { [k: string]: any } = PropsData, + InstanceCreationData extends { [k: string]: any } = PropsCreationData > { // just to make typing work properly - [dataTypeSymbol]: Data; - [creationDataTypeSymbol]: CreationData; + [propsDataTypeSymbol]: PropsData; + [propsCreationDataTypeSymbol]: PropsCreationData; + [instanceDataTypeSymbol]: InstanceData; + [instanceCreationDataTypeSymbol]: InstanceCreationData; /** * Model type name. @@ -63,8 +71,9 @@ export abstract class BaseModel< /** * Data part of the model, which is observable and will be serialized in snapshots. * Use it if one of the data properties matches one of the model properties/functions. + * This also allows access to the backed values of transformed properties. */ - readonly $!: Data + readonly $!: PropsData /** * Optional hook that will run once this model instance is attached to the tree of a model marked as @@ -89,7 +98,7 @@ export abstract class BaseModel< */ fromSnapshot?(snapshot: { [k: string]: any - }): SnapshotInOfObject & { [modelTypeKey]?: string; [modelIdKey]?: string } + }): SnapshotInOfObject & { [modelTypeKey]?: string; [modelIdKey]?: string } /** * Performs a type check over the model instance. @@ -105,10 +114,11 @@ export abstract class BaseModel< /** * Creates an instance of Model. */ - constructor(data: CreationData & { [modelIdKey]?: string }) { - const initialData: any = data + constructor(data: InstanceCreationData & { [modelIdKey]?: string }) { + let initialData: any = data const snapshotInitialData: any = arguments[1] const clazz: ModelClass = arguments[2] + const propsWithTransforms: [string, PropTransform][] = arguments[4] Object.setPrototypeOf(this, clazz.prototype) @@ -116,6 +126,24 @@ export abstract class BaseModel< // plain new assertIsObject(initialData, "initialData") + // apply transforms to initial data if needed + const propsWithTransformsLen = propsWithTransforms.length + if (propsWithTransformsLen > 0) { + initialData = Object.assign(initialData) + for (let i = 0; i < propsWithTransformsLen; i++) { + const propWithTransform = propsWithTransforms[i] + const propName = propWithTransform[0] + const propTransform = propWithTransform[1] + + const memoTransform = memoTransformCache.getOrCreateMemoTransform( + this, + propName, + propTransform + ) + initialData[propName] = memoTransform.dataToProp(initialData[propName], undefined) + } + } + internalNewModel( this, clazz, @@ -168,7 +196,7 @@ export interface AnyModel extends BaseModel {} * Extracts the instance type of a model class. */ export interface ModelClass { - new (initialData: ModelCreationData & { [modelIdKey]?: string }): M + new (initialData: ModelInstanceCreationData & { [modelIdKey]?: string }): M } /** @@ -205,14 +233,45 @@ export function modelClass(type: { prototype: T }): ModelCla } /** - * The data type of a model. + * The props data type of a model. + */ +export type ModelPropsData = M["$"] + +/** + * The props creation data type of a model. + */ +export type ModelPropsCreationData = M extends BaseModel< + any, + infer PropsCreationData, + any, + any +> + ? PropsCreationData + : never + +/** + * The instance data type of a model. */ -export type ModelData = M["$"] +export type ModelInstanceData = M extends BaseModel< + any, + any, + infer InstanceData, + any +> + ? InstanceData + : never /** - * The creation data type of a model. + * The transformed creation data type of a model. */ -export type ModelCreationData = M extends BaseModel ? C : never +export type ModelInstanceCreationData = M extends BaseModel< + any, + any, + any, + infer InstanceCreationData +> + ? InstanceCreationData + : never /** * Add missing model metadata to a model creation snapshot to generate a proper model snapshot. diff --git a/packages/lib/src/model/Model.ts b/packages/lib/src/model/Model.ts index 2e912287..42b85080 100644 --- a/packages/lib/src/model/Model.ts +++ b/packages/lib/src/model/Model.ts @@ -1,4 +1,5 @@ import { O } from "ts-toolbelt" +import { memoTransformCache, PropTransform } from "../propTransform/propTransform" import { typesObject } from "../typeChecking/object" import { LateTypeChecker } from "../typeChecking/TypeChecker" import { typesUnchecked } from "../typeChecking/unchecked" @@ -17,14 +18,26 @@ import { modelPropertiesSymbol, modelUnwrappedClassSymbol, } from "./modelSymbols" -import { ModelProps, ModelPropsToCreationData, ModelPropsToData, OptionalModelProps } from "./prop" +import { + ModelProps, + ModelPropsToInstanceCreationData, + ModelPropsToInstanceData, + ModelPropsToPropCreationData, + ModelPropsToPropData, + OptionalModelProps, +} from "./prop" import { assertIsModelClass } from "./utils" declare const propsDataSymbol: unique symbol -declare const creationPropsDataSymbol: unique symbol -declare const optPropsDataSymbol: unique symbol -declare const creationDataSymbol: unique symbol -declare const composedCreationDataSymbol: unique symbol +declare const instanceDataSymbol: unique symbol + +declare const optDataSymbol: unique symbol + +declare const propsCreationDataSymbol: unique symbol +declare const instanceCreationDataSymbol: unique symbol + +declare const composedPropsCreationDataSymbol: unique symbol +declare const composedInstanceCreationDataSymbol: unique symbol export interface _Model { /** @@ -32,23 +45,31 @@ export interface _Model { */ readonly [modelTypeKey]: string | undefined - [propsDataSymbol]: ModelPropsToData - [creationPropsDataSymbol]: ModelPropsToCreationData + [propsDataSymbol]: ModelPropsToPropData + [instanceDataSymbol]: ModelPropsToInstanceData - [optPropsDataSymbol]: OptionalModelProps + [optDataSymbol]: OptionalModelProps - [creationDataSymbol]: O.Optional< - this[typeof creationPropsDataSymbol], - this[typeof optPropsDataSymbol] - > + [propsCreationDataSymbol]: ModelPropsToPropCreationData + [instanceCreationDataSymbol]: ModelPropsToInstanceCreationData - [composedCreationDataSymbol]: SuperModel extends BaseModel - ? O.Merge - : this[typeof creationDataSymbol] + [composedPropsCreationDataSymbol]: SuperModel extends BaseModel + ? O.Merge + : this[typeof propsCreationDataSymbol] + [composedInstanceCreationDataSymbol]: SuperModel extends BaseModel + ? O.Merge + : this[typeof instanceCreationDataSymbol] - new (data: this[typeof composedCreationDataSymbol] & { [modelIdKey]?: string }): SuperModel & - BaseModel & - Omit + new ( + data: this[typeof composedInstanceCreationDataSymbol] & { [modelIdKey]?: string } + ): SuperModel & + BaseModel< + this[typeof propsDataSymbol], + this[typeof composedPropsCreationDataSymbol], + this[typeof instanceDataSymbol], + this[typeof composedInstanceCreationDataSymbol] + > & + Omit } /** @@ -111,7 +132,7 @@ function internalModel( } } else { // define $modelId on the base - extraDescriptors[modelIdKey] = createModelPropDescriptor(modelIdKey, true) + extraDescriptors[modelIdKey] = createModelPropDescriptor(modelIdKey, undefined, true) } // create type checker if needed @@ -131,11 +152,19 @@ function internalModel( for (const modelPropName of Object.keys(modelProps).filter( mp => !baseModelPropNames.has(mp as any) )) { - extraDescriptors[modelPropName] = createModelPropDescriptor(modelPropName, false) + extraDescriptors[modelPropName] = createModelPropDescriptor( + modelPropName, + modelProps[modelPropName].transform, + false + ) } const base: any = baseModel || BaseModel + const propsWithTransform = Object.entries(modelProps) + .filter(([_propName, prop]) => !!prop.transform) + .map(([propName, prop]) => [propName, prop.transform!] as const) + // we use this weird hack rather than just class CustomBaseModel extends base {} // in order to work around problems with ES5 classes extending ES6 classes // see https://github.com/xaviergonz/mobx-keystone/issues/15 @@ -153,7 +182,8 @@ function internalModel( initialData, snapshotInitialData, modelConstructor || this.constructor, - generateNewIds + generateNewIds, + propsWithTransform ) } @@ -179,23 +209,60 @@ function _inheritsLoose(subClass: any, superClass: any) { subClass.__proto__ = superClass } -function createModelPropDescriptor(modelPropName: string, enumerable: boolean): PropertyDescriptor { - return { - enumerable, - configurable: true, - get(this: AnyModel) { - // no need to use get since these vars always get on the initial $ - return this.$[modelPropName] - }, - set(this: AnyModel, v?: any) { - // hack to only permit setting these values once fully constructed - // this is to ignore abstract properties being set by babel - // see https://github.com/xaviergonz/mobx-keystone/issues/18 - if (!(this as any)[modelInitializedSymbol]) { - return - } - // no need to use set since these vars always get on the initial $ - this.$[modelPropName] = v - }, +function createModelPropDescriptor( + modelPropName: string, + transform: PropTransform | undefined, + enumerable: boolean +): PropertyDescriptor { + // the code is duplicated to ensure better speed + if (transform) { + return { + enumerable, + configurable: true, + get(this: AnyModel) { + // no need to use get since these vars always get on the initial $ + const memoTransform = memoTransformCache.getOrCreateMemoTransform( + this, + modelPropName, + transform + ) + return memoTransform.propToData(this.$[modelPropName]) + }, + set(this: AnyModel, v?: any) { + // hack to only permit setting these values once fully constructed + // this is to ignore abstract properties being set by babel + // see https://github.com/xaviergonz/mobx-keystone/issues/18 + if (!(this as any)[modelInitializedSymbol]) { + return + } + // no need to use set since these vars always get on the initial $ + const memoTransform = memoTransformCache.getOrCreateMemoTransform( + this, + modelPropName, + transform + ) + const oldPropValue = this.$[modelPropName] + this.$[modelPropName] = memoTransform.dataToProp(v, oldPropValue) + }, + } + } else { + return { + enumerable, + configurable: true, + get(this: AnyModel) { + // no need to use get since these vars always get on the initial $ + return this.$[modelPropName] + }, + set(this: AnyModel, v?: any) { + // hack to only permit setting these values once fully constructed + // this is to ignore abstract properties being set by babel + // see https://github.com/xaviergonz/mobx-keystone/issues/18 + if (!(this as any)[modelInitializedSymbol]) { + return + } + // no need to use set since these vars always get on the initial $ + this.$[modelPropName] = v + }, + } } } diff --git a/packages/lib/src/model/newModel.ts b/packages/lib/src/model/newModel.ts index 98b2b460..e4232232 100644 --- a/packages/lib/src/model/newModel.ts +++ b/packages/lib/src/model/newModel.ts @@ -4,7 +4,7 @@ import { getGlobalConfig, isModelAutoTypeCheckingEnabled } from "../globalConfig import { tweakModel } from "../tweaker/tweakModel" import { tweakPlainObject } from "../tweaker/tweakPlainObject" import { failure, inDevMode, makePropReadonly } from "../utils" -import { AnyModel, ModelClass, ModelCreationData } from "./BaseModel" +import { AnyModel, ModelClass, ModelPropsCreationData } from "./BaseModel" import { getModelDataType } from "./getModelDataType" import { modelIdKey, modelTypeKey } from "./metadata" import { modelInfoByClass } from "./modelInfo" @@ -20,7 +20,7 @@ export const internalNewModel = action( ( origModelObj: M, modelClass: ModelClass, - initialData: (ModelCreationData & { [modelIdKey]?: string }) | undefined, + initialData: (ModelPropsCreationData & { [modelIdKey]?: string }) | undefined, snapshotInitialData: | { unprocessedSnapshot: any diff --git a/packages/lib/src/model/prop.ts b/packages/lib/src/model/prop.ts index 98c718ed..d249d741 100644 --- a/packages/lib/src/model/prop.ts +++ b/packages/lib/src/model/prop.ts @@ -1,3 +1,5 @@ +import { O } from "ts-toolbelt" +import { PropTransform } from "../propTransform/propTransform" import { LateTypeChecker, TypeChecker } from "../typeChecking/TypeChecker" import { IsOptionalValue } from "../utils/types" @@ -9,13 +11,22 @@ export const noDefaultValue = Symbol("noDefaultValue") /** * A model property. */ -export interface ModelProp { - $valueType: TValue - $creationValueType: TCreationValue +export interface ModelProp< + TPropValue, + TPropCreationValue, + TIsOptional, + TInstanceValue = TPropValue, + TInstanceCreationValue = TPropCreationValue +> { + $propValueType: TPropValue + $propCreationValueType: TPropCreationValue + $instanceValueType: TInstanceValue + $instanceCreationValueType: TInstanceCreationValue $isOptional: TIsOptional - defaultFn: (() => TValue) | typeof noDefaultValue - defaultValue: TValue | typeof noDefaultValue + defaultFn: (() => TPropValue) | typeof noDefaultValue + defaultValue: TPropValue | typeof noDefaultValue typeChecker: TypeChecker | LateTypeChecker | undefined + transform: PropTransform | undefined } /** @@ -29,14 +40,28 @@ export type OptionalModelProps = { [K in keyof MP]: MP[K]["$isOptional"] & K }[keyof MP] -export type ModelPropsToData = { - [k in keyof MP]: MP[k]["$valueType"] +export type ModelPropsToPropData = { + [k in keyof MP]: MP[k]["$propValueType"] } -export type ModelPropsToCreationData = { - [k in keyof MP]: MP[k]["$creationValueType"] +export type ModelPropsToPropCreationData = O.Optional< + { + [k in keyof MP]: MP[k]["$propCreationValueType"] + }, + OptionalModelProps +> + +export type ModelPropsToInstanceData = { + [k in keyof MP]: MP[k]["$instanceValueType"] } +export type ModelPropsToInstanceCreationData = O.Optional< + { + [k in keyof MP]: MP[k]["$instanceCreationValueType"] + }, + OptionalModelProps +> + /** * Defines a model property with no default value. * @@ -93,11 +118,15 @@ export function prop(def?: any): ModelProp { const isDefFn = typeof def === "function" return { - $valueType: null as any, - $creationValueType: null as any, + $propValueType: null as any, + $propCreationValueType: null as any, $isOptional: null as any, + $instanceValueType: null as any, + $instanceCreationValueType: null as any, + defaultFn: hasDefaultValue && isDefFn ? def : noDefaultValue, defaultValue: hasDefaultValue && !isDefFn ? def : noDefaultValue, typeChecker: undefined, + transform: undefined, } } diff --git a/packages/lib/src/propTransform/asDate.ts b/packages/lib/src/propTransform/asDate.ts deleted file mode 100644 index 3c881858..00000000 --- a/packages/lib/src/propTransform/asDate.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { propTransform } from "./propTransform" - -/** - * Prop transform for ISO date strings to Date objects - * and vice-versa. - */ -export const stringAsDate = propTransform({ - propToData(prop) { - if (prop == null) return prop - return new Date(prop) - }, - dataToProp(date) { - if (date == null) return date - return date.toJSON() - }, -}) - -/** - * Prop transform for number timestamps to Date objects - * and vice-versa. - */ -export const timestampAsDate = propTransform({ - propToData(prop) { - if (prop == null) return prop - return new Date(prop) - }, - dataToProp(date) { - if (date == null) return date - return +date - }, -}) diff --git a/packages/lib/src/propTransform/index.ts b/packages/lib/src/propTransform/index.ts index b59f6547..abb44ef3 100644 --- a/packages/lib/src/propTransform/index.ts +++ b/packages/lib/src/propTransform/index.ts @@ -1,2 +1,4 @@ -export * from "./asDate" -export * from "./propTransform" +export { PropTransform, propTransform, PropTransformDecorator } from "./propTransform" +export * from "./transformArrayAsSet" +export * from "./transformStringAsDate" +export * from "./transformTimestampAsDate" diff --git a/packages/lib/src/propTransform/propTransform.ts b/packages/lib/src/propTransform/propTransform.ts index 019ab490..756c614b 100644 --- a/packages/lib/src/propTransform/propTransform.ts +++ b/packages/lib/src/propTransform/propTransform.ts @@ -1,4 +1,5 @@ -import { computed, IComputedValue } from "mobx" +import { ModelProp } from "../model/prop" +import { failure } from "../utils" /** * A prop transform. @@ -7,18 +8,19 @@ export interface PropTransform { /** * Transform from property value to custom data. * - * @param prop + * @param propValue * @returns */ - propToData(prop: TProp): TData + propToData(propValue: TProp): TData /** - * Transform from cutom data to property value. + * Transform from custom data to property value. + * You might throw here to make the property read-only. * - * @param data + * @param dataValue * @returns */ - dataToProp(data: TData): TProp + dataToProp(dataValue: TData): TProp } /** @@ -62,24 +64,29 @@ export type PropTransformDecorator = ( */ export function propTransform( transform: PropTransform -): PropTransformDecorator & PropTransform { +): PropTransformDecorator & typeof transform { const parametrizedDecorator: PropTransformDecorator = boundPropName => { const decorator = (target: object, propertyKey: string) => { - // hidden computed getter - let computedFn: IComputedValue - // make the field a getter setter Object.defineProperty(target, propertyKey, { get(this: any): TData { - if (!computedFn) { - computedFn = computed(() => { - return transform.propToData(this.$[boundPropName]) - }) - } - return computedFn.get() + const memoTransform = memoTransformCache.getOrCreateMemoTransform( + this, + propertyKey, + transform + ) + + return memoTransform.propToData(this.$[boundPropName]) }, set(this: any, value: any) { - this.$[boundPropName] = transform.dataToProp(value) + const memoTransform = memoTransformCache.getOrCreateMemoTransform( + this, + propertyKey, + transform + ) + + const oldPropValue = this.$[boundPropName] + this.$[boundPropName] = memoTransform.dataToProp(value, oldPropValue) return true }, }) @@ -92,3 +99,90 @@ export function propTransform( return parametrizedDecorator as any } + +class MemoTransformCache { + private readonly cache = new WeakMap>>() + + getOrCreateMemoTransform( + target: object, + propName: string, + baseTransform: PropTransform + ): MemoPropTransform { + let transformsPerProperty = this.cache.get(target) + if (!transformsPerProperty) { + transformsPerProperty = new Map() + this.cache.set(target, transformsPerProperty) + } + let memoTransform = transformsPerProperty.get(propName) + if (!memoTransform) { + memoTransform = toMemoPropTransform(baseTransform) + transformsPerProperty.set(propName, memoTransform) + } + return memoTransform + } +} + +/** + * @ignore + * @internal + */ +export const memoTransformCache = new MemoTransformCache() + +/** + * @ignore + * @internal + */ +export interface MemoPropTransform { + isMemoPropTransform: true + + propToData(propValue: TProp): TData + dataToProp(newDataValue: TData, currentPropValue: TProp): TProp +} + +const valueNotMemoized = Symbol("valueNotMemoized") + +function toMemoPropTransform( + transform: PropTransform +): MemoPropTransform { + let lastPropValue: any = valueNotMemoized + let lastDataValue: any = valueNotMemoized + + return { + isMemoPropTransform: true, + + propToData(propValue: any) { + if (lastPropValue !== propValue) { + lastDataValue = transform.propToData(propValue) + lastPropValue = propValue + } + return lastDataValue + }, + dataToProp(newDataValue: any, oldPropValue: any) { + // check the last prop value too just in case the backed value changed + // yet we try to re-set the same data + if (lastDataValue !== newDataValue || lastPropValue !== oldPropValue) { + lastPropValue = transform.dataToProp(newDataValue) + lastDataValue = newDataValue + } + return lastPropValue + }, + } +} + +/** + * @ignore + * @internal + */ +export function transformedProp( + prop: ModelProp, + transform: PropTransform +): ModelProp { + if (prop.transform) { + throw failure("a property cannot have more than one transform") + } + + return { + ...prop, + transform, + } +} diff --git a/packages/lib/src/propTransform/transformArrayAsSet.ts b/packages/lib/src/propTransform/transformArrayAsSet.ts new file mode 100644 index 00000000..0ab36190 --- /dev/null +++ b/packages/lib/src/propTransform/transformArrayAsSet.ts @@ -0,0 +1,33 @@ +import { ModelProp } from "../model/prop" +import { arrayAsSet } from "../wrappers/arrayAsSet" +import { propTransform, transformedProp } from "./propTransform" + +const arrayAsSetInnerTransform = propTransform< + any[] | null | undefined, + Set | null | undefined +>({ + propToData(arr) { + return arr ? arrayAsSet(() => arr) : arr + }, + dataToProp(newSet) { + return newSet ? [...newSet.values()] : newSet + }, +}) + +/** + * Implicit property transform for that allows a backed array to be used as if it were a set. + * + * @param prop + */ +export function transformArrayAsSet( + prop: ModelProp +): ModelProp< + TValue, + TCreationValue, + TIsOptional, + (TValue extends Array ? Set : never) | Extract, + | (TCreationValue extends Array ? Set : never) + | Extract +> { + return transformedProp(prop, arrayAsSetInnerTransform) +} diff --git a/packages/lib/src/propTransform/transformStringAsDate.ts b/packages/lib/src/propTransform/transformStringAsDate.ts new file mode 100644 index 00000000..0039b54e --- /dev/null +++ b/packages/lib/src/propTransform/transformStringAsDate.ts @@ -0,0 +1,35 @@ +import { ModelProp } from "../model/prop" +import { propTransform, transformedProp } from "./propTransform" + +/** + * @deprecated Consider using `transformStringAsDate` instead. + * + * Decorator property transform for ISO date strings to Date objects and vice-versa. + */ +export const stringAsDate = propTransform({ + propToData(prop) { + if (prop == null) return prop + return new Date(prop) + }, + dataToProp(date) { + if (date == null) return date + return date.toJSON() + }, +}) + +/** + * Implicit property transform for ISO date strings to Date objects and vice-versa. + * + * @param prop + */ +export function transformStringAsDate( + prop: ModelProp +): ModelProp< + TValue, + TCreationValue, + TIsOptional, + (TValue extends string ? Date : never) | Extract, + (TCreationValue extends string ? Date : never) | Extract +> { + return transformedProp(prop, stringAsDate) +} diff --git a/packages/lib/src/propTransform/transformTimestampAsDate.ts b/packages/lib/src/propTransform/transformTimestampAsDate.ts new file mode 100644 index 00000000..40bd9baf --- /dev/null +++ b/packages/lib/src/propTransform/transformTimestampAsDate.ts @@ -0,0 +1,35 @@ +import { ModelProp } from "../model/prop" +import { propTransform, transformedProp } from "./propTransform" + +/** + * @deprecated Consider using `transformTimestampAsDate` instead. + * + * Decorator property transform for number timestamps to Date objects and vice-versa. + */ +export const timestampAsDate = propTransform({ + propToData(prop) { + if (prop == null) return prop + return new Date(prop) + }, + dataToProp(date) { + if (date == null) return date + return +date + }, +}) + +/** + * Implicit property transform for number timestamps to Date objects and vice-versa. + * + * @param prop + */ +export function transformTimestampAsDate( + prop: ModelProp +): ModelProp< + TValue, + TCreationValue, + TIsOptional, + (TValue extends number ? Date : never) | Extract, + (TCreationValue extends number ? Date : never) | Extract +> { + return transformedProp(prop, timestampAsDate) +} diff --git a/packages/lib/src/snapshot/SnapshotOf.ts b/packages/lib/src/snapshot/SnapshotOf.ts index aab8667e..f2ffc714 100644 --- a/packages/lib/src/snapshot/SnapshotOf.ts +++ b/packages/lib/src/snapshot/SnapshotOf.ts @@ -1,6 +1,6 @@ import { Frozen, frozenKey } from "../frozen/Frozen" import { modelIdKey, modelTypeKey } from "../model" -import { AnyModel, ModelCreationData, ModelData } from "../model/BaseModel" +import { AnyModel, ModelPropsCreationData, ModelPropsData } from "../model/BaseModel" import { ArraySet, ObjectMap } from "../wrappers" // snapshot out @@ -14,7 +14,7 @@ export type SnapshotOutOfObject = { [k in keyof T]: SnapshotOutOf extends infer R ? R : never } -export type SnapshotOutOfModel = SnapshotOutOfObject> & { +export type SnapshotOutOfModel = SnapshotOutOfObject> & { [modelTypeKey]: string [modelIdKey]: string } @@ -76,7 +76,7 @@ export type SnapshotInOfObject = { } export type SnapshotInOfModel = SnapshotInOfObject< - M extends { fromSnapshot(sn: infer S): any } ? S : ModelCreationData + M extends { fromSnapshot(sn: infer S): any } ? S : ModelPropsCreationData > & { [modelTypeKey]: string [modelIdKey]: string diff --git a/packages/lib/src/tweaker/tweak.ts b/packages/lib/src/tweaker/tweak.ts index 339db58d..f8153e48 100644 --- a/packages/lib/src/tweaker/tweak.ts +++ b/packages/lib/src/tweaker/tweak.ts @@ -73,13 +73,15 @@ function internalTweak(value: T, parentPath: ParentPath | undefined): T // unsupported if (isMap(value)) { throw failure( - "maps are not supported. consider using 'objectMap', 'objectAsMap', or 'arrayAsMap' instead." + "maps are not directly supported. consider applying 'transformObjectAsMap' over a '{[k: string]: V}' property, or 'transformArrayAsMap' over a '[string, V][]' property instead." ) } // unsupported if (isSet(value)) { - throw failure("sets are not supported. consider using 'arraySet', or 'arrayAsSet' instead.") + throw failure( + "sets are not directly supported. consider applying 'transformArrayAsSet' over a 'V[]' property instead." + ) } throw failure( diff --git a/packages/lib/src/typeChecking/tProp.ts b/packages/lib/src/typeChecking/tProp.ts index 3860423d..bf7df1b8 100644 --- a/packages/lib/src/typeChecking/tProp.ts +++ b/packages/lib/src/typeChecking/tProp.ts @@ -124,11 +124,15 @@ export function tProp(typeOrDefaultValue: any, def?: any): ModelProp 1 const isDefFn = typeof def === "function" return { - $valueType: null as any, - $creationValueType: null as any, + $propValueType: null as any, + $propCreationValueType: null as any, $isOptional: null as any, + $instanceValueType: null as any, + $instanceCreationValueType: null as any, + defaultFn: hasDefaultValue && isDefFn ? def : noDefaultValue, defaultValue: hasDefaultValue && !isDefFn ? def : noDefaultValue, typeChecker: resolveStandardType(typeOrDefaultValue) as any, + transform: undefined, } } diff --git a/packages/lib/src/wrappers/arrayAsMap.ts b/packages/lib/src/wrappers/arrayAsMap.ts index 34360221..02279d8b 100644 --- a/packages/lib/src/wrappers/arrayAsMap.ts +++ b/packages/lib/src/wrappers/arrayAsMap.ts @@ -1,6 +1,6 @@ import { action } from "mobx" -class ArrayAsMap implements Map { +export class ArrayAsMap implements Map { constructor(private readonly getTarget: () => [K, V][]) {} @action diff --git a/packages/lib/src/wrappers/arrayAsSet.ts b/packages/lib/src/wrappers/arrayAsSet.ts index 1c098b1b..e19d0970 100644 --- a/packages/lib/src/wrappers/arrayAsSet.ts +++ b/packages/lib/src/wrappers/arrayAsSet.ts @@ -1,6 +1,6 @@ import { action, values } from "mobx" -class ArrayAsSet implements Set { +export class ArrayAsSet implements Set { constructor(private readonly getTarget: () => V[]) {} @action diff --git a/packages/lib/src/wrappers/objectAsMap.ts b/packages/lib/src/wrappers/objectAsMap.ts index c49fcdf3..5995e52b 100644 --- a/packages/lib/src/wrappers/objectAsMap.ts +++ b/packages/lib/src/wrappers/objectAsMap.ts @@ -1,6 +1,6 @@ import { action, entries, get, has, keys, remove, set, values } from "mobx" -class ObjectAsMap implements Map { +export class ObjectAsMap implements Map { constructor(private readonly getTarget: () => { [k: string]: V }) {} @action diff --git a/packages/lib/test/propTransform/decorator/stringAsDate.test.ts b/packages/lib/test/propTransform/decorator/stringAsDate.test.ts new file mode 100644 index 00000000..83858b89 --- /dev/null +++ b/packages/lib/test/propTransform/decorator/stringAsDate.test.ts @@ -0,0 +1,111 @@ +import { reaction } from "mobx" +import { + ActionCall, + applyAction, + model, + Model, + modelAction, + onActionMiddleware, + prop, + stringAsDate, +} from "../../../src" +import "../../commonSetup" +import { autoDispose } from "../../utils" + +test("stringAsDate", () => { + @model("stringAsDate/M") + class M extends Model({ + time: prop(), + }) { + @stringAsDate("time") + date!: Date + + @modelAction + setDate(date: Date) { + this.date = date + } + } + + const dateNow = new Date(0) + + const m = new M({ time: dateNow.toJSON() }) + + // getter + expect(m.date instanceof Date).toBeTruthy() + expect(m.date).toEqual(dateNow) // created from backed prop + + // should be cached + expect(m.date).toBe(m.date) + + const reactions: Date[] = [] + autoDispose( + reaction( + () => m.date, + d => { + reactions.push(d) + } + ) + ) + + // should be cached + expect(m.date).toBe(m.date) + + // setter + const actionCalls: ActionCall[] = [] + autoDispose( + onActionMiddleware(m, { + onStart(actionCall) { + actionCalls.push(actionCall) + }, + onFinish(actionCall) { + actionCalls.push(actionCall) + }, + }) + ) + + const dateNow2 = new Date(1569524561993) + m.setDate(dateNow2) + expect(m.date).toBe(dateNow2) + expect(m.time).toBe(dateNow2.toJSON()) + + expect(stringAsDate.propToData(m.time)).toStrictEqual(dateNow2) + expect(stringAsDate.dataToProp(dateNow2)).toStrictEqual(m.time) + + expect(actionCalls).toMatchInlineSnapshot(` + Array [ + Object { + "actionName": "setDate", + "args": Array [ + 2019-09-26T19:02:41.993Z, + ], + "targetPath": Array [], + "targetPathIds": Array [], + }, + Object { + "actionName": "setDate", + "args": Array [ + 2019-09-26T19:02:41.993Z, + ], + "targetPath": Array [], + "targetPathIds": Array [], + }, + ] + `) + + expect(reactions).toMatchInlineSnapshot(` + Array [ + 2019-09-26T19:02:41.993Z, + ] + `) + + // apply action should work + applyAction(m, { + actionName: "setDate", + args: [dateNow], + targetPath: [], + targetPathIds: [], + }) + + expect(m.date).toEqual(dateNow) // created from backed prop + expect(m.time).toBe(dateNow.toJSON()) +}) diff --git a/packages/lib/test/propTransform/propTransform.test.ts b/packages/lib/test/propTransform/decorator/timestampAsDate.test.ts similarity index 50% rename from packages/lib/test/propTransform/propTransform.test.ts rename to packages/lib/test/propTransform/decorator/timestampAsDate.test.ts index cb1b79de..71461f0e 100644 --- a/packages/lib/test/propTransform/propTransform.test.ts +++ b/packages/lib/test/propTransform/decorator/timestampAsDate.test.ts @@ -7,11 +7,10 @@ import { modelAction, onActionMiddleware, prop, - stringAsDate, timestampAsDate, -} from "../../src" -import "../commonSetup" -import { autoDispose } from "../utils" +} from "../../../src" +import "../../commonSetup" +import { autoDispose } from "../../utils" test("timestampAsDate", () => { @model("timestampAsDate/M") @@ -25,6 +24,11 @@ test("timestampAsDate", () => { setDate(date: Date) { this.date = date } + + @modelAction + setTimestamp(timestamp: number) { + this.timestamp = timestamp + } } const now = 0 @@ -37,10 +41,10 @@ test("timestampAsDate", () => { // getter expect(m.date instanceof Date).toBeTruthy() - expect(m.date).toEqual(dateNow) + expect(m.date).toEqual(dateNow) // created from backed prop - // when not observed it should not be cached - expect(m.date).not.toBe(m.date) + // should be cached + expect(m.date).toBe(m.date) const reactions: Date[] = [] autoDispose( @@ -52,7 +56,7 @@ test("timestampAsDate", () => { ) ) - // when observed it should be cached + // should be cached expect(m.date).toBe(m.date) // setter @@ -75,7 +79,7 @@ test("timestampAsDate", () => { expect(timestampAsDate.dataToProp(dateNow2)).toStrictEqual(now2) m.setDate(dateNow2) - expect(m.date).toEqual(dateNow2) + expect(m.date).toBe(dateNow2) expect(m.timestamp).toBe(now2) expect(actionCalls).toMatchInlineSnapshot(` @@ -113,104 +117,34 @@ test("timestampAsDate", () => { targetPathIds: [], }) - expect(m.date).toEqual(dateNow) + expect(m.date).toEqual(dateNow) // created from backed prop expect(m.timestamp).toBe(now) -}) - -test("stringAsDate", () => { - @model("stringAsDate/M") - class M extends Model({ - time: prop(), - }) { - @stringAsDate("time") - date!: Date - - @modelAction - setDate(date: Date) { - this.date = date - } - } - - const dateNow = new Date(0) - - const m = new M({ time: dateNow.toJSON() }) - - // getter - expect(m.date instanceof Date).toBeTruthy() - expect(m.date).toEqual(dateNow) - - // when not observed it should not be cached - expect(m.date).not.toBe(m.date) - - const reactions: Date[] = [] - autoDispose( - reaction( - () => m.date, - d => { - reactions.push(d) - } - ) - ) - - // when observed it should be cached - expect(m.date).toBe(m.date) - - // setter - const actionCalls: ActionCall[] = [] - autoDispose( - onActionMiddleware(m, { - onStart(actionCall) { - actionCalls.push(actionCall) - }, - onFinish(actionCall) { - actionCalls.push(actionCall) - }, - }) - ) - const dateNow2 = new Date(1569524561993) - m.setDate(dateNow2) - expect(m.date).toEqual(dateNow2) - expect(m.time).toBe(dateNow2.toJSON()) - - expect(stringAsDate.propToData(m.time)).toStrictEqual(dateNow2) - expect(stringAsDate.dataToProp(dateNow2)).toStrictEqual(m.time) + // changing the date to be the same should keep the cached value intact + reactions.length = 0 + m.setDate(dateNow) + expect(m.date).toBe(dateNow) + expect(reactions).toHaveLength(0) + + // changing the backed value to be the same should keep the cached value intact + reactions.length = 0 + m.setTimestamp(now) + expect(m.date).toEqual(dateNow) // created from backed prop + expect(reactions).toHaveLength(0) + + // changing the backed prop and trying to set the same data back should work + m.setTimestamp(5000) + m.setDate(dateNow) + expect(m.timestamp).toBe(now) - expect(actionCalls).toMatchInlineSnapshot(` + // changing the backed prop should change the other, and it should react + reactions.length = 0 + m.setTimestamp(10000) + expect(m.date).not.toBe(dateNow) + expect(+m.date).toBe(10000) + expect(reactions).toMatchInlineSnapshot(` Array [ - Object { - "actionName": "setDate", - "args": Array [ - 2019-09-26T19:02:41.993Z, - ], - "targetPath": Array [], - "targetPathIds": Array [], - }, - Object { - "actionName": "setDate", - "args": Array [ - 2019-09-26T19:02:41.993Z, - ], - "targetPath": Array [], - "targetPathIds": Array [], - }, + 1970-01-01T00:00:10.000Z, ] `) - - expect(reactions).toMatchInlineSnapshot(` - Array [ - 2019-09-26T19:02:41.993Z, - ] - `) - - // apply action should work - applyAction(m, { - actionName: "setDate", - args: [dateNow], - targetPath: [], - targetPathIds: [], - }) - - expect(m.date).toEqual(dateNow) - expect(m.time).toBe(dateNow.toJSON()) }) diff --git a/packages/lib/test/propTransform/implicit/transformArrayAsSet.test.ts b/packages/lib/test/propTransform/implicit/transformArrayAsSet.test.ts new file mode 100644 index 00000000..5fc4c31c --- /dev/null +++ b/packages/lib/test/propTransform/implicit/transformArrayAsSet.test.ts @@ -0,0 +1,148 @@ +import { reaction } from "mobx" +import { assert, _ } from "spec.ts" +import { + ActionCall, + applyAction, + ArrayAsSet, + getSnapshot, + model, + Model, + modelAction, + onActionMiddleware, + prop, + SnapshotInOf, + SnapshotOutOf, + transformArrayAsSet, +} from "../../../src" +import "../../commonSetup" +import { autoDispose } from "../../utils" + +function expectSimilarSet(s1: Set, s2: Set) { + expect([...s1.values()]).toStrictEqual([...s2.values()]) +} + +test("transformArrayAsSet", () => { + @model("transformArrayAsSet/M") + class M extends Model({ + set: transformArrayAsSet( + prop(() => []) + ), + }) { + @modelAction + setSet(set: Set) { + this.set = set + } + + @modelAction + setAdd(n: number) { + this.set.add(n) + } + } + + assert(_ as SnapshotInOf["set"], _ as number[] | null | undefined) + assert(_ as SnapshotOutOf["set"], _ as number[]) + + const initialSet = new Set([1, 2, 3]) + + const m = new M({ set: initialSet }) + + expect(getSnapshot(m).set).toEqual([1, 2, 3]) + + // getter + expect(m.set instanceof ArrayAsSet).toBeTruthy() + expectSimilarSet(m.set, initialSet) + + // should be cached + expect(m.set).toBe(m.set) + + const reactions: Set[] = [] + autoDispose( + reaction( + () => m.set, + d => { + reactions.push(d) + } + ) + ) + + // do some ops + expect(m.set.has(4)).toBe(false) + expect(m.$.set.includes(4)).toBe(false) + m.setAdd(4) + expect(m.set.has(4)).toBe(true) + expect(m.$.set.includes(4)).toBe(true) + + expect(reactions).toHaveLength(0) // since only the contents changed + + // should be cached + expect(m.set).toBe(m.set) + + // setter + const actionCalls: ActionCall[] = [] + autoDispose( + onActionMiddleware(m, { + onStart(actionCall) { + actionCalls.push(actionCall) + }, + onFinish(actionCall) { + actionCalls.push(actionCall) + }, + }) + ) + + const newSet = new Set([5, 6, 7]) + m.setSet(newSet) + expectSimilarSet(m.set, newSet) + + expect(m.$.set).toEqual([5, 6, 7]) + + expect(actionCalls).toMatchInlineSnapshot(` + Array [ + Object { + "actionName": "setSet", + "args": Array [ + Set { + 5, + 6, + 7, + }, + ], + "targetPath": Array [], + "targetPathIds": Array [], + }, + Object { + "actionName": "setSet", + "args": Array [ + Set { + 5, + 6, + 7, + }, + ], + "targetPath": Array [], + "targetPathIds": Array [], + }, + ] + `) + + expect(reactions).toMatchInlineSnapshot(` + Array [ + Set { + 5, + 6, + 7, + }, + ] + `) + + // apply action should work + applyAction(m, { + actionName: "setSet", + args: [initialSet], + targetPath: [], + targetPathIds: [], + }) + + expectSimilarSet(m.set, initialSet) + expect(m.$.set).toEqual([...initialSet.values()]) +}) diff --git a/packages/lib/test/propTransform/implicit/transformStringAsDate.test.ts b/packages/lib/test/propTransform/implicit/transformStringAsDate.test.ts new file mode 100644 index 00000000..10c4f403 --- /dev/null +++ b/packages/lib/test/propTransform/implicit/transformStringAsDate.test.ts @@ -0,0 +1,115 @@ +import { reaction } from "mobx" +import { assert, _ } from "spec.ts" +import { + ActionCall, + applyAction, + getSnapshot, + model, + Model, + modelAction, + onActionMiddleware, + SnapshotInOf, + SnapshotOutOf, + tProp, + transformStringAsDate, + types, +} from "../../../src" +import "../../commonSetup" +import { autoDispose } from "../../utils" + +test("transformStringAsDate", () => { + @model("transformStringAsDate/M") + class M extends Model({ + date: transformStringAsDate(tProp(types.string)), + }) { + @modelAction + setDate(date: Date) { + this.date = date + } + } + + assert(_ as SnapshotInOf["date"], _ as string) + assert(_ as SnapshotOutOf["date"], _ as string) + + const dateNow = new Date(0) + + const m = new M({ date: dateNow }) + + expect(getSnapshot(m).date).toBe(dateNow.toJSON()) + + // getter + expect(m.date instanceof Date).toBeTruthy() + expect(m.date).toBe(dateNow) // same instance + + // should be cached + expect(m.date).toBe(m.date) + + const reactions: Date[] = [] + autoDispose( + reaction( + () => m.date, + d => { + reactions.push(d) + } + ) + ) + + // should be cached + expect(m.date).toBe(m.date) + + // setter + const actionCalls: ActionCall[] = [] + autoDispose( + onActionMiddleware(m, { + onStart(actionCall) { + actionCalls.push(actionCall) + }, + onFinish(actionCall) { + actionCalls.push(actionCall) + }, + }) + ) + + const dateNow2 = new Date(1569524561993) + m.setDate(dateNow2) + expect(m.date).toBe(dateNow2) + expect(m.$.date).toBe(dateNow2.toJSON()) + + expect(actionCalls).toMatchInlineSnapshot(` + Array [ + Object { + "actionName": "setDate", + "args": Array [ + 2019-09-26T19:02:41.993Z, + ], + "targetPath": Array [], + "targetPathIds": Array [], + }, + Object { + "actionName": "setDate", + "args": Array [ + 2019-09-26T19:02:41.993Z, + ], + "targetPath": Array [], + "targetPathIds": Array [], + }, + ] + `) + + expect(reactions).toMatchInlineSnapshot(` + Array [ + 2019-09-26T19:02:41.993Z, + ] + `) + + // apply action should work + applyAction(m, { + actionName: "setDate", + args: [dateNow], + targetPath: [], + targetPathIds: [], + }) + + expect(m.date).toEqual(dateNow) + expect(m.$.date).toBe(dateNow.toJSON()) +}) diff --git a/packages/lib/test/propTransform/implicit/transformTimestampAsDate.test.ts b/packages/lib/test/propTransform/implicit/transformTimestampAsDate.test.ts new file mode 100644 index 00000000..7fa4c4b6 --- /dev/null +++ b/packages/lib/test/propTransform/implicit/transformTimestampAsDate.test.ts @@ -0,0 +1,154 @@ +import { reaction } from "mobx" +import { assert, _ } from "spec.ts" +import { + ActionCall, + applyAction, + getSnapshot, + model, + Model, + modelAction, + onActionMiddleware, + prop, + SnapshotInOf, + SnapshotOutOf, + timestampAsDate, + transformTimestampAsDate, +} from "../../../src" +import "../../commonSetup" +import { autoDispose } from "../../utils" + +test("transformTimestampAsDate", () => { + @model("transformTimestampAsDate/M") + class M extends Model({ + date: transformTimestampAsDate(prop()), + }) { + @modelAction + setDate(date: Date) { + this.date = date + } + + @modelAction + setTimestamp(timestamp: number) { + this.$.date = timestamp + } + } + + assert(_ as SnapshotInOf["date"], _ as number) + assert(_ as SnapshotOutOf["date"], _ as number) + + const now = 0 + const dateNow = new Date(now) + + const m = new M({ date: dateNow }) + + expect(getSnapshot(m).date).toBe(now) + + // getter + expect(m.date instanceof Date).toBeTruthy() + expect(m.date).toBe(dateNow) // must be the same instance + + // should be cached + expect(m.date).toBe(m.date) + + const reactions: Date[] = [] + autoDispose( + reaction( + () => m.date, + d => { + reactions.push(d) + } + ) + ) + + // should be cached + expect(m.date).toBe(m.date) + + // setter + const actionCalls: ActionCall[] = [] + autoDispose( + onActionMiddleware(m, { + onStart(actionCall) { + actionCalls.push(actionCall) + }, + onFinish(actionCall) { + actionCalls.push(actionCall) + }, + }) + ) + + const now2 = 1569524561993 + const dateNow2 = new Date(now2) + + expect(timestampAsDate.propToData(now2)).toStrictEqual(dateNow2) + expect(timestampAsDate.dataToProp(dateNow2)).toStrictEqual(now2) + + m.setDate(dateNow2) + expect(m.date).toBe(dateNow2) + expect(m.$.date).toBe(now2) + + expect(actionCalls).toMatchInlineSnapshot(` + Array [ + Object { + "actionName": "setDate", + "args": Array [ + 2019-09-26T19:02:41.993Z, + ], + "targetPath": Array [], + "targetPathIds": Array [], + }, + Object { + "actionName": "setDate", + "args": Array [ + 2019-09-26T19:02:41.993Z, + ], + "targetPath": Array [], + "targetPathIds": Array [], + }, + ] + `) + + expect(reactions).toMatchInlineSnapshot(` + Array [ + 2019-09-26T19:02:41.993Z, + ] + `) + + // apply action should work + applyAction(m, { + actionName: "setDate", + args: [dateNow], + targetPath: [], + targetPathIds: [], + }) + + expect(m.date).toEqual(dateNow) + expect(m.$.date).toBe(now) + + // changing the date to be the same should keep the cached value intact + reactions.length = 0 + m.setDate(dateNow) + expect(m.date).toBe(dateNow) + expect(reactions).toHaveLength(0) + + // changing the backed value to be the same should keep the cached value intact + reactions.length = 0 + m.setTimestamp(now) + expect(m.date).toBe(dateNow) + expect(reactions).toHaveLength(0) + + // changing the backed prop and trying to set the same data back should work + m.setTimestamp(5000) + m.setDate(dateNow) + expect(m.$.date).toBe(now) + + // changing the backed prop should change the other, and it should react + reactions.length = 0 + m.setTimestamp(10000) + expect(m.date).not.toBe(dateNow) + expect(+m.date).toBe(10000) + expect(reactions).toMatchInlineSnapshot(` + Array [ + 1970-01-01T00:00:10.000Z, + ] + `) +}) diff --git a/packages/site/src/models.mdx b/packages/site/src/models.mdx index bae63c75..0ed5fc35 100644 --- a/packages/site/src/models.mdx +++ b/packages/site/src/models.mdx @@ -177,7 +177,10 @@ setGlobalConfig({ ## Getting the Typescript types for model data and model ceation data -You can give the types of both the model creation data (the object that gets passed to new as parameter) and the model data (the type of the model data once instantiated) by using `ModelCreationData` and `ModelData`. +- `ModelPropsData` is the the type of the model props without transformations (as accessible via `model.$`). +- `ModelInstanceData` is the type of the model props with transformation (as accessible via `this`). +- `ModelPropsCreationData` is the the type of the creation model props without transformations (like `SnapshotIn` excluding `$modelId` and `$modelType`). +- `ModelInstanceCreationData` is the type of the first parameter passed to `new Model(...)`. For example, given: @@ -189,7 +192,7 @@ export class Todo extends Model({ }) {} ``` -`ModelCreationData` would be: +`ModelInstanceCreationData` would be: ```ts { @@ -198,7 +201,7 @@ export class Todo extends Model({ } ``` -and `ModelData` would be: +and `ModelInstanceData` would be: ```ts { From 245927e132dc7626585c2ee01cd90855d2cf4389 Mon Sep 17 00:00:00 2001 From: Javier Gonzalez Date: Fri, 7 Feb 2020 20:31:47 +0100 Subject: [PATCH 2/4] fix test --- packages/lib/src/model/Model.ts | 8 +- packages/lib/src/model/prop.ts | 4 +- packages/lib/test/model/defaultProps.test.ts | 6 +- packages/lib/test/model/subclassing.test.ts | 80 +++++++++++++++----- 4 files changed, 70 insertions(+), 28 deletions(-) diff --git a/packages/lib/src/model/Model.ts b/packages/lib/src/model/Model.ts index 42b85080..74b87c88 100644 --- a/packages/lib/src/model/Model.ts +++ b/packages/lib/src/model/Model.ts @@ -22,8 +22,8 @@ import { ModelProps, ModelPropsToInstanceCreationData, ModelPropsToInstanceData, - ModelPropsToPropCreationData, - ModelPropsToPropData, + ModelPropsToPropsCreationData, + ModelPropsToPropsData, OptionalModelProps, } from "./prop" import { assertIsModelClass } from "./utils" @@ -45,12 +45,12 @@ export interface _Model { */ readonly [modelTypeKey]: string | undefined - [propsDataSymbol]: ModelPropsToPropData + [propsDataSymbol]: ModelPropsToPropsData [instanceDataSymbol]: ModelPropsToInstanceData [optDataSymbol]: OptionalModelProps - [propsCreationDataSymbol]: ModelPropsToPropCreationData + [propsCreationDataSymbol]: ModelPropsToPropsCreationData [instanceCreationDataSymbol]: ModelPropsToInstanceCreationData [composedPropsCreationDataSymbol]: SuperModel extends BaseModel diff --git a/packages/lib/src/model/prop.ts b/packages/lib/src/model/prop.ts index d249d741..29e09002 100644 --- a/packages/lib/src/model/prop.ts +++ b/packages/lib/src/model/prop.ts @@ -40,11 +40,11 @@ export type OptionalModelProps = { [K in keyof MP]: MP[K]["$isOptional"] & K }[keyof MP] -export type ModelPropsToPropData = { +export type ModelPropsToPropsData = { [k in keyof MP]: MP[k]["$propValueType"] } -export type ModelPropsToPropCreationData = O.Optional< +export type ModelPropsToPropsCreationData = O.Optional< { [k in keyof MP]: MP[k]["$propCreationValueType"] }, diff --git a/packages/lib/test/model/defaultProps.test.ts b/packages/lib/test/model/defaultProps.test.ts index 9401dd8a..6686f67d 100644 --- a/packages/lib/test/model/defaultProps.test.ts +++ b/packages/lib/test/model/defaultProps.test.ts @@ -1,5 +1,5 @@ import { assert, _ } from "spec.ts" -import { Model, model, ModelCreationData, ModelData, prop, tProp, types } from "../../src" +import { Model, model, ModelPropsCreationData, ModelPropsData, prop, tProp, types } from "../../src" import "../commonSetup" @model("M") @@ -25,7 +25,7 @@ class M extends Model({ test("default props", () => { assert( - _ as ModelCreationData, + _ as ModelPropsCreationData, _ as { x?: number | null xx?: number | null @@ -48,7 +48,7 @@ test("default props", () => { ) assert( - _ as ModelData, + _ as ModelPropsData, _ as { x: number xx: number | undefined diff --git a/packages/lib/test/model/subclassing.test.ts b/packages/lib/test/model/subclassing.test.ts index 69829455..9529ea95 100644 --- a/packages/lib/test/model/subclassing.test.ts +++ b/packages/lib/test/model/subclassing.test.ts @@ -9,13 +9,13 @@ import { modelAction, modelClass, ModelClassDeclaration, - ModelCreationData, - ModelData, + ModelPropsCreationData, + ModelPropsData, prop, tProp, types, } from "../../src" -import { ModelPropsToData } from "../../src/model/prop" +import { ModelPropsToPropsData } from "../../src/model/prop" import "../commonSetup" // @model("P") @@ -62,12 +62,22 @@ test("subclassing with additional props", () => { } } - type D = ModelData - type CD = ModelCreationData - assert(_ as D, _ as { x: number; y: number; z: number } & { a: number; b: number }) + type D = ModelPropsData + type CD = ModelPropsCreationData + assert( + _ as D, + _ as { x: number; y: number; z: number } & { + a: number + b: number + } + ) assert( _ as CD, - _ as { x?: number | null; y?: number | null; z?: number | null } & { + _ as { + x?: number | null + y?: number | null + z?: number | null + } & { x?: number | null y?: number | null z?: number | null @@ -125,12 +135,23 @@ test("subclassing without additional props", () => { } } - type D = ModelData - type CD = ModelCreationData - assert(_ as D, _ as { x: number; y: number; z: number } & ModelPropsToData<{}>) + type D = ModelPropsData + type CD = ModelPropsCreationData + assert( + _ as D, + _ as { + x: number + y: number + z: number + } & ModelPropsToPropsData<{}> + ) assert( _ as CD, - _ as { x?: number | null; y?: number | null; z?: number | null } & ModelPropsToData<{}> + _ as { + x?: number | null + y?: number | null + z?: number | null + } & ModelPropsToPropsData<{}> ) const p2 = new P2({ x: 20 }) @@ -167,12 +188,23 @@ test("subclassing without anything new", () => { @model("P2_nothingNew") class P2 extends ExtendedModel(P, {}) {} - type D = ModelData - type CD = ModelCreationData - assert(_ as D, _ as { x: number; y: number; z: number } & ModelPropsToData<{}>) + type D = ModelPropsData + type CD = ModelPropsCreationData + assert( + _ as D, + _ as { + x: number + y: number + z: number + } & ModelPropsToPropsData<{}> + ) assert( _ as CD, - _ as { x?: number | null; y?: number | null; z?: number | null } & ModelPropsToData<{}> + _ as { + x?: number | null + y?: number | null + z?: number | null + } & ModelPropsToPropsData<{}> ) const p2 = new P2({ x: 20 }) @@ -235,16 +267,26 @@ test("three level subclassing", () => { } } - type D = ModelData - type CD = ModelCreationData - assert(_ as D, _ as { x: number; y: number; z: number } & { a: number } & { b: number }) + type D = ModelPropsData + type CD = ModelPropsCreationData + assert( + _ as D, + _ as { x: number; y: number; z: number } & { + a: number + } & { b: number } + ) assert( _ as CD, _ as { x?: number | null | undefined y?: number | null | undefined z?: number | null | undefined - } & { x?: number | null; y?: number | null; z?: number | null; a?: number | null } & { + } & { + x?: number | null + y?: number | null + z?: number | null + a?: number | null + } & { x?: number | null y?: number | null z?: number | null From 552d30319faf58ef458574475a840df426296971 Mon Sep 17 00:00:00 2001 From: Javier Gonzalez Date: Wed, 12 Feb 2020 20:04:35 +0100 Subject: [PATCH 3/4] implicit property transforms --- CHANGELOG.md | 7 +- packages/lib/src/model/prop.ts | 37 ++- packages/lib/src/propTransform/index.ts | 2 + .../lib/src/propTransform/propTransform.ts | 23 +- .../src/propTransform/transformArrayAsMap.ts | 78 +++++++ .../src/propTransform/transformArrayAsSet.ts | 78 +++++-- .../src/propTransform/transformObjectAsMap.ts | 76 ++++++ .../propTransform/transformStringAsDate.ts | 66 ++++-- .../propTransform/transformTimestampAsDate.ts | 66 ++++-- packages/lib/src/snapshot/SnapshotOf.ts | 30 +-- packages/lib/src/snapshot/fromSnapshot.ts | 10 +- .../lib/src/snapshot/reconcileSnapshot.ts | 9 +- packages/lib/src/tweaker/tweakArray.ts | 2 +- packages/lib/src/typeChecking/array.ts | 2 +- packages/lib/src/typeChecking/schemas.ts | 4 +- packages/lib/src/typeChecking/tProp.ts | 29 ++- packages/lib/src/typeChecking/tuple.ts | 76 ++++++ packages/lib/src/typeChecking/types.ts | 16 ++ .../{arrayAsMap.ts => arrayAsMapWrapper.ts} | 2 + .../{arrayAsSet.ts => arrayAsSetWrapper.ts} | 2 + packages/lib/src/wrappers/index.ts | 6 +- .../{objectAsMap.ts => objectAsMapWrapper.ts} | 6 +- .../implicit/transformArrayAsMap.test.ts | 168 ++++++++++++++ .../implicit/transformArrayAsSet.test.ts | 15 +- .../implicit/transformObjectAsMap.test.ts | 155 +++++++++++++ .../implicit/transformStringAsDate.test.ts | 6 +- .../implicit/transformTimestampAsDate.test.ts | 7 +- .../test/typeChecking/typeChecking.test.ts | 15 ++ packages/site/doczrc.js | 2 +- .../src/examples/clientServer/app.tsx.txt | 22 +- .../examples/clientServer/appInstance.tsx.txt | 79 ++++++- .../src/examples/clientServer/server.ts.txt | 54 ++++- .../site/src/examples/todoList/app.tsx.txt | 108 ++++++++- .../site/src/examples/todoList/logs.tsx.txt | 118 +++++++++- .../site/src/examples/todoList/store.ts.txt | 109 ++++++++- packages/site/src/mapsAndSets.mdx | 102 -------- packages/site/src/mapsSetsDates.mdx | 219 ++++++++++++++++++ packages/site/src/propertyTransforms.mdx | 6 +- packages/site/src/runtimeTypeChecking.mdx | 8 + 39 files changed, 1557 insertions(+), 263 deletions(-) create mode 100644 packages/lib/src/propTransform/transformArrayAsMap.ts create mode 100644 packages/lib/src/propTransform/transformObjectAsMap.ts create mode 100644 packages/lib/src/typeChecking/tuple.ts rename packages/lib/src/wrappers/{arrayAsMap.ts => arrayAsMapWrapper.ts} (96%) rename packages/lib/src/wrappers/{arrayAsSet.ts => arrayAsSetWrapper.ts} (96%) rename packages/lib/src/wrappers/{objectAsMap.ts => objectAsMapWrapper.ts} (91%) create mode 100644 packages/lib/test/propTransform/implicit/transformArrayAsMap.test.ts create mode 100644 packages/lib/test/propTransform/implicit/transformObjectAsMap.test.ts mode change 120000 => 100644 packages/site/src/examples/clientServer/app.tsx.txt mode change 120000 => 100644 packages/site/src/examples/clientServer/appInstance.tsx.txt mode change 120000 => 100644 packages/site/src/examples/clientServer/server.ts.txt mode change 120000 => 100644 packages/site/src/examples/todoList/app.tsx.txt mode change 120000 => 100644 packages/site/src/examples/todoList/logs.tsx.txt mode change 120000 => 100644 packages/site/src/examples/todoList/store.ts.txt delete mode 100644 packages/site/src/mapsAndSets.mdx create mode 100644 packages/site/src/mapsSetsDates.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index e826dadc..869dede9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ # Change Log -- [BREAKING CHANGE] Some type helpers have been renamed: `ModelData` -> `ModelPropsData` / `ModelInstanceData`, `ModelCreationData` -> `ModelPropsCreationData` / `ModelInstanceCreationData`. -- New feature: "Implicit property transforms", which are preferred over the old decorator based property transforms, wrappers (`arrayAsSet`, `arrayAsMap`, `objectAsMap`) and collection models (`ArraySet`, `ObjectMap`). -- Property transforms can now be used standalone. +- [BREAKING CHANGE - types] Some type helpers have been renamed: `ModelData` -> `ModelPropsData` / `ModelInstanceData`, `ModelCreationData` -> `ModelPropsCreationData` / `ModelInstanceCreationData`. +- New feature: "Implicit property transforms", which are sometimes preferred over the old decorator based property transforms, wrappers (`arrayAsSet`, `arrayAsMap`, `objectAsMap`) and collection models (`ArraySet`, `ObjectMap`). Check the "Maps, Sets, Dates" section in the docs for more info. +- Added `types.tuple`. +- Property transforms decorators can now also be used standalone. ## 0.39.0 diff --git a/packages/lib/src/model/prop.ts b/packages/lib/src/model/prop.ts index 29e09002..55a0cb98 100644 --- a/packages/lib/src/model/prop.ts +++ b/packages/lib/src/model/prop.ts @@ -62,6 +62,33 @@ export type ModelPropsToInstanceCreationData = O.Optional OptionalModelProps > +/** + * @ignore + */ +export type OnlyPrimitives = Exclude + +/** + * A model prop that maybe / maybe not is optional, depending on if the value can take undefined. + */ +export type MaybeOptionalModelProp = ModelProp< + TPropValue, + TPropValue, + IsOptionalValue, + TInstanceValue, + TInstanceValue +> + +/** + * A model prop that is definitely optional. + */ +export type OptionalModelProp = ModelProp< + TPropValue, + TPropValue | null | undefined, + string, + TInstanceValue, + TInstanceValue | null | undefined +> + /** * Defines a model property with no default value. * @@ -74,7 +101,7 @@ export type ModelPropsToInstanceCreationData = O.Optional * @typeparam TValue Value type. * @returns */ -export function prop(): ModelProp> +export function prop(): MaybeOptionalModelProp /** * Defines a model property, with an optional function to generate a default value @@ -90,9 +117,7 @@ export function prop(): ModelProp( - defaultFn: () => TValue -): ModelProp +export function prop(defaultFn: () => TValue): OptionalModelProp /** * Defines a model property, with an optional default value @@ -109,9 +134,7 @@ export function prop( * @param defaultValue Default primitive value. * @returns */ -export function prop( - defaultValue: Exclude -): ModelProp +export function prop(defaultValue: OnlyPrimitives): OptionalModelProp export function prop(def?: any): ModelProp { const hasDefaultValue = arguments.length > 0 diff --git a/packages/lib/src/propTransform/index.ts b/packages/lib/src/propTransform/index.ts index abb44ef3..4283c3b0 100644 --- a/packages/lib/src/propTransform/index.ts +++ b/packages/lib/src/propTransform/index.ts @@ -1,4 +1,6 @@ export { PropTransform, propTransform, PropTransformDecorator } from "./propTransform" +export * from "./transformArrayAsMap" export * from "./transformArrayAsSet" +export * from "./transformObjectAsMap" export * from "./transformStringAsDate" export * from "./transformTimestampAsDate" diff --git a/packages/lib/src/propTransform/propTransform.ts b/packages/lib/src/propTransform/propTransform.ts index 756c614b..b6510a88 100644 --- a/packages/lib/src/propTransform/propTransform.ts +++ b/packages/lib/src/propTransform/propTransform.ts @@ -1,4 +1,4 @@ -import { ModelProp } from "../model/prop" +import { ModelProp, noDefaultValue } from "../model/prop" import { failure } from "../utils" /** @@ -35,7 +35,7 @@ export type PropTransformDecorator = ( * * For example, to transform from a number timestamp into a date: * ```ts - * const asDate = propTransform({ + * const asDate = propTransformDecorator({ * propToData(prop: number) { * return new Date(prop) * }, @@ -175,14 +175,29 @@ function toMemoPropTransform( */ export function transformedProp( prop: ModelProp, - transform: PropTransform + transform: PropTransform, + transformDefault: boolean ): ModelProp { if (prop.transform) { throw failure("a property cannot have more than one transform") } - return { + const p = { ...prop, transform, } + + // transform defaults if needed + if (transformDefault) { + if (p.defaultValue !== noDefaultValue) { + const originalDefaultValue = p.defaultValue + p.defaultValue = transform.dataToProp(originalDefaultValue) + } + if (p.defaultFn !== noDefaultValue) { + const originalDefaultFn = p.defaultFn as () => any + p.defaultFn = () => transform.dataToProp(originalDefaultFn()) + } + } + + return p } diff --git a/packages/lib/src/propTransform/transformArrayAsMap.ts b/packages/lib/src/propTransform/transformArrayAsMap.ts new file mode 100644 index 00000000..f34dbe6c --- /dev/null +++ b/packages/lib/src/propTransform/transformArrayAsMap.ts @@ -0,0 +1,78 @@ +import { MaybeOptionalModelProp, OnlyPrimitives, OptionalModelProp, prop } from "../model/prop" +import { AnyType, TypeToData } from "../typeChecking/schemas" +import { tProp } from "../typeChecking/tProp" +import { isArray, isMap } from "../utils" +import { arrayAsMap } from "../wrappers" +import { PropTransform, transformedProp } from "./propTransform" + +/** + * A map expressed as an array. + */ +export type TransformArrayAsMap = [string, V][] + +const arrayAsMapInnerTransform: PropTransform< + [string, any][] | unknown, + Map | unknown +> = { + propToData(arr) { + return isArray(arr) ? arrayAsMap(() => arr) : arr + }, + dataToProp(newMap) { + if (!isMap(newMap)) { + return newMap + } + + const arr: TransformArrayAsMap = [] + for (const k of newMap.keys()) { + arr.push([k, newMap.get(k)]) + } + + return arr + }, +} + +/** + * Transforms maps into arrays. + */ +export type TransformMapToArray = + | (T extends Map ? [string, I][] : never) + | Exclude> + +/** + * Transforms arrays into maps. + */ +export type TransformArrayToMap = + | (T extends [string, infer I][] ? Map : never) + | Exclude + +export function prop_mapArray(): MaybeOptionalModelProp, TValue> + +export function prop_mapArray( + defaultFn: () => TValue +): OptionalModelProp, TValue> + +export function prop_mapArray( + defaultValue: OnlyPrimitives +): OptionalModelProp, TValue> + +export function prop_mapArray(def?: any) { + return transformedProp(prop(def), arrayAsMapInnerTransform, true) +} + +export function tProp_mapArray( + type: TType +): MaybeOptionalModelProp, TransformArrayToMap>> + +export function tProp_mapArray( + type: TType, + defaultFn: () => TransformArrayToMap> +): OptionalModelProp, TransformArrayToMap>> + +export function tProp_mapArray( + type: TType, + defaultValue: OnlyPrimitives>> +): OptionalModelProp, TransformArrayToMap>> + +export function tProp_mapArray(typeOrDefaultValue: any, def?: any) { + return transformedProp(tProp(typeOrDefaultValue, def), arrayAsMapInnerTransform, true) +} diff --git a/packages/lib/src/propTransform/transformArrayAsSet.ts b/packages/lib/src/propTransform/transformArrayAsSet.ts index 0ab36190..cdbbff7f 100644 --- a/packages/lib/src/propTransform/transformArrayAsSet.ts +++ b/packages/lib/src/propTransform/transformArrayAsSet.ts @@ -1,33 +1,61 @@ -import { ModelProp } from "../model/prop" -import { arrayAsSet } from "../wrappers/arrayAsSet" -import { propTransform, transformedProp } from "./propTransform" - -const arrayAsSetInnerTransform = propTransform< - any[] | null | undefined, - Set | null | undefined ->({ +import { MaybeOptionalModelProp, OnlyPrimitives, OptionalModelProp, prop } from "../model/prop" +import { AnyType, TypeToData } from "../typeChecking/schemas" +import { tProp } from "../typeChecking/tProp" +import { isArray, isSet } from "../utils" +import { arrayAsSet } from "../wrappers/arrayAsSetWrapper" +import { PropTransform, transformedProp } from "./propTransform" + +const arrayAsSetInnerTransform: PropTransform | unknown> = { propToData(arr) { - return arr ? arrayAsSet(() => arr) : arr + return isArray(arr) ? arrayAsSet(() => arr) : arr }, dataToProp(newSet) { - return newSet ? [...newSet.values()] : newSet + return isSet(newSet) ? [...newSet.values()] : newSet }, -}) +} /** - * Implicit property transform for that allows a backed array to be used as if it were a set. - * - * @param prop + * Transforms sets into arrays. */ -export function transformArrayAsSet( - prop: ModelProp -): ModelProp< - TValue, - TCreationValue, - TIsOptional, - (TValue extends Array ? Set : never) | Extract, - | (TCreationValue extends Array ? Set : never) - | Extract -> { - return transformedProp(prop, arrayAsSetInnerTransform) +export type TransformSetToArray = + | (T extends Set ? Array : never) + | Exclude> + +/** + * Transforms arrays into sets. + */ +export type TransformArrayToSet = + | (T extends Array ? Set : never) + | Exclude> + +export function prop_setArray(): MaybeOptionalModelProp, TValue> + +export function prop_setArray( + defaultFn: () => TValue +): OptionalModelProp, TValue> + +export function prop_setArray( + defaultValue: OnlyPrimitives +): OptionalModelProp, TValue> + +export function prop_setArray(def?: any) { + return transformedProp(prop(def), arrayAsSetInnerTransform, true) +} + +export function tProp_setArray( + type: TType +): MaybeOptionalModelProp, TransformArrayToSet>> + +export function tProp_setArray( + type: TType, + defaultFn: () => TransformArrayToSet> +): OptionalModelProp, TransformArrayToSet>> + +export function tProp_setArray( + type: TType, + defaultValue: OnlyPrimitives>> +): OptionalModelProp, TransformArrayToSet>> + +export function tProp_setArray(typeOrDefaultValue: any, def?: any) { + return transformedProp(tProp(typeOrDefaultValue, def), arrayAsSetInnerTransform, true) } diff --git a/packages/lib/src/propTransform/transformObjectAsMap.ts b/packages/lib/src/propTransform/transformObjectAsMap.ts new file mode 100644 index 00000000..41859cdb --- /dev/null +++ b/packages/lib/src/propTransform/transformObjectAsMap.ts @@ -0,0 +1,76 @@ +import { MaybeOptionalModelProp, OnlyPrimitives, OptionalModelProp, prop } from "../model/prop" +import { AnyType, TypeToData } from "../typeChecking/schemas" +import { tProp } from "../typeChecking/tProp" +import { isMap, isObject } from "../utils" +import { objectAsMap } from "../wrappers" +import { PropTransform, transformedProp } from "./propTransform" + +const objectAsMapInnerTransform: PropTransform< + Record | unknown, + Map | unknown +> = { + propToData(map) { + return isObject(map) ? objectAsMap(() => map as any) : map + }, + dataToProp(newMap) { + if (!isMap(newMap)) { + return newMap + } + + const obj: any = {} + for (const k of newMap.keys()) { + obj[k] = newMap.get(k) + } + + return obj + }, +} + +/** + * Transforms maps into objects. + */ +export type TransformMapToObject = + | (T extends Map ? Record : never) + | Exclude> + +/** + * Transforms objects into maps. + */ +export type TransformObjectToMap = + | (T extends Record ? Map : never) + | Exclude> + +export function prop_mapObject(): MaybeOptionalModelProp< + TransformMapToObject, + TValue +> + +export function prop_mapObject( + defaultFn: () => TValue +): OptionalModelProp, TValue> + +export function prop_mapObject( + defaultValue: OnlyPrimitives +): OptionalModelProp, TValue> + +export function prop_mapObject(def?: any) { + return transformedProp(prop(def), objectAsMapInnerTransform, true) +} + +export function tProp_mapObject( + type: TType +): MaybeOptionalModelProp, TransformObjectToMap>> + +export function tProp_mapObject( + type: TType, + defaultFn: () => TransformObjectToMap> +): OptionalModelProp, TransformObjectToMap>> + +export function tProp_mapObject( + type: TType, + defaultValue: OnlyPrimitives>> +): OptionalModelProp, TransformObjectToMap>> + +export function tProp_mapObject(typeOrDefaultValue: any, def?: any) { + return transformedProp(tProp(typeOrDefaultValue, def), objectAsMapInnerTransform, true) +} diff --git a/packages/lib/src/propTransform/transformStringAsDate.ts b/packages/lib/src/propTransform/transformStringAsDate.ts index 0039b54e..8826ad6b 100644 --- a/packages/lib/src/propTransform/transformStringAsDate.ts +++ b/packages/lib/src/propTransform/transformStringAsDate.ts @@ -1,35 +1,63 @@ -import { ModelProp } from "../model/prop" +import { MaybeOptionalModelProp, OnlyPrimitives, OptionalModelProp, prop } from "../model/prop" +import { AnyType, TypeToData } from "../typeChecking/schemas" +import { tProp } from "../typeChecking/tProp" import { propTransform, transformedProp } from "./propTransform" /** - * @deprecated Consider using `transformStringAsDate` instead. + * @deprecated Consider using `prop_dateString` or `tProp_dateString` instead. * * Decorator property transform for ISO date strings to Date objects and vice-versa. */ export const stringAsDate = propTransform({ propToData(prop) { - if (prop == null) return prop - return new Date(prop) + return typeof prop === "string" ? new Date(prop) : prop }, dataToProp(date) { - if (date == null) return date - return date.toJSON() + return date instanceof Date ? date.toJSON() : date }, }) /** - * Implicit property transform for ISO date strings to Date objects and vice-versa. - * - * @param prop + * Transforms dates into strings. + */ +export type TransformDateToString = (T extends Date ? string : never) | Exclude + +/** + * Transforms strings into dates. */ -export function transformStringAsDate( - prop: ModelProp -): ModelProp< - TValue, - TCreationValue, - TIsOptional, - (TValue extends string ? Date : never) | Extract, - (TCreationValue extends string ? Date : never) | Extract -> { - return transformedProp(prop, stringAsDate) +export type TransformStringToDate = (T extends string ? Date : never) | Exclude + +export function prop_dateString(): MaybeOptionalModelProp< + TransformDateToString, + TValue +> + +export function prop_dateString( + defaultFn: () => TValue +): OptionalModelProp, TValue> + +export function prop_dateString( + defaultValue: OnlyPrimitives +): OptionalModelProp, TValue> + +export function prop_dateString(def?: any) { + return transformedProp(prop(def), stringAsDate, true) +} + +export function tProp_dateString( + type: TType +): MaybeOptionalModelProp, TransformStringToDate>> + +export function tProp_dateString( + type: TType, + defaultFn: () => TransformStringToDate> +): OptionalModelProp, TransformStringToDate>> + +export function tProp_dateString( + type: TType, + defaultValue: OnlyPrimitives>> +): OptionalModelProp, TransformStringToDate>> + +export function tProp_dateString(typeOrDefaultValue: any, def?: any) { + return transformedProp(tProp(typeOrDefaultValue, def), stringAsDate, true) } diff --git a/packages/lib/src/propTransform/transformTimestampAsDate.ts b/packages/lib/src/propTransform/transformTimestampAsDate.ts index 40bd9baf..d1452baa 100644 --- a/packages/lib/src/propTransform/transformTimestampAsDate.ts +++ b/packages/lib/src/propTransform/transformTimestampAsDate.ts @@ -1,35 +1,63 @@ -import { ModelProp } from "../model/prop" +import { MaybeOptionalModelProp, OnlyPrimitives, OptionalModelProp, prop } from "../model/prop" +import { AnyType, TypeToData } from "../typeChecking/schemas" +import { tProp } from "../typeChecking/tProp" import { propTransform, transformedProp } from "./propTransform" /** - * @deprecated Consider using `transformTimestampAsDate` instead. + * @deprecated Consider using `prop_dateTimestamp` or `tProp_dateTimestamp` instead. * * Decorator property transform for number timestamps to Date objects and vice-versa. */ export const timestampAsDate = propTransform({ propToData(prop) { - if (prop == null) return prop - return new Date(prop) + return typeof prop === "number" ? new Date(prop) : prop }, dataToProp(date) { - if (date == null) return date - return +date + return date instanceof Date ? +date : date }, }) /** - * Implicit property transform for number timestamps to Date objects and vice-versa. - * - * @param prop + * Transforms dates into timestamps. + */ +export type TransformDateToTimestamp = (T extends Date ? number : never) | Exclude + +/** + * Transforms timestamps into dates. */ -export function transformTimestampAsDate( - prop: ModelProp -): ModelProp< - TValue, - TCreationValue, - TIsOptional, - (TValue extends number ? Date : never) | Extract, - (TCreationValue extends number ? Date : never) | Extract -> { - return transformedProp(prop, timestampAsDate) +export type TransformTimestampToDate = (T extends number ? Date : never) | Exclude + +export function prop_dateTimestamp(): MaybeOptionalModelProp< + TransformDateToTimestamp, + TValue +> + +export function prop_dateTimestamp( + defaultFn: () => TValue +): OptionalModelProp, TValue> + +export function prop_dateTimestamp( + defaultValue: OnlyPrimitives +): OptionalModelProp, TValue> + +export function prop_dateTimestamp(def?: any) { + return transformedProp(prop(def), timestampAsDate, true) +} + +export function tProp_dateTimestamp( + type: TType +): MaybeOptionalModelProp, TransformTimestampToDate>> + +export function tProp_dateTimestamp( + type: TType, + defaultFn: () => TransformTimestampToDate> +): OptionalModelProp, TransformTimestampToDate>> + +export function tProp_dateTimestamp( + type: TType, + defaultValue: OnlyPrimitives>> +): OptionalModelProp, TransformTimestampToDate>> + +export function tProp_dateTimestamp(typeOrDefaultValue: any, def?: any) { + return transformedProp(tProp(typeOrDefaultValue, def), timestampAsDate, true) } diff --git a/packages/lib/src/snapshot/SnapshotOf.ts b/packages/lib/src/snapshot/SnapshotOf.ts index f2ffc714..3cd828e4 100644 --- a/packages/lib/src/snapshot/SnapshotOf.ts +++ b/packages/lib/src/snapshot/SnapshotOf.ts @@ -7,10 +7,7 @@ import { ArraySet, ObjectMap } from "../wrappers" // infer is there just to cache type generation -export interface SnapshotOutOfArray extends Array> {} -export interface SnapshotOutOfReadonlyArray extends ReadonlyArray> {} - -export type SnapshotOutOfObject = { +export type SnapshotOutOfObject = { [k in keyof T]: SnapshotOutOf extends infer R ? R : never } @@ -36,15 +33,7 @@ export interface SnapshotOutOfArraySet { [modelIdKey]: string } -export type SnapshotOutOf = T extends Array - ? SnapshotOutOfArray extends infer R - ? R - : never - : T extends ReadonlyArray - ? SnapshotOutOfReadonlyArray extends infer R - ? R - : never - : T extends ObjectMap +export type SnapshotOutOf = T extends ObjectMap ? SnapshotOutOfObjectMap extends infer R ? R : never @@ -68,10 +57,7 @@ export type SnapshotOutOf = T extends Array // snapshot in -export interface SnapshotInOfArray extends Array> {} -export interface SnapshotInOfReadonlyArray extends ReadonlyArray> {} - -export type SnapshotInOfObject = { +export type SnapshotInOfObject = { [k in keyof T]: SnapshotInOf extends infer R ? R : never } @@ -99,15 +85,7 @@ export interface SnapshotInOfArraySet { [modelIdKey]: string } -export type SnapshotInOf = T extends Array - ? SnapshotInOfArray extends infer R - ? R - : never - : T extends ReadonlyArray - ? SnapshotInOfReadonlyArray extends infer R - ? R - : never - : T extends ObjectMap +export type SnapshotInOf = T extends ObjectMap ? SnapshotInOfObjectMap extends infer R ? R : never diff --git a/packages/lib/src/snapshot/fromSnapshot.ts b/packages/lib/src/snapshot/fromSnapshot.ts index 302fb046..d875ddc7 100644 --- a/packages/lib/src/snapshot/fromSnapshot.ts +++ b/packages/lib/src/snapshot/fromSnapshot.ts @@ -7,13 +7,7 @@ import { isModelSnapshot } from "../model/utils" import { tweakArray } from "../tweaker/tweakArray" import { tweakPlainObject } from "../tweaker/tweakPlainObject" import { failure, isArray, isMap, isPlainObject, isPrimitive, isSet } from "../utils" -import { - SnapshotInOf, - SnapshotInOfArray, - SnapshotInOfModel, - SnapshotInOfObject, - SnapshotOutOf, -} from "./SnapshotOf" +import { SnapshotInOf, SnapshotInOfModel, SnapshotInOfObject, SnapshotOutOf } from "./SnapshotOf" /** * From snapshot options. @@ -92,7 +86,7 @@ function internalFromSnapshot( throw failure(`unsupported snapshot - ${sn}`) } -function fromArraySnapshot(sn: SnapshotInOfArray, ctx: FromSnapshotContext): any[] { +function fromArraySnapshot(sn: SnapshotInOfObject, ctx: FromSnapshotContext): any[] { const arr = observable.array([] as any[], observableOptions) const ln = sn.length for (let i = 0; i < ln; i++) { diff --git a/packages/lib/src/snapshot/reconcileSnapshot.ts b/packages/lib/src/snapshot/reconcileSnapshot.ts index 345cb057..09fc6bc6 100644 --- a/packages/lib/src/snapshot/reconcileSnapshot.ts +++ b/packages/lib/src/snapshot/reconcileSnapshot.ts @@ -8,12 +8,7 @@ import { fastGetParentPathIncludingDataObjects } from "../parent" import { failure, isArray, isMap, isPlainObject, isPrimitive, isSet } from "../utils" import { ModelPool } from "../utils/ModelPool" import { fromSnapshot } from "./fromSnapshot" -import { - SnapshotInOfArray, - SnapshotInOfFrozen, - SnapshotInOfModel, - SnapshotInOfObject, -} from "./SnapshotOf" +import { SnapshotInOfFrozen, SnapshotInOfModel, SnapshotInOfObject } from "./SnapshotOf" /** * @ignore @@ -52,7 +47,7 @@ export function reconcileSnapshot(value: any, sn: any, modelPool: ModelPool): an function reconcileArraySnapshot( value: any, - sn: SnapshotInOfArray, + sn: SnapshotInOfObject, modelPool: ModelPool ): any[] { if (!isArray(value)) { diff --git a/packages/lib/src/tweaker/tweakArray.ts b/packages/lib/src/tweaker/tweakArray.ts index 85591607..315e73fc 100644 --- a/packages/lib/src/tweaker/tweakArray.ts +++ b/packages/lib/src/tweaker/tweakArray.ts @@ -89,7 +89,7 @@ export function tweakArray( function arrayDidChange(change: IArrayChange | IArraySplice) { const arr = change.object - let { standard: oldSnapshot } = getInternalSnapshot(arr)! + let { standard: oldSnapshot } = getInternalSnapshot(arr as Array)! const patchRecorder = new InternalPatchRecorder() diff --git a/packages/lib/src/typeChecking/array.ts b/packages/lib/src/typeChecking/array.ts index 1ad17721..0d0031ee 100644 --- a/packages/lib/src/typeChecking/array.ts +++ b/packages/lib/src/typeChecking/array.ts @@ -16,7 +16,7 @@ import { TypeCheckError } from "./TypeCheckError" * @param itemType Type of inner items. * @returns */ -export function typesArray(itemType: T): ArrayType { +export function typesArray(itemType: T): ArrayType { const typeInfoGen: TypeInfoGen = t => new ArrayTypeInfo(t, resolveStandardType(itemType)) return lateTypeChecker(() => { diff --git a/packages/lib/src/typeChecking/schemas.ts b/packages/lib/src/typeChecking/schemas.ts index 3b98831f..89c040a4 100644 --- a/packages/lib/src/typeChecking/schemas.ts +++ b/packages/lib/src/typeChecking/schemas.ts @@ -12,7 +12,9 @@ export interface IdentityType { export interface ArrayType { /** @ignore */ - $$arrayType: Array> extends infer R ? R : never + $$arrayType: { + [k in keyof S]: TypeToData extends infer R ? R : never + } } export interface ObjectOfTypes { diff --git a/packages/lib/src/typeChecking/tProp.ts b/packages/lib/src/typeChecking/tProp.ts index bf7df1b8..4b0eff43 100644 --- a/packages/lib/src/typeChecking/tProp.ts +++ b/packages/lib/src/typeChecking/tProp.ts @@ -1,5 +1,10 @@ -import { ModelProp, noDefaultValue } from "../model/prop" -import { IsOptionalValue } from "../utils/types" +import { + MaybeOptionalModelProp, + ModelProp, + noDefaultValue, + OnlyPrimitives, + OptionalModelProp, +} from "../model/prop" import { typesBoolean, typesNumber, typesString } from "./primitives" import { resolveStandardType } from "./resolveTypeChecker" import { AnyType, TypeToData } from "./schemas" @@ -16,7 +21,7 @@ import { AnyType, TypeToData } from "./schemas" * @param defaultValue Default value. * @returns */ -export function tProp(defaultValue: string): ModelProp +export function tProp(defaultValue: string): OptionalModelProp /** * Defines a number model property with a default value. @@ -30,7 +35,7 @@ export function tProp(defaultValue: string): ModelProp +export function tProp(defaultValue: number): OptionalModelProp /** * Defines a boolean model property with a default value. @@ -44,7 +49,7 @@ export function tProp(defaultValue: number): ModelProp +export function tProp(defaultValue: boolean): OptionalModelProp /** * Defines a model property with no default value and an associated type checker. @@ -60,13 +65,7 @@ export function tProp(defaultValue: boolean): ModelProp( - type: TType -): ModelProp< - TypeToData, - TypeToData, - IsOptionalValue, string, never> -> +export function tProp(type: TType): MaybeOptionalModelProp> /** * Defines a model property, with an optional function to generate a default value @@ -87,7 +86,7 @@ export function tProp( export function tProp( type: TType, defaultFn: () => TypeToData -): ModelProp, TypeToData | null | undefined, string> +): OptionalModelProp> /** * Defines a model property, with an optional default value @@ -108,8 +107,8 @@ export function tProp( */ export function tProp( type: TType, - defaultValue: Exclude, object> -): ModelProp, TypeToData | null | undefined, string> + defaultValue: OnlyPrimitives> +): OptionalModelProp> export function tProp(typeOrDefaultValue: any, def?: any): ModelProp { switch (typeof typeOrDefaultValue) { diff --git a/packages/lib/src/typeChecking/tuple.ts b/packages/lib/src/typeChecking/tuple.ts new file mode 100644 index 00000000..92ba1bc2 --- /dev/null +++ b/packages/lib/src/typeChecking/tuple.ts @@ -0,0 +1,76 @@ +import { isArray, lateVal } from "../utils" +import { resolveStandardType, resolveTypeChecker } from "./resolveTypeChecker" +import { AnyStandardType, AnyType, ArrayType } from "./schemas" +import { getTypeInfo, lateTypeChecker, TypeChecker, TypeInfo, TypeInfoGen } from "./TypeChecker" +import { TypeCheckError } from "./TypeCheckError" + +/** + * A type that represents an tuple of values of a given type. + * + * Example: + * ```ts + * const stringNumberTupleType = types.tuple(types.string, types.number) + * ``` + * + * @typeparam T Item types. + * @param itemType Type of inner items. + * @returns + */ +export function typesTuple(...itemTypes: T): ArrayType { + const typeInfoGen: TypeInfoGen = t => new TupleTypeInfo(t, itemTypes.map(resolveStandardType)) + + return lateTypeChecker(() => { + const checkers = itemTypes.map(resolveTypeChecker) + + const getTypeName = (...recursiveTypeCheckers: TypeChecker[]) => { + const typeNames = checkers.map(tc => { + if (recursiveTypeCheckers.includes(tc)) { + return "..." + } + return tc.getTypeName(...recursiveTypeCheckers, tc) + }) + + return "[" + typeNames.join(", ") + "]" + } + + const thisTc: TypeChecker = new TypeChecker( + (array, path) => { + if (!isArray(array) || array.length !== itemTypes.length) { + return new TypeCheckError(path, getTypeName(thisTc), array) + } + + for (let i = 0; i < array.length; i++) { + const itemChecker = checkers[i] + if (!itemChecker.unchecked) { + const itemError = itemChecker.check(array[i], [...path, i]) + if (itemError) { + return itemError + } + } + } + + return null + }, + getTypeName, + typeInfoGen + ) + + return thisTc + }, typeInfoGen) as any +} + +/** + * `types.tuple` type info. + */ +export class TupleTypeInfo extends TypeInfo { + // memoize to always return the same array on the getter + private _itemTypeInfos = lateVal(() => this.itemTypes.map(getTypeInfo)) + + get itemTypeInfos(): ReadonlyArray { + return this._itemTypeInfos() + } + + constructor(thisType: AnyStandardType, readonly itemTypes: ReadonlyArray) { + super(thisType) + } +} diff --git a/packages/lib/src/typeChecking/types.ts b/packages/lib/src/typeChecking/types.ts index 42e8e467..aa145734 100644 --- a/packages/lib/src/typeChecking/types.ts +++ b/packages/lib/src/typeChecking/types.ts @@ -29,6 +29,8 @@ import { import { RecordTypeInfo, typesRecord } from "./record" import { RefTypeInfo, typesRef } from "./ref" import { RefinementTypeInfo, typesRefinement } from "./refinement" +import { AnyType } from "./schemas" +import { TupleTypeInfo, typesTuple } from "./tuple" import { typesUnchecked, UncheckedTypeInfo } from "./unchecked" export { getTypeInfo, TypeInfo } from "./TypeChecker" export { @@ -49,6 +51,7 @@ export { ModelTypeInfo, ModelTypeInfoProps, OrTypeInfo, + TupleTypeInfo, } export const types = { @@ -74,4 +77,17 @@ export const types = { nonEmptyString: typesNonEmptyString, objectMap: typesObjectMap, arraySet: typesArraySet, + tuple: typesTuple, + + mapArray(valueType: T) { + return typesArray(typesTuple(typesString, valueType)) + }, + setArray(valueType: T) { + return typesArray(valueType) + }, + mapObject(valueType: T) { + return typesRecord(valueType) + }, + dateString: typesNonEmptyString, + dateTimestamp: typesInteger, } diff --git a/packages/lib/src/wrappers/arrayAsMap.ts b/packages/lib/src/wrappers/arrayAsMapWrapper.ts similarity index 96% rename from packages/lib/src/wrappers/arrayAsMap.ts rename to packages/lib/src/wrappers/arrayAsMapWrapper.ts index 02279d8b..1f97113d 100644 --- a/packages/lib/src/wrappers/arrayAsMap.ts +++ b/packages/lib/src/wrappers/arrayAsMapWrapper.ts @@ -96,6 +96,8 @@ export class ArrayAsMap implements Map { } /** + * @deprecated Consider using `prop_mapArray` or `tProp_mapArray` instead. + * * Returns a wrapper that wraps an observable tuple array `[K, V][]` * into a map alike interface. * diff --git a/packages/lib/src/wrappers/arrayAsSet.ts b/packages/lib/src/wrappers/arrayAsSetWrapper.ts similarity index 96% rename from packages/lib/src/wrappers/arrayAsSet.ts rename to packages/lib/src/wrappers/arrayAsSetWrapper.ts index e19d0970..3b51316b 100644 --- a/packages/lib/src/wrappers/arrayAsSet.ts +++ b/packages/lib/src/wrappers/arrayAsSetWrapper.ts @@ -83,6 +83,8 @@ export class ArrayAsSet implements Set { } /** + * @deprecated Consider using `prop_setArray` or `tProp_setArray` instead. + * * Returns a wrapper that wraps an observable array `V[]` * into a set alike interface. * diff --git a/packages/lib/src/wrappers/index.ts b/packages/lib/src/wrappers/index.ts index 79e08d96..0e34612a 100644 --- a/packages/lib/src/wrappers/index.ts +++ b/packages/lib/src/wrappers/index.ts @@ -1,5 +1,5 @@ -export * from "./arrayAsMap" -export * from "./arrayAsSet" +export * from "./arrayAsMapWrapper" +export * from "./arrayAsSetWrapper" export * from "./ArraySet" -export * from "./objectAsMap" +export * from "./objectAsMapWrapper" export * from "./ObjectMap" diff --git a/packages/lib/src/wrappers/objectAsMap.ts b/packages/lib/src/wrappers/objectAsMapWrapper.ts similarity index 91% rename from packages/lib/src/wrappers/objectAsMap.ts rename to packages/lib/src/wrappers/objectAsMapWrapper.ts index 5995e52b..f064b2f9 100644 --- a/packages/lib/src/wrappers/objectAsMap.ts +++ b/packages/lib/src/wrappers/objectAsMapWrapper.ts @@ -97,13 +97,15 @@ export class ObjectAsMap implements Map { } /** + * @deprecated Consider using `prop_mapObject` or `tProp_mapObject` instead. + * * Returns a wrapper that wraps an observable object - * `{ [k: string]: V }` into a map alike interface. + * `Record` into a map alike interface. * * @typeparam V Value type * @param getTarget Target store object getter. * @returns */ -export function objectAsMap(getTarget: () => { [k: string]: V }): Map { +export function objectAsMap(getTarget: () => Record): Map { return new ObjectAsMap(getTarget) } diff --git a/packages/lib/test/propTransform/implicit/transformArrayAsMap.test.ts b/packages/lib/test/propTransform/implicit/transformArrayAsMap.test.ts new file mode 100644 index 00000000..1f7a55f0 --- /dev/null +++ b/packages/lib/test/propTransform/implicit/transformArrayAsMap.test.ts @@ -0,0 +1,168 @@ +import { reaction } from "mobx" +import { assert, _ } from "spec.ts" +import { + ActionCall, + applyAction, + ArrayAsMap, + getSnapshot, + model, + Model, + modelAction, + onActionMiddleware, + SnapshotInOf, + SnapshotOutOf, + tProp_mapArray, + types, +} from "../../../src" +import "../../commonSetup" +import { autoDispose } from "../../utils" + +function expectSimilarMap(m1: Map, m2: Map) { + expect([...m1.entries()]).toStrictEqual([...m2.entries()]) +} + +test("transformArrayAsMap", () => { + @model("transformArrayAsMap/M") + class M extends Model({ + // map: prop_mapArray(() => new Map()), + + map: tProp_mapArray(types.mapArray(types.number), () => new Map()), + }) { + @modelAction + setMap(map: Map) { + this.map = map + } + + @modelAction + mapAdd(k: string, n: number) { + this.map.set(k, n) + } + } + + assert(_ as SnapshotInOf["map"], _ as [string, number][] | null | undefined) + assert(_ as SnapshotOutOf["map"], _ as [string, number][]) + + const initialMap = new Map([ + ["1", 1], + ["2", 2], + ["3", 3], + ]) + + const m = new M({ map: initialMap }) + + expect(getSnapshot(m).map).toEqual([ + ["1", 1], + ["2", 2], + ["3", 3], + ]) + + // getter + expect(m.map instanceof ArrayAsMap).toBeTruthy() + expectSimilarMap(m.map, initialMap) + + // should be cached + expect(m.map).toBe(m.map) + + const reactions: Map[] = [] + autoDispose( + reaction( + () => m.map, + d => { + reactions.push(d) + } + ) + ) + + // do some ops + expect(m.map.has("4")).toBe(false) + expect(m.$.map.some(i => i[0] === "4")).toBe(false) + m.mapAdd("4", 4) + expect(m.map.get("4")).toBe(4) + expect(m.$.map.find(i => i[0] === "4")![1]).toBe(4) + + expect(reactions).toHaveLength(0) // since only the contents changed + + // should be cached + expect(m.map).toBe(m.map) + + // setter + const actionCalls: ActionCall[] = [] + autoDispose( + onActionMiddleware(m, { + onStart(actionCall) { + actionCalls.push(actionCall) + }, + onFinish(actionCall) { + actionCalls.push(actionCall) + }, + }) + ) + + const newMap = new Map([ + ["5", 5], + ["6", 6], + ["7", 7], + ]) + m.setMap(newMap) + expectSimilarMap(m.map, newMap) + + expect(m.$.map).toEqual([ + ["5", 5], + ["6", 6], + ["7", 7], + ]) + + expect(actionCalls).toMatchInlineSnapshot(` + Array [ + Object { + "actionName": "setMap", + "args": Array [ + Map { + "5" => 5, + "6" => 6, + "7" => 7, + }, + ], + "targetPath": Array [], + "targetPathIds": Array [], + }, + Object { + "actionName": "setMap", + "args": Array [ + Map { + "5" => 5, + "6" => 6, + "7" => 7, + }, + ], + "targetPath": Array [], + "targetPathIds": Array [], + }, + ] + `) + + expect(reactions).toMatchInlineSnapshot(` + Array [ + Map { + "5" => 5, + "6" => 6, + "7" => 7, + }, + ] + `) + + // apply action should work + applyAction(m, { + actionName: "setMap", + args: [initialMap], + targetPath: [], + targetPathIds: [], + }) + + expectSimilarMap(m.map, initialMap) + expect(m.$.map).toEqual([ + ["1", 1], + ["2", 2], + ["3", 3], + ]) +}) diff --git a/packages/lib/test/propTransform/implicit/transformArrayAsSet.test.ts b/packages/lib/test/propTransform/implicit/transformArrayAsSet.test.ts index 5fc4c31c..2d656508 100644 --- a/packages/lib/test/propTransform/implicit/transformArrayAsSet.test.ts +++ b/packages/lib/test/propTransform/implicit/transformArrayAsSet.test.ts @@ -9,10 +9,10 @@ import { Model, modelAction, onActionMiddleware, - prop, SnapshotInOf, SnapshotOutOf, - transformArrayAsSet, + tProp_setArray, + types, } from "../../../src" import "../../commonSetup" import { autoDispose } from "../../utils" @@ -24,9 +24,11 @@ function expectSimilarSet(s1: Set, s2: Set) { test("transformArrayAsSet", () => { @model("transformArrayAsSet/M") class M extends Model({ - set: transformArrayAsSet( - prop(() => []) - ), + // set: prop_setArray( + // () => new Set([10, 20, 30]) + // ), + + set: tProp_setArray(types.setArray(types.number), () => new Set([10, 20, 30])), }) { @modelAction setSet(set: Set) { @@ -42,6 +44,9 @@ test("transformArrayAsSet", () => { assert(_ as SnapshotInOf["set"], _ as number[] | null | undefined) assert(_ as SnapshotOutOf["set"], _ as number[]) + const m2 = new M({}) + expect(getSnapshot(m2).set).toEqual([10, 20, 30]) + const initialSet = new Set([1, 2, 3]) const m = new M({ set: initialSet }) diff --git a/packages/lib/test/propTransform/implicit/transformObjectAsMap.test.ts b/packages/lib/test/propTransform/implicit/transformObjectAsMap.test.ts new file mode 100644 index 00000000..19120bc6 --- /dev/null +++ b/packages/lib/test/propTransform/implicit/transformObjectAsMap.test.ts @@ -0,0 +1,155 @@ +import { reaction } from "mobx" +import { assert, _ } from "spec.ts" +import { + ActionCall, + applyAction, + getSnapshot, + model, + Model, + modelAction, + ObjectAsMap, + onActionMiddleware, + SnapshotInOf, + SnapshotOutOf, + tProp_mapObject, + types, +} from "../../../src" +import "../../commonSetup" +import { autoDispose } from "../../utils" + +function expectSimilarMap(m1: Map, m2: Map) { + expect([...m1.entries()]).toStrictEqual([...m2.entries()]) +} + +test("transformObjectAsMap", () => { + @model("transformObjectAsMap/M") + class M extends Model({ + // map: prop_mapObject(() => new Map()) + map: tProp_mapObject(types.mapObject(types.number), () => new Map()), + }) { + @modelAction + setMap(map: Map) { + this.map = map + } + + @modelAction + mapAdd(k: string, n: number) { + this.map.set(k, n) + } + } + + assert(_ as SnapshotInOf["map"], _ as { [k: string]: number } | null | undefined) + assert(_ as SnapshotOutOf["map"], _ as { [k: string]: number }) + + const initialMap = new Map([ + ["1", 1], + ["2", 2], + ["3", 3], + ]) + + const m = new M({ map: initialMap }) + + expect(getSnapshot(m).map).toEqual({ 1: 1, 2: 2, 3: 3 }) + + // getter + expect(m.map instanceof ObjectAsMap).toBeTruthy() + expectSimilarMap(m.map, initialMap) + + // should be cached + expect(m.map).toBe(m.map) + + const reactions: Map[] = [] + autoDispose( + reaction( + () => m.map, + d => { + reactions.push(d) + } + ) + ) + + // do some ops + expect(m.map.has("4")).toBe(false) + expect(m.$.map["4"]).toBe(undefined) + m.mapAdd("4", 4) + expect(m.map.get("4")).toBe(4) + expect(m.$.map["4"]).toBe(4) + + expect(reactions).toHaveLength(0) // since only the contents changed + + // should be cached + expect(m.map).toBe(m.map) + + // setter + const actionCalls: ActionCall[] = [] + autoDispose( + onActionMiddleware(m, { + onStart(actionCall) { + actionCalls.push(actionCall) + }, + onFinish(actionCall) { + actionCalls.push(actionCall) + }, + }) + ) + + const newMap = new Map([ + ["5", 5], + ["6", 6], + ["7", 7], + ]) + m.setMap(newMap) + expectSimilarMap(m.map, newMap) + + expect(m.$.map).toEqual({ 5: 5, 6: 6, 7: 7 }) + + expect(actionCalls).toMatchInlineSnapshot(` + Array [ + Object { + "actionName": "setMap", + "args": Array [ + Map { + "5" => 5, + "6" => 6, + "7" => 7, + }, + ], + "targetPath": Array [], + "targetPathIds": Array [], + }, + Object { + "actionName": "setMap", + "args": Array [ + Map { + "5" => 5, + "6" => 6, + "7" => 7, + }, + ], + "targetPath": Array [], + "targetPathIds": Array [], + }, + ] + `) + + expect(reactions).toMatchInlineSnapshot(` + Array [ + Map { + "5" => 5, + "6" => 6, + "7" => 7, + }, + ] + `) + + // apply action should work + applyAction(m, { + actionName: "setMap", + args: [initialMap], + targetPath: [], + targetPathIds: [], + }) + + expectSimilarMap(m.map, initialMap) + expect(m.$.map).toEqual({ 1: 1, 2: 2, 3: 3 }) +}) diff --git a/packages/lib/test/propTransform/implicit/transformStringAsDate.test.ts b/packages/lib/test/propTransform/implicit/transformStringAsDate.test.ts index 10c4f403..f15bd488 100644 --- a/packages/lib/test/propTransform/implicit/transformStringAsDate.test.ts +++ b/packages/lib/test/propTransform/implicit/transformStringAsDate.test.ts @@ -10,8 +10,7 @@ import { onActionMiddleware, SnapshotInOf, SnapshotOutOf, - tProp, - transformStringAsDate, + tProp_dateString, types, } from "../../../src" import "../../commonSetup" @@ -20,7 +19,8 @@ import { autoDispose } from "../../utils" test("transformStringAsDate", () => { @model("transformStringAsDate/M") class M extends Model({ - date: transformStringAsDate(tProp(types.string)), + // date: prop_dateString() + date: tProp_dateString(types.dateString), }) { @modelAction setDate(date: Date) { diff --git a/packages/lib/test/propTransform/implicit/transformTimestampAsDate.test.ts b/packages/lib/test/propTransform/implicit/transformTimestampAsDate.test.ts index 7fa4c4b6..96355e7d 100644 --- a/packages/lib/test/propTransform/implicit/transformTimestampAsDate.test.ts +++ b/packages/lib/test/propTransform/implicit/transformTimestampAsDate.test.ts @@ -8,11 +8,11 @@ import { Model, modelAction, onActionMiddleware, - prop, SnapshotInOf, SnapshotOutOf, timestampAsDate, - transformTimestampAsDate, + tProp_dateTimestamp, + types, } from "../../../src" import "../../commonSetup" import { autoDispose } from "../../utils" @@ -20,7 +20,8 @@ import { autoDispose } from "../../utils" test("transformTimestampAsDate", () => { @model("transformTimestampAsDate/M") class M extends Model({ - date: transformTimestampAsDate(prop()), + // date: prop_dateTimestamp(), + date: tProp_dateTimestamp(types.dateTimestamp), }) { @modelAction setDate(date: Date) { diff --git a/packages/lib/test/typeChecking/typeChecking.test.ts b/packages/lib/test/typeChecking/typeChecking.test.ts index 9600b5e1..bc799573 100644 --- a/packages/lib/test/typeChecking/typeChecking.test.ts +++ b/packages/lib/test/typeChecking/typeChecking.test.ts @@ -39,6 +39,7 @@ import { setGlobalConfig, StringTypeInfo, tProp, + TupleTypeInfo, typeCheck, TypeCheckError, TypeInfo, @@ -304,6 +305,20 @@ test("array - simple types", () => { expect(typeInfo.itemTypeInfo).toEqual(getTypeInfo(types.number)) }) +test("tuple - simple types", () => { + const type = types.tuple(types.number, types.string) + assert(_ as TypeToData, _ as [number, string]) + + expectTypeCheckOk(type, [1, "str1"]) + expectTypeCheckOk(type, [2, "str2"]) + expectTypeCheckFail(type, "ho", [], "[number, string]") + expectTypeCheckFail(type, [1, 2], [1], "string") + + const typeInfo = expectValidTypeInfo(type, TupleTypeInfo) + expect(typeInfo.itemTypes).toEqual([types.number, types.string]) + expect(typeInfo.itemTypeInfos).toEqual([getTypeInfo(types.number), getTypeInfo(types.string)]) +}) + test("record - simple types", () => { const type = types.record(types.number) assert(_ as TypeToData, _ as { [k: string]: number }) diff --git a/packages/site/doczrc.js b/packages/site/doczrc.js index 014e2c12..25a2ab17 100644 --- a/packages/site/doczrc.js +++ b/packages/site/doczrc.js @@ -15,7 +15,7 @@ export default { "Root Stores", "Snapshots", "Patches", - "Maps & Sets", + "Maps, Sets, Dates", { name: "Action Middlewares", menu: [ diff --git a/packages/site/src/examples/clientServer/app.tsx.txt b/packages/site/src/examples/clientServer/app.tsx.txt deleted file mode 120000 index 33a41bc6..00000000 --- a/packages/site/src/examples/clientServer/app.tsx.txt +++ /dev/null @@ -1 +0,0 @@ -app.tsx \ No newline at end of file diff --git a/packages/site/src/examples/clientServer/app.tsx.txt b/packages/site/src/examples/clientServer/app.tsx.txt new file mode 100644 index 00000000..666167f5 --- /dev/null +++ b/packages/site/src/examples/clientServer/app.tsx.txt @@ -0,0 +1,21 @@ +import { observer } from "mobx-react" +import React from "react" +import { AppInstance } from "./appInstance" + +// we will expose both app instances in the ui + +export const App = observer(() => { + return ( +
+
+

App Instance #1

+ +
+ +
+

App Instance #2

+ +
+
+ ) +}) diff --git a/packages/site/src/examples/clientServer/appInstance.tsx.txt b/packages/site/src/examples/clientServer/appInstance.tsx.txt deleted file mode 120000 index 8b347595..00000000 --- a/packages/site/src/examples/clientServer/appInstance.tsx.txt +++ /dev/null @@ -1 +0,0 @@ -appInstance.tsx \ No newline at end of file diff --git a/packages/site/src/examples/clientServer/appInstance.tsx.txt b/packages/site/src/examples/clientServer/appInstance.tsx.txt new file mode 100644 index 00000000..db8e0498 --- /dev/null +++ b/packages/site/src/examples/clientServer/appInstance.tsx.txt @@ -0,0 +1,78 @@ +import { + ActionTrackingResult, + applySerializedActionAndSyncNewModelIds, + fromSnapshot, + onActionMiddleware, + serializeActionCall, + SerializedActionCallWithModelIdOverrides, +} from "mobx-keystone" +import { observer } from "mobx-react" +import React, { useState } from "react" +import { TodoListView } from "../todoList/app" +import { cancelledActionSymbol, LogsView } from "../todoList/logs" +import { TodoList } from "../todoList/store" +import { server } from "./server" + +function initAppInstance() { + // we get the snapshot from the server, which is a serializable object + const rootStoreSnapshot = server.getInitialState() + + // and hydrate it into a proper object + const rootStore = fromSnapshot(rootStoreSnapshot) + + let serverAction = false + const runServerActionLocally = (actionCall: SerializedActionCallWithModelIdOverrides) => { + let wasServerAction = serverAction + serverAction = true + try { + // in clients we use the sync new model ids version to make sure that + // any model ids that were generated in the server side end up being + // the same in the client side + applySerializedActionAndSyncNewModelIds(rootStore, actionCall) + } finally { + serverAction = wasServerAction + } + } + + // listen to action messages to be replicated into the local root store + server.onMessage(actionCall => { + runServerActionLocally(actionCall) + }) + + // also listen to local actions, cancel them and send them to the server + onActionMiddleware(rootStore, { + onStart(actionCall, ctx) { + if (!serverAction) { + // if the action does not come from the server cancel it silently + // and send it to the server + // it will then be replicated by the server and properly executed + server.sendMessage(serializeActionCall(actionCall, rootStore)) + + ctx.data[cancelledActionSymbol] = true // just for logging purposes + + // "cancel" the action by returning undefined + return { + result: ActionTrackingResult.Return, + value: undefined, + } + } else { + // just run the server action unmodified + return undefined + } + }, + }) + + return rootStore +} + +export const AppInstance = observer(() => { + const [rootStore] = useState(() => initAppInstance()) + + return ( + <> + +
+ + + ) +}) diff --git a/packages/site/src/examples/clientServer/server.ts.txt b/packages/site/src/examples/clientServer/server.ts.txt deleted file mode 120000 index 65ac4aab..00000000 --- a/packages/site/src/examples/clientServer/server.ts.txt +++ /dev/null @@ -1 +0,0 @@ -server.ts \ No newline at end of file diff --git a/packages/site/src/examples/clientServer/server.ts.txt b/packages/site/src/examples/clientServer/server.ts.txt new file mode 100644 index 00000000..8cecbaa0 --- /dev/null +++ b/packages/site/src/examples/clientServer/server.ts.txt @@ -0,0 +1,53 @@ +import { + applySerializedActionAndTrackNewModelIds, + getSnapshot, + SerializedActionCall, + SerializedActionCallWithModelIdOverrides, +} from "mobx-keystone" +import { createRootStore } from "../todoList/store" + +type MsgListener = (actionCall: SerializedActionCallWithModelIdOverrides) => void + +class Server { + private serverRootStore = createRootStore() + private msgListeners: MsgListener[] = [] + + getInitialState() { + return getSnapshot(this.serverRootStore) + } + + onMessage(listener: (actionCall: SerializedActionCallWithModelIdOverrides) => void) { + this.msgListeners.push(listener) + } + + sendMessage(actionCall: SerializedActionCall) { + // the timeouts are just to simulate network delays + setTimeout(() => { + // apply the action over the server root store + // sometimes applying actions might fail (for example on invalid operations + // such as when one client asks to delete a model from an array and other asks to mutate it) + // so we try / catch it + let serializedActionCallToReplicate: SerializedActionCallWithModelIdOverrides | undefined + try { + // we use this to apply the action on the server side and keep track of new model IDs being + // generated, so the clients will have the chance to keep those in sync + const applyActionResult = applySerializedActionAndTrackNewModelIds( + this.serverRootStore, + actionCall + ) + serializedActionCallToReplicate = applyActionResult.serializedActionCall + } catch (err) { + console.error("error applying action to server:", err) + } + + if (serializedActionCallToReplicate) { + setTimeout(() => { + // and distribute message, which includes new model IDs to keep them in sync + this.msgListeners.forEach(listener => listener(serializedActionCallToReplicate!)) + }, 500) + } + }, 500) + } +} + +export const server = new Server() diff --git a/packages/site/src/examples/todoList/app.tsx.txt b/packages/site/src/examples/todoList/app.tsx.txt deleted file mode 120000 index 33a41bc6..00000000 --- a/packages/site/src/examples/todoList/app.tsx.txt +++ /dev/null @@ -1 +0,0 @@ -app.tsx \ No newline at end of file diff --git a/packages/site/src/examples/todoList/app.tsx.txt b/packages/site/src/examples/todoList/app.tsx.txt new file mode 100644 index 00000000..d87a3ab4 --- /dev/null +++ b/packages/site/src/examples/todoList/app.tsx.txt @@ -0,0 +1,107 @@ +import { observer } from "mobx-react" +import React, { useState } from "react" +import { LogsView } from "./logs" +import { createRootStore, Todo, TodoList } from "./store" + +// we use mobx-react to connect to the data, as it is usual in mobx +// this library is framework agnostic, so it can work anywhere mobx can work +// (even outside of a UI) + +export const App = observer(() => { + const [rootStore] = useState(() => createRootStore()) + + return ( + <> + +
+ + + ) +}) + +export const TodoListView = observer(({ list }: { list: TodoList }) => { + const [newTodo, setNewTodo] = React.useState("") + + const renderTodo = (todo: Todo) => ( + todo.setDone(!todo.done)} + onRemove={() => list.remove(todo)} + /> + ) + + return ( +
+ {list.pending.length > 0 && ( + <> +
TODO
+ {list.pending.map(t => renderTodo(t))} + + )} + + {list.done.length > 0 && ( + <> +
DONE
+ {list.done.map(t => renderTodo(t))} + + )} +
+ { + setNewTodo(ev.target.value || "") + }} + placeholder="I will..." + /> + +
+ ) +}) + +function TodoView({ + done, + text, + onClick, + onRemove, +}: { + done: boolean + text: string + onClick(): void + onRemove(): void +}) { + return ( +
+ + + {done ? "✔️" : "👀"} + + {text} + {} + + + ❌ + +
+ ) +} diff --git a/packages/site/src/examples/todoList/logs.tsx.txt b/packages/site/src/examples/todoList/logs.tsx.txt deleted file mode 120000 index f16382fb..00000000 --- a/packages/site/src/examples/todoList/logs.tsx.txt +++ /dev/null @@ -1 +0,0 @@ -logs.tsx \ No newline at end of file diff --git a/packages/site/src/examples/todoList/logs.tsx.txt b/packages/site/src/examples/todoList/logs.tsx.txt new file mode 100644 index 00000000..2e10b178 --- /dev/null +++ b/packages/site/src/examples/todoList/logs.tsx.txt @@ -0,0 +1,117 @@ +import { ActionCall, getSnapshot, onActionMiddleware, onPatches, Patch } from "mobx-keystone" +import { observer, useLocalStore } from "mobx-react" +import React, { useEffect } from "react" +import { TodoList } from "./store" + +// this is just for the client/server demo +export const cancelledActionSymbol = Symbol("cancelledAction") +interface ExtendedActionCall extends ActionCall { + cancelled: boolean +} + +export const LogsView = observer((props: { rootStore: TodoList }) => { + const data = useLocalStore(() => ({ + actions: [] as ExtendedActionCall[], + patchesList: [] as Patch[][], + + addAction(actionCall: ExtendedActionCall) { + this.actions.push(actionCall) + }, + addPatches(patches: Patch[]) { + this.patchesList.push(patches) + }, + })) + + useEffect(() => { + // we can use action middlewares for several things + // in this case we will keep a log of the actions done over the todo list + const disposer = onActionMiddleware(props.rootStore, { + onFinish(actionCall, ctx) { + const extendedActionCall: ExtendedActionCall = { + ...actionCall, + cancelled: !!ctx.data[cancelledActionSymbol], + } + data.addAction(extendedActionCall) + }, + }) + return disposer + }, [props.rootStore]) + + useEffect(() => { + // also it is possible to get a list of changes in the form of patches, + // even with inverse patches to undo the changes + const disposer = onPatches(props.rootStore, (patches, _inversePatches) => { + data.addPatches(patches) + }) + return disposer + }) + + // we can convert any model (or part of it) into a plain JS structure + // with it we can: + // - serialize to later deserialize it with `fromSnapshot` + // - pass it to non mobx-friendly components + // snapshots respect immutability, so if a subobject is changed + // its refrence will be kept + const rootStoreSnapshot = getSnapshot(props.rootStore) + + return ( + <> + + {data.actions.map((action, index) => ( + + ))} + + + {data.patchesList.map(patchesToText)} + + + {JSON.stringify(rootStoreSnapshot, null, 2)} + + + ) +}) + +function ActionCallToText(props: { actionCall: ExtendedActionCall }) { + const actionCall = props.actionCall + + const args = actionCall.args.map(arg => JSON.stringify(arg)).join(", ") + const path = actionCall.targetPath.join("/") + let text = `[${path}] ${actionCall.actionName}(${args})` + if (actionCall.cancelled) { + return ( + <> + {text}{" "} + (cancelled and sent to server) +
+ + ) + } + return ( + <> + {text} +
+ + ) +} + +function patchToText(patch: Patch) { + const path = patch.path.join("/") + let str = `[${path}] ${patch.op}` + if (patch.op !== "remove") { + str += " -> " + JSON.stringify(patch.value) + } + return str + "\n" +} + +function patchesToText(patches: Patch[]) { + return patches.map(patchToText) +} + +function PreSection(props: { title: string; children: React.ReactNode }) { + return ( + <> +
{props.title}
+
{props.children}
+ + ) +} diff --git a/packages/site/src/examples/todoList/store.ts.txt b/packages/site/src/examples/todoList/store.ts.txt deleted file mode 120000 index a856a10f..00000000 --- a/packages/site/src/examples/todoList/store.ts.txt +++ /dev/null @@ -1 +0,0 @@ -store.ts \ No newline at end of file diff --git a/packages/site/src/examples/todoList/store.ts.txt b/packages/site/src/examples/todoList/store.ts.txt new file mode 100644 index 00000000..e324eb8c --- /dev/null +++ b/packages/site/src/examples/todoList/store.ts.txt @@ -0,0 +1,108 @@ +import { computed } from "mobx" +import { + connectReduxDevTools, + model, + Model, + modelAction, + ModelAutoTypeCheckingMode, + registerRootStore, + setGlobalConfig, + tProp, + types, +} from "mobx-keystone" +import uuid from "uuid" + +// for this example we will enable runtime data checking even in production mode +setGlobalConfig({ + modelAutoTypeChecking: ModelAutoTypeCheckingMode.AlwaysOn, +}) + +// the model decorator marks this class as a model, an object with actions, etc. +// the string identifies this model type and must be unique across your whole application +@model("todoSample/Todo") +export class Todo extends Model({ + // here we define the type of the model data, which is observable and snapshottable + // and also part of the required initialization data of the model + + // in this case we use runtime type checking, + id: tProp(types.string, () => uuid.v4()), // an optional string that will use a random id when not provided + text: tProp(types.string), // a required string + done: tProp(types.boolean, false), // an optional boolean that will default to false + + // if we didn't require runtime type checking we could do this + // id: prop(() => uuid.v4()) + // text: prop(), + // done: prop(false) +}) { + // the modelAction decorator marks the function as a model action, giving it access + // to modify any model data and other superpowers such as action + // middlewares, replication, etc. + @modelAction + setDone(done: boolean) { + this.done = done + } + + @modelAction + setText(text: string) { + this.text = text + } +} + +@model("todoSample/TodoList") +export class TodoList extends Model({ + // in this case the default uses an arrow function to create the object since it is not a primitive + // and we need a different array for each model instane + todos: tProp(types.array(types.model(Todo)), () => []), + + // if we didn't require runtime type checking + // todos: prop(() => []) +}) { + // standard mobx decorators (such as computed) can be used as usual, since props are observables + @computed + get pending() { + return this.todos.filter(t => !t.done) + } + + @computed + get done() { + return this.todos.filter(t => t.done) + } + + @modelAction + add(todo: Todo) { + this.todos.push(todo) + } + + @modelAction + remove(todo: Todo) { + const index = this.todos.indexOf(todo) + if (index >= 0) { + this.todos.splice(index, 1) + } + } +} + +export function createRootStore(): TodoList { + // the parameter is the initial data for the model + const rootStore = new TodoList({ + todos: [ + new Todo({ text: "make mobx-keystone awesome!" }), + new Todo({ text: "spread the word" }), + new Todo({ text: "buy some milk", done: true }), + ], + }) + + // although not strictly required, it is always a good idea to register your root stores + // as such, since this allows the model hook `onAttachedToRootStore` to work and other goodies + registerRootStore(rootStore) + + // we can also connect the store to the redux dev tools + const remotedev = require("remotedev") + const connection = remotedev.connectViaExtension({ + name: "Todo List Example", + }) + + connectReduxDevTools(remotedev, connection, rootStore) + + return rootStore +} diff --git a/packages/site/src/mapsAndSets.mdx b/packages/site/src/mapsAndSets.mdx deleted file mode 100644 index 564c50fb..00000000 --- a/packages/site/src/mapsAndSets.mdx +++ /dev/null @@ -1,102 +0,0 @@ ---- -name: Maps & Sets -route: /mapsAndSets ---- - -import { FixStyle } from "./components/FixStyle.tsx" - - - -# Maps & Sets - -## Overview - -Although `mobx-keystone` doesn't support properties which are Maps/Sets directly (for JSON compatibility purposes), you can still simulate Map/Set interfaces in two ways: - -1. The `ObjectMap` and `ArraySet` models. -2. The wrappers `arrayAsSet`, `arrayAsMap` and `objectAsMap`. - -## `ObjectMap` model - -```ts -class ... extends Model({ - myNumberMap: prop(() => objectMap()) - // or if there's no default value - myNumberMap: prop>() -}) {} -``` - -All the usual map operations are available (clear, set, get, has, keys, values, ...), and the snapshot representation of this model will be something like: - -```ts -{ - $modelType: "mobx-keystone/ObjectMap", - $modelId: "Td244...", - items: { - "key1": value1, - "key2": value2, - } -} -``` - -## `ArraySet` model - -```ts -class ... extends Model({ - myNumberSet: prop(() => arraySet()) - // or if there's no default value - myNumberSet: prop>() -}) {} -``` - -All the usual set operations are available (clear, add, has, keys, values, ...), and the snapshot representation of this model will be something like: - -```ts -{ - $modelType: "mobx-keystone/ArraySet", - $modelId: "Td244...", - items: [ - value1, - value2 - ] -} -``` - -## `objectAsMap` wrapper - -`objectAsMap` will wrap a property of type `{ [k: string]: V }` and wrap it into a `Map` alike interface: - -```ts -class ... { - // given myRecord: prop<{ [k: string]: V }>(() => ({})) - readonly myMap = objectAsMap(() => this.myRecord) -} - -// then myMap can be used as a standard Map -``` - -## `arrayAsMap` wrapper - -`arrayAsMap` will wrap a property of type `[string, V][]` and wrap it into a `Map` alike interface: - -```ts -class ... { - // given myArrayMap: prop<[string, V][]>(() => []) - readonly myMap = arrayAsMap(() => this.myArrayMap) -} - -// then myMap can be used as a standard Map -``` - -## `arrayAsSet` wrapper - -`arrayAsSet` will wrap a property of type `V[]` and wrap it into a `Set` alike interface: - -```ts -class ... { - // given myArraySet: prop(() => []) - readonly mySet = arrayAsSet(() => this.myArraySet) -} - -// then mySet can be used as a standard Set -``` diff --git a/packages/site/src/mapsSetsDates.mdx b/packages/site/src/mapsSetsDates.mdx new file mode 100644 index 00000000..1e4417f2 --- /dev/null +++ b/packages/site/src/mapsSetsDates.mdx @@ -0,0 +1,219 @@ +--- +name: Maps, Sets, Dates +route: /mapsSetsDates +--- + +import { FixStyle } from "./components/FixStyle.tsx" + + + +# Maps, Sets, Dates + +## Overview + +Although `mobx-keystone` doesn't support properties which are Maps/Sets/Dates directly (for JSON compatibility purposes), you can still simulate them in three ways: + +1. The properties with implicit transforms: + - `prop_mapObject` / `tProp_mapObject` + - `prop_mapArray` / `tProp_mapArray` + - `prop_setArray` / `tProp_setArray` + - `prop_dateString` / `tProp_dateString` + - `prop_dateTimestamp` / `tProp_dateTimestamp` +2. The `ObjectMap` and `ArraySet` collection models. +3. _(Deprecated in favor of 1. properties with implicit transforms)_ The `arrayAsSet`, `arrayAsMap` and `objectAsMap` collection wrappers. + +## Properties with implicit transforms + +### `prop_mapObject` / `tProp_mapObject` + +A map backed by an object with string keys. + +```ts +class ... extends Model({ + // without type checking + myNumberMap: prop_mapObject(() => new Map()) + // or if there's no default value + myNumberMap: prop_mapObject>() + + // with type checking + myNumberMap: tProp_mapObject(types.mapObject(types.number), () => new Map()) + // or if there's no default value + myNumberMap: tProp_mapObject(types.mapObject(types.number)) +}) {} + +model.myNumberMap // the transformed Map() +model.$.myNumberMap // the backed Record, same as in the snapshot +``` + +### `prop_mapArray` / `tProp_mapArray` + +A map backed by a [string, VALUE] array. Note that, currently, since the backed property is actually an array lookups will need to iterate over all array items. + +```ts +class ... extends Model({ + // without type checking + myNumberMap: prop_mapArray(() => new Map()) + // or if there's no default value + myNumberMap: prop_mapArray>() + + // with type checking + myNumberMap: tProp_mapArray(types.mapArray(types.number), () => new Map()) + // or if there's no default value + myNumberMap: tProp_mapArray(types.mapArray(types.number)) +}) {} + +model.myNumberMap // the transformed Map() +model.$.myNumberMap // the backed [string, number][], same as in the snapshot +``` + +### `prop_setArray` / `tProp_setArray` + +A set backed by an array. Note that, currently, since the backed property is actually an array lookups will need to iterate over all array items. + +```ts +class ... extends Model({ + // without type checking + myNumberSet: prop_setArray(() => new Set()) + // or if there's no default value + myNumberSet: prop_setArray>() + + // with type checking + myNumberMap: tProp_setArray(types.setArray(types.number), () => new Set()) + // or if there's no default value + myNumberMap: tProp_setArray(types.setArray(types.number)) +}) {} + +model.myNumberSet // the transformed Set() +model.$.myNumberSet // the backed number[], same as in the snapshot +``` + +### `prop_dateString` / `tProp_dateString` + +A date backed by an ISO date string. + +```ts +class ... extends Model({ + // without type checking + myDate: prop_dateString(() => new Date()) + // or if there's no default value + myDate: prop_dateString() + + // with type checking + myDate: tProp_dateString(types.dateString, () => new Date()) + // or if there's no default value + myDate: tProp_dateString(types.datestring) +}) {} + +model.myDate // the transformed Date +model.$.myDate // the backed ISO date string, same as in the snapshot +``` + +### `prop_dateString` / `tProp_dateString` + +A date backed by a number timestamp. + +```ts +class ... extends Model({ + // without type checking + myDate: prop_dateTimestamp(() => new Date()) + // or if there's no default value + myDate: prop_dateTimestamp() + + // with type checking + myDate: tProp_dateTimestamp(types.dateTimestamp, () => new Date()) + // or if there's no default value + myDate: tProp_dateTimestamp(types.dateTimestamp) +}) {} + +model.myDate // the transformed Date +model.$.myDate // the backed number date timestamp, same as in the snapshot +``` + +## Collection models + +### `ObjectMap` collection model + +```ts +class ... extends Model({ + myNumberMap: prop(() => objectMap()) + // or if there's no default value + myNumberMap: prop>() +}) {} +``` + +All the usual map operations are available (clear, set, get, has, keys, values, ...), and the snapshot representation of this model will be something like: + +```ts +{ + $modelType: "mobx-keystone/ObjectMap", + $modelId: "Td244...", + items: { + "key1": value1, + "key2": value2, + } +} +``` + +### `ArraySet` collection model + +```ts +class ... extends Model({ + myNumberSet: prop(() => arraySet()) + // or if there's no default value + myNumberSet: prop>() +}) {} +``` + +All the usual set operations are available (clear, add, has, keys, values, ...), and the snapshot representation of this model will be something like: + +```ts +{ + $modelType: "mobx-keystone/ArraySet", + $modelId: "Td244...", + items: [ + value1, + value2 + ] +} +``` + +## (Deprecated) Collection wrappers + +### (Deprecated) `objectAsMap` collection wrapper + +`objectAsMap` will wrap a property of type `{ [k: string]: V }` and wrap it into a `Map` alike interface: + +```ts +class ... { + // given myRecord: prop<{ [k: string]: V }>(() => ({})) + readonly myMap = objectAsMap(() => this.myRecord) +} + +// then myMap can be used as a standard Map +``` + +### (Deprecated) `arrayAsMap` collection wrapper + +`arrayAsMap` will wrap a property of type `[string, V][]` and wrap it into a `Map` alike interface: + +```ts +class ... { + // given myArrayMap: prop<[string, V][]>(() => []) + readonly myMap = arrayAsMap(() => this.myArrayMap) +} + +// then myMap can be used as a standard Map +``` + +### (Deprecated) `arrayAsSet` collection wrapper + +`arrayAsSet` will wrap a property of type `V[]` and wrap it into a `Set` alike interface: + +```ts +class ... { + // given myArraySet: prop(() => []) + readonly mySet = arrayAsSet(() => this.myArraySet) +} + +// then mySet can be used as a standard Set +``` diff --git a/packages/site/src/propertyTransforms.mdx b/packages/site/src/propertyTransforms.mdx index c61d11a6..918f65bd 100644 --- a/packages/site/src/propertyTransforms.mdx +++ b/packages/site/src/propertyTransforms.mdx @@ -7,7 +7,7 @@ import { FixStyle } from "./components/FixStyle.tsx" -# Property Transforms +# Property Transform Decorators ## Overview @@ -18,7 +18,7 @@ In order to address this you can use a property transform. For example, to transform from a number timestamp into a `Date` object you can start by defining a property transform: ```ts -const asDate = propTransform({ +const asDate = propTransformDecorator({ propToData(prop: number) { return new Date(prop) }, @@ -88,6 +88,8 @@ then propToData(PV) must be DV Since date manipulation is very common, these built-in property transforms are already provided. `stringAsDate` transfrom from an ISO date string to a `Date` object, while `timestampAsDate` transforms from a number timestamp to a `Date` object. +That being said, check the new `prop_dateString`, `tProp_dateString`, `prop_dateTimestamp` and `tProp_dateTimestamp` described in the [Map, Sets, Dates](./mapsSetsDates) section. It should be more straightforward. + ## Action serialization with custom types as arguments Action serialization (via `serializeActionCall` and `deserializeActionCall`) supports many cases by default: diff --git a/packages/site/src/runtimeTypeChecking.mdx b/packages/site/src/runtimeTypeChecking.mdx index 242b8ca4..d239438c 100644 --- a/packages/site/src/runtimeTypeChecking.mdx +++ b/packages/site/src/runtimeTypeChecking.mdx @@ -171,6 +171,14 @@ A type that represents an array of values of a given type. const numberArrayType = types.array(types.number) ``` +### `types.tuple(...itemTypes)` + +A type that represents a tuple of values of a given type. + +```ts +const stringNumberTupleType = types.tuple(types.string, types.number) +``` + ### `types.record(valuesType)` A type that represents an object-like map, an object with string keys and values all of a same given type. From 97332ca2c9f638c182e0f15409445dc4560b3168 Mon Sep 17 00:00:00 2001 From: Javier Gonzalez Date: Wed, 12 Feb 2020 20:05:17 +0100 Subject: [PATCH 4/4] change changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 869dede9..05ec54a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Change Log - [BREAKING CHANGE - types] Some type helpers have been renamed: `ModelData` -> `ModelPropsData` / `ModelInstanceData`, `ModelCreationData` -> `ModelPropsCreationData` / `ModelInstanceCreationData`. -- New feature: "Implicit property transforms", which are sometimes preferred over the old decorator based property transforms, wrappers (`arrayAsSet`, `arrayAsMap`, `objectAsMap`) and collection models (`ArraySet`, `ObjectMap`). Check the "Maps, Sets, Dates" section in the docs for more info. +- New feature: "Implicit property transforms", which are sometimes preferred over the old decorator based property transforms, collection wrappers (`arrayAsSet`, `arrayAsMap`, `objectAsMap`) and collection models (`ArraySet`, `ObjectMap`). Check the "Maps, Sets, Dates" section in the docs for more info. - Added `types.tuple`. - Property transforms decorators can now also be used standalone.