import { destroy, flow, types } from "mobx-state-tree";
|
import { Modal } from "../components/Common/Modal/Modal";
|
import {
|
FF_DEV_2887,
|
FF_DISABLE_GLOBAL_USER_FETCHING,
|
FF_LOPS_E_3,
|
FF_REGION_VISIBILITY_FROM_URL,
|
isFF,
|
} from "../utils/feature-flags";
|
import { History } from "../utils/history";
|
import { isDefined } from "../utils/utils";
|
import { Action } from "./Action";
|
import * as DataStores from "./DataStores";
|
import { DynamicModel, registerModel } from "./DynamicModel";
|
import { TabStore } from "./Tabs";
|
import { CustomJSON } from "./types";
|
import { User } from "./Users";
|
import { ActivityObserver } from "../utils/ActivityObserver";
|
|
/**
|
* @type {ActivityObserver | null}
|
*/
|
let networkActivity = null;
|
|
const PROJECTS_FETCH_PERIOD = 20 * 1000; // interaction timer for 20 sec fetch period for project api
|
|
export const AppStore = types
|
.model("AppStore", {
|
mode: types.optional(types.enumeration(["explorer", "labelstream", "labeling"]), "explorer"),
|
|
viewsStore: types.optional(TabStore, {
|
views: [],
|
}),
|
|
project: types.optional(CustomJSON, {}),
|
|
loading: types.optional(types.boolean, false),
|
|
loadingData: false,
|
|
users: types.optional(types.array(User), []),
|
|
taskStore: types.optional(
|
types.late(() => {
|
return DynamicModel.get("tasksStore");
|
}),
|
{},
|
),
|
|
annotationStore: types.optional(
|
types.late(() => {
|
return DynamicModel.get("annotationsStore");
|
}),
|
{},
|
),
|
|
availableActions: types.optional(types.array(Action), []),
|
|
serverError: types.map(CustomJSON),
|
|
crashed: false,
|
|
interfaces: types.map(types.boolean),
|
|
toolbar: types.string,
|
})
|
.views((self) => ({
|
/** @returns {import("../sdk/dm-sdk").DataManager} */
|
get SDK() {
|
return self._sdk;
|
},
|
|
/** @returns {import("../sdk/lsf-sdk").LSFWrapper} */
|
get LSF() {
|
return self.SDK.lsf;
|
},
|
|
/** @returns {import("../utils/api-proxy").APIProxy} */
|
get API() {
|
return self.SDK.api;
|
},
|
|
get apiVersion() {
|
return self.SDK.apiVersion;
|
},
|
|
get isLabeling() {
|
return !!self.dataStore?.selected || self.isLabelStreamMode || self.mode === "labeling";
|
},
|
|
get isLabelStreamMode() {
|
return self.mode === "labelstream";
|
},
|
|
get isExplorerMode() {
|
return self.mode === "explorer" || self.mode === "labeling";
|
},
|
|
get currentView() {
|
return self.viewsStore.selected;
|
},
|
|
get dataStore() {
|
switch (self.target) {
|
case "tasks":
|
return self.taskStore;
|
case "annotations":
|
return self.annotationStore;
|
default:
|
return null;
|
}
|
},
|
|
get target() {
|
return self.viewsStore.selected?.target ?? "tasks";
|
},
|
|
get labelingIsConfigured() {
|
return self.project?.config_has_control_tags === true;
|
},
|
|
get labelingConfig() {
|
return self.project.label_config_line ?? self.project.label_config;
|
},
|
|
get showPreviews() {
|
return self.SDK.showPreviews;
|
},
|
|
get currentSelection() {
|
return self.currentView.selected.snapshot;
|
},
|
|
get currentFilter() {
|
return self.currentView.filterSnapshot;
|
},
|
|
get usersMap() {
|
return new Map(self.users.map((user) => [user.id, user]));
|
},
|
}))
|
.volatile(() => ({
|
needsDataFetch: false,
|
projectFetch: false,
|
requestsInFlight: new Map(),
|
}))
|
.actions((self) => ({
|
startPolling() {
|
if (self._poll) return;
|
if (self.SDK.polling === false) return;
|
|
const poll = async (self) => {
|
if (networkActivity.active) await self.fetchProject({ interaction: "timer" });
|
self._poll = setTimeout(() => poll(self), PROJECTS_FETCH_PERIOD);
|
};
|
|
poll(self);
|
},
|
|
afterCreate() {
|
networkActivity?.destroy();
|
networkActivity = new ActivityObserver();
|
},
|
|
beforeDestroy() {
|
clearTimeout(self._poll);
|
window.removeEventListener("popstate", self.handlePopState);
|
networkActivity.destroy();
|
},
|
|
setMode(mode) {
|
self.mode = mode;
|
},
|
|
setActions(actions) {
|
if (!Array.isArray(actions)) throw new Error("Actions must be an array");
|
self.availableActions = actions;
|
},
|
|
removeAction(id) {
|
const action = self.availableActions.find((action) => action.id === id);
|
|
if (action) destroy(action);
|
},
|
|
interfaceEnabled(name) {
|
return self.interfaces.get(name) === true;
|
},
|
|
enableInterface(name) {
|
if (!self.interfaces.has(name)) {
|
console.warn(`Unknown interface ${name}`);
|
} else {
|
self.interfaces.set(name, true);
|
}
|
},
|
|
disableInterface(name) {
|
if (!self.interfaces.has(name)) {
|
console.warn(`Unknown interface ${name}`);
|
} else {
|
self.interfaces.set(name, false);
|
}
|
},
|
|
setToolbar(toolbarString) {
|
self.toolbar = toolbarString;
|
},
|
|
setTask: flow(function* ({ taskID, annotationID, pushState }) {
|
if (pushState !== false) {
|
History.navigate({
|
task: taskID,
|
annotation: annotationID ?? null,
|
interaction: null,
|
region: null,
|
});
|
} else if (isFF(FF_REGION_VISIBILITY_FROM_URL)) {
|
const { task, region, annotation } = History.getParams();
|
History.navigate(
|
{
|
task,
|
region,
|
annotation,
|
},
|
true,
|
);
|
}
|
|
if (!isDefined(taskID)) return;
|
|
self.setLoadingData(true);
|
|
if (self.mode === "labelstream") {
|
yield self.taskStore.loadNextTask({
|
select: !!taskID && !!annotationID,
|
});
|
}
|
|
if (annotationID !== undefined) {
|
self.annotationStore.setSelected(annotationID);
|
} else {
|
self.taskStore.setSelected(taskID);
|
}
|
|
const taskPromise = self.taskStore.loadTask(taskID, {
|
select: !!taskID && !!annotationID,
|
});
|
|
// wait for the task to be loaded and LSF to be initialized
|
yield taskPromise.then(async () => {
|
// wait for self.LSF to be initialized with currentAnnotation
|
let maxWait = 1000;
|
while (!self.LSF?.currentAnnotation && maxWait > 0) {
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
maxWait -= 1;
|
}
|
|
if (self.LSF) {
|
const annotation = self.LSF?.currentAnnotation;
|
const id = annotation?.pk ?? annotation?.id;
|
|
self.LSF?.setLSFTask(self.taskStore.selected, id);
|
|
if (isFF(FF_REGION_VISIBILITY_FROM_URL)) {
|
const { annotation: annIDFromUrl, region: regionIDFromUrl } = History.getParams();
|
const annotationStore = self.LSF?.lsf?.annotationStore;
|
|
if (annIDFromUrl && annotationStore) {
|
const lsfAnnotation = [...annotationStore.annotations, ...annotationStore.predictions].find((a) => {
|
return a.pk === annIDFromUrl || a.id === annIDFromUrl;
|
});
|
|
if (lsfAnnotation) {
|
const annID = lsfAnnotation.pk ?? lsfAnnotation.id;
|
self.LSF?.setLSFTask(self.taskStore.selected, annID, undefined, lsfAnnotation.type === "prediction");
|
}
|
}
|
if (regionIDFromUrl) {
|
const currentAnn = self.LSF?.currentAnnotation;
|
// Focus on the region by hiding all other regions
|
currentAnn?.regionStore?.setRegionVisible(regionIDFromUrl);
|
// Select the region so outliner details are visible
|
currentAnn?.regionStore?.selectRegionByID(regionIDFromUrl);
|
}
|
}
|
} else {
|
console.error("LSF not initialized properly");
|
}
|
|
self.setLoadingData(false);
|
});
|
}),
|
|
setLoadingData(value) {
|
self.loadingData = value;
|
},
|
|
unsetTask(options) {
|
try {
|
self.annotationStore.unset();
|
self.taskStore.unset();
|
} catch (_e) {
|
/* Something weird */
|
}
|
|
if (options?.pushState !== false) {
|
History.navigate({ task: null, annotation: null });
|
}
|
},
|
|
unsetSelection() {
|
self.annotationStore.unset({ withHightlight: true });
|
self.taskStore.unset({ withHightlight: true });
|
},
|
|
createDataStores() {
|
const grouppedColumns = self.viewsStore.columns.reduce((res, column) => {
|
res.set(column.target, res.get(column.target) ?? []);
|
res.get(column.target).push(column);
|
return res;
|
}, new Map());
|
|
grouppedColumns.forEach((columns, target) => {
|
const dataStore = DataStores[target].create?.(columns);
|
|
if (dataStore) registerModel(`${target}Store`, dataStore);
|
});
|
},
|
|
startLabelStream(options = {}) {
|
if (!self.confirmLabelingConfigured()) return;
|
|
const nextAction = () => {
|
self.SDK.setMode("labelstream");
|
|
if (options?.pushState !== false) {
|
History.navigate({ labeling: 1 });
|
}
|
};
|
|
if (isFF(FF_DEV_2887) && self.LSF?.lsf?.annotationStore?.selected?.commentStore?.hasUnsaved) {
|
Modal.confirm({
|
title: "You have unsaved changes",
|
body: "There are comments which are not persisted. Please submit the annotation. Continuing will discard these comments.",
|
onOk() {
|
nextAction();
|
},
|
okText: "Discard and continue",
|
});
|
return;
|
}
|
|
nextAction();
|
},
|
|
startLabeling(item, options = {}) {
|
if (!self.confirmLabelingConfigured()) return;
|
|
if (self.dataStore.loadingItem) return;
|
|
const nextAction = () => {
|
self.SDK.setMode("labeling");
|
|
if (item?.id && !item.isSelected) {
|
const labelingParams = {
|
pushState: options?.pushState,
|
};
|
|
if (isDefined(item.task_id)) {
|
Object.assign(labelingParams, {
|
annotationID: item.id,
|
taskID: item.task_id,
|
});
|
} else {
|
Object.assign(labelingParams, {
|
taskID: item.id,
|
});
|
}
|
|
self.setTask(labelingParams);
|
} else {
|
self.closeLabeling();
|
}
|
};
|
|
if (isFF(FF_DEV_2887) && self.LSF?.lsf?.annotationStore?.selected?.commentStore?.hasUnsaved) {
|
Modal.confirm({
|
title: "You have unsaved changes",
|
body: "There are comments which are not persisted. Please submit the annotation. Continuing will discard these comments.",
|
onOk() {
|
nextAction();
|
},
|
okText: "Discard and continue",
|
});
|
return;
|
}
|
|
nextAction();
|
},
|
|
confirmLabelingConfigured() {
|
if (!self.labelingIsConfigured) {
|
Modal.confirm({
|
title: "You're almost there!",
|
body: "Before you can annotate the data, set up labeling configuration",
|
onOk() {
|
self.SDK.invoke("settingsClicked");
|
},
|
okText: "Go to setup",
|
});
|
return false;
|
}
|
return true;
|
},
|
|
closeLabeling(options) {
|
const { SDK } = self;
|
|
self.unsetTask(options);
|
|
let viewId;
|
const tabFromURL = History.getParams().tab;
|
|
if (isDefined(self.currentView)) {
|
viewId = self.currentView.tabKey;
|
} else if (isDefined(tabFromURL)) {
|
viewId = tabFromURL;
|
} else if (isDefined(self.viewsStore)) {
|
viewId = self.viewsStore.views[0]?.tabKey;
|
}
|
|
if (isDefined(viewId)) {
|
History.forceNavigate({ tab: viewId });
|
}
|
|
SDK.setMode("explorer");
|
SDK.destroyLSF();
|
},
|
|
handlePopState: (({ state }) => {
|
const { tab, task, annotation, labeling, region } = state ?? {};
|
|
if (tab) {
|
const tabId = Number.parseInt(tab);
|
|
self.viewsStore.setSelected(Number.isNaN(tabId) ? tab : tabId, {
|
pushState: false,
|
createDefault: false,
|
});
|
}
|
|
if (task) {
|
const params = {};
|
|
if (annotation) {
|
params.task_id = Number.parseInt(task);
|
params.id = Number.parseInt(annotation);
|
} else {
|
params.id = Number.parseInt(task);
|
}
|
if (region) {
|
params.region = region;
|
} else {
|
delete params.region;
|
}
|
|
self.startLabeling(params, { pushState: false });
|
} else if (labeling) {
|
self.startLabelStream({ pushState: false });
|
} else {
|
self.closeLabeling({ pushState: false });
|
}
|
}).bind(self),
|
|
resolveURLParams() {
|
window.addEventListener("popstate", self.handlePopState);
|
},
|
|
setLoading(value) {
|
self.loading = value;
|
},
|
|
fetchProject: flow(function* (options = {}) {
|
self.projectFetch = options.force === true;
|
|
const isTimer = options.interaction === "timer";
|
const params =
|
options && options.interaction
|
? {
|
interaction: options.interaction,
|
...(isTimer
|
? {
|
include: [
|
"task_count",
|
"task_number",
|
"annotation_count",
|
"num_tasks_with_annotations",
|
"queue_total",
|
].join(","),
|
}
|
: null),
|
}
|
: null;
|
|
try {
|
const newProject = yield self.apiCall("project", params);
|
const hasExistingProjectData = Object.entries(self.project ?? {}).length > 0;
|
const hasNewProjectData = Object.entries(newProject ?? {}).length > 0;
|
|
self.needsDataFetch =
|
options.force !== true && hasExistingProjectData && hasNewProjectData
|
? self.project.task_count !== newProject.task_count ||
|
self.project.task_number !== newProject.task_number ||
|
self.project.annotation_count !== newProject.annotation_count ||
|
self.project.num_tasks_with_annotations !== newProject.num_tasks_with_annotations
|
: false;
|
|
if (options.interaction === "timer") {
|
self.project = Object.assign(self.project ?? {}, newProject ?? {});
|
} else if (JSON.stringify(newProject ?? {}) !== JSON.stringify(self.project ?? {})) {
|
self.project = newProject;
|
}
|
if (isFF(FF_LOPS_E_3)) {
|
const itemType = self.SDK.type === "DE" ? "dataset" : "project";
|
|
self.SDK.invoke(`${itemType}Updated`, self.project);
|
}
|
} catch {
|
// When in timer (polling project counts) mode, we can still continue
|
// but we need to crash for non-polling interactions
|
// because we can't display the app without the project itself and will need to redirect
|
if (options.interaction !== "timer") {
|
self.crash({
|
error: `Project ID: ${self.SDK.projectId} does not exist or is no longer available`,
|
redirect: true,
|
});
|
}
|
return false;
|
}
|
self.projectFetch = false;
|
return true;
|
}),
|
|
/**
|
* @deprecated Use the useActions hook instead for better caching and performance
|
* This method is kept for backward compatibility but is no longer actively used
|
*/
|
fetchActions: flow(function* () {
|
try {
|
const serverActions = yield self.apiCall("actions");
|
|
const actions = (serverActions ?? []).map((action) => {
|
return [action, undefined];
|
});
|
|
self.SDK.updateActions(actions);
|
} catch (error) {
|
console.error("Error fetching actions:", error);
|
}
|
}),
|
|
fetchActionForm: flow(function* (actionId) {
|
const form = yield self.apiCall("actionForm", { actionId });
|
return form;
|
}),
|
|
fetchUsers: flow(function* () {
|
const list = yield self.apiCall("users", {
|
__useQueryCache: {
|
prefixKey: "organizationMembers",
|
staleTime: 60 * 1000,
|
},
|
});
|
|
self.users.push(...list);
|
}),
|
|
fetchData: flow(function* ({ isLabelStream } = {}) {
|
self.setLoading(true);
|
|
const { tab, task, labeling, query } = History.getParams();
|
|
self.viewsStore.fetchColumns();
|
|
const requests = [self.fetchProject()];
|
|
// Only fetch all users if not disabled globally
|
if (!isFF(FF_DISABLE_GLOBAL_USER_FETCHING)) {
|
requests.push(self.fetchUsers());
|
}
|
|
if (!isLabelStream || (self.project?.show_annotation_history && task)) {
|
if (self.SDK.settings?.onlyVirtualTabs && self.project?.show_annotation_history && !task) {
|
requests.push(
|
self.viewsStore.addView(
|
{
|
virtual: true,
|
projectId: self.SDK.projectId,
|
tab,
|
},
|
{ autosave: false, reload: false },
|
),
|
);
|
} else if (self.SDK.type === "labelops") {
|
requests.push(
|
self.viewsStore.addView(
|
{
|
virtual: false,
|
projectId: self.SDK.projectId,
|
tab,
|
},
|
{ autosave: false, autoSelect: true, reload: true },
|
),
|
);
|
} else {
|
requests.push(self.viewsStore.fetchTabs(tab, task, labeling));
|
}
|
} else if (isLabelStream && !!tab) {
|
const { selectedItems } = JSON.parse(decodeURIComponent(query ?? "{}"));
|
|
requests.push(self.viewsStore.fetchSingleTab(tab, selectedItems ?? {}));
|
}
|
|
const [projectFetched] = yield Promise.all(requests);
|
|
if (projectFetched) {
|
self.resolveURLParams();
|
|
self.setLoading(false);
|
|
self.startPolling();
|
}
|
}),
|
|
/**
|
* Main API calls provider for the whole application.
|
* `params` are used both for var substitution and query params if var is unknown:
|
* `{ project: 123, order: "desc" }` for method `"tasks": "/project/:pk/tasks"`
|
* will produce `/project/123/tasks?order=desc` url
|
* @param {string} methodName one of the methods in api-config
|
* @param {object} params url vars and query string params
|
* @param {object} body for POST/PATCH requests
|
* @param {{ errorHandler?: fn, headers?: object, allowToCancel?: boolean }} [options] additional options like errorHandler
|
*/
|
apiCall: flow(function* (methodName, params, body, options) {
|
const isAllowCancel = options?.allowToCancel;
|
const controller = new AbortController();
|
const signal = controller.signal;
|
const apiTransform = self.SDK.apiTransform?.[methodName];
|
const requestParams = apiTransform?.params?.(params) ?? params ?? {};
|
const requestBody = apiTransform?.body?.(body) ?? body ?? {};
|
const requestHeaders = apiTransform?.headers?.(options?.headers) ?? options?.headers ?? {};
|
const requestKey = `${methodName}_${JSON.stringify(params || {})}`;
|
|
if (isAllowCancel) {
|
requestHeaders.signal = signal;
|
if (self.requestsInFlight.has(requestKey)) {
|
/* if already in flight cancel the first in favor of new one */
|
self.requestsInFlight.get(requestKey).abort();
|
console.log(`Request ${requestKey} canceled`);
|
}
|
self.requestsInFlight.set(requestKey, controller);
|
}
|
const result = yield self.API[methodName](requestParams, {
|
headers: requestHeaders,
|
body: requestBody.body ?? requestBody,
|
options,
|
});
|
|
if (isAllowCancel) {
|
result.isCanceled = signal.aborted;
|
self.requestsInFlight.delete(requestKey);
|
}
|
// We don't want to show errors when loading data in polling mode
|
// we will just allow it to try again later
|
if (result.error && result.status !== 404 && !signal.aborted && params.interaction !== "timer") {
|
if (options?.errorHandler?.(result)) {
|
return result;
|
}
|
|
if (result.response) {
|
try {
|
self.serverError.set(methodName, {
|
error: "Something went wrong",
|
response: result.response,
|
});
|
} catch {
|
// ignore
|
}
|
}
|
|
console.warn({
|
message: "Error occurred when loading data",
|
description: result?.response?.detail ?? result.error,
|
});
|
|
self.SDK.invoke("error", result);
|
|
// notification.error({
|
// message: "Error occurred when loading data",
|
// description: result?.response?.detail ?? result.error,
|
// });
|
} else {
|
try {
|
self.serverError.delete(methodName);
|
} catch {
|
// ignore
|
}
|
}
|
|
return result;
|
}),
|
|
invokeAction: flow(function* (actionId, options = {}) {
|
const view = self.currentView ?? {};
|
const viewReloaded = view;
|
let projectFetched = self.project;
|
|
const needsLock = self.availableActions.findIndex((a) => a.id === actionId) >= 0;
|
|
const { selected } = view;
|
const actionCallback = self.SDK.getAction(actionId);
|
|
if (view && needsLock && !actionCallback) view.lock();
|
|
const labelStreamMode = localStorage.getItem("dm:labelstream:mode");
|
|
// @todo this is dirty way to sync across nested apps
|
// don't apply filters for "all" on "next_task"
|
const actionParams = {
|
ordering: view.ordering,
|
selectedItems: selected?.snapshot ?? { all: false, included: [] },
|
filters: {
|
conjunction: view.conjunction ?? "and",
|
items: view.serializedFilters ?? [],
|
},
|
};
|
|
if (actionId === "next_task") {
|
const isSelectAll = actionParams.selectedItems.all === true;
|
const isAllLabelStreamMode = labelStreamMode === "all";
|
const isFilteredLabelStreamMode = labelStreamMode === "filtered";
|
if (isAllLabelStreamMode && !isSelectAll) {
|
delete actionParams.filters;
|
|
if (actionParams.selectedItems.all === false && actionParams.selectedItems.included.length === 0) {
|
delete actionParams.selectedItems;
|
delete actionParams.ordering;
|
}
|
} else if (isFilteredLabelStreamMode) {
|
delete actionParams.selectedItems;
|
}
|
}
|
|
if (actionCallback instanceof Function) {
|
const result = actionCallback(actionParams, view);
|
self.SDK.invoke("actionDialogOkComplete", actionId, {
|
result,
|
view: viewReloaded,
|
project: projectFetched,
|
});
|
return result;
|
}
|
|
const requestParams = {
|
id: actionId,
|
};
|
|
if (isDefined(view.id) && !view?.virtual) {
|
requestParams.tabID = view.id;
|
}
|
|
if (options.body) {
|
Object.assign(actionParams, options.body);
|
}
|
|
const result = yield self.apiCall("invokeAction", requestParams, {
|
body: actionParams,
|
});
|
|
if (result.async) {
|
self.SDK.invoke("toast", { message: "Your action is being processed in the background.", type: "info" });
|
}
|
|
if (result.reload) {
|
self.SDK.reload();
|
self.SDK.invoke("actionDialogOkComplete", actionId, {
|
result,
|
view: viewReloaded,
|
project: projectFetched,
|
});
|
return;
|
}
|
|
if (options.reload !== false) {
|
yield view.reload();
|
yield self.fetchProject();
|
projectFetched = self.project;
|
view.clearSelection();
|
}
|
|
view?.unlock?.();
|
|
self.SDK.invoke("actionDialogOkComplete", actionId, {
|
result,
|
view: viewReloaded,
|
project: projectFetched,
|
});
|
return result;
|
}),
|
|
crash(options = {}) {
|
if (options.redirect !== true) {
|
self.destroy();
|
self.crashed = true;
|
}
|
self.SDK.invoke("crash", options);
|
},
|
|
destroy() {
|
if (self.taskStore) {
|
self.taskStore?.clear();
|
self.taskStore = undefined;
|
}
|
|
if (self.annotationStore) {
|
self.annotationStore?.clear();
|
self.annotationStore = undefined;
|
}
|
|
clearTimeout(self._poll);
|
},
|
}));
|