import deepEqual from "deep-equal";
|
import { clone, destroy, flow, getParent, getRoot, getSnapshot, types } from "mobx-state-tree";
|
import { guidGenerator } from "../../utils/random";
|
import { normalizeFilterValue } from "./filter_utils";
|
import { TabFilter } from "./tab_filter";
|
import { TabHiddenColumns } from "./tab_hidden_columns";
|
import { TabSelectedItems } from "./tab_selected_items";
|
import { History } from "../../utils/history";
|
import { CustomJSON, StringOrNumberID, ThresholdType } from "../types";
|
import { clamp } from "../../utils/helpers";
|
import { FF_ANNOTATION_RESULTS_FILTERING, isFF } from "../../utils/feature-flags";
|
|
const THRESHOLD_MIN = 0;
|
const THRESHOLD_MIN_DIFF = 0.001;
|
|
export const Tab = types
|
.model("View", {
|
id: StringOrNumberID,
|
|
title: "Tasks",
|
oldTitle: types.maybeNull(types.string),
|
|
key: types.optional(types.string, guidGenerator),
|
|
type: types.optional(types.enumeration(["list", "grid"]), "list"),
|
|
target: types.optional(types.enumeration(["tasks", "annotations"]), "tasks"),
|
|
filters: types.array(types.late(() => TabFilter)),
|
conjunction: types.optional(types.enumeration(["and", "or"]), "and"),
|
hiddenColumns: types.maybeNull(types.optional(TabHiddenColumns, {})),
|
ordering: types.optional(types.array(types.string), []),
|
selected: types.optional(TabSelectedItems, {}),
|
opener: types.optional(types.maybeNull(types.late(() => Tab)), null),
|
columnsWidth: types.map(types.maybeNull(types.number)),
|
columnsDisplayType: types.map(types.maybeNull(types.string)),
|
gridWidth: 4,
|
gridFitImagesToWidth: false,
|
|
enableFilters: false,
|
renameMode: false,
|
saved: false,
|
virtual: false,
|
locked: false,
|
editable: true,
|
deletable: true,
|
semantic_search: types.optional(types.array(CustomJSON), []),
|
threshold: types.optional(types.maybeNull(ThresholdType), null),
|
agreement_selected: types.optional(CustomJSON, {}),
|
})
|
.volatile(() => {
|
const defaultWidth = getComputedStyle(document.body)
|
.getPropertyValue("--menu-sidebar-width")
|
.replace("px", "")
|
.trim();
|
|
const labelingTableWidth = Number.parseInt(localStorage.getItem("labelingTableWidth") ?? defaultWidth ?? 200);
|
|
return {
|
labelingTableWidth,
|
};
|
})
|
.views((self) => ({
|
/** @returns {import("../../components/App/App").AppStore} */
|
get root() {
|
return getRoot(self);
|
},
|
|
get parent() {
|
return getParent(getParent(self));
|
},
|
|
get columns() {
|
return self.root.viewsStore.columns;
|
},
|
|
get targetColumns() {
|
return self.columns.filter((c) => {
|
return c.target === self.target && !c.isAnnotationResultsFilterColumn;
|
});
|
},
|
|
// get fields formatted as columns structure for react-table
|
get fieldsAsColumns() {
|
return self.columns.reduce((res, column) => {
|
if (!column.parent) {
|
res.push(...column.asField);
|
}
|
return res;
|
}, []);
|
},
|
|
get hiddenColumnsList() {
|
return self.columns.filter((c) => c.hidden).map((c) => c.key);
|
},
|
|
get availableFilters() {
|
return self.parent.availableFilters;
|
},
|
|
get dataStore() {
|
return self.root.dataStore;
|
},
|
|
get taskStore() {
|
return self.root.taskStore;
|
},
|
|
get annotationStore() {
|
return self.root.annotationStore;
|
},
|
|
get currentFilters() {
|
return self.filters.filter((f) => {
|
const targetMatches = f.target === self.target;
|
const annotationResultsOK = isFF(FF_ANNOTATION_RESULTS_FILTERING) || !f.field.isAnnotationResultsFilterColumn;
|
|
return targetMatches && annotationResultsOK;
|
});
|
},
|
|
get currentOrder() {
|
return self.ordering.length
|
? self.ordering.reduce((res, field) => {
|
const fieldName = field.replace(/^-/, "");
|
const desc = field[0] === "-";
|
|
return {
|
...res,
|
[fieldName]: desc,
|
desc,
|
field: fieldName,
|
column: self.columns.find((c) => c.id === fieldName),
|
};
|
}, {})
|
: null;
|
},
|
|
get filtersApplied() {
|
return self.validFilters.length;
|
},
|
|
get validFilters() {
|
return self.filters.filter((f) => !!f.isValidFilter);
|
},
|
|
get serializedFilters() {
|
const serialize = (filterModel) => {
|
const item = {
|
...getSnapshot(filterModel),
|
type: filterModel.filter.currentType,
|
};
|
|
// cleanup or recurse on child_filter
|
if (item.child_filter) {
|
if (!filterModel.child_filter?.isValidFilter) {
|
item.child_filter = null;
|
} else {
|
item.child_filter = serialize(filterModel.child_filter);
|
}
|
}
|
|
item.value = normalizeFilterValue(item.type, item.operator, item.value);
|
return item;
|
};
|
|
return self.validFilters.map((el) => serialize(el));
|
},
|
|
get selectedCount() {
|
const selectedCount = self.selected.list.length;
|
const dataLength = self.dataStore.total;
|
|
return self.selected.all ? dataLength - selectedCount : selectedCount;
|
},
|
|
get allSelected() {
|
return self.selectedCount === self.dataStore.total;
|
},
|
|
get filterSnapshot() {
|
return {
|
conjunction: self.conjunction,
|
items: self.serializedFilters,
|
};
|
},
|
|
// key used in urls
|
get tabKey() {
|
return self.virtual ? self.key : self.id;
|
},
|
|
get hiddenColumnsSnapshot() {
|
return getSnapshot(self.hiddenColumns);
|
},
|
|
get query() {
|
return JSON.stringify({
|
filters: self.filterSnapshot,
|
ordering: self.ordering.toJSON(),
|
hiddenColumns: self.hiddenColumnsSnapshot,
|
agreement_selected: self.agreement_selected,
|
});
|
},
|
|
serialize() {
|
if (self.virtual) {
|
return {
|
title: self.title,
|
filters: self.filterSnapshot,
|
ordering: self.ordering.toJSON(),
|
agreement_selected: self.agreement_selected,
|
};
|
}
|
|
const tab = {};
|
const { apiVersion } = self.root;
|
|
const data = {
|
title: self.title,
|
ordering: self.ordering.toJSON(),
|
type: self.type,
|
target: self.target,
|
filters: self.filterSnapshot,
|
hiddenColumns: getSnapshot(self.hiddenColumns),
|
columnsWidth: self.columnsWidth.toPOJO(),
|
columnsDisplayType: self.columnsDisplayType.toPOJO(),
|
gridWidth: self.gridWidth,
|
gridFitImagesToWidth: self.gridFitImagesToWidth,
|
semantic_search: self.semantic_search?.toJSON() ?? [],
|
threshold: self.threshold?.toJSON(),
|
agreement_selected: self.agreement_selected,
|
};
|
|
if (self.saved || apiVersion === 1) {
|
tab.id = self.id;
|
}
|
|
if (apiVersion === 2) {
|
tab.data = data;
|
tab.project = self.root.SDK.projectId;
|
} else {
|
Object.assign(tab, data);
|
}
|
|
self.root.SDK.invoke("tabTypeChanged", { tab: tab.id, type: self.type });
|
return tab;
|
},
|
}))
|
.volatile(() => ({
|
snapshot: {},
|
}))
|
.actions((self) => ({
|
lock() {
|
self.locked = true;
|
},
|
|
unlock() {
|
self.locked = false;
|
},
|
|
setType(type) {
|
self.type = type;
|
self.root.SDK.invoke("tabTypeChanged", { tab: self.id, type });
|
self.save({ reload: false });
|
},
|
|
setTarget(target) {
|
self.target = target;
|
self.save();
|
},
|
|
setTitle(title) {
|
self.title = title;
|
},
|
|
setRenameMode(mode) {
|
self.renameMode = mode;
|
if (self.renameMode) self.oldTitle = self.title;
|
},
|
|
setConjunction(value) {
|
self.conjunction = value;
|
self.save();
|
},
|
|
setOrdering(value) {
|
if (value === null) {
|
self.ordering = [];
|
} else {
|
const direction = self.currentOrder?.[value];
|
let ordering = value;
|
|
if (direction !== undefined) {
|
ordering = direction ? value : `-${value}`;
|
}
|
|
self.ordering[0] = ordering;
|
}
|
|
self.clearSelection();
|
self.save({ interaction: "ordering" });
|
},
|
|
setLabelingTableWidth(width) {
|
self.labelingTableWidth = width;
|
localStorage.setItem("labelingTableWidth", self.labelingTableWidth);
|
},
|
|
setGridWidth(width) {
|
self.gridWidth = width;
|
self.save();
|
},
|
|
setFitImagesToWidth(responsive) {
|
self.gridFitImagesToWidth = responsive;
|
self.save();
|
},
|
|
setSelected(ids) {
|
self.selected = ids;
|
},
|
|
setSemanticSearch(semanticSearchList, min, max) {
|
self.semantic_search = semanticSearchList ?? [];
|
/* if no semantic search we have to clean up threshold */
|
if (self.semantic_search.length === 0) {
|
self.threshold = null;
|
return self.save();
|
}
|
/* if we have a min and max we need to make sure we save that too.
|
this prevents firing 2 view save requests to accomplish the same thing */
|
return !isNaN(min) && !isNaN(max) ? self.setSemanticSearchThreshold(min, max) : self.save();
|
},
|
|
setSemanticSearchThreshold(_min, max) {
|
const min = clamp(_min ?? THRESHOLD_MIN, THRESHOLD_MIN, max - THRESHOLD_MIN_DIFF);
|
|
if (self.semantic_search?.length && !isNaN(min) && !isNaN(max)) {
|
self.threshold = { min, max };
|
return self.save();
|
}
|
},
|
|
clearSemanticSearchThreshold(save = true) {
|
self.threshold = null;
|
return save && self.save();
|
},
|
|
selectAll() {
|
self.selected.toggleSelectedAll();
|
},
|
|
clearSelection() {
|
self.selected.clear();
|
},
|
|
toggleSelected(id) {
|
self.selected.toggleItem(id);
|
},
|
|
setColumnWidth(columnID, width) {
|
if (width) {
|
self.columnsWidth.set(columnID, width);
|
} else {
|
self.columnsWidth.delete(columnID);
|
}
|
},
|
|
setColumnDisplayType(columnID, type) {
|
if (type !== null) {
|
const filters = self.filters.filter(({ filter }) => {
|
return columnID === filter.field.id;
|
});
|
|
filters.forEach((f) => {
|
if (f.type !== type) f.delete();
|
});
|
|
self.columnsDisplayType.set(columnID, type);
|
} else {
|
self.columnsDisplayType.delete(columnID);
|
}
|
},
|
|
createFilter() {
|
const filterType = self.availableFilters[0];
|
const filter = TabFilter.create({
|
filter: filterType,
|
view: self.id,
|
});
|
|
self.filters.push(filter);
|
|
// Immediately materialize child filter for the default column, if any
|
self.applyChildFilter(filter);
|
|
if (filter.isValidFilter) self.save();
|
},
|
|
/**
|
* Create a new filter row for the provided filter *type* (column).
|
*/
|
createChildFilterForType(filterType, parentFilter) {
|
const filter = TabFilter.create({
|
filter: filterType,
|
view: self.id,
|
});
|
|
// Don't add to main filters array - child is owned by parent
|
parentFilter.child_filter = filter;
|
|
return filter;
|
},
|
|
toggleColumn(column) {
|
if (self.hiddenColumns.hasColumn(column)) {
|
self.hiddenColumns.remove(column);
|
} else {
|
self.hiddenColumns.add(column);
|
}
|
self.save();
|
},
|
|
setAgreementFilters({
|
ground_truth = false,
|
annotators = { all: true, ids: [] },
|
models = { all: true, ids: [] },
|
}) {
|
self.agreement_selected = {
|
ground_truth,
|
annotators: {
|
all: annotators.all,
|
ids: annotators.ids,
|
},
|
models: {
|
all: models.all,
|
ids: models.ids,
|
},
|
};
|
},
|
|
reload: flow(function* ({ interaction } = {}) {
|
if (self.saved) {
|
yield self.dataStore.reload({ id: self.id, interaction });
|
}
|
if (self.virtual) {
|
yield self.dataStore.reload({ query: self.query, interaction });
|
}
|
|
getRoot(self).SDK?.invoke?.("tabReloaded", self);
|
}),
|
|
deleteFilter(filter) {
|
// Recursively delete child filter first
|
if (filter.child_filter) {
|
self.deleteFilter(filter.child_filter);
|
}
|
|
const index = self.filters.findIndex((f) => f === filter);
|
if (index > -1) {
|
self.filters.splice(index, 1);
|
destroy(filter);
|
self.save();
|
}
|
},
|
|
afterAttach() {
|
self.hiddenColumns = self.hiddenColumns ?? clone(self.parent.defaultHidden);
|
},
|
|
afterCreate() {
|
self.snapshot = self.serialize();
|
},
|
|
save: flow(function* ({ reload, interaction } = {}) {
|
const serialized = self.serialize();
|
|
if (!self.saved || !deepEqual(self.snapshot, serialized)) {
|
self.snapshot = serialized;
|
if (self.virtual === true) {
|
const snapshot = self.serialize();
|
|
self.key = self.parent.snapshotToUrl(snapshot);
|
|
const projectId = self.root.SDK.projectId;
|
|
// Save the virtual tab of the project to local storage to persist between page navigations
|
if (projectId) {
|
localStorage.setItem(`virtual-tab-${projectId}`, JSON.stringify(snapshot));
|
}
|
|
History.navigate({ tab: self.key }, true);
|
self.reload({ interaction });
|
} else {
|
yield self.parent.saveView(self, { reload, interaction });
|
}
|
}
|
}),
|
|
saveVirtual: flow(function* (options) {
|
self.virtual = false;
|
yield self.save(options);
|
History.navigate({ tab: self.id }, true);
|
}),
|
|
delete: flow(function* () {
|
yield self.root.apiCall("deleteTab", { tabID: self.id });
|
}),
|
|
markSaved() {
|
self.saved = true;
|
},
|
|
/**
|
* Create child filters for a given root filter according to its column's `child_filter` metadata.
|
*/
|
applyChildFilter(rootFilter) {
|
if (!rootFilter || !rootFilter.filter || !rootFilter.filter.field) return;
|
|
const column = rootFilter.field;
|
const childFilter = column?.child_filter;
|
|
if (!childFilter) return;
|
|
// NOTE: using targetColumns instead of columns means that annotation results columns cannot be used in child_filters, but seems fine for now
|
const firstChildColumn = self.targetColumns.find((c) => c.alias === childFilter);
|
|
if (firstChildColumn && !rootFilter.child_filter) {
|
const filterType = self.availableFilters.find((ft) => ft.field.id === firstChildColumn.id);
|
|
if (filterType) {
|
const childFilter = self.createChildFilterForType(filterType, rootFilter);
|
}
|
}
|
},
|
|
/** Remove any child filters previously created */
|
clearChildFilter(rootFilter) {
|
if (rootFilter.child_filter) {
|
self.deleteFilter(rootFilter.child_filter);
|
rootFilter.child_filter = null;
|
}
|
},
|
}))
|
.preProcessSnapshot((snapshot) => {
|
if (snapshot === null) return snapshot;
|
|
const { filters, agreement_selected, ...sn } = snapshot ?? {};
|
|
if (filters && !Array.isArray(filters)) {
|
const { conjunction, items } = filters ?? {};
|
|
Object.assign(sn, {
|
filters: items ?? [],
|
conjunction: conjunction ?? "and",
|
});
|
} else {
|
sn.filters = filters;
|
}
|
|
if (agreement_selected) {
|
Object.assign(sn, {
|
agreement_selected:
|
typeof agreement_selected === "string" ? JSON.parse(agreement_selected) : agreement_selected,
|
});
|
}
|
delete sn.selectedItems;
|
|
return sn;
|
});
|