import { applySnapshot, flow, getEnv, getRoot, types } from "mobx-state-tree"; import { createRef } from "react"; import Types from "../../core/Types"; import Utils from "../../utils"; import { camelizeKeys, snakeizeKeys } from "../../utils/utilities"; import { UserExtended } from "../UserStore"; import { Anchor } from "./Anchor"; /** * A reduced version of the Comment model. * It is used only for creating a new comment, storing values in the similar structure * and to handle some actions that should be present in both cases (creating and editing). * So that some actions have to be overridden in the Comment model in case we want them to work properly with the backend. */ export const CommentBase = types .model("CommentBase", { text: types.string, regionRef: types.optional(types.maybeNull(Anchor), null), classifications: types.optional(types.frozen({}), null), }) .views((self) => ({ get commentsStore() { try { return Types.getParentOfTypeString(self, "CommentStore"); } catch (e) { return null; } }, get annotation() { /* * The `getEnv` is used in case when we use "CommentBase" separately * to provide the same functionality of comment (`setRegionLink`) * during creating new comment. * In this case, the comment is stored in a volatile field "currentComment" * of 'CommentStore' and cannot access the MST tree by itself. */ const env = getEnv(self); if (env?.annotationStore) { return env.annotationStore.selected; } // otherwise, we use the standard way to get the annotation const commentsStore = self.commentsStore; return commentsStore?.annotation; }, get isHighlighted() { const highlightedRegionKey = self.commentsStore?.highlightedComment?.regionRef?.targetKey; const currentRegionKey = self.regionRef?.targetKey; return !!highlightedRegionKey && highlightedRegionKey === currentRegionKey; }, })) .actions((self) => { return { setText(text) { self.text = text; }, unsetLink() { self.regionRef = null; }, setRegionLink(region) { self.regionRef = { regionId: region.cleanId, }; }, setClassifications(classifications) { self.classifications = classifications; }, setResultLink(result) { self.regionRef = { regionId: result.area.cleanId, controlName: result.from_name.name, }; }, setHighlighted(value = true) { const commentsStore = self.commentsStore; if (commentsStore) { if (value) { commentsStore.setHighlightedComment(self); } else if (self.isHighlighted) { commentsStore.setHighlightedComment(undefined); } } }, }; }); /** * The main Comment model. * Should be fully functional and used for all cases except creating a new comment. */ export const Comment = CommentBase.named("Comment") .props({ id: types.identifierNumber, text: types.string, createdAt: types.optional(types.string, Utils.UDate.currentISODate()), updatedAt: types.optional(types.string, Utils.UDate.currentISODate()), resolvedAt: types.optional(types.maybeNull(types.string), null), createdBy: types.optional(types.maybeNull(types.safeReference(UserExtended)), null), isResolved: false, isEditMode: types.optional(types.boolean, false), isDeleted: types.optional(types.boolean, false), isConfirmDelete: types.optional(types.boolean, false), isUpdating: types.optional(types.boolean, false), }) .preProcessSnapshot((sn) => { return camelizeKeys(sn ?? {}); }) .volatile((self) => { return { _commentRef: createRef(), }; }) .views((self) => ({ get sdk() { return getEnv(self).events; }, get isPersisted() { return self.id > 0 && !self.isUpdating; }, get canResolveAny() { const p = getRoot(self); return p.interfaces.includes("comments:resolve-any"); }, })) .actions((self) => { const toggleResolve = flow(function* () { if (!self.isPersisted || self.isDeleted) return; self.isResolved = !self.isResolved; try { yield self.sdk.invoke("comments:update", { id: self.id, is_resolved: self.isResolved, }); } catch (err) { self.isResolved = !self.isResolved; throw err; } }); function setEditMode(newMode) { self.isEditMode = newMode; } function setDeleted(newMode) { self.isDeleted = newMode; } function setConfirmMode(newMode) { self.isConfirmDelete = newMode; } const updateComment = flow(function* (comment, classifications = undefined) { if (self.isPersisted && !self.isDeleted) { const payload = { id: self.id, text: comment, }; if (classifications !== undefined) { payload.classifications = classifications; } yield self.sdk.invoke("comments:update", payload); } self.setEditMode(false); }); const update = flow(function* (props) { if (self.isPersisted && !self.isDeleted && !self.isUpdating) { self.isUpdating = true; const [result] = yield self.sdk.invoke("comments:update", { id: self.id, ...snakeizeKeys(props), }); if (result.error) { self.isUpdating = false; return; } const data = camelizeKeys(result); applySnapshot(self, data); self.isUpdating = false; } }); function setRegionLink(region) { const regionRef = { regionId: region.cleanId, }; self.update({ regionRef }); } function setResultLink(result) { const regionRef = { regionId: result.area.cleanId, controlName: result.from_name.name, }; self.update({ regionRef }); } function unsetLink() { const regionRef = null; self.update({ regionRef }); } const deleteComment = flow(function* () { if (self.isPersisted && !self.isDeleted && self.isConfirmDelete) { yield self.sdk.invoke("comments:delete", { id: self.id, }); } self.setDeleted(true); self.setConfirmMode(false); }); const scrollIntoView = () => { const commentEl = self._commentRef.current; if (!commentEl) return; if (commentEl.scrollIntoViewIfNeeded) { commentEl.scrollIntoViewIfNeeded(); } else { commentEl.scrollIntoView({ block: "center", behavior: "smooth" }); } }; return { toggleResolve, setEditMode, setDeleted, setConfirmMode, updateComment, update, deleteComment, setRegionLink, setResultLink, unsetLink, scrollIntoView, }; });