import { isEqual } from 'lodash' import log from 'loglevel' import { makeObservable, override, reaction, runInAction } from 'mobx' import { v4 as uuid } from 'uuid' import { Template } from '../../interfaces' import { AttributeValue, ElementFormData, ElementSchemaVersion, instanceOfLocalElement, } from '../../interfaces/element.interface' import { TICKET_TYPE } from '../constants' import { cloneElementData, Element } from '../element' /* CommitState provisional - element is local only staged - element is local but scheduled to be uploaded committed - element has been uploaded to backend */ export enum CommitState { PROVISIONAL, STAGED, IN_PROGRESS, COMMITTED, ERRORED, } // Now we use a generic version which can apply a constraint on // the class which this mixin is applied to // eslint-disable-next-line @typescript-eslint/no-explicit-any type GConstructor = new (...args: any[]) => T export const isLocal = (Base: TBase) => { return class LocalElement extends Base { state: CommitState = CommitState.PROVISIONAL // these local elements need to be created before this one. createQueue: LocalElement[] = [] // eslint-disable-next-line @typescript-eslint/no-explicit-any constructor(...args: any[]) { super(args[0], uuid(), args[2], false, args[4]) // MobX 6: the mixin overrides Element's `update` and `setAttributeValue` // (both annotated as `action` in Element). We must declare them as // `override` here; otherwise Element's `makeObservable` (run via super) // walks the prototype chain and finds the mixin's overrides on the wrong // prototype level, which v6 rejects. makeObservable(this, { update: override, setAttributeValue: override, }) this.autoSave = false this.source = 'local' if ( args[4] && this.data && this.originalData && !this.data.organization ) { this.data.organization = args[4].organization this.originalData.organization = args[4].organization } this.saveHandler() // dispose Element's saveHandler this.saveHandler = reaction( () => isEqual(this.originalData, this.data), async (isEqual) => { if (isEqual === true) { this.dirty = false log.debug('saveHandler, isEqual === true') if (!this.dataHash) { this.updateDataHash() } return } this.dirty = true log.debug('localElement changed, updating dataHash') this.updateDataHash() if ( this.template && this.store.userStore.schemaVersion === ElementSchemaVersion.V2 ) { this.computeEffects(this.template as Template) } }, ) } setHash(hash: string) { this.hash = hash } async publish(parent?: Element): Promise { if (this.state === CommitState.COMMITTED) { log.warn('element is already published') return null } if (!this.data || !this.type) { log.warn(`localElement ${this.elementName} has no data or type`) return null } this.state = CommitState.STAGED runInAction(() => { this.store.elementStore.elements.set(this.hash, this) }) if (!this.store.connectivityService?.isConnected) { log.log('cannot publish in offline') this.dirty = false await this.store.elementStore.saveElementToStorage(this) return null } this.state = CommitState.IN_PROGRESS if (parent) { log.log(`localElement has parent ${parent.hash}`) this.parent = parent } if (instanceOfLocalElement(this.parent)) { log.log('unshifting parent in queue because its local') this.createQueue.unshift(this.parent as LocalElement) } // create queued elements await this.resolveQueue() const flattenedData = Element.dehydrateData(this.data) log.log( 'publishing, flattenedData', JSON.stringify(flattenedData, null, 2), ) this.loading = true // create element here try { let actualElement: { hash: string } if (this.type === TICKET_TYPE) { actualElement = await this.store.elementStore.createTicket( flattenedData, this.data.organization, ) } else { actualElement = await this.store.elementStore.create( this.type, flattenedData, this.data.organization, { parent: this.parent, }, ) } if (!actualElement) { throw new Error(`localElement ${this.elementName} not created`) } log.log(`removing ${this.elementName}#${this.hash} from elementStore`) await this.store.elementStore.removeElement(this.hash) this.setHash(actualElement.hash) this.state = CommitState.COMMITTED // set original data to data so that no changes are made before we allow autoSave this.originalData = cloneElementData(this.data) if (this.children.length > 0) { const childPromises = this.children.map((child) => { if (instanceOfLocalElement(child)) { return child.publish(this) } else { log.log(`child ${child.hash} created before parent`) return } }) await Promise.all(childPromises) } this.autoSave = true this.loading = false this.dirty = false return actualElement.hash } catch (e) { this.loading = false this.state = CommitState.ERRORED log.error(e) throw e } finally { await this.store.elementStore.saveElementToStorage(this) } } async update(data?: ElementFormData) { if (this.state !== CommitState.COMMITTED) { log.warn( 'trying to save uncommited element', this.elementName, this.hash, ) return false } return super.update(data) } setAttributeValue( attribute: string, value: AttributeValue, isPublic?: boolean, ) { // log.debug('setting attribute value', attribute, value) const set = super.setAttributeValue(attribute, value, isPublic) if (!set) { log.debug('attribute not set', attribute, value) return false } const type = this.getAttributeType(attribute) if (type === 'Element') { const valueArray = Array.isArray(value) ? value : [value] const local: LocalElement[] = (valueArray as []).filter((v) => instanceOfLocalElement(v), ) this.createQueue.push(...local) } this.updateDataHash() return set } async resolveQueue() { const promises = this.createQueue.map((localElement) => { return localElement.publish() }) console.log('resolveQueue, length:', promises.length) return Promise.all(promises) } } }