import { destroy, getEnv, getParent, getRoot, types } from "mobx-state-tree";
|
|
import { errorBuilder } from "../../core/DataValidator/ConfigValidator";
|
import { DataValidator, ValidationError, VALIDATORS } from "../../core/DataValidator";
|
import { guidGenerator } from "../../core/Helpers";
|
import Registry from "../../core/Registry";
|
import Tree from "../../core/Tree";
|
import Types from "../../core/Types";
|
import { StoreExtender } from "../../mixins/SharedChoiceStore/extender";
|
import { ViewModel } from "../../tags/visual";
|
import Utils from "../../utils";
|
import { FF_DEV_3034, FF_DEV_3391, FF_SIMPLE_INIT, isFF } from "../../utils/feature-flags";
|
import { emailFromCreatedBy } from "../../utils/utilities";
|
import { Annotation } from "./Annotation";
|
import { HistoryItem } from "./HistoryItem";
|
|
const SelectedItem = types.union(Annotation, HistoryItem);
|
|
const localStorageKeys = {
|
viewingAll: "annotation-store-viewing-all",
|
};
|
|
const AnnotationStoreModel = types
|
.model("AnnotationStore", {
|
selected: types.maybeNull(types.reference(SelectedItem)),
|
selectedHistory: types.maybeNull(types.safeReference(SelectedItem)),
|
|
root: Types.allModelsTypes(),
|
names: types.map(types.reference(Types.allModelsTypes())),
|
toNames: types.map(types.array(types.reference(Types.allModelsTypes()))),
|
|
annotations: types.array(Annotation),
|
predictions: types.array(Annotation),
|
history: types.array(HistoryItem),
|
|
viewingAllAnnotations: types.optional(
|
types.boolean,
|
// Initialize from localStorage, defaulting to false if not set
|
() => {
|
return window.localStorage.getItem(localStorageKeys.viewingAll) === "true";
|
},
|
),
|
|
validation: types.maybeNull(types.array(ValidationError)),
|
})
|
.volatile(() => ({
|
initialized: false,
|
}))
|
.views((self) => ({
|
get store() {
|
return getRoot(self);
|
},
|
|
get viewingAll() {
|
// Even if we have View All flag stored as true, but we are in environment without it
|
// or if we are not ready yet — don't go into View All mode, it will be broken.
|
if (!self.initialized) return false;
|
if (!self.store.hasInterface("annotations:view-all")) return false;
|
return self.viewingAllAnnotations;
|
},
|
}))
|
.actions((self) => {
|
function toggleViewingAll() {
|
self.viewingAllAnnotations = !self.viewingAllAnnotations;
|
// Persist the state to localStorage
|
window.localStorage.setItem(localStorageKeys.viewingAll, String(self.viewingAllAnnotations));
|
|
if (self.viewingAllAnnotations) {
|
if (self.selected) {
|
// const comments = self.store.commentStore;
|
|
// @todo `currentComment` is an object and saving them was not a part of original fix
|
// @todo so I leave it for next fix coming soon
|
// if (comments.currentComment) {
|
// // comment will save draft automatically
|
// comments.commentFormSubmit();
|
// } else
|
if (self.selected.type === "annotation") {
|
// save draft if there are changes waiting to be saved — it's handled inside
|
self.selected.saveDraftImmediately();
|
}
|
|
self.selected.unselectAll();
|
self.selected.selected = false;
|
}
|
|
// When FF_SIMPLE_INIT is enabled, we need to ensure all tags have their results set from the annotations.
|
// This process is normally done via selecting explicitly all annotations but, when the flag is enabled, it is
|
// skipped in AppStore::initializeStore to enforcing better performance.
|
// The byproduct of this performance improvement is that we do not update the objects with their corresponding
|
// results and, when entering the ViewAll mode, we can only see the results updated for the previously selected
|
// annotation but not the rest of them.
|
// This fix aims to mimic the behaviour of selectAnnotation when it comes to updating the objects only without
|
// actually executing the full process of selecting the annotation.
|
if (isFF(FF_SIMPLE_INIT)) {
|
[...self.predictions, ...self.annotations].forEach((a) => {
|
// Skip the current annotation as it's already handled
|
if (a === self.selected) return;
|
|
// Set results for each annotation without selecting it
|
a.updateObjects();
|
});
|
}
|
|
self.annotations.forEach((a) => {
|
a.editable = false;
|
});
|
} else {
|
selectAnnotation(self.annotations.at(isFF(FF_SIMPLE_INIT) ? -1 : 0).id, { fromViewAll: true });
|
}
|
}
|
|
// @todo that's just an alias, rewrite it everywhere
|
function toggleViewingAllAnnotations() {
|
toggleViewingAll();
|
}
|
|
function unselectViewingAll() {
|
self.viewingAllAnnotations = false;
|
// Persist the state to localStorage
|
window.localStorage.setItem(localStorageKeys.viewingAll, String(self.viewingAllAnnotations));
|
}
|
|
function _unselectAll() {
|
if (self.selected) {
|
self.selected.unselectAll();
|
self.selected.selected = false;
|
}
|
}
|
|
// used only in old version of View All — Grid.jsx
|
function _selectItem(item) {
|
self._unselectAll();
|
item.editable = false;
|
item.selected = true;
|
self.selected = item;
|
item.updateObjects();
|
}
|
|
// Select annotation or prediction
|
function selectItem(id, list, resetHistory = true) {
|
// might be better to protect this change with FF_SIMPLE_INIT
|
// unselectViewingAll();
|
|
self._unselectAll();
|
|
// sad hack with pk while sdk are not using pk everywhere
|
const c = list.find((c) => c.id === id || c.pk === String(id)) || list[0];
|
|
if (!c) return null;
|
c.selected = true;
|
|
if (resetHistory) {
|
self.selectedHistory = null;
|
self.history = [];
|
}
|
|
self.selected = c;
|
|
c.updateObjects();
|
if (c.type === "annotation") c.setInitialValues();
|
|
return c;
|
}
|
|
/**
|
* Select annotation
|
* @param {*} id
|
*/
|
function selectAnnotation(id, options = {}) {
|
// `selectAnnotation()` is used a lot during init, usually for all annotations.
|
// It should only exit View All mode when it's explicitly requested.
|
// All other calls are just setting things up and should not affect View All mode.
|
if (options.exitViewAll) {
|
unselectViewingAll();
|
}
|
|
if (!self.annotations.length) return null;
|
|
const { selected } = self;
|
const c = selectItem(id, self.annotations, !options.retainHistory);
|
|
c.editable = true;
|
c.setupHotKeys();
|
|
getEnv(self).events.invoke("selectAnnotation", c, selected, options ?? {});
|
if (c.pk) getParent(self).addAnnotationToTaskHistory(c.pk);
|
return c;
|
}
|
|
function selectPrediction(id, options = {}) {
|
// The same logic as in `selectAnnotation()`
|
if (options.exitViewAll) {
|
unselectViewingAll();
|
}
|
|
return selectItem(id, self.predictions);
|
}
|
|
function clearDeletedParents(annotation) {
|
if (!annotation?.pk) return;
|
self.annotations.forEach((anno) => {
|
if (anno.parent_annotation && +anno.parent_annotation === +annotation.pk) {
|
anno.parent_annotation = null;
|
}
|
});
|
}
|
|
function deleteAnnotation(annotation) {
|
getEnv(self).events.invoke("deleteAnnotation", self.store, annotation);
|
|
/**
|
* MST destroy annotation
|
*/
|
destroy(annotation);
|
|
/**
|
* Clear any other parent_annotations connected to this annotation
|
*/
|
self.clearDeletedParents(annotation);
|
|
self.selected = null;
|
/**
|
* Select other annotation
|
*/
|
if (self.annotations.length > 0) {
|
self.selectAnnotation(self.annotations[0].id);
|
}
|
}
|
|
function showError(err) {
|
if (err) self.addErrors([errorBuilder.generalError(err)]);
|
// we have to return at least empty View to display interface
|
return (self.root = ViewModel.create({ id: "error" }));
|
}
|
|
function upsertToName(node) {
|
const val = self.toNames.get(node.toname);
|
|
if (val) {
|
val.push(node.name);
|
} else {
|
self.addToName(node);
|
}
|
}
|
|
function addToName(node) {
|
self.toNames.set(node.toname, [node.name]);
|
}
|
|
function addName(node) {
|
self.names.put(node);
|
}
|
|
function initRoot(config) {
|
if (self.root) return;
|
|
if (!config) {
|
return (self.root = ViewModel.create({ id: "empty" }));
|
}
|
|
// convert config to mst model
|
let rootModel;
|
|
try {
|
rootModel = Tree.treeToModel(config, self.store);
|
} catch (e) {
|
console.error(e);
|
return showError(e);
|
}
|
const modelClass = Registry.getModelByTag(rootModel.type);
|
// hacky way to get all the available object tag names
|
const objectTypes = Registry.objectTypes().map((type) => type.name.replace("Model", "").toLowerCase());
|
const objects = [];
|
|
self.validate(VALIDATORS.CONFIG, rootModel);
|
|
try {
|
self.root = modelClass.create(rootModel);
|
} catch (e) {
|
console.error(e);
|
return showError(e);
|
}
|
|
if (isFF(FF_DEV_3391)) {
|
// initialize toName bindings [DOCS] name & toName are used to
|
// connect different components to each other
|
const { names, toNames } = Tree.extractNames(self.root);
|
|
names.forEach((tag) => self.names.put(tag));
|
toNames.forEach((tags, name) => self.toNames.set(name, tags));
|
|
Tree.traverseTree(self.root, (node) => {
|
if (self.store.task && node.updateValue) node.updateValue(self.store);
|
});
|
|
self.initialized = true;
|
|
return self.root;
|
}
|
|
// initialize toName bindings [DOCS] name & toName are used to
|
// connect different components to each other
|
Tree.traverseTree(self.root, (node) => {
|
if (node?.name) {
|
self.addName(node);
|
if (objectTypes.includes(node.type)) objects.push(node.name);
|
}
|
|
const isControlTag = node.name && !objectTypes.includes(node.type);
|
|
// auto-infer missed toName if there is only one object tag in the config
|
if (isControlTag && !node.toname && objects.length === 1) {
|
node.toname = objects[0];
|
}
|
|
if (node && node.toname) {
|
self.upsertToName(node);
|
}
|
|
if (self.store.task && node.updateValue) node.updateValue(self.store);
|
});
|
|
self.initialized = true;
|
|
return self.root;
|
}
|
|
function findNonInteractivePredictionResults() {
|
return self.predictions.reduce((results, prediction) => {
|
return [
|
...results,
|
...prediction._initialAnnotationObj
|
.filter((result) => result.interactive_mode === false)
|
.map((r) => ({ ...r })),
|
];
|
}, []);
|
}
|
|
function createItem(options) {
|
const { user, config } = self.store;
|
|
if (!self.root) initRoot(config);
|
|
let pk = options.pk || options.id;
|
|
if (options.type === "annotation" && pk && isNaN(pk)) {
|
/* something happened where our annotation pk was replaced with the id */
|
pk = self.annotations?.[self.annotations.length - 1]?.storedValue?.pk;
|
}
|
|
//
|
const node = {
|
userGenerate: false,
|
createdDate: Utils.UDate.currentISODate(),
|
|
...options,
|
|
// id is internal so always new to prevent collisions
|
id: guidGenerator(5),
|
// pk and id may be missing, so undefined | string
|
pk: pk && String(pk),
|
root: options.root ?? self.root,
|
};
|
|
if (user && !("createdBy" in node)) node.createdBy = user.displayName;
|
if (options.user) node.user = options.user;
|
|
return node;
|
}
|
|
function addPrediction(options = {}) {
|
options.editable = false;
|
options.type = "prediction";
|
|
const item = createItem(options);
|
|
if (isFF(FF_SIMPLE_INIT)) {
|
self.predictions.push(item);
|
|
return self.predictions.at(-1);
|
}
|
|
self.predictions.unshift(item);
|
|
const record = self.predictions[0];
|
|
return record;
|
}
|
|
function addAnnotation(options = {}) {
|
options.type = "annotation";
|
|
const item = createItem(options);
|
|
if (item.userGenerate) {
|
let actual_user;
|
|
if (isFF(FF_DEV_3034)) {
|
// drafts can be created by other user, but we don't have much info
|
// so parse "id", get email and find user by it
|
const email = emailFromCreatedBy(item.createdBy);
|
const user = email && self.store.users.find((user) => user.email === email);
|
|
if (user) actual_user = user.id;
|
}
|
item.completed_by = actual_user ?? getRoot(self).user?.id ?? undefined;
|
}
|
|
if (isFF(FF_SIMPLE_INIT)) {
|
self.annotations.push(item);
|
} else {
|
self.annotations.unshift(item);
|
}
|
|
const record = self.annotations.at(isFF(FF_SIMPLE_INIT) ? -1 : 0);
|
|
record.addVersions({
|
result: options.result,
|
draft: options.draft,
|
});
|
|
return record;
|
}
|
|
function createAnnotation(options = { userGenerate: true }) {
|
const result = findNonInteractivePredictionResults();
|
const c = self.addAnnotation({ ...options, result });
|
|
if (result && result.length) {
|
const ids = {};
|
|
// Area id is <uniq-id>#<annotation-id> to be uniq across all tree
|
result.forEach((r) => {
|
if ("id" in r) {
|
const id = r.id.replace(/#.*$/, `#${c.id}`);
|
|
ids[r.id] = id;
|
r.id = id;
|
}
|
});
|
|
result.forEach((r) => {
|
if (r.parent_id) {
|
if (ids[r.parent_id]) r.parent_id = ids[r.parent_id];
|
// impossible case but to not break the app better to reset it
|
else r.parent_id = null;
|
}
|
});
|
|
selectAnnotation(c.id);
|
c.deserializeAnnotation(result);
|
// reinit will trigger `updateObjects()` so we omit it here
|
c.reinitHistory();
|
} else {
|
c.setDefaultValues();
|
}
|
return c;
|
}
|
|
function addHistory(options = {}) {
|
options.type = "history";
|
|
if (isFF(FF_DEV_3391)) {
|
options.root = self.selected.root;
|
}
|
|
const item = createItem(options);
|
|
self.history.push(item);
|
|
const record = self.history[self.history.length - 1];
|
|
return record;
|
}
|
|
function clearHistory() {
|
self.history.forEach((item) => destroy(item));
|
self.history.length = 0;
|
}
|
|
function selectHistory(item) {
|
self.selectedHistory = item;
|
setTimeout(() => {
|
// update classifications after render
|
const updatedItem = item ?? self.selected;
|
|
Array.from(updatedItem.names.values())
|
.filter((t) => t.isClassificationTag)
|
.forEach((t) => t.updateFromResult([]));
|
|
updatedItem?.results
|
.filter((r) => r.area.classification)
|
.forEach((r) => r.from_name.updateFromResult?.(r.mainValue));
|
});
|
|
getEnv(self).events.invoke("selectHistory", self.store, self.selected, self.selectedHistory);
|
}
|
|
function addAnnotationFromPrediction(entity) {
|
// immutable work, because we'll change ids soon
|
const s = entity._initialAnnotationObj.map((r) => ({ ...r }));
|
const c = self.addAnnotation({ userGenerate: true, result: s });
|
|
const ids = {};
|
|
// Area id is <uniq-id>#<annotation-id> to be uniq across all tree
|
s.forEach((r) => {
|
if ("id" in r) {
|
const id = r.id.replace(/#.*$/, `#${c.id}`);
|
|
ids[r.id] = id;
|
r.id = id;
|
}
|
});
|
|
s.forEach((r) => {
|
if (r.parent_id) {
|
if (ids[r.parent_id]) r.parent_id = ids[r.parent_id];
|
// impossible case but to not break the app better to reset it
|
else r.parent_id = null;
|
}
|
});
|
|
selectAnnotation(c.id);
|
c.deserializeAnnotation(s);
|
// reinit will trigger `updateObjects()` so we omit it here
|
c.reinitHistory();
|
|
// parent link for the new annotations
|
if (entity.pk) {
|
if (entity.type === "prediction") {
|
c.parent_prediction = Number.parseInt(entity.pk);
|
} else if (entity.type === "annotation") {
|
c.parent_annotation = Number.parseInt(entity.pk);
|
}
|
}
|
|
return c;
|
}
|
|
/** ERRORS HANDLING */
|
const handleErrors = (errors) => {
|
self.addErrors(errors);
|
};
|
|
const addErrors = (errors) => {
|
const ids = [];
|
|
const newErrors = [...(self.validation ?? []), ...errors].reduce((res, error) => {
|
const id = error.identifier;
|
|
if (ids.indexOf(id) < 0) {
|
ids.push(id);
|
res.push(error);
|
}
|
|
return res;
|
}, []);
|
|
self.validation = newErrors;
|
};
|
|
const afterCreate = () => {
|
self._validator = new DataValidator();
|
self._validator.addErrorCallback(handleErrors);
|
};
|
|
const beforeDestroy = () => {
|
self._validator.removeErrorCallback(handleErrors);
|
};
|
|
const validate = (validatorName, data) => {
|
return self._validator.validate(validatorName, data);
|
};
|
|
const resetAnnotations = () => {
|
self.selected = null;
|
self.selectedHistory = null;
|
self.annotations = [];
|
self.predictions = [];
|
self.history = [];
|
};
|
|
return {
|
afterCreate,
|
beforeDestroy,
|
|
toggleViewingAllAnnotations,
|
|
initRoot,
|
addToName,
|
addName,
|
upsertToName,
|
|
addPrediction,
|
addAnnotation,
|
createAnnotation,
|
addAnnotationFromPrediction,
|
addHistory,
|
clearHistory,
|
selectHistory,
|
|
addErrors,
|
validate,
|
|
selectAnnotation,
|
selectPrediction,
|
|
_selectItem,
|
_unselectAll,
|
|
deleteAnnotation,
|
clearDeletedParents,
|
resetAnnotations,
|
};
|
});
|
|
export default types.compose("AnnotationStore", AnnotationStoreModel, StoreExtender);
|