diff --git a/web/libs/datamanager/src/stores/AppStore.js b/web/libs/datamanager/src/stores/AppStore.js index 78a914305576..352b0e22c9a4 100644 --- a/web/libs/datamanager/src/stores/AppStore.js +++ b/web/libs/datamanager/src/stores/AppStore.js @@ -1,6 +1,6 @@ import { destroy, flow, types } from "mobx-state-tree"; import { Modal } from "../components/Common/Modal/Modal"; -import { FF_DEV_2887, FF_LOPS_E_3, isFF } from "../utils/feature-flags"; +import { FF_DEV_2887, 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"; @@ -199,7 +199,22 @@ export const AppStore = types setTask: flow(function* ({ taskID, annotationID, pushState }) { if (pushState !== false) { - History.navigate({ task: taskID, annotation: annotationID ?? null, interaction: null }); + 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; @@ -216,20 +231,41 @@ export const AppStore = types self.annotationStore.setSelected(annotationID); } else { self.taskStore.setSelected(taskID); + } - const taskPromise = self.taskStore.loadTask(taskID, { - select: !!taskID && !!annotationID, - }); + const taskPromise = self.taskStore.loadTask(taskID, { + select: !!taskID && !!annotationID, + }); - taskPromise.then(() => { - const annotation = self.LSF?.currentAnnotation; - const id = annotation?.pk ?? annotation?.id; + taskPromise.then(() => { + const annotation = self.LSF?.currentAnnotation; + const id = annotation?.pk ?? annotation?.id; - self.LSF?.setLSFTask(self.taskStore.selected, id); + self.LSF?.setLSFTask(self.taskStore.selected, id); - self.setLoadingData(false); - }); - } + if (isFF(FF_REGION_VISIBILITY_FROM_URL)) { + const { annotation: annIDFromUrl, region: regionIDFromUrl } = History.getParams(); + if (annIDFromUrl) { + const lsfAnnotation = self.LSF.lsf.annotationStore.annotations.find((a) => { + return a.pk === annIDFromUrl || a.id === annIDFromUrl; + }); + + if (lsfAnnotation) { + const annID = lsfAnnotation.pk ?? lsfAnnotation.id; + self.LSF?.setLSFTask(self.taskStore.selected, annID); + } + } + 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); + } + } + + self.setLoadingData(false); + }); }), setLoadingData(value) { @@ -379,7 +415,7 @@ export const AppStore = types }, handlePopState: (({ state }) => { - const { tab, task, annotation, labeling } = state ?? {}; + const { tab, task, annotation, labeling, region } = state ?? {}; if (tab) { const tabId = Number.parseInt(tab); @@ -399,6 +435,11 @@ export const AppStore = types } else { params.id = Number.parseInt(task); } + if (region) { + params.region = region; + } else { + delete params.region; + } self.startLabeling(params, { pushState: false }); } else if (labeling) { diff --git a/web/libs/datamanager/src/utils/feature-flags.js b/web/libs/datamanager/src/utils/feature-flags.js index 947bfa8ee4e2..d8e2cbf8d00a 100644 --- a/web/libs/datamanager/src/utils/feature-flags.js +++ b/web/libs/datamanager/src/utils/feature-flags.js @@ -73,6 +73,11 @@ export const FF_GRID_PREVIEW = "fflag_feat_front_leap_1424_grid_preview_short"; export const FF_MEMORY_LEAK_FIX = "fflag_feat_all_optic_1178_reduce_memory_leak_short"; +/** + * Add ability to show specific region from URL params (by hiding all other regions). + */ +export const FF_REGION_VISIBILITY_FROM_URL = "fflag_feat_front_optic_1553_url_based_region_visibility_short"; + // Customize flags const flags = {}; diff --git a/web/libs/editor/src/stores/RegionStore.js b/web/libs/editor/src/stores/RegionStore.js index 03b29555e8c7..2dccb7503f87 100644 --- a/web/libs/editor/src/stores/RegionStore.js +++ b/web/libs/editor/src/stores/RegionStore.js @@ -507,17 +507,26 @@ export default types }, findRegionID(id) { + if (!id) return null; return self.regions.find((r) => r.id === id); }, findRegion(id) { - return self.regions.find((r) => r.id === id); + return self.findRegionID(id); }, filterByParentID(id) { return self.regions.filter((r) => r.parentID === id); }, + normalizeRegionID(regionId) { + if (!regionId) return ""; + if (!regionId.includes("#")) { + regionId = `${regionId}#${self.annotation.id}`; + } + return regionId; + }, + afterCreate() { onPatch(self, (patch) => { if ((patch.op === "add" || patch.op === "delete") && patch.path.indexOf("/regions/") !== -1) { @@ -582,6 +591,28 @@ export default types } }); }, + + selectRegionByID(regionId) { + const normalizedRegionId = self.normalizeRegionID(regionId); + const targetRegion = self.findRegionID(normalizedRegionId); + if (!targetRegion) return; + self.toggleSelection(targetRegion, true); + }, + + setRegionVisible(regionId) { + const normalizedRegionId = self.normalizeRegionID(regionId); + const targetRegion = self.findRegionID(normalizedRegionId); + if (!targetRegion) return; + + self.regions.forEach((area) => { + if (!area.hidden) { + area.toggleHidden(); + } + }); + + targetRegion.toggleHidden(); + }, + setHiddenByTool(shouldBeHidden, label) { self.regions.forEach((area) => { if (area.hidden !== shouldBeHidden && area.type === label.type) { @@ -589,6 +620,7 @@ export default types } }); }, + setHiddenByLabel(shouldBeHidden, label) { self.regions.forEach((area) => { if (area.hidden !== shouldBeHidden) { @@ -604,6 +636,7 @@ export default types } }); }, + highlight(area) { self.selection.highlight(area); }, diff --git a/web/libs/editor/tests/integration/data/outliner/hide-all.ts b/web/libs/editor/tests/integration/data/outliner/hide-all.ts index e49be0559bb9..055ada26bbec 100644 --- a/web/libs/editor/tests/integration/data/outliner/hide-all.ts +++ b/web/libs/editor/tests/integration/data/outliner/hide-all.ts @@ -3,7 +3,7 @@ export const simpleRegionsConfig = ` `; diff --git a/web/libs/editor/tests/integration/e2e/outliner/hide-all.cy.ts b/web/libs/editor/tests/integration/e2e/outliner/hide-all.cy.ts index 062c7b688b84..2f3bda4aed1e 100644 --- a/web/libs/editor/tests/integration/e2e/outliner/hide-all.cy.ts +++ b/web/libs/editor/tests/integration/e2e/outliner/hide-all.cy.ts @@ -45,6 +45,97 @@ describe("Outliner - Hide all regions", () => { Sidebar.hasHiddenRegion(3); }); + it("should hide all regions except the target region by ID from param", () => { + LabelStudio.params() + .config(simpleRegionsConfig) + .data(simpleRegionsData) + .withResult(simpleRegionsResult) + .withParam("region", "label_2") + .init(); + + cy.window().then((window: any | unknown) => { + window.Htx.annotationStore.annotations[0].regionStore.setRegionVisible(window.LSF_CONFIG.region); + }); + + Sidebar.hasRegions(3); + Sidebar.hasHiddenRegion(2); + + Sidebar.assertRegionHidden(0, "Label 1", true); + Sidebar.assertRegionHidden(1, "Label 2", false); + Sidebar.assertRegionHidden(2, "Label 3", true); + }); + + it("should hide all regions except the target region by ID within the targeted annotation tab specified by param", () => { + LabelStudio.params() + .config(simpleRegionsConfig) + .data(simpleRegionsData) + .withAnnotation({ id: "10", result: simpleRegionsResult }) + .withAnnotation({ id: "20", result: simpleRegionsResult }) + .withParam("annotation", "10") + .withParam("region", "label_2") + .init(); + + cy.window().then((window: any | unknown) => { + const annIdFromParam = window.LSF_CONFIG.annotation; + const annotations = window.Htx.annotationStore.annotations; + const lsfAnnotation = annotations.find((ann: any) => ann.pk === annIdFromParam || ann.id === annIdFromParam); + const annID = lsfAnnotation.pk ?? lsfAnnotation.id; + + expect(annID).to.equal("10"); + + // Move to the annotation tab specified by param + cy.get('[class="lsf-annotations-list__toggle"]').click(); + cy.get('[class="lsf-annotations-list__entity-id"]').contains("10").click(); + + annotations[1].regionStore.setRegionVisible(window.LSF_CONFIG.region); + }); + + Sidebar.hasRegions(3); + Sidebar.hasHiddenRegion(2); + + Sidebar.assertRegionHidden(0, "Label 1", true); + Sidebar.assertRegionHidden(1, "Label 2", false); + Sidebar.assertRegionHidden(2, "Label 3", true); + }); + + it("should not hide regions in the non-targeted annotaion tab", () => { + LabelStudio.params() + .config(simpleRegionsConfig) + .data(simpleRegionsData) + .withAnnotation({ id: "10", result: simpleRegionsResult }) + .withAnnotation({ id: "20", result: simpleRegionsResult }) + .withParam("annotation", "10") + .withParam("region", "label_2") + .init(); + + cy.window().then((window: any | unknown) => { + window.Htx.annotationStore.annotations[1].regionStore.setRegionVisible(window.LSF_CONFIG.region); + }); + + // Validate the annotation tab + cy.get('[class="lsf-annotations-list__entity-id"]').should("contain.text", "20"); + + Sidebar.hasRegions(3); + Sidebar.hasHiddenRegion(0); + }); + + it("should select the target region by ID from param", () => { + LabelStudio.params() + .config(simpleRegionsConfig) + .data(simpleRegionsData) + .withResult(simpleRegionsResult) + .withParam("region", "label_2") + .init(); + + cy.window().then((window: any | unknown) => { + window.Htx.annotationStore.annotations[0].regionStore.selectRegionByID(window.LSF_CONFIG.region); + }); + + Sidebar.hasRegions(3); + Sidebar.hasSelectedRegions(1); + Sidebar.hasSelectedRegion(1); + }); + it("should have tooltip for hide action", () => { LabelStudio.params().config(simpleRegionsConfig).data(simpleRegionsData).withResult(simpleRegionsResult).init(); @@ -61,6 +152,7 @@ describe("Outliner - Hide all regions", () => { Sidebar.showAllRegionsButton.trigger("mouseenter"); Tooltip.hasText("Show all regions"); }); + it("should react to changes in regions' visibility", () => { LabelStudio.params().config(simpleRegionsConfig).data(simpleRegionsData).withResult(simpleRegionsResult).init(); diff --git a/web/libs/frontend-test/src/helpers/LSF/Sidebar.ts b/web/libs/frontend-test/src/helpers/LSF/Sidebar.ts index 4d99273dbe9a..e18c7358e5b1 100644 --- a/web/libs/frontend-test/src/helpers/LSF/Sidebar.ts +++ b/web/libs/frontend-test/src/helpers/LSF/Sidebar.ts @@ -93,4 +93,8 @@ export const Sidebar = { expandDetailsRightPanel() { cy.get(".lsf-sidepanels__wrapper_align_right .lsf-panel__header").should("be.visible").click(); }, + assertRegionHidden(idx: number, id: string, shouldBeHidden: boolean) { + const expectation = shouldBeHidden ? "have.class" : "not.have.class"; + this.findRegionByIndex(idx).should("contain.text", id).parent().should(expectation, "lsf-tree__node_hidden"); + }, }; diff --git a/web/webpack.config.js b/web/webpack.config.js index 0f1768f888f8..c340a76ff336 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -264,15 +264,18 @@ module.exports = composePlugins( publicPath: `${FRONTEND_HOSTNAME}/react-app/`, }, allowedHosts: "all", // Allow access from Django's server - proxy: [ - { - context: ["/api"], - target: DJANGO_HOSTNAME, + proxy: { + "/api": { + target: `${DJANGO_HOSTNAME}/api`, + changeOrigin: true, + pathRewrite: { "^/api": "" }, + secure: false, + }, + "/": { + target: `${DJANGO_HOSTNAME}`, + changeOrigin: true, + secure: false, }, - ], - historyApiFallback: { - index: "/index.html", - disableDotRule: true, }, }, });