import { apply } from 'json-logic-js' import { castArray, clone, cloneDeep, isDate, isEqual, isNumber, isObject, isString, noop, omit, partition, uniqBy, } from 'lodash' import log from 'loglevel' import { action, computed, makeObservable, observable, reaction, runInAction, set, } from 'mobx' import { applyPatch, Operation as RfcOperation } from 'rfc6902' import { AttributeModifier, AttributeType, DEFAULT_MARKER_CONFIG, ElementSchema, IWorkRecord, List, MarkerConfig, SchemaId, Template, TextBuiltIn, WorkRecordData, } from '../interfaces' import { AccessDeniedError, AttributeData, AttributeDefinition, AttributeDefinitions, AttributeFileData, AttributeFileValue, AttributeMeta, AttributeValue, AttributeValueSingle, AttributeValueTypeElement, DataSource, DehydratedAttributeValue, DehydratedAttributeValueSingle, DehydratedElementData, eDocuImageSize, ElementAttributeData, ElementData, ElementFormData, ElementSchemaVersion, ElementTemplateV1, FileBatchResponse, GetElementV1Response, GetElementV2Response, isFileData, isLocalFile, isRemoteFile, LinkRel, Operation, Operations, PreparedFiles, RestApiLink, User, } from '../interfaces/element.interface' import { IListingConfig } from '../interfaces/listing.interface' import { DuplicateError } from '../platform/fileServiceProvider' import { getSchemaId } from '../schema/schemaIdUrl' import { OrganizationInactiveError } from '../services' import { ElementGetQueryParams, ElementPrivileges, } from '../services/edocu/elements' import { MainStore } from '../stores/mainStore' import { extendObject } from '../util/extendObject' import { isArrayLike } from '../util/isArrayLike' import { parseDynamicValue } from '../util/parseDynamicValue' import { parseRulesLogic } from '../util/parseRulesLogic' import { retryTransient } from './_default/retry' import { ELEMENT_NAME, ELEMENT_PARENT, IMAGE_SIZE_RELS, META_ATTRIBUTES, } from './constants' export class Element { childrenData: ElementData[] = [] originalData?: ElementData = undefined data?: ElementData = undefined dataHash?: string = undefined children: Element[] = [] childrenCount?: number = undefined workRecords: Map = new Map() workRecordsCount?: number = undefined effects: Record< string, Record > = {} relationElements: { [attribute: string]: Element[] } = {} references: { [attribute: string]: Element[] } = {} interalListingHashes: string[] = [] predecessors?: Element[] = undefined predecessorsCount?: number = undefined hash: string store: MainStore completeData = false loading = false loadingPromise: Promise | undefined = undefined errored?: Error = undefined dirty = false source: DataSource = 'backend' parent?: Element autoSave = true saveHandler: () => void onChangeDataHash: () => void listeners: (() => void)[] = [] changeListenerRef?: () => void constructor( store: MainStore, hash: string, data?: ElementData, shouldLoad = false, ) { // MobX 6: explicitly activate observability for this class. Must run // before any observable writes below and before the reaction() // registrations at the bottom of the constructor (those read .data, // .originalData, .dataHash). Subclasses add their own makeObservable // call with annotations for the fields they add — see e.g. Ticket. makeObservable(this, { childrenData: observable, originalData: observable, data: observable, dataHash: observable, children: observable, childrenCount: observable, workRecords: observable, workRecordsCount: observable, effects: observable, relationElements: observable, references: observable, interalListingHashes: observable, predecessors: observable, predecessorsCount: observable, completeData: observable, loading: observable, loadingPromise: observable, errored: observable, dirty: observable, autoSave: observable, mapLabel: computed, markerConfig: computed, elementName: computed, organization: computed, sortValue: computed, type: computed, displayType: computed, privileges: computed, elementSchemaId: computed, template: computed, typeData: computed, addInteralListingHash: action, extendElementData: action, setAttributes: action, loadData: action, setResponseScopeData: action, createWorkRecord: action, createCountingWorkRecord: action, loadRelation: action, togglePublishAttributes: action, update: action, setAttributeValue: action, hydrateRelationElements: action, performUpdate: action, }) this.store = store this.hash = hash if (data) { this.originalData = cloneElementData(data) } this.data = data const parentData = this.data?._element_parent if (parentData?.value && typeof parentData.value === 'object') { this.parent = this.store.elementStore.instantiateElement( parentData.value.hash, parentData.value as unknown as ElementData, ) } if (shouldLoad) { this.loadingPromise = this.loadData() } else { this.loadingPromise = this.initialize() } this.saveHandler = reaction( () => isEqual(this.originalData, this.data), (isEqual) => { if (isEqual === true) { this.dirty = false if (!this.dataHash) { this.updateDataHash() } return } this.dirty = true this.updateDataHash() this.autoUpdate() }, ) this.onChangeDataHash = reaction( () => this.dataHash, () => { if ( this.template && this.store.userStore.schemaVersion === ElementSchemaVersion.V2 ) { this.computeEffects(this.template as Template) } }, ) } getChangeListenerTopic() { if (!this.store.userStore.user) { return } return `element-notifications/${this.organization}/${this.store.userStore.user.id}/${this.type}/${this.hash}` } registerChangeListener(): () => void { if (this.changeListenerRef) { log.trace(`tried to resubscribe to element ${this.hash}`) return noop } const subscription = this.store.elementStore.subscribeToElementUpdates(this) if (subscription) { log.debug(`subscribed to element ${this.hash}`) this.changeListenerRef = subscription } else { log.warn(`could not subscribe to element ${this.hash}`) } return () => { subscription?.() this.changeListenerRef = undefined } } // Called when element is cleared destructor(): void { // execute all dispose functions this.listeners.map((dispose) => dispose()) this.dispose() } static get TYPE(): string { return '' } static get ATTRIBUTES() { return { ELEMENT_NAME: 'element_name', ARCHIVED: 'archived', ELEMENT_PARENT, } } static get SHORTNAME(): string { return 'Element' } static get PREVIEW_ATTRIBUTES(): string[] { return [] } static get LINEVIEW_ATTRIBUTES(): string[] { return [Element.ATTRIBUTES.ELEMENT_NAME] } static get GEO_RELATION_ATTRIBUTES(): string[] { return [] } static get DISABLED_ATTRIBUTES(): string[] { return [] } getPrefilledAttributes(): Record { return {} } get mapLabel(): string | null { return null } get markerConfig(): MarkerConfig { return DEFAULT_MARKER_CONFIG } get elementName(): string { // If we have element name attribute, we use it const nameValue = (this.data?.[ELEMENT_NAME] as AttributeData)?.value as | string | undefined if (nameValue && nameValue !== '') { return nameValue } // If we have a schema, we can use the valueTemplate to generate the name if ( this.dataHash && // this is important for refreshing value on data change this.typeData && this.store.userStore.schemaVersion === ElementSchemaVersion.V2 ) { const schema = this.typeData as ElementSchema const nameProperty = schema.properties?.[ELEMENT_NAME] as | TextBuiltIn | undefined const valueTemplate = nameProperty?.valueTemplate // log.debug(`valueTemplate: ${valueTemplate}`) if (valueTemplate && valueTemplate !== '') { const dynamic = parseDynamicValue(valueTemplate, { element: this, uid: this.store.userStore.user?.id, userElement: this.store.userStore.userElement, }) // log.debug(`dynamic: ${dynamic} ${this.dataHash}`) if (dynamic && dynamic !== valueTemplate && dynamic !== '') { return dynamic } } } // For local elements, we return empty string if (!nameValue && (this.source === 'local' || this.loading)) { return '' } // If we don't have a schema, or the schema doesn't have a valueTemplate, we // fallback to hash if no name is set return this.hash } get organization(): string | undefined { return this.data?.organization } // Default sort elements by Name get sortValue() { return this.getAttributeTextValue(Element.ATTRIBUTES.ELEMENT_NAME) } get type(): string | undefined { return this?.data?._type || undefined } get displayType(): string { if ( !this.store.translationStore.translations[ this.store.translationStore.locale ] ) { return this.type || '' } const short = (this.constructor as typeof Element).SHORTNAME if (short === 'Element' && this.type) { return this.store.translationStore.getTranslatedType(this.type) || short } return short } addInteralListingHash(hash: string) { this.interalListingHashes = [ ...new Set([hash, ...this.interalListingHashes.slice()]), ] } async initialize(): Promise { return } get privileges(): ElementPrivileges | undefined { if ( !this.data?.organization || !this.type || !this.store.elementStore.privileges.has(this.type) ) { return } return this.store.elementStore.privileges.get(this.type)?.[ this.data.organization ] } getEditableAttributes(): string[] { // set to null on sub-classes to disallow editing if (!this.privileges) { return [] } return Object.entries(this.privileges.attributes) .filter(([, value]) => { return value.includes('update') }) .map(([key]) => key) } extendElementData( data?: Partial, onlyAcceptNewerTimestamp = false, ) { if (!data) { return } // console.log(`extendingElementData ${this.hash}`, data) const jsData = cloneElementData(data) const hasAutoSave = this.autoSave this.autoSave = false let shouldRefreshDataHash = false Object.entries(jsData).forEach(([attribute, value]) => { if (!this.data || !this.originalData || !value) { return } if (META_ATTRIBUTES.includes(attribute)) { if (!this.data[attribute]) { set(this.data, attribute, value) } if (!this.originalData[attribute]) { set(this.data, attribute, value) } } else { if (this.getAttributeType(attribute) === AttributeType.FILE) { // merge file values const oldFiles = (this.data[attribute] as AttributeFileData) || [] const newFiles = (value as AttributeFileData) || [] // compute if there are any new file hashes const filesChanged = !isEqual( oldFiles.filter(isRemoteFile), newFiles.filter(isRemoteFile), ) if (filesChanged) { const uniqueFiles = uniqBy([...newFiles, ...oldFiles], (f) => isRemoteFile(f) ? f.file.hash : f.id, ) set(this.data, attribute, uniqueFiles) set(this.originalData, attribute, uniqueFiles) shouldRefreshDataHash = true } } else { const oldTimestamp = (this.data[attribute] as AttributeData) ?.timestamp const newTimestamp = (jsData[attribute] as AttributeData)?.timestamp ?? new Date().toISOString() if ( !onlyAcceptNewerTimestamp || !oldTimestamp || new Date(oldTimestamp) < new Date(newTimestamp) ) { set(this.data, attribute, value) set(this.originalData, attribute, value) shouldRefreshDataHash = true } } } }) if (shouldRefreshDataHash) { log.info( `Refreshing data for ${this.hash}, onlyAcceptNewerTimestamp: ${onlyAcceptNewerTimestamp}`, ) this.updateDataHash() } this.autoSave = hasAutoSave // console.timeEnd(`extendingElementData ${this.hash}`) } setAttributes(values: ElementFormData): Record { return Object.entries(values).reduce( (acc, [attribute, value]) => ({ ...acc, [attribute]: this.setAttributeValue(attribute, value), }), {}, ) } getAttributeFile(attribute: string): AttributeFileData | undefined { if (!this.attributeExists(attribute)) { return } return this.data?.[attribute] as unknown as AttributeFileData } getAttributeFirstFileUri( attribute: string, size: eDocuImageSize = 'download', ) { const files = this.getAttributeFile(attribute) if (!files || files.length === 0) { return } const firstFile = files.find( (f) => !isLocalFile(f) && !f?.file?.archived, ) as AttributeFileValue if (!firstFile) { return } return firstFile.links.find((link) => link.rel === size)?.href } getAttributeElementValue( attribute: string, onlyModel?: new (...params: unknown[]) => T, ): T[] | undefined { const value = this.getAttributeRawValue(attribute) if (!value) { return } return this.store.elementStore .instantiateElementValue(value) ?.filter((e) => { return onlyModel ? e instanceof onlyModel : true }) as T[] // TS complains on this but this should work } getAttributeTextValue(attribute: string): string { const attributeType = this.getAttributeType(attribute) if (attributeType === AttributeType.FILE) { const files = this.getAttributeFile(attribute) return ( files ?.filter(isRemoteFile) .map((f) => f.file.filename) .join(', ') || '' ) } const value = this.getAttributeRawValue(attribute) if (value === undefined) { return '' } if (attributeType === AttributeType.DROPDOWN) { return this.store.translationStore.getTranslatedAttributeValue( this.type, value as string, ) } if (attributeType === AttributeType.ELEMENT) { const v = castArray(value) as ( | AttributeValueTypeElement | string | Element )[] return v .map((e) => { if (typeof e === 'string') { return e } else if (e instanceof Element) { return e.elementName } else if ( isObject(e) && Object.prototype.hasOwnProperty.call(e, 'hash') ) { return (e as AttributeValueTypeElement).hash } return '' }) .join(', ') } const arrayValue = castArray(value) return arrayValue.filter((v) => isString(v) || isNumber(v)).join(', ') } async loadRaw(force = false) { return this.loadData(force, { scope: ['element'] }) } // PURE function that gets the element's scope async loadElementQuery(queryParams: ElementGetQueryParams) { if (!this.store.connectivityService?.isConnected) { return } const params: ElementGetQueryParams = extendObject(queryParams, { forceVersion: this.store.userStore.schemaVersion, }) const response = await this.store.elements.get( this.hash, this.type, false, params, ) if (!response.ok) { return undefined } return response.data } async loadData(force = false, queryParams?: ElementGetQueryParams) { if ((!force && this.completeData) || this.loading) { return } if (this.store.connectivityService?.isConnected === false) { return } const params: ElementGetQueryParams = Object.assign( {}, (this.constructor as typeof Element).GET_QUERY_PARAMS(), Object.assign({}, queryParams || {}, { forceVersion: this.store.userStore.schemaVersion, }), ) this.loading = true const response = await this.store.elements.get( this.hash, this.type, false, params, ) if (response.ok && response.data) { if ( response.data.visibilityException && response.data.visibilityException.hiddenAttrs === 'all' ) { this.errored = new Error( "You don't have permission to view this element.", ) this.loading = false return } //determine version const version = response.data.schemaVersion const originalAutoSave = this.autoSave const parentData: Record> = response .data.parent ? { [ELEMENT_PARENT]: { value: response.data.parent, timestamp: response.data.parent.timestamp || new Date().toISOString(), organization: response.data.parent.organization, author: response.data.parent.author || { uid: '', cn: '', sn: '', }, isPublic: response.data.parent.isPublic, }, } : {} const dataWithParent = { ...parentData, ...response.data.element, } as ElementData runInAction(() => { this.autoSave = false this.originalData = cloneDeep(dataWithParent) this.data = dataWithParent this.autoSave = originalAutoSave this.updateDataHash() }) if (version !== this.store.userStore.schemaVersion) { console.warn('recieved incorrect version metadata') return } this.setResponseScopeData(response.data) if (version === ElementSchemaVersion.V1) { const responseV1 = response.data as GetElementV1Response if (responseV1.references) { this.references = this.instantiateReferences(responseV1.references) } if ( responseV1.template && this.type && responseV1.element.organization ) { this.store.elementStore.addTemplateV1Data( this.type, responseV1.element.organization, responseV1.template, ) } if (responseV1.element_type) { this.store.elementStore.addTypeData( responseV1.element._type, responseV1.element_type.attributes, ) this.store.elementStore.addWorkRecordActions( responseV1.element._type, responseV1.element_type.actions, ) } } else if (version === ElementSchemaVersion.V2) { const responseV2 = response.data as GetElementV2Response if (!this.elementSchemaId) { return } this.store.elementStore.elementSchemas.set( this.elementSchemaId, responseV2.schema, ) const templateId = responseV2.template.id.split('/').pop() this.store.elementStore.setTemplate( this.elementSchemaId, responseV2.template, templateId, ) } this.hydrateRelationElements() this.completeData = true this.source = 'backend' this.errored = undefined } else { if (response.status === 404) { this.errored = new Error('Element not found') } else if ( response.status === 403 || response.status === 401 || response.headers?.['location']?.includes('idp.edocu.eu') || // redirect to login page response.headers?.['Location']?.includes('idp.edocu.eu') ) { this.errored = new AccessDeniedError() } else if (response.status === 422) { this.errored = new OrganizationInactiveError('Organization inactive') } else if (!response.ok && response.data?.error) { this.errored = new Error(response.data?.error.description) } else { this.errored = new Error('Failed to load element') } } await this.initialize() this.store.elementStore.saveElementToStorage(this) this.loading = false return response } setResponseScopeData(response?: GetElementV1Response | GetElementV2Response) { if (!response) { return } if (response.children) { this.children = response.children .map(({ element: child }) => { return this.store.elementStore.instantiateElement(child.hash, child) }) .filter(Boolean) as Element[] if (response.childrenCount) this.childrenCount = response.childrenCount } if (response.workRecords) { Object.entries(response.workRecords).forEach(([hash, w]) => this.workRecords.set(hash, w.work_record), ) if (response.workRecordsCount) this.workRecordsCount = response.workRecordsCount } if (response.parent) { this.parent = this.store.elementStore.instantiateElement( response.parent.hash, response.parent, ) } if (response.predecessors) { this.predecessors = response.predecessors .map(({ element: predecessor }) => { return this.store.elementStore.instantiateElement( predecessor.hash, predecessor, ) }) .filter(Boolean) as Element[] this.predecessorsCount = response.predecessorsCount } } async loadAttributes(attributes: string[]): Promise { const response = await this.store.batch.getBatchAttributes({ elements: [this.hash], metadata: attributes, }) if ( !response || !response.ok || !response.data || !response.data.elements[this.hash] ) { log.error('failed to load metadata') return false } this.extendElementData(response.data.elements[this.hash].element) return true } async createWorkRecord(data: WorkRecordData) { if (!this.type || !this.data?.organization) { log.warn('Cannot create work record without type and organization') return } const files = data.files delete data.files const res = await this.store.workRecords.create( this.type, this.hash, this.data.organization, data, ) if (!res.ok || !res.data) { log.error('failed to create work record') return } let additionalData: Partial = {} if (files?.length) { const uploadResponse = await this.store.elementStore.uploadFiles( this.type, this.hash, [ { attribute: { hash: res.data.hash, id: '_workRecords', }, value: files, }, ], res.data.hash, ) if (uploadResponse) { if (!uploadResponse.ok || !uploadResponse.data) { console.warn('Failed to upload files', uploadResponse) return } const { attachment } = transformUploadedFiles( uploadResponse.data, this.data.organization, ) additionalData = { ...additionalData, files: attachment, } } } const newRecord: IWorkRecord = { archive: false, hash: res.data.hash, organization: this.data.organization, actionId: data.actionID, content: data.content, metadata: data.metadata, createdAt: new Date().toISOString(), author: { cn: '', sn: '', uid: '', }, ...additionalData, } this.workRecords.set(res.data.hash, newRecord) return newRecord } async createCountingWorkRecord(data: WorkRecordData) { if (!this.type || !this.data?.organization) { console.warn('Cannot create work record without type and organization') return } const res = await this.store.workRecords.createCountingWorkRecord( this.type, this.hash, this.data.organization, data, ) if (!res.ok || !res.data) { console.warn('failed to create counting work record', res) return } let additionalData: Partial = {} if (data.files?.length) { const uploadResponse = await this.store.elementStore.uploadFiles( this.type, this.hash, [ { attribute: { hash: res.data._id, id: '_workRecords', }, value: data.files, }, ], res.data._id, ) if (uploadResponse) { if (!uploadResponse.ok || !uploadResponse.data) { console.warn('Failed to upload files', uploadResponse) return } const { attachment } = transformUploadedFiles( uploadResponse.data, this.data.organization, ) additionalData = { ...additionalData, files: attachment, } } } const newRecord: IWorkRecord = { archive: false, hash: res.data._id, organization: this.data.organization, actionId: data.actionID, content: data.content, metadata: data.metadata, createdAt: new Date().toISOString(), author: { cn: '', sn: '', uid: '', }, ...additionalData, } this.workRecords.set(res.data._id, newRecord) return newRecord } instantiateReferences(references: { [attribute: string]: ElementData[] }) { return Object.keys(references).reduce( (acc: Record, referenceAttribute) => { const elements = references[referenceAttribute] .map((element) => this.store.elementStore.instantiateElement(element.hash, element), ) .filter(Boolean) as Element[] acc[referenceAttribute] = elements return acc }, {}, ) } async loadRelation(relation: string) { if (!this.relationElements[relation]) { return } const related = this.relationElements[relation].map((element) => element.loadData(), ) await Promise.all(related) } async togglePublishAttributes(publish: boolean) { if (!this.originalData || !this.data || !this.type) { return false } const autoSave = this.autoSave this.autoSave = false this.loading = true Object.entries(this.data || {}).forEach(([attribute, value]) => { if (isValueAttributeData(attribute, value)) { ;(this.data?.[attribute] as AttributeData).isPublic = publish } }) const original = {} const modified = Element.dehydrateData(this.data) try { await this.performUpdate(modified, original) await this.store.elementStore.saveElementToStorage(this) return true } catch (e) { return false } finally { this.loading = false this.autoSave = autoSave } } updateDataHash() { if (!this.data) { return } log.trace('updateDataHash()') const newHash = this.store.elementStore.computeElementDataHash(this.data) if (newHash !== this.dataHash) { log.trace(`Updating data hash for ${this.hash} to ${newHash}`) this.dataHash = newHash } } getLocalFiles(modified: DehydratedElementData) { const files = this.store.elementStore.getLocalFiles(modified) const fileAttributes: string[] = [] files.forEach((file) => { const fileAttribute = typeof file.attribute === 'string' ? file.attribute : file.attribute.id // we can throw this data out now so it doesnt mess up the element update if (fileAttribute) { fileAttributes.push(fileAttribute) } }) return { files, fileAttributes, } } autoUpdate() { try { if (!this.autoSave) { return } log.debug(`autoUpdate() invoked for ${this.hash}`) return this.update() } catch (e) { if (e instanceof Error) { this.errored = e } return false } } async update(data: ElementFormData = {}): Promise { if (!this.originalData || !this.data || !this.type) { return false } const autoSaveOriginal = this.autoSave try { this.autoSave = false this.loading = true // These attributes are always sent, even if they are not changed const diffIgnoredAttributes: string[] = [] Object.entries(data).forEach(([key, value]) => { const type = this.getAttributeType(key) if (type === AttributeType.DROPDOWN_BUTTON) { diffIgnoredAttributes.push(key) } this.setAttributeValue(key, value) }) if (this.store.connectivityService?.isConnected) { const originalData = omit(this.originalData, diffIgnoredAttributes) const original = Element.dehydrateData(originalData) const modified = Element.dehydrateData(this.data) const { files, fileAttributes } = this.getLocalFiles(modified) fileAttributes.forEach((attribute) => { delete original[attribute] delete modified[attribute] }) const dataUpdateRes = await this.performUpdate(modified, original) const fileUploadRes = await this.uploadFiles(files) if (dataUpdateRes === false) { // performUpdate already set this.errored with the specific cause throw this.errored ?? new Error('Data update failed') } if (!fileUploadRes || fileUploadRes.ok === false) { const err = fileUploadRes?.error || new Error('File upload failed') throw err } } await this.store.elementStore.saveElementToStorage(this) await Promise.all(this.children.map((c) => c.update())) this.errored = undefined return true } catch (e) { if (e instanceof DuplicateError) { throw e } // Keep the cause on the instance so consumers (which only get `false` // back) can inspect and report why the save failed. this.errored = e instanceof Error ? e : new Error(String(e)) log.warn(`update failed for ${this.type}#${this.hash}`, e) return false } finally { this.loading = false this.autoSave = autoSaveOriginal } } async uploadFiles( files: PreparedFiles[], ): Promise<{ ok: boolean; files: PreparedFiles[]; error?: Error } | void> { if (files.length === 0) { return { ok: true, files: [], } } if (!this.type) { return } const clearLocalFiles = () => { files.forEach((prepared) => { if (!this.data) { return } const fileAttribute = typeof prepared.attribute === 'string' ? prepared.attribute : prepared.attribute.id const uris = prepared.value.map((file) => file.id) const val = ( (this.data[fileAttribute] as AttributeFileData) ?? [] ).filter((f) => (isLocalFile(f) ? !uris.includes(f.id) : true)) this.data[fileAttribute] = val }) } try { const uploadResponse = await this.store.elementStore.uploadFiles( this.type, this.hash, files, ) clearLocalFiles() const uploadedFiles = uploadResponse?.data || [] uploadedFiles.forEach((uploadedFile) => { const downloadLink = uploadedFile.links.find( (f) => f.rel === LinkRel.DOWNLOAD, ) // we have to enhance links as the response doesnt contain different sizes const links: RestApiLink[] = [ ...uploadedFile.links, ...IMAGE_SIZE_RELS.map( (is) => ({ rel: is, href: `${downloadLink?.href}?resolution=${is.replace( 'image-preview-', '', )}`, }) as RestApiLink, ), ] const file: AttributeFileValue = { file: { author: { cn: '', sn: '', uid: '', }, comments: [], file_size: uploadedFile['file-size'], filename: uploadedFile['file-name'], hash: uploadedFile['hash'], organization: this.store.userStore.organization.id, relevant: false, timestamp: new Date().toISOString(), archived: false, }, links: links, } this.setAttributeValue(uploadedFile['attribute-name'], [file]) }) this.updateDataHash() return { ok: true, files, } } catch (e) { console.error(e) // Deliberately keep the local file entries in this.data on failure so a // retry can re-attempt the upload. Clearing them here permanently lost // the user's file — a later retry then saved without it. return { ok: false, files, error: e as Error, } } } dispose() { // clean up autosave observer if (this.saveHandler) { this.saveHandler() } } static GET_QUERY_PARAMS(): ElementGetQueryParams { return {} } // eslint-disable-next-line @typescript-eslint/no-unused-vars static LISTING_TITLE(mainStore: MainStore, _preset?: IListingConfig): string { return mainStore.translationStore.getTranslatedType(this.TYPE) || '' } static LISTING_PRESETS( // eslint-disable-next-line @typescript-eslint/no-unused-vars _mainStore: MainStore, // eslint-disable-next-line @typescript-eslint/no-unused-vars _payload?: string, ): IListingConfig[] { return [] } async performFileAction( action: 'archived' | 'relevant' | 'public', attribute: string, fileHash: string, ) { if (!this.type || !this.data) { return } const current = this.getAttributeFile(attribute) if (!current) { return } const fileIdx = current.findIndex( (f) => isRemoteFile(f) && f?.file.hash === fileHash, ) const fileData = current[fileIdx] as AttributeFileValue const fileKey = action === 'public' ? 'isPublic' : action const setFileDataToActionState = ( file: AttributeFileValue, actionValue: boolean, ) => { if (!this.data) { return } const current = this.getAttributeFile(attribute) if (!current) { return } const fileIdx = current.findIndex( (f) => isRemoteFile(f) && f?.file.hash === fileHash, ) const modifiedFile = cloneDeep(file) as AttributeFileValue modifiedFile.file[fileKey] = actionValue current.splice(fileIdx, 1) const newValue = current.concat([modifiedFile]) this.data[attribute] = newValue this.updateDataHash() } if (!fileData) { log.error( 'File not found on element', this.elementName, attribute, fileHash, ) return } setFileDataToActionState(fileData, !fileData.file[fileKey]) const res = await this.store.files.fileAction( { type: this.type, hash: this.hash }, attribute, fileHash, fileData.file.organization, fileData.file[fileKey] === true ? 'delete' : 'post', action, ) if (!res.ok) { // revert changes setFileDataToActionState(fileData, fileData.file[fileKey] ?? false) throw new Error(`Failed to ${action} file`) } } async archive() { return this.update({ archived: 'true', }) } async unarchive() { return this.update({ archived: 'false', }) } static dehydrateDataAndUnwrapValues( element: Partial, ): Record { const data = Element.dehydrateData(element) const unwrapped = {} as Record< string, AttributeFileData | DehydratedAttributeValue > Object.entries(data).forEach(([key, value]) => { if (isValueAttributeData(key, value)) { unwrapped[key] = ( value as AttributeData ).value } else { unwrapped[key] = value as AttributeFileData } }) return unwrapped } static dehydrateData(element: Partial): DehydratedElementData { return Object.entries(element).reduce((acc, [key, wrappedValue]) => { if (wrappedValue === undefined || key === 'links') { return acc } if (isValueAttributeData(key, wrappedValue)) { const attributeData = wrappedValue as AttributeData const flattenedValue = Element.dehydrateAttributeValue( (wrappedValue as AttributeData).value, ) if (flattenedValue !== undefined) { acc[key] = { ...attributeData, value: flattenedValue, } } } else { acc[key] = wrappedValue as never } return acc }, {} as DehydratedElementData) } static dehydrateAttributeValue( value: AttributeValue, ): DehydratedAttributeValue { if (isArrayLike(value)) { return value.map(tryUnwrap).filter(Boolean) as string[] } else { return tryUnwrap(value) } } get elementSchemaId(): SchemaId | undefined { if (!this.type) { return } return getSchemaId(this.type) } get template(): ElementTemplateV1 | Template | undefined { if (!this.organization || !this.type || !this.elementSchemaId) { return } if (this.store.userStore.schemaVersion === ElementSchemaVersion.V1) { return this.store.elementStore.elementTemplatesV1.get(this.type)?.[ this.organization ] } if (this.store.userStore.schemaVersion === ElementSchemaVersion.V2) { return this.store.elementStore.elementTemplates.get( `#${this.elementSchemaId}/Default`, ) } return } getAttributeType(attributeKey: string): AttributeType | undefined { if (!this.type || !this.organization) { console.warn('getAttributeType: no type or organization') return } return this.store.elementStore.getAttributeType(this.type, attributeKey) } isAttributeMultiple(attributeKey: string): boolean { if (this.store.userStore.schemaVersion === ElementSchemaVersion.V1) { // do custom mapping return ( (this.typeData as AttributeDefinitions)?.[attributeKey].multiple || false ) } return ( ((this.typeData as ElementSchema).properties?.[attributeKey] as List) ?.type === 'array' ) } get typeData(): AttributeDefinitions | ElementSchema | undefined { if (!this.organization || !this.type || !this.elementSchemaId) { return } if (this.store.userStore.schemaVersion === ElementSchemaVersion.V1) { return this.store.elementStore.elementsTypeDataV1.get(this.type) } if (this.store.userStore.schemaVersion === ElementSchemaVersion.V2) { return this.store.elementStore.elementSchemas.get(this.elementSchemaId) } return } getAttributeDefinition(attribute: string): AttributeDefinition | undefined { if (!this.type) { return } const typeDataV1 = this.store.elementStore.elementsTypeDataV1.get(this.type) if (!typeDataV1 || !typeDataV1[attribute]) { return } return typeDataV1[attribute] } attributeExists(attribute: string): boolean { const isSet = Object.prototype.hasOwnProperty.call(this.data, attribute) return isSet } setAttributeValue( attribute: string, value: AttributeValue | AttributeFileData | undefined, isPublic?: boolean, ) { if (!this.data) { return false } if (META_ATTRIBUTES.includes(attribute)) { log.warn(`Trying to set meta attribute ${attribute}`) return false } const attributeType = this.getAttributeType(attribute) // Attribute probably doesnt exist if (!attributeType) { log.warn(`Trying to set attribute that doesnt exist ${attribute}`) return false } if (attribute === ELEMENT_PARENT && value instanceof Element) { this.parent = value } // File, handle early if (attributeType === AttributeType.FILE) { // console.log('settingFileValue()') // push new file into the array const currentValue = (this.data?.[attribute] ?? []) as AttributeFileData const newValue: AttributeFileData = [] const arrayValue = ( Array.isArray(value) ? value : [value] ) as AttributeFileData // Order is important, uniqBy takes first match // therefore new value array is first const uniqueFileValues = uniqBy( newValue.concat(arrayValue, currentValue), (f) => (isRemoteFile(f) ? f.file.hash : f.id), ) this.data[attribute] = uniqueFileValues return true } // Handle all other attributes let transformedValue: unknown if ( this.isAttributeMultiple(attribute) && !Array.isArray(value) && value !== null ) { transformedValue = [value] } else if ( !this.isAttributeMultiple(attribute) && Array.isArray(value) && attributeType !== AttributeType.GEO ) { transformedValue = value[0] } else { transformedValue = value } const attributeBase: AttributeMeta = this.attributeExists(attribute) ? (this.data[attribute] as AttributeData) : { timestamp: new Date().toISOString(), organization: this.store.userStore.organization.id, author: { uid: '', cn: '', sn: '', }, } this.data[attribute] = { ...attributeBase, value: transformedValue as AttributeValue, isPublic: isPublic ?? false, } return true } getAttributeRawValue(attribute: string): AttributeValue | undefined { if (META_ATTRIBUTES.includes(attribute)) { return this.data?.[attribute] as unknown as AttributeValue } // Files should use getAttributeFile if (this.getAttributeType(attribute) === AttributeType.FILE) { throw new Error('Files should use getAttributeFile()') } if (!this.attributeExists(attribute)) { return } return (this.data?.[attribute] as AttributeData).value } getAttributeValue( attribute: string, ): AttributeValue | AttributeFileData | undefined { const attributeType = this.getAttributeType(attribute) // Files should use getAttributeFile if (attributeType === AttributeType.FILE) { return this.getAttributeFile(attribute) } const value = this.getAttributeRawValue(attribute) if (value === undefined) { return } if (attributeType === AttributeType.ELEMENT) { return this.getAttributeElementValue(attribute) } return value } hydrateRelationElements() { if (!this.typeData) { return } Object.entries(this.typeData).map(([attributeKey, attribute]) => { if (attribute.type === AttributeType.ELEMENT) { const attributeValue = this.getAttributeElementValue(attributeKey) set(this.relationElements, attributeKey, attributeValue) } }) } async performUpdate( modified: ElementAttributeData, original: ElementAttributeData, ) { if (!this.originalData || !this.data || !this.type) { return false } const operations = this.store.elementStore.prepareOperations( this.type, modified, original, ) const forceVersion = this.store.userStore.schemaVersion if (operations.length > 0) { const { type, hash } = this const organization = this.data.organization // Retry transient network errors (flaky mobile connection) so a brief // drop doesn't silently lose the write. Matches Ticket.setStatus/update. const updateRequest = await retryTransient(() => this.store.elements.patch(type, hash, operations, organization, { forceVersion, }), ) if (!updateRequest.ok) { if (updateRequest.status === 404) { this.errored = new Error('Element not found') } else if (updateRequest.status === 403) { this.errored = new AccessDeniedError() } else if (updateRequest.status === 422) { this.errored = new OrganizationInactiveError('Organization inactive') } else { // Include the transport problem so consumers can see why the // update failed instead of an opaque generic message. throw new Error( `Failed to update element (${updateRequest.problem})`, ) } // Deliberately keep this.data as modified (no revert to originalData): // reverting silently destroyed the user's unsaved input, so a retry // had nothing left to send. The data stays dirty and the pending diff // is re-sent by the next update() call. return false } this.dirty = false // Break references for "value" property. this.originalData = cloneElementData(this.data) } return true } getEffects(attribute: string): AttributeModifier['effects'] { if ( this.template && this.store.userStore.schemaVersion === ElementSchemaVersion.V2 ) { if (!this.effects[this.template.id]) { this.computeEffects(this.template as Template) } return this.effects[this.template.id][attribute] } else { return {} } } computeEffects(template: Template) { this.effects = { ...this.effects, [template.id]: this.getEvaluatedEffects(template), } } getEvaluatedEffects( template?: Template, ): Record { const dehydrated = Element.dehydrateData(this.data || {}) const attributeValues = Object.entries(dehydrated).reduce( (acc, [attribute, attributeData]) => { if (Object.prototype.hasOwnProperty.call(attributeData, 'value')) { return { ...acc, [attribute]: (attributeData as AttributeData).value, } } else { return acc } }, {} as Record, ) return Object.entries(template?.attributeModifiers || {}).reduce( (acc, [key, modifiers]) => { const e = evaluateModifiers(attributeValues, modifiers) acc[key] = e return acc }, {} as Record, ) } } const isValueAttributeData = ( key: string, value: AttributeData | AttributeFileData | unknown | undefined, ) => { return value !== undefined && key !== 'links' && !isArrayLike(value) && isObject(value) && 'value' in value ? true : false } const tryUnwrap = ( value: AttributeValueSingle, ): DehydratedAttributeValueSingle => { if (value instanceof Element) { return value.hash } else if ( isObject(value) && Object.prototype.hasOwnProperty.call(value, 'hash') ) { return (value as AttributeValueTypeElement).hash } else if ( isObject(value) && Object.prototype.hasOwnProperty.call(value, 'uid') ) { return (value as User).uid } else if (isDate(value)) { return value.toISOString() } return value as string | number | boolean } /** * This method is used to break references and safely work with this data. * It clones the provided data object and returns a new object with the same structure and values. * This ensures that any modifications made to the cloned object will not affect the original data object. * * @param data - The data object to be cloned. * @returns A new object with the same structure and values as the provided data object. */ export const cloneElementData = (data: Partial): ElementData => { return Object.entries(data).reduce((acc, [attribute, attributeData]) => { if (isValueAttributeData(attribute, attributeData)) { const data = attributeData as AttributeData const isElementInstance = data.value instanceof Element acc[attribute] = { ...(attributeData as AttributeData), value: isElementInstance ? data.value : clone(data.value), } } else if (isFileData(attributeData)) { acc[attribute] = attributeData.slice() } else { acc[attribute] = attributeData as never } return acc }, {} as ElementData) } export const getPatchedElementFromOperations = ( operations: Operation[], elementData: ElementFormData, ) => { const arrayAttributes = getArrayOperationAttributes(operations) const defaultObject = { element: elementData } arrayAttributes.forEach((attribute: string) => { defaultObject.element[attribute] = defaultObject.element[attribute] || [] }) const existValueInDataArray = (value: string, attribute: string) => { const attrValue = defaultObject.element[attribute] as string[] return ( arrayAttributes.includes(attribute) && Array.isArray(attrValue) && attrValue.includes(value) ) } const [plusOperations, otherOperations] = partition( operations, (o) => o.op === 'plus', ) const cleanOperations = otherOperations.filter((operation) => { const [, , attribute, pathValue] = operation.path.split('/') return ( // Prevent duplicates in array (operation.op === Operations.ADD ? !existValueInDataArray(operation.value as string, attribute) : true) && // Prevent trying remove (operation.op === Operations.REMOVE && pathValue ? existValueInDataArray(pathValue, attribute) : true) ) }) // console.log('cleanOperations', cleanOperations) const cleanWithModifiedArrayRemoved = cleanOperations .map((operation) => { const [_first, _second, attribute, pathValue, action] = operation.path.split('/') const getIndexByValue = (value: string): string | number => { const attributeValue = defaultObject.element[attribute] as string[] return (attributeValue ?? []).indexOf(value) } const getIndexByFileHash = (hash: string): number => { const attributeFileValue = defaultObject.element[ attribute ] as AttributeFileData if (!attributeFileValue || !Array.isArray(attributeFileValue)) { return -1 } return attributeFileValue.findIndex( (value) => isRemoteFile(value) && value.file.hash === hash, ) } // This is used to remove values from array like User, Element if (operation.op === Operations.REMOVE && pathValue) { const idx = getIndexByValue(pathValue) if (idx === -1) { return null } return { ...operation, path: pathValue ? [_first, _second, attribute, idx].join('/') : operation.path, } } // This is used to replace file values like archived, relevant, public if (operation.op === Operations.REPLACE && pathValue && action) { const idx = getIndexByFileHash(pathValue) log.debug(`getIndexByFileHash ${pathValue} ${idx}`) if (idx === -1) { return null } log.debug(JSON.stringify(operation.path)) const newPath = [_first, _second, attribute, idx, 'file', action].join( '/', ) log.debug(`newPath ${newPath}`) return { ...operation, path: [_first, _second, attribute, idx, 'file', action].join('/'), } } return operation }) .filter(Boolean) // console.log('cleanWithModifiedArrayRemoved', cleanWithModifiedArrayRemoved) applyPatch(defaultObject, cleanWithModifiedArrayRemoved as RfcOperation[]) // handle PLUS operations plusOperations.forEach((operation) => { if (operation.op !== 'plus') { return } const [, , attribute] = operation.path.split('/') const defaultObjAttributeValue = defaultObject.element[attribute] as | number | undefined defaultObject.element[attribute] = defaultObjAttributeValue ? defaultObjAttributeValue + Number(operation.value) : Number(operation.value) }) return defaultObject } const getArrayOperationAttributes = (operations: Operation[]): string[] => { // checks for "-" at the end of string (array notation in jsonpatch) const regex = /-$/ return operations .filter( (op) => regex.test(op.path) || (op.op === Operations.REMOVE && getRemovedValueFromOperation(op)), ) .map(getAttributeNameFromOperation) } export const getAttributeNameFromOperation = (op: Operation) => { const path = op.path.split('/') // return attribute name only return path[2] } const getRemovedValueFromOperation = (op: Operation) => { const path = op.path.split('/') return path[3] } export const evaluateModifiers = ( data: Record, modifiers: AttributeModifier[], ): AttributeModifier['effects'] => { return modifiers.reduce((acc, modifier) => { const { condition, effects } = modifier const parsedCondition = parseRulesLogic(condition) const isEvaluated = apply(parsedCondition, data) if (isEvaluated) { return { ...acc, ...effects } } return acc }, {}) } export const transformUploadedFiles = ( uploadedFiles: FileBatchResponse, organization: string, ) => { return uploadedFiles.reduce( (acc: Record, uploadedFile) => { const downloadLink = uploadedFile.links.find( (f) => f.rel === LinkRel.DOWNLOAD, ) // we have to enhance links as the response doesnt contain different sizes const links: RestApiLink[] = [ ...uploadedFile.links, ...IMAGE_SIZE_RELS.map( (is) => ({ rel: is, href: `${downloadLink?.href}?resolution=${is.replace( 'image-preview-', '', )}`, }) as RestApiLink, ), ] const file: AttributeFileValue = { file: { author: { cn: '', sn: '', uid: '', }, comments: [], file_size: uploadedFile['file-size'], filename: uploadedFile['file-name'], hash: uploadedFile.hash, organization: organization, relevant: false, timestamp: new Date().toISOString(), archived: false, }, links: links, } return { ...acc, [uploadedFile['attribute-name']]: [ ...(acc[uploadedFile['attribute-name']] || []), file, ], } }, {}, ) }