import dayjs from 'dayjs' import { sortBy } from 'lodash' import log from 'loglevel' import { action, computed, makeObservable, observable, override, runInAction, } from 'mobx' import { AttributeData, AttributeDefinition, AttributeFileData, AttributeModifier, AttributeValue, CommentValue, ElementAttributeData, ElementData, ElementFormData, PlatformFile, Template, TicketData, TicketProperty, TicketStatus, } from '../../interfaces' import { ElementGetQueryParams } from '../../services' import { Toast } from '../../services/toast' import { MainStore } from '../../stores' import { TICKET_SPECIAL_ATTRIBUTES, TICKET_TYPE } from '../constants' import { Element, evaluateModifiers } from '../element' import { CheckList } from './checkList' import { CheckPoint } from './checkPoint' // Re-exported so existing importers (and tests) keep working after the helper // was extracted into a shared module also used by Element.performUpdate. export { retryTransient } from './retry' import { retryTransient } from './retry' const BASE_ATTRIBUTE = { order: 0, required: false, } const SPECIAL_ATTRIBUTE_DEFINITIONS: Record = { dueDate: { ...BASE_ATTRIBUTE, key: 'dueDate', type: 'DateTime', }, subject: { ...BASE_ATTRIBUTE, key: 'subject', type: 'Text', }, description: { ...BASE_ATTRIBUTE, key: 'description', type: 'Textarea', }, is_incomplete: { ...BASE_ATTRIBUTE, key: 'is_incomplete', type: 'Dropdown', values: ['Yes', 'No'], useCheckbox: true, }, location: { ...BASE_ATTRIBUTE, key: 'location', type: 'Geo', }, status: { ...BASE_ATTRIBUTE, key: 'status', type: 'Dropdown', values: ['Open', 'Close', 'In-progress'], }, assignees: { ...BASE_ATTRIBUTE, key: 'assignees', type: 'User', multiple: true, }, approvers: { ...BASE_ATTRIBUTE, key: 'approvers', type: 'User', multiple: true, }, participants: { ...BASE_ATTRIBUTE, key: 'participants', type: 'User', multiple: true, }, elements: { ...BASE_ATTRIBUTE, key: 'elements', type: 'Element', multiple: true, }, priority: { ...BASE_ATTRIBUTE, key: 'priority', type: 'Dropdown', values: [], }, createdBy: { ...BASE_ATTRIBUTE, key: 'createdBy', type: 'DateTime', }, createdAt: { ...BASE_ATTRIBUTE, key: 'createdAt', type: 'DateTime', }, checklist: { ...BASE_ATTRIBUTE, key: 'checklist', type: 'Element', }, _parent: { ...BASE_ATTRIBUTE, key: '_parent', type: 'Element', }, } export class Ticket extends Element { // eslint-disable-next-line @typescript-eslint/no-explicit-any constructor(...args: any) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: this is fine super(...args) // MobX 6: subclasses annotate their own additions; `override` is used for // Element actions that Ticket reimplements (update, loadData, setAttributeValue). makeObservable(this, { originalTicketData: observable, ticketData: observable, elements: observable, checklist: observable, checkpoint: observable, status: computed, isIncomplete: computed, update: override, loadData: override, setAttributeValue: override, setStatus: action, setTicketData: action, setTicketElements: action, setChecklistData: action, }) } static get TYPE() { return TICKET_TYPE } children: Ticket[] = [] originalTicketData?: TicketData = undefined ticketData?: TicketData = undefined elements: Element[] = [] checklist?: CheckList = undefined checkpoint?: Partial = undefined get status(): TicketStatus | undefined { return this.ticketData?.status?.value } get isIncomplete() { return this.ticketData?.is_incomplete?.value === 'Yes' ? true : false } isOverdue() { const dueDate = this.ticketData?.dueDate?.value if (!dueDate) { return false } return dayjs().isAfter(dueDate) } static get ATTRIBUTES() { return { ...Element.ATTRIBUTES, DUE_DATE: 'dueDate', ELEMENTS: 'elements', PARTICIPANTS: 'participants', DESCRIPTION: 'description', DESCRIPTION_IMAGE: 'descriptionImage', // DEPRECATED STATUS: 'status', CHECKLIST: 'checklist', ASSIGNEES: 'assignees', SUBJECT: 'subject', FILES: 'files', IS_INCOMPLETE: 'is_incomplete', LOCATIONS: 'loc_gis_ate', } } async update(data?: ElementFormData): Promise { const ordinaryAttributes: ElementFormData = {} const ticketData = Object.entries(data || {}).reduce( (acc, [key, value]) => { if (!TICKET_SPECIAL_ATTRIBUTES.includes(key)) { ordinaryAttributes[key] = value } else { acc[key] = value } return acc }, // eslint-disable-next-line @typescript-eslint/no-explicit-any {} as Record, ) const hasSpecial = Object.keys(ticketData).length > 0 const [specialResponse, ordinaryOk] = await Promise.all([ hasSpecial ? retryTransient(() => this.store.tickets.update(this.hash, ticketData)) : Promise.resolve(undefined), super.update(ordinaryAttributes), ]) if (specialResponse && !specialResponse.ok) { throw new Error( `Failed to update ticket ${this.hash}: ${specialResponse.problem}`, ) } return ordinaryOk } getAttributeDefinition(attribute: string): AttributeDefinition | undefined { const definition = super.getAttributeDefinition(attribute) if (!definition) { return SPECIAL_ATTRIBUTE_DEFINITIONS[attribute] } return definition } async loadData(force = false, queryParams?: ElementGetQueryParams) { const dataPromise = super.loadData(force, queryParams) const ticketPromise = this.store.tickets.get(this.hash) const [dataResponse, ticketResponse] = await Promise.all([ dataPromise, ticketPromise, ]) if (!ticketResponse.ok || !ticketResponse.data) { Toast.show('Failed to load ticket data', 'danger') return } const ticketData = ticketResponse.data if (dataResponse?.data && dataResponse.ok) { this.setTicketElements( ( dataResponse?.data?.element?.elements as unknown as AttributeData< ElementData[] > ).value, ) } this.setTicketData(ticketData) // Ensures that all checklist data is loaded, in order to evaluate // for example: allow_skip_checkpoints await this.checklist?.loadRaw() return dataResponse } getEvaluatedEffects( template?: Template, ): Record { const dehydrated = Element.dehydrateData(this.data || {}) const dehydratedTicketData = Ticket.dehydrateTicketData(this.ticketData) 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, ) const mergedValues = { ...attributeValues, ...dehydratedTicketData, } return Object.entries(template?.attributeModifiers || {}).reduce( (acc, [key, modifiers]) => { const e = evaluateModifiers(mergedValues, modifiers) acc[key] = e return acc }, {} as Record, ) } getAttributeRawValue(attribute: string) { const attributeValue = super.getAttributeRawValue(attribute) if (!attributeValue) { if ( this.ticketData && attribute !== Ticket.ATTRIBUTES.ELEMENTS && Object.prototype.hasOwnProperty.call(this.ticketData, attribute) ) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: this is fine return this.ticketData[attribute]?.value } } return attributeValue } setAttributeValue( attribute: string, value: AttributeFileData | AttributeValue | undefined, isPublic?: boolean | undefined, ) { // Element name and subject is interchangeable if (attribute === 'element_name') { this.setAttributeValue(Ticket.ATTRIBUTES.SUBJECT, value, isPublic) } return super.setAttributeValue(attribute, value, isPublic) } async setStatus( status: TicketStatus, extraData?: Record, ) { const changes: Record = { [Ticket.ATTRIBUTES.STATUS]: status, ...(extraData || {}), } const response = await retryTransient(() => this.store.tickets.patchTicket(this.hash, changes), ) if (!response.ok || !response.data) { log.warn('something wrong with setting ticket status', response.problem) throw new Error( `Failed to set ticket status ${this.hash}: ${response.problem ?? 'unknown'}`, ) } const ticketData = response.data runInAction(() => this.setTicketData(ticketData)) } setTicketData(ticketData: TicketData) { this.originalTicketData = ticketData this.ticketData = ticketData const checklistValue = ticketData?.checklist?.value if (checklistValue) { this.setChecklistData(checklistValue, ticketData.organization) } if (ticketData.children) { // Assign ticketData to all children this.children.forEach((child) => { const childTicketData = ticketData.children.find( (td) => td.hash === child.hash, ) if (!childTicketData) { return } child.setTicketData(childTicketData) }) // Order checkpoints this.children = sortBy(this.children, (a) => { const orderA = parseInt( (a.ticketData?.checklist?.value?.order as string) || '-1', 10, ) return orderA }) } } setTicketElements(elements: ElementData[]) { this.elements = (elements || []) .map((elementData) => { return this.store.elementStore.instantiateElement( elementData.hash, elementData, false, { extendWithOlderData: true, }, ) }) .filter(Boolean) as Element[] } setChecklistData(checklistData: ElementFormData, organization: string) { if (!checklistData) { return } const { _type, hash, ...otherChecklistData } = checklistData const element = this.store.elementStore.instantiateElement( hash as string, { _type, organization, } as ElementData, ) if (!element) { log.warn(`Could not instantiate checklist element ${hash}`) return } element.autoSave = false element.setAttributes(otherChecklistData) if (element instanceof CheckList) { this.checklist = element } else if (element instanceof CheckPoint) { this.checkpoint = element } } async postComment(value: string) { if (!this.data?.organization || !this.ticketData) { return } const comment = await this.store.tickets.postComment( this.hash, this.data.organization, value, ) if (!comment.ok) { throw new Error('Unable to post comment') } if (!this.ticketData?.comments) { this.ticketData.comments = [] } this.ticketData.comments.push({ ...comment.data, lastChangeAt: dayjs().unix(), } as CommentValue) return comment } static GET_QUERY_PARAMS() { return { childrenAtt: ['element_name', 'descriptionImage'], } } static async create( mainStore: MainStore, data: ElementAttributeData, organization: string, ) { const simpleData = Object.entries(data).reduce( (acc, [key, value]) => { if (Array.isArray(value)) { acc[key] = value } else { acc[key] = value.value } return acc }, // eslint-disable-next-line @typescript-eslint/no-explicit-any {} as Record, ) const ticketCreate = await mainStore.tickets.create( simpleData, organization, ) if (!ticketCreate.ok || !ticketCreate.data) { console.log('failed to create ticket') return } const ticketElement = mainStore.elementStore.instantiateElement( ticketCreate.data.hash, { _type: Ticket.TYPE, organization, } as ElementData, ) as Ticket ticketElement?.setTicketData(ticketCreate.data) return ticketElement } static dehydrateTicketData(data?: TicketData) { return Object.entries(data || {}).reduce( (acc: Record, [key, value]) => { if ( value && Object.prototype.hasOwnProperty.call(value, 'value') && value.value ) { return { ...acc, [key]: Element.dehydrateAttributeValue( (value as TicketProperty).value!, ), } } else { return acc } }, {}, ) } }