import { action, observable } from 'mobx'; import { alias, list, serializable } from 'serializr'; import { DocServer } from '../client/DocServer'; import { ScriptingGlobals } from '../client/util/ScriptingGlobals'; import { afterDocDeserialize, autoObject, Deserializable } from '../client/util/SerializationHelper'; import { Field } from './Doc'; import { FieldTuples, Self, SelfProxy } from './DocSymbols'; import { Copy, FieldChanged, Parent, ToScriptString, ToString } from './FieldSymbols'; import { ObjectField } from './ObjectField'; import { ProxyField } from './Proxy'; import { RefField } from './RefField'; import { listSpec } from './Schema'; import { Cast } from './Types'; import { deleteProperty, getter, setter, containedFieldChangedHandler } from './util'; function toObjectField(field: Field) { return field instanceof RefField ? new ProxyField(field) : field; } function toRealField(field: Field) { return field instanceof ProxyField ? field.value : field; } type StoredType = T extends RefField ? ProxyField : T; export const ListFieldName = 'fields'; @Deserializable('list') class ListImpl extends ObjectField { static listHandlers: any = { /// Mutator methods copyWithin() { throw new Error('copyWithin not supported yet'); }, fill(value: any, 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(): any { const field = toRealField(this[Self].__fieldTuples.pop()); this[SelfProxy][FieldChanged]?.(); return field; }, push: action(function (this: ListImpl, ...items: any[]) { items = items.map(toObjectField); const list = this[Self]; const length = list.__fieldTuples.length; 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() { const res = this[Self].__fieldTuples.reverse(); this[SelfProxy][FieldChanged]?.(); return res; }, shift() { const res = toRealField(this[Self].__fieldTuples.shift()); this[SelfProxy][FieldChanged]?.(); return res; }, sort(cmpFunc: any) { this[Self].__realFields(); // coerce retrieving entire array const res = this[Self].__fieldTuples.sort(cmpFunc ? (first: any, second: any) => cmpFunc(toRealField(first), toRealField(second)) : undefined); this[SelfProxy][FieldChanged]?.(); return res; }, splice: action(function (this: any, start: number, deleteCount: number, ...items: any[]) { this[Self].__realFields(); // coerce retrieving entire array items = items.map(toObjectField); const list = this[Self]; const removed = list.__fieldTuples.filter((item: any, 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); } } let hintArray: { val: any; 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(...items: any[]) { items = items.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: any, ...items: any[]) { this[Self].__realFields(); return this[Self].__fieldTuples.map(toRealField).concat(...items); }), includes(valueToFind: any, fromIndex: number) { if (valueToFind instanceof RefField) { return this[Self].__realFields().includes(valueToFind, fromIndex); } else { return this[Self].__fieldTuples.includes(valueToFind, fromIndex); } }, indexOf(valueToFind: any, fromIndex: number) { if (valueToFind instanceof RefField) { return this[Self].__realFields().indexOf(valueToFind, fromIndex); } return this[Self].__fieldTuples.indexOf(valueToFind, fromIndex); }, join(separator: any) { this[Self].__realFields(); return this[Self].__fieldTuples.map(toRealField).join(separator); }, lastElement() { return this[Self].__realFields().lastElement(); }, lastIndexOf(valueToFind: any, fromIndex: number) { if (valueToFind instanceof RefField) { return this[Self].__realFields().lastIndexOf(valueToFind, fromIndex); } else { return this[Self].__fieldTuples.lastIndexOf(valueToFind, fromIndex); } }, slice(begin: number, end: number) { this[Self].__realFields(); return this[Self].__fieldTuples.slice(begin, end).map(toRealField); }, /// Iteration methods entries() { return this[Self].__realFields().entries(); }, every(callback: any, thisArg: any) { 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(callback: any, thisArg: any) { 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(callback: any, thisArg: any) { 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(callback: any, thisArg: any) { 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(callback: any, thisArg: any) { 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(callback: any, thisArg: any) { 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(callback: any, initialValue: any) { 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(callback: any, initialValue: any) { 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(callback: any, thisArg: any) { 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() { return this[Self].__realFields().values(); }, [Symbol.iterator]() { return this[Self].__realFields().values(); }, }; static listGetter(target: any, prop: string | symbol, receiver: any): any { if (ListImpl.listHandlers.hasOwnProperty(prop)) { return ListImpl.listHandlers[prop]; } return getter(target, prop, receiver); } constructor(fields?: T[]) { super(); const list = new Proxy(this, { set: setter, get: ListImpl.listGetter, ownKeys: target => Object.keys(target.__fieldTuples), 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"); }, }); this[SelfProxy] = list as any as List; // bcz: ugh .. don't know how to convince typesecript that list is a List if (fields) { this[SelfProxy].push(...fields); } return list; } [key: number]: T | (T extends RefField ? Promise : never); // this requests all ProxyFields at the same time to avoid the overhead // of separate network requests and separate updates to the React dom. private __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 = DocServer.GetRefFields(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[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, list(autoObject(), { afterDeserialize: afterDocDeserialize }))) private get __fieldTuples() { return this[FieldTuples]; } private set __fieldTuples(value) { this[FieldTuples] = value; for (const key in value) { const item = value[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 any); return deepCopy; } // @serializable(alias("fields", list(autoObject()))) @observable private [FieldTuples]: StoredType[] = []; private [Self] = this; private [SelfProxy]: List; // also used in utils.ts even though it won't be found using find all references [ToScriptString]() { return `new List([${(this as any).map((field: any) => Field.toScriptString(field))}])`; } [ToString]() { return `List(${(this as any).length})`; } } export type List = ListImpl & (T | (T extends RefField ? Promise : never))[]; export const List: { new (fields?: T[]): List } = ListImpl as any; ScriptingGlobals.add('List', List); ScriptingGlobals.add(function compareLists(l1: any, l2: any) { const L1 = Cast(l1, listSpec('string'), []); const L2 = Cast(l2, listSpec('string'), []); return !L1 && !L2 ? true : L1 && L2 && L1.length === L2.length && L2.reduce((p, v) => p && L1.includes(v), true); }, 'compare two lists');