import { action, computed, makeObservable, observable } from 'mobx'; import { alias, list as serializrList, serializable } from 'serializr'; import { ScriptingGlobals } from '../client/util/ScriptingGlobals'; import { Deserializable, afterDocDeserialize, autoObject } from '../client/util/SerializationHelper'; import { Doc, Field, FieldType, ObjGetRefFields, StrListCast } from './Doc'; import { FieldTuples, Self, SelfProxy } from './DocSymbols'; import { Copy, FieldChanged, Parent, ToJavascriptString, ToScriptString, ToString } from './FieldSymbols'; import { ObjectField } from './ObjectField'; import { ProxyField } from './Proxy'; import { RefField } from './RefField'; import { containedFieldChangedHandler, deleteProperty, getter, setter } from './util'; function toObjectField(field: FieldType) { return field instanceof Doc ? new ProxyField(field) : field; } function toRealField(field: FieldType | undefined) { return field instanceof ProxyField ? field.value : field; } type StoredType = T extends Doc ? ProxyField : T; export const ListFieldName = 'fields'; @Deserializable('list') export class ListImpl extends ObjectField { static listHandlers = { /// Mutator methods copyWithin: function (this: ListImpl) { throw new Error('copyWithin not supported yet'); }, fill: function (this: ListImpl, value: FieldType, start?: number, end?: number) { if (value instanceof RefField) { throw new Error('fill with RefFields not supported yet'); } const res = this[Self].__fieldTuples.fill(value, start, end); this[SelfProxy][FieldChanged]?.(); return res; }, pop: function (this: ListImpl): FieldType { const field = toRealField(this[Self].__fieldTuples.pop()); this[SelfProxy][FieldChanged]?.(); return field; }, push: action(function (this: ListImpl, ...itemsIn: FieldType[]) { const items = itemsIn.map(toObjectField); const list = this[Self]; const { length } = list.__fieldTuples; for (let i = 0; i < items.length; i++) { const item = items[i]; // TODO Error checking to make sure parent doesn't already exist if (item instanceof ObjectField) { item[Parent] = list; item[FieldChanged] = containedFieldChangedHandler(this[SelfProxy], i + length, item); } } const res = list.__fieldTuples.push(...items); this[SelfProxy][FieldChanged]?.({ op: '$addToSet', items, length: length + items.length }); return res; }), reverse: function (this: ListImpl) { const res = this[Self].__fieldTuples.reverse(); this[SelfProxy][FieldChanged]?.(); return res; }, shift: function (this: ListImpl) { const res = toRealField(this[Self].__fieldTuples.shift()); this[SelfProxy][FieldChanged]?.(); return res; }, sort: function (this: ListImpl, cmpFunc: (first: FieldType | undefined, second: FieldType | undefined) => number) { this[Self].__realFields; // coerce retrieving entire array const res = this[Self].__fieldTuples.sort(cmpFunc ? (first: FieldType, second: FieldType) => cmpFunc(toRealField(first), toRealField(second)) : undefined); this[SelfProxy][FieldChanged]?.(); return res; }, splice: action(function (this: ListImpl, start: number, deleteCount: number, ...itemsIn: FieldType[]) { this[Self].__realFields; // coerce retrieving entire array const items = itemsIn.map(toObjectField); const list = this[Self]; const removed = list.__fieldTuples.filter((item: FieldType, i: number) => i >= start && i < start + deleteCount); for (let i = 0; i < items.length; i++) { const item = items[i]; // TODO Error checking to make sure parent doesn't already exist // TODO Need to change indices of other fields in array if (item instanceof ObjectField) { item[Parent] = list; item[FieldChanged] = containedFieldChangedHandler(this, i + start, item); } } const hintArray: { val: FieldType; index: number }[] = []; for (let i = start; i < start + deleteCount; i++) { hintArray.push({ val: list.__fieldTuples[i], index: i }); } const res = list.__fieldTuples.splice(start, deleteCount, ...items); // the hint object sends the starting index of the slice and the number // of elements to delete. this[SelfProxy][FieldChanged]?.( items.length === 0 && deleteCount ? { op: '$remFromSet', items: removed, hint: { start, deleteCount }, length: list.__fieldTuples.length } : items.length && !deleteCount && start === list.__fieldTuples.length ? { op: '$addToSet', items, length: list.__fieldTuples.length } : undefined ); return res.map(toRealField); }), unshift: function (this: ListImpl, ...itemsIn: FieldType[]) { const items = itemsIn.map(toObjectField); const list = this[Self]; for (let i = 0; i < items.length; i++) { const item = items[i]; // TODO Error checking to make sure parent doesn't already exist // TODO Need to change indices of other fields in array if (item instanceof ObjectField) { item[Parent] = list; item[FieldChanged] = containedFieldChangedHandler(this, i, item); } } const res = this[Self].__fieldTuples.unshift(...items); this[SelfProxy][FieldChanged]?.(); return res; }, /// Accessor methods concat: action(function (this: ListImpl, ...items: FieldType[]) { this[Self].__realFields; return this[Self].__fieldTuples.map(toRealField).concat(...items); }), includes: function (this: ListImpl, valueToFind: FieldType, fromIndex: number) { if (valueToFind instanceof RefField) { return this[Self].__realFields.includes(valueToFind, fromIndex); } return this[Self].__fieldTuples.includes(valueToFind, fromIndex); }, indexOf: function (this: ListImpl, valueToFind: FieldType, fromIndex: number) { if (valueToFind instanceof RefField) { return this[Self].__realFields.indexOf(valueToFind, fromIndex); } return this[Self].__fieldTuples.indexOf(valueToFind, fromIndex); }, join: function (this: ListImpl, separator: string) { this[Self].__realFields; return this[Self].__fieldTuples.map(toRealField).join(separator); }, lastElement: function (this: ListImpl) { return this[Self].__realFields.lastElement(); }, lastIndexOf: function (this: ListImpl, valueToFind: FieldType, fromIndex: number) { if (valueToFind instanceof RefField) { return this[Self].__realFields.lastIndexOf(valueToFind, fromIndex); } return this[Self].__fieldTuples.lastIndexOf(valueToFind, fromIndex); }, slice: function (this: ListImpl, begin: number, end: number) { this[Self].__realFields; return this[Self].__fieldTuples.slice(begin, end).map(toRealField); }, /// Iteration methods entries: function (this: ListImpl) { return this[Self].__realFields.entries(); }, every: function (this: ListImpl, callback: (value: FieldType, index: number, array: FieldType[]) => unknown, thisArg: unknown) { return this[Self].__realFields.every(callback, thisArg); // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. // If we don't want to support the array parameter, we should use this version instead // return this[Self].__fieldTuples.every((element:any, index:number, array:any) => callback(toRealField(element), index, array), thisArg); }, filter: function (this: ListImpl, callback: (value: FieldType, index: number, array: FieldType[]) => FieldType[], thisArg: unknown) { return this[Self].__realFields.filter(callback, thisArg); // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. // If we don't want to support the array parameter, we should use this version instead // return this[Self].__fieldTuples.filter((element:any, index:number, array:any) => callback(toRealField(element), index, array), thisArg); }, find: function (this: ListImpl, callback: (value: FieldType, index: number, obj: FieldType[]) => FieldType, thisArg: unknown) { return this[Self].__realFields.find(callback, thisArg); // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. // If we don't want to support the array parameter, we should use this version instead // return this[Self].__fieldTuples.find((element:any, index:number, array:any) => callback(toRealField(element), index, array), thisArg); }, findIndex: function (this: ListImpl, callback: (value: FieldType, index: number, obj: FieldType[]) => number, thisArg: unknown) { return this[Self].__realFields.findIndex(callback, thisArg); // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. // If we don't want to support the array parameter, we should use this version instead // return this[Self].__fieldTuples.findIndex((element:any, index:number, array:any) => callback(toRealField(element), index, array), thisArg); }, forEach: function (this: ListImpl, callback: (value: FieldType, index: number, array: FieldType[]) => void, thisArg: unknown) { return this[Self].__realFields.forEach(callback, thisArg); // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. // If we don't want to support the array parameter, we should use this version instead // return this[Self].__fieldTuples.forEach((element:any, index:number, array:any) => callback(toRealField(element), index, array), thisArg); }, map: function (this: ListImpl, callback: (value: FieldType, index: number, array: FieldType[]) => unknown, thisArg: unknown) { return this[Self].__realFields.map(callback, thisArg); // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. // If we don't want to support the array parameter, we should use this version instead // return this[Self].__fieldTuples.map((element:any, index:number, array:any) => callback(toRealField(element), index, array), thisArg); }, reduce: function (this: ListImpl, callback: (previousValue: unknown, currentValue: FieldType, currentIndex: number, array: FieldType[]) => unknown, initialValue: unknown) { return this[Self].__realFields.reduce(callback, initialValue); // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. // If we don't want to support the array parameter, we should use this version instead // return this[Self].__fieldTuples.reduce((acc:any, element:any, index:number, array:any) => callback(acc, toRealField(element), index, array), initialValue); }, reduceRight: function (this: ListImpl, callback: (previousValue: unknown, currentValue: FieldType, currentIndex: number, array: FieldType[]) => unknown, initialValue: unknown) { return this[Self].__realFields.reduceRight(callback, initialValue); // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. // If we don't want to support the array parameter, we should use this version instead // return this[Self].__fieldTuples.reduceRight((acc:any, element:any, index:number, array:any) => callback(acc, toRealField(element), index, array), initialValue); }, some: function (this: ListImpl, callback: (value: FieldType, index: number, array: FieldType[]) => boolean, thisArg: unknown) { return this[Self].__realFields.some(callback, thisArg); // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. // If we don't want to support the array parameter, we should use this version instead // return this[Self].__fieldTuples.some((element:any, index:number, array:any) => callback(toRealField(element), index, array), thisArg); }, values: function (this: ListImpl) { return this[Self].__realFields.values(); }, [Symbol.iterator]: function (this: ListImpl) { return this[Self].__realFields.values(); }, }; static listGetter(target: ListImpl, prop: string | symbol, receiver: ListImpl): unknown { if (Object.prototype.hasOwnProperty.call(ListImpl.listHandlers, prop)) { return (ListImpl.listHandlers as { [key: string | symbol]: unknown })[prop]; } return getter(target, prop, receiver); } constructor(fields?: T[]) { super(); makeObservable(this); const list = new Proxy(this, { set: setter, get: ListImpl.listGetter, ownKeys: target => { const keys = Object.keys(target.__fieldTuples); return [...keys, FieldTuples, Self, SelfProxy, '__realFields']; }, getOwnPropertyDescriptor: (target, prop) => { if (prop in target[FieldTuples]) { return { configurable: true, // TODO Should configurable be true? enumerable: true, }; } return Reflect.getOwnPropertyDescriptor(target, prop); }, deleteProperty: deleteProperty, defineProperty: () => { throw new Error("Currently properties can't be defined on documents using Object.defineProperty"); }, }); // eslint-disable-next-line no-use-before-define this[SelfProxy] = list as unknown as List; // bcz: ugh .. don't know how to convince typesecript that list is a List if (fields) { this[SelfProxy].push(...fields); } return list; // need to return the proxy here, otherwise we don't get any of our list handler functions } [key: number]: T | (T extends RefField ? Promise : never); [key2: symbol]: unknown; [key3: string]: unknown; // this requests all ProxyFields at the same time to avoid the overhead // of separate network requests and separate updates to the React dom. @computed private get __realFields() { const unrequested = this[FieldTuples].filter(f => f instanceof ProxyField && f.needsRequesting).map(f => f as ProxyField); // if we find any ProxyFields that don't have a current value, then // start the server request for all of them if (unrequested.length) { const batchPromise = ObjGetRefFields(unrequested.map(p => p.fieldId)); // as soon as we get the fields from the server, set all the list values in one // action to generate one React dom update. const allSetPromise = batchPromise.then(action(pfields => unrequested.map(toReq => toReq.setValue(pfields.get(toReq.fieldId))))); // we also have to mark all lists items with this promise so that any calls to them // will await the batch request and return the requested field value. unrequested.forEach(p => p.setExternalValuePromise(allSetPromise)); } return this[FieldTuples].map(toRealField); } @serializable(alias(ListFieldName, serializrList(autoObject(), { afterDeserialize: afterDocDeserialize }))) get __fieldTuples() { return this[FieldTuples]; } set __fieldTuples(value) { this[FieldTuples] = value; Object.keys(value).forEach(key => { const item = value[Number(key)]; if (item instanceof ObjectField) { item[Parent] = this[Self]; item[FieldChanged] = containedFieldChangedHandler(this[SelfProxy], Number(key), item); } }); } [Copy]() { const copiedData = this[Self].__fieldTuples.map(f => (f instanceof ObjectField ? f[Copy]() : f)); const deepCopy = new ListImpl(copiedData as T[]); return deepCopy; } // @serializable(alias("fields", list(autoObject()))) @observable private [FieldTuples]: StoredType[] = []; private [Self] = this; // eslint-disable-next-line no-use-before-define private [SelfProxy]: List; // also used in utils.ts even though it won't be found using find all references [ToScriptString]() { return `new List(${this[ToJavascriptString]()})`; } // prettier-ignore [ToJavascriptString]() { return `[${(this[FieldTuples]).map(field => Field.toScriptString(field))}]`; } // prettier-ignore [ToString]() { return `[${(this[FieldTuples]).map(field => Field.toString(field))}]`; } // prettier-ignore } // declare List as a type so you can use it in type declarations, e.g., { l: List, ...} export type List = ListImpl & (T | (T extends RefField ? Promise : never))[]; // declare List as a value so you can invoke 'new' on it, e.g., new List() (since List IS ListImpl, we can safely cast the 'new' return value to return List) // eslint-disable-next-line no-use-before-define export const List: { new (fields?: T[]): List } = ListImpl as unknown as { new (fields?: T[]): List }; ScriptingGlobals.add('List', List); // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function compareLists(l1: List, l2: List) { const L1 = StrListCast(l1); const L2 = StrListCast(l2); return !L1 && !L2 ? true : L1 && L2 && L1.length === L2.length && L2.reduce((p, v) => p && L1.includes(v), true); }, 'compare two lists');