diff --git a/package-lock.json b/package-lock.json index d633f4d85a..7a5d34e053 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7282,7 +7282,8 @@ "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "optional": true }, "concat-map": { "version": "0.0.1", @@ -7293,7 +7294,8 @@ "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -7423,6 +7425,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -7552,7 +7555,8 @@ "number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "optional": true }, "object-assign": { "version": "4.1.1", @@ -7650,7 +7654,8 @@ "safe-buffer": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", - "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -7686,6 +7691,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -7748,12 +7754,14 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "optional": true }, "yallist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz", - "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=" + "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=", + "optional": true } } }, diff --git a/src/common/localization/en-us.ts b/src/common/localization/en-us.ts index c9074a6307..2a07e1e715 100644 --- a/src/common/localization/en-us.ts +++ b/src/common/localization/en-us.ts @@ -256,6 +256,15 @@ export const english: IAppStrings = { apply: "Apply Tag with Hot Key", lock: "Lock Tag with Hot Key", }, + rename: { + title: "Rename Tag", + confirmation: "Are you sure you want to rename this tag? It will be renamed throughout all assets", + }, + delete: { + title: "Delete Tag", + confirmation: "Are you sure you want to delete this tag? It will be deleted throughout all assets \ + and any regions where this is the only tag will also be deleted", + }, }, canvas: { removeAllRegions: { @@ -267,7 +276,8 @@ export const english: IAppStrings = { enforceTaggedRegions: { title: "Invalid region(s) detected", // tslint:disable-next-line:max-line-length - description: "1 or more regions have not been tagged. Ensure all regions are tagged before continuing to next asset.", + description: "1 or more regions have not been tagged. Ensure all regions are tagged before \ + continuing to next asset.", }, }, }, diff --git a/src/common/localization/es-cl.ts b/src/common/localization/es-cl.ts index 1e9d3cd396..41315013db 100644 --- a/src/common/localization/es-cl.ts +++ b/src/common/localization/es-cl.ts @@ -258,6 +258,16 @@ export const spanish: IAppStrings = { apply: "Aplicar etiqueta con tecla de acceso rápido", lock: "Bloquear etiqueta con tecla de acceso rápido", }, + rename: { + title: "Cambiar el nombre de la etiqueta", + confirmation: "¿Está seguro que quiere cambiar el nombre de esta etiqueta? \ + Será cambiada en todos los activos", + }, + delete: { + title: "Delete Tag", + confirmation: "¿Está seguro que quiere borrar esta etiqueta? Será borrada en todos \ + los activos y en las regiones donde esta etiqueta sea la única, la region también será borrada", + }, }, canvas: { removeAllRegions: { diff --git a/src/common/mockFactory.ts b/src/common/mockFactory.ts index 1db2f08d61..8dfb8ef3e1 100644 --- a/src/common/mockFactory.ts +++ b/src/common/mockFactory.ts @@ -810,14 +810,16 @@ export default class MockFactory { */ public static projectActions(): IProjectActions { return { - loadProject: jest.fn((project: IProject) => Promise.resolve()), - saveProject: jest.fn((project: IProject) => Promise.resolve()), - deleteProject: jest.fn((project: IProject) => Promise.resolve()), + loadProject: jest.fn(() => Promise.resolve()), + saveProject: jest.fn(() => Promise.resolve()), + deleteProject: jest.fn(() => Promise.resolve()), closeProject: jest.fn(() => Promise.resolve()), - loadAssets: jest.fn((project: IProject) => Promise.resolve()), - exportProject: jest.fn((project: IProject) => Promise.resolve()), - loadAssetMetadata: jest.fn((project: IProject, asset: IAsset) => Promise.resolve()), - saveAssetMetadata: jest.fn((project: IProject, assetMetadata: IAssetMetadata) => Promise.resolve()), + loadAssets: jest.fn(() => Promise.resolve()), + exportProject: jest.fn(() => Promise.resolve()), + loadAssetMetadata: jest.fn(() => Promise.resolve()), + saveAssetMetadata: jest.fn(() => Promise.resolve()), + updateProjectTag: jest.fn(() => Promise.resolve()), + deleteProjectTag: jest.fn(() => Promise.resolve()), }; } diff --git a/src/common/strings.ts b/src/common/strings.ts index 31e0114d75..7a842c0949 100644 --- a/src/common/strings.ts +++ b/src/common/strings.ts @@ -255,6 +255,14 @@ export interface IAppStrings { apply: string; lock: string; }, + rename: { + title: string; + confirmation: string; + }, + delete: { + title: string; + confirmation: string; + }, } canvas: { removeAllRegions: { diff --git a/src/react/components/common/tagInput/tagInput.tsx b/src/react/components/common/tagInput/tagInput.tsx index 7291ad16f9..540b540171 100644 --- a/src/react/components/common/tagInput/tagInput.tsx +++ b/src/react/components/common/tagInput/tagInput.tsx @@ -1,4 +1,4 @@ -import React, { KeyboardEvent } from "react"; +import React, { KeyboardEvent, RefObject } from "react"; import ReactDOM from "react-dom"; import Align from "rc-align"; import { randomIntInRange } from "../../../../common/utils"; @@ -31,9 +31,9 @@ export interface ITagInputProps { /** Function to call on clicking individual tag while holding CTRL key */ onCtrlTagClick?: (tag: ITag) => void; /** Function to call when tag is renamed */ - onTagRenamed?: (oldTag: string, newTag: string) => void; + onTagRenamed?: (tagName: string, newTagName: string) => void; /** Function to call when tag is deleted */ - onTagDeleted?: (tag: ITag) => void; + onTagDeleted?: (tagName: string) => void; /** Always show tag input box */ showTagInputBox?: boolean; /** Always show tag search box */ @@ -72,7 +72,7 @@ export class TagInput extends React.Component { portalElement: defaultDOMNode(), }; - private tagItemRefs: { [id: string]: TagInputItem } = {}; + private tagItemRefs: Map> = new Map>(); private portalDiv = document.createElement("div"); public render() { @@ -109,7 +109,7 @@ export class TagInput extends React.Component { } {this.getColorPickerPortal()}
- {this.getTagItems()} + {this.renderTagItems()}
{ this.state.addTags && @@ -154,11 +154,13 @@ export class TagInput extends React.Component { } } - private getTagNode = (tag: ITag) => { + private getTagNode = (tag: ITag): Element => { if (!tag) { return defaultDOMNode(); } - return ReactDOM.findDOMNode(this.tagItemRefs[tag.name]) as Element; + + const itemRef = this.tagItemRefs.get(tag.name); + return (itemRef ? ReactDOM.findDOMNode(itemRef.current) : defaultDOMNode()) as Element; } private onEditTag = (tag: ITag) => { @@ -219,20 +221,25 @@ export class TagInput extends React.Component { }, () => this.props.onChange(tags)); } - private updateTag = (oldTag: ITag, newTag: ITag) => { - if (oldTag === newTag) { + private updateTag = (tag: ITag, newTag: ITag) => { + if (tag === newTag) { return; } if (!newTag.name.length) { toast.warn(strings.tags.warnings.emptyName); return; } - if (newTag.name !== oldTag.name && this.state.tags.some((t) => t.name === newTag.name)) { + const nameChange = tag.name !== newTag.name; + if (nameChange && this.state.tags.some((t) => t.name === newTag.name)) { toast.warn(strings.tags.warnings.existingName); return; } + if (nameChange && this.props.onTagRenamed) { + this.props.onTagRenamed(tag.name, newTag.name); + return; + } const tags = this.state.tags.map((t) => { - return (t.name === oldTag.name) ? newTag : t; + return (t.name === tag.name) ? newTag : t; }); this.setState({ tags, @@ -293,12 +300,15 @@ export class TagInput extends React.Component { return this.state.editingTagNode || document; } - private getTagItems = () => { - let props = this.getTagItemProps(); + private renderTagItems = () => { + let props = this.createTagItemProps(); const query = this.state.searchQuery; + this.tagItemRefs.clear(); + if (query.length) { props = props.filter((prop) => prop.tag.name.toLowerCase().includes(query.toLowerCase())); } + return props.map((prop) => { } private setTagItemRef = (item, tag) => { - if (item) { - this.tagItemRefs[tag.name] = item; - } + this.tagItemRefs.set(tag.name, item); + return item; } - private getTagItemProps = (): ITagInputItemProps[] => { + private createTagItemProps = (): ITagInputItemProps[] => { const tags = this.state.tags; const selectedRegionTagSet = this.getSelectedRegionTagSet(); - return tags.map((tag) => { - const item: ITagInputItemProps = { + + return tags.map((tag) => ( + { tag, index: tags.findIndex((t) => t.name === tag.name), isLocked: this.props.lockedTags && this.props.lockedTags.findIndex((t) => t === tag.name) > -1, @@ -326,9 +336,8 @@ export class TagInput extends React.Component { appliedToSelectedRegions: selectedRegionTagSet.has(tag.name), onClick: this.handleClick, onChange: this.updateTag, - }; - return item; - }); + } as ITagInputItemProps + )); } private getSelectedRegionTagSet = (): Set => { @@ -346,6 +355,7 @@ export class TagInput extends React.Component { private onAltClick = (tag: ITag, clickedColor: boolean) => { const { editingTag } = this.state; const newEditingTag = editingTag && editingTag.name === tag.name ? null : tag; + this.setState({ editingTag: newEditingTag, editingTagNode: this.getTagNode(newEditingTag), @@ -355,16 +365,16 @@ export class TagInput extends React.Component { } private handleClick = (tag: ITag, props: ITagClickProps) => { + // Lock tags if (props.ctrlKey && this.props.onCtrlTagClick) { this.props.onCtrlTagClick(tag); this.setState({ clickedColor: props.clickedColor }); - } else if (props.altKey) { + } else if (props.altKey) { // Edit tag this.onAltClick(tag, props.clickedColor); - } else { + } else { // Select tag const { editingTag, selectedTag } = this.state; const inEditMode = editingTag && tag.name === editingTag.name; const alreadySelected = selectedTag && selectedTag.name === tag.name; - const newEditingTag = inEditMode ? null : editingTag; this.setState({ @@ -389,12 +399,19 @@ export class TagInput extends React.Component { if (!tag) { return; } + if (this.props.onTagDeleted) { + this.props.onTagDeleted(tag.name); + return; + } + const index = this.state.tags.indexOf(tag); const tags = this.state.tags.filter((t) => t.name !== tag.name); + this.setState({ tags, selectedTag: this.getNewSelectedTag(tags, index), }, () => this.props.onChange(tags)); + if (this.props.lockedTags.find((l) => l === tag.name)) { this.props.onLockedTagsChange( this.props.lockedTags.filter((lockedTag) => lockedTag !== tag.name), diff --git a/src/react/components/pages/editorPage/canvas.test.tsx b/src/react/components/pages/editorPage/canvas.test.tsx index d4ca52d109..a37c6f6a85 100644 --- a/src/react/components/pages/editorPage/canvas.test.tsx +++ b/src/react/components/pages/editorPage/canvas.test.tsx @@ -179,7 +179,7 @@ describe("Editor Canvas", () => { }); const canvas = wrapper.instance() as Canvas; expect(wrapper.state().currentAsset).toEqual(assetMetadata); - expect(() => canvas.updateCanvasToolsRegions()).not.toThrowError(); + expect(() => canvas.updateCanvasToolsRegionTags()).not.toThrowError(); }); it("canvas content source is updated when asset is deactivated", () => { diff --git a/src/react/components/pages/editorPage/canvas.tsx b/src/react/components/pages/editorPage/canvas.tsx index 33d9dc5f6a..0205e6e186 100644 --- a/src/react/components/pages/editorPage/canvas.tsx +++ b/src/react/components/pages/editorPage/canvas.tsx @@ -4,7 +4,7 @@ import { CanvasTools } from "vott-ct"; import { RegionData } from "vott-ct/lib/js/CanvasTools/Core/RegionData"; import { EditorMode, IAssetMetadata, - IProject, IRegion, RegionType, IBoundingBox, ISize, + IProject, IRegion, RegionType, } from "../../../../models/applicationState"; import CanvasHelpers from "./canvasHelpers"; import { AssetPreview, ContentSource } from "../../common/assetPreview/assetPreview"; @@ -73,7 +73,8 @@ export default class Canvas extends React.Component } public componentDidUpdate = async (prevProps: Readonly, prevState: Readonly) => { - if (this.props.selectedAsset.asset.id !== prevProps.selectedAsset.asset.id) { + // Handles asset changing + if (this.props.selectedAsset !== prevProps.selectedAsset) { this.setState({ currentAsset: this.props.selectedAsset }); } @@ -83,9 +84,16 @@ export default class Canvas extends React.Component this.editor.AS.setSelectionMode({ mode: this.props.selectionMode, template: options }); } + const assetIdChanged = this.state.currentAsset.asset.id !== prevState.currentAsset.asset.id; + + // When the selected asset has changed but is still the same asset id + if (!assetIdChanged && this.state.currentAsset !== prevState.currentAsset) { + this.refreshCanvasToolsRegions(); + } + // When the project tags change re-apply tags to regions if (this.props.project.tags !== prevProps.project.tags) { - this.updateCanvasToolsRegions(); + this.updateCanvasToolsRegionTags(); } // Handles when the canvas is enabled & disabled @@ -192,7 +200,7 @@ export default class Canvas extends React.Component return this.state.currentAsset.regions.filter((r) => selectedRegions.find((id) => r.id === id)); } - public updateCanvasToolsRegions = (): void => { + public updateCanvasToolsRegionTags = (): void => { for (const region of this.state.currentAsset.regions) { this.editor.RM.updateTagsById( region.id, @@ -493,7 +501,7 @@ export default class Canvas extends React.Component this.editor.RM.updateTagsById(update.id, CanvasHelpers.getTagsDescriptor(this.props.project.tags, update)); } this.updateAssetRegions(updatedRegions); - this.updateCanvasToolsRegions(); + this.updateCanvasToolsRegionTags(); } /** diff --git a/src/react/components/pages/editorPage/editorPage.test.tsx b/src/react/components/pages/editorPage/editorPage.test.tsx index 786375aba5..f8a717366f 100644 --- a/src/react/components/pages/editorPage/editorPage.test.tsx +++ b/src/react/components/pages/editorPage/editorPage.test.tsx @@ -29,6 +29,8 @@ import { appInfo } from "../../../../common/appInfo"; import SplitPane from "react-split-pane"; import EditorSideBar from "./editorSideBar"; import Alert from "../../common/alert/alert"; +import registerMixins from "../../../../registerMixins"; +import { TagInput } from "../../common/tagInput/tagInput"; function createComponent(store, props: IEditorPageProps): ReactWrapper { return mount( @@ -125,12 +127,10 @@ describe("Editor Page Component", () => { const wrapper = createComponent(store, props); const editorPage = wrapper.find(EditorPage).childAt(0); - expect(getState(wrapper).project).toBeNull(); editorPage.props().project = testProject; await MockFactory.flushUi(); expect(editorPage.props().project).toEqual(testProject); - expect(getState(wrapper).project).toEqual(testProject); }); it("Loads and merges project assets with asset provider assets when state changes", async () => { @@ -163,7 +163,7 @@ describe("Editor Page Component", () => { }); }); - it("Raises onAssetSelected handler when an asset is selected from the sidebar", async () => { + it("Default asset is loaded and saved during initial page rendering", async () => { // create test project and asset const testProject = MockFactory.createTestProject("TestProject"); const defaultAsset = testAssets[0]; @@ -181,6 +181,7 @@ describe("Editor Page Component", () => { const editorPage = wrapper.find(EditorPage).childAt(0) as ReactWrapper; await MockFactory.flushUi(); + wrapper.update(); const expectedAsset = editorPage.state().assets[0]; const partialProject = { @@ -657,6 +658,11 @@ describe("Editor Page Component", () => { }); describe("Basic tag interaction tests", () => { + + beforeAll(() => { + registerMixins(); + }); + it("tags are initialized correctly", () => { const project = MockFactory.createTestProject(); const store = createReduxStore({ @@ -665,26 +671,31 @@ describe("Editor Page Component", () => { }); const wrapper = createComponent(store, MockFactory.editorPageProps()); - expect(getState(wrapper).project.tags).toEqual(project.tags); + expect(wrapper.find(TagInput).props().tags).toEqual(project.tags); }); - it("create a new tag from text box", () => { + it("create a new tag updates project tags", async () => { const project = MockFactory.createTestProject(); const store = createReduxStore({ ...MockFactory.initialState(), currentProject: project, }); + const wrapper = createComponent(store, MockFactory.editorPageProps()); - expect(getState(wrapper).project.tags).toEqual(project.tags); + await waitForSelectedAsset(wrapper); - const newTagName = "My new tag"; - wrapper.find("div.tag-input-toolbar-item.plus").simulate("click"); - wrapper.find(".tag-input-box").simulate("keydown", { key: "Enter", target: { value: newTagName } }); + const newTag = MockFactory.createTestTag("NewTag"); + const updatedTags = [...project.tags, newTag]; + wrapper.find(TagInput).props().onChange(updatedTags); + + await MockFactory.flushUi(); + wrapper.update(); - const stateTags = getState(wrapper).project.tags; + const editorPage = wrapper.find(EditorPage).childAt(0) as ReactWrapper; + const projectTags = editorPage.props().project.tags; - expect(stateTags).toHaveLength(project.tags.length + 1); - expect(stateTags[stateTags.length - 1].name).toEqual(newTagName); + expect(projectTags).toHaveLength(updatedTags.length); + expect(projectTags[projectTags.length - 1].name).toEqual(newTag.name); }); it("Remove a tag", async () => { @@ -697,12 +708,20 @@ describe("Editor Page Component", () => { const wrapper = createComponent(store, MockFactory.editorPageProps()); await waitForSelectedAsset(wrapper); - expect(getState(wrapper).project.tags).toEqual(project.tags); - wrapper.find(".tag-content").last().simulate("click"); - wrapper.find("i.tag-input-toolbar-icon.fas.fa-trash").simulate("click"); + const tagToDelete = project.tags[project.tags.length - 1]; + wrapper.find(TagInput).props().onTagDeleted(tagToDelete.name); + + // Accept the modal delete warning + wrapper.update(); + wrapper.find(".modal-footer button").first().simulate("click"); + + await MockFactory.flushUi(); + wrapper.update(); + + const editorPage = wrapper.find(EditorPage).childAt(0) as ReactWrapper; + const projectTags = editorPage.props().project.tags; - const stateTags = getState(wrapper).project.tags; - expect(stateTags).toHaveLength(project.tags.length - 1); + expect(projectTags).toHaveLength(project.tags.length - 1); }); it("Adds tag to locked tags when CmdOrCtrl clicked", async () => { diff --git a/src/react/components/pages/editorPage/editorPage.tsx b/src/react/components/pages/editorPage/editorPage.tsx index 86d4719838..c4d3a3b723 100644 --- a/src/react/components/pages/editorPage/editorPage.tsx +++ b/src/react/components/pages/editorPage/editorPage.tsx @@ -28,6 +28,7 @@ import "./editorPage.scss"; import EditorSideBar from "./editorSideBar"; import { EditorToolbar } from "./editorToolbar"; import Alert from "../../common/alert/alert"; +import Confirm from "../../common/confirm/confirm"; // tslint:disable-next-line:no-var-requires const tagColors = require("../../common/tagColors.json"); @@ -50,8 +51,6 @@ export interface IEditorPageProps extends RouteComponentProps, React.Props { public state: IEditorPageState = { - project: this.props.project, selectedTag: null, lockedTags: [], selectionMode: SelectionMode.RECT, @@ -120,6 +118,8 @@ export default class EditorPage extends React.Component = React.createRef(); + private renameTagConfirm: React.RefObject = React.createRef(); + private deleteTagConfirm: React.RefObject = React.createRef(); public async componentDidMount() { const projectId = this.props.match.params["projectId"]; @@ -131,7 +131,7 @@ export default class EditorPage extends React.Component) { if (this.props.project && this.state.assets.length === 0) { await this.loadProjectAssets(); } @@ -139,12 +139,17 @@ export default class EditorPage extends React.Component + + this.canvas.current.applyTag(tag.name)); } + /** + * Open confirm dialog for tag renaming + */ + private confirmTagRenamed = (tagName: string, newTagName: string): void => { + this.renameTagConfirm.current.open(tagName, newTagName); + } + + /** + * Renames tag in assets and project, and saves files + * @param tagName Name of tag to be renamed + * @param newTagName New name of tag + */ + private onTagRenamed = async (tagName: string, newTagName: string): Promise => { + const assetUpdates = await this.props.actions.updateProjectTag(this.props.project, tagName, newTagName); + const selectedAsset = assetUpdates.find((am) => am.asset.id === this.state.selectedAsset.asset.id); + + if (selectedAsset) { + if (selectedAsset) { + this.setState({ selectedAsset }); + } + } + } + + /** + * Open Confirm dialog for tag deletion + */ + private confirmTagDeleted = (tagName: string): void => { + this.deleteTagConfirm.current.open(tagName); + } + + /** + * Removes tag from assets and projects and saves files + * @param tagName Name of tag to be deleted + */ + private onTagDeleted = async (tagName: string): Promise => { + const assetUpdates = await this.props.actions.deleteProjectTag(this.props.project, tagName); + const selectedAsset = assetUpdates.find((am) => am.asset.id === this.state.selectedAsset.asset.id); + + if (selectedAsset) { + this.setState({ selectedAsset }); + } + } + private onCtrlTagClicked = (tag: ITag): void => { const locked = this.state.lockedTags; this.setState({ @@ -423,17 +483,13 @@ export default class EditorPage extends React.Component { + private onTagsChanged = async (tags) => { const project = { ...this.props.project, tags, }; - this.setState({ project }, async () => { - await this.props.actions.saveProject(project); - if (this.canvas.current) { - this.canvas.current.updateCanvasToolsRegions(); - } - }); + + await this.props.actions.saveProject(project); } private onLockedTagsChanged = (lockedTags: string[]) => { @@ -598,4 +654,19 @@ export default class EditorPage extends React.Component { + const updatedAssets = [...this.state.assets]; + updatedAssets.forEach((asset) => { + const projectAsset = this.props.project.assets[asset.id]; + if (projectAsset) { + asset.state = projectAsset.state; + } + }); + + this.setState({ assets: updatedAssets }); + } } diff --git a/src/redux/actions/actionTypes.ts b/src/redux/actions/actionTypes.ts index 6f1ad9623c..2116460825 100644 --- a/src/redux/actions/actionTypes.ts +++ b/src/redux/actions/actionTypes.ts @@ -16,6 +16,8 @@ export enum ActionTypes { CLOSE_PROJECT_SUCCESS = "CLOSE_PROJECT_SUCCESS", LOAD_PROJECT_ASSETS_SUCCESS = "LOAD_PROJECT_ASSETS_SUCCESS", EXPORT_PROJECT_SUCCESS = "EXPORT_PROJECT_SUCCESS", + UPDATE_PROJECT_TAG_SUCCESS = "UPDATE_PROJECT_TAG_SUCCESS", + DELETE_PROJECT_TAG_SUCCESS = "DELETE_PROJECT_TAG_SUCCESS", // Connections LOAD_CONNECTION_SUCCESS = "LOAD_CONNECTION_SUCCESS", diff --git a/src/redux/actions/projectActions.test.ts b/src/redux/actions/projectActions.test.ts index 8a8197ed37..f9a49d5827 100644 --- a/src/redux/actions/projectActions.test.ts +++ b/src/redux/actions/projectActions.test.ts @@ -1,3 +1,4 @@ +import _ from "lodash"; import createMockStore, { MockStoreEnhanced } from "redux-mock-store"; import { ActionTypes } from "./actionTypes"; import * as projectActions from "./projectActions"; @@ -11,24 +12,29 @@ jest.mock("../../services/assetService"); import { AssetService } from "../../services/assetService"; import { ExportProviderFactory } from "../../providers/export/exportProviderFactory"; import { ExportAssetState, IExportProvider } from "../../providers/export/exportProvider"; -import { IApplicationState } from "../../models/applicationState"; +import { IApplicationState, IProject } from "../../models/applicationState"; import initialState from "../store/initialState"; import { appInfo } from "../../common/appInfo"; +import registerMixins from "../../registerMixins"; describe("Project Redux Actions", () => { let store: MockStoreEnhanced; let projectServiceMock: jest.Mocked; const appSettings = MockFactory.appSettings(); + beforeAll(registerMixins); + beforeEach(() => { const middleware = [thunk]; const mockState: IApplicationState = { ...initialState, appSettings, }; + store = createMockStore(middleware)(mockState); projectServiceMock = ProjectService as jest.Mocked; projectServiceMock.prototype.load = jest.fn((project) => Promise.resolve(project)); + projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project)); }); it("Load Project action resolves a promise and dispatches redux action", async () => { @@ -255,4 +261,81 @@ describe("Project Redux Actions", () => { expect(mockExportProvider.export).toHaveBeenCalled(); }); + + describe("Updating project tags", () => { + let project: IProject = null; + + beforeEach(() => { + project = MockFactory.createTestProject("TestProject"); + const middleware = [thunk]; + const mockState: IApplicationState = { + ...initialState, + currentProject: project, + appSettings, + }; + + store = createMockStore(middleware)(mockState); + }); + + it("Updates tags across all project assets when a tag is renamed", async () => { + const projectAssets = _.values(project.assets); + const updatedTag = project.tags[project.tags.length - 1]; + + const updatedAssets = [ + MockFactory.createTestAssetMetadata(projectAssets[0]), + MockFactory.createTestAssetMetadata(projectAssets[1]), + ]; + + const expectedTagName = `${updatedTag.name} - updated`; + + const assetServiceMock = AssetService as jest.Mocked; + assetServiceMock.prototype.renameTag = jest.fn(() => Promise.resolve(updatedAssets)); + + const actualUpdatedAssets = await projectActions.updateProjectTag( + project, + updatedTag.name, + expectedTagName, + )(store.dispatch, store.getState); + + const actions = store.getActions(); + + expect(actions.length).toEqual(5); + expect(actions[0].type).toEqual(ActionTypes.SAVE_ASSET_METADATA_SUCCESS); + expect(actions[1].type).toEqual(ActionTypes.SAVE_ASSET_METADATA_SUCCESS); + expect(actions[2].type).toEqual(ActionTypes.SAVE_PROJECT_SUCCESS); + expect(actions[3].type).toEqual(ActionTypes.LOAD_PROJECT_SUCCESS); + expect(actions[4].type).toEqual(ActionTypes.UPDATE_PROJECT_TAG_SUCCESS); + + expect(actualUpdatedAssets).toEqual(updatedAssets); + }); + + it("Deletes tags across all project assets when a tag is renamed", async () => { + const projectAssets = _.values(project.assets); + const deletedTag = project.tags[project.tags.length - 1]; + + const updatedAssets = [ + MockFactory.createTestAssetMetadata(projectAssets[0]), + MockFactory.createTestAssetMetadata(projectAssets[1]), + ]; + + const assetServiceMock = AssetService as jest.Mocked; + assetServiceMock.prototype.deleteTag = jest.fn(() => Promise.resolve(updatedAssets)); + + const actualUpdatedAssets = await projectActions.deleteProjectTag( + project, + deletedTag.name, + )(store.dispatch, store.getState); + + const actions = store.getActions(); + + expect(actions.length).toEqual(5); + expect(actions[0].type).toEqual(ActionTypes.SAVE_ASSET_METADATA_SUCCESS); + expect(actions[1].type).toEqual(ActionTypes.SAVE_ASSET_METADATA_SUCCESS); + expect(actions[2].type).toEqual(ActionTypes.SAVE_PROJECT_SUCCESS); + expect(actions[3].type).toEqual(ActionTypes.LOAD_PROJECT_SUCCESS); + expect(actions[4].type).toEqual(ActionTypes.DELETE_PROJECT_TAG_SUCCESS); + + expect(actualUpdatedAssets).toEqual(updatedAssets); + }); + }); }); diff --git a/src/redux/actions/projectActions.ts b/src/redux/actions/projectActions.ts index 0b9b1b3670..5d853ec6ad 100644 --- a/src/redux/actions/projectActions.ts +++ b/src/redux/actions/projectActions.ts @@ -30,6 +30,8 @@ export default interface IProjectActions { loadAssets(project: IProject): Promise; loadAssetMetadata(project: IProject, asset: IAsset): Promise; saveAssetMetadata(project: IProject, assetMetadata: IAssetMetadata): Promise; + updateProjectTag(project: IProject, oldTagName: string, newTagName: string): Promise; + deleteProjectTag(project: IProject, tagName): Promise; } /** @@ -187,6 +189,69 @@ export function saveAssetMetadata( }; } +/** + * Updates a project and all asset references from oldTagName to newTagName + * @param project The project to update tags + * @param oldTagName The old tag name + * @param newTagName The new tag name + */ +export function updateProjectTag(project: IProject, oldTagName: string, newTagName: string) + : (dispatch: Dispatch, getState: () => IApplicationState) => Promise { + return async (dispatch: Dispatch, getState: () => IApplicationState) => { + // Find tags to rename + const assetService = new AssetService(project); + const assetUpdates = await assetService.renameTag(oldTagName, newTagName); + + // Save updated assets + await assetUpdates.forEachAsync(async (assetMetadata) => { + await saveAssetMetadata(project, assetMetadata)(dispatch); + }); + + const currentProject = getState().currentProject; + const updatedProject = { + ...currentProject, + tags: project.tags.map((t) => (t.name === oldTagName) ? { ...t, name: newTagName } : t), + }; + + // Save updated project tags + await saveProject(updatedProject)(dispatch, getState); + dispatch(updateProjectTagAction(updatedProject)); + + return assetUpdates; + }; +} + +/** + * Updates a project and all asset references from oldTagName to newTagName + * @param project The project to delete tags + * @param tagName The tag to delete + */ +export function deleteProjectTag(project: IProject, tagName) + : (dispatch: Dispatch, getState: () => IApplicationState) => Promise { + return async (dispatch: Dispatch, getState: () => IApplicationState) => { + // Find tags to rename + const assetService = new AssetService(project); + const assetUpdates = await assetService.deleteTag(tagName); + + // Save updated assets + await assetUpdates.forEachAsync(async (assetMetadata) => { + await saveAssetMetadata(project, assetMetadata)(dispatch); + }); + + const currentProject = getState().currentProject; + const updatedProject = { + ...currentProject, + tags: project.tags.filter((t) => t.name !== tagName), + }; + + // Save updated project tags + await saveProject(updatedProject)(dispatch, getState); + dispatch(deleteProjectTagAction(updatedProject)); + + return assetUpdates; + }; +} + /** * Initialize export provider, get export data and dispatch export project action * @param project - Project to export @@ -267,6 +332,20 @@ export interface IExportProjectAction extends IPayloadAction { type: ActionTypes.EXPORT_PROJECT_SUCCESS; } +/** + * Update Project Tag action type + */ +export interface IUpdateProjectTagAction extends IPayloadAction { + type: ActionTypes.UPDATE_PROJECT_TAG_SUCCESS; +} + +/** + * Delete project tag action type + */ +export interface IDeleteProjectTagAction extends IPayloadAction { + type: ActionTypes.DELETE_PROJECT_TAG_SUCCESS; +} + /** * Instance of Load Project action */ @@ -303,3 +382,13 @@ export const saveAssetMetadataAction = */ export const exportProjectAction = createPayloadAction(ActionTypes.EXPORT_PROJECT_SUCCESS); +/** + * Instance of Update project tag action + */ +export const updateProjectTagAction = + createPayloadAction(ActionTypes.UPDATE_PROJECT_TAG_SUCCESS); +/** + * Instance of Delete project tag action + */ +export const deleteProjectTagAction = + createPayloadAction(ActionTypes.DELETE_PROJECT_TAG_SUCCESS); diff --git a/src/services/assetService.test.ts b/src/services/assetService.test.ts index 90d07d15bd..58c7e67173 100644 --- a/src/services/assetService.test.ts +++ b/src/services/assetService.test.ts @@ -1,5 +1,5 @@ import { AssetService } from "./assetService"; -import { AssetType, IAssetMetadata, AssetState } from "../models/applicationState"; +import { AssetType, IAssetMetadata, AssetState, IAsset, IProject } from "../models/applicationState"; import MockFactory from "../common/mockFactory"; import { AssetProviderFactory, IAssetProvider } from "../providers/storage/assetProviderFactory"; import { StorageProviderFactory } from "../providers/storage/storageProviderFactory"; @@ -7,6 +7,8 @@ import { constants } from "../common/constants"; import { TFRecordsBuilder, FeatureType } from "../providers/export/tensorFlowRecords/tensorFlowBuilder"; import HtmlFileReader from "../common/htmlFileReader"; import { encodeFileURI } from "../common/utils"; +import _ from "lodash"; +import registerMixins from "../registerMixins"; describe("Asset Service", () => { describe("Static Methods", () => { @@ -323,4 +325,105 @@ describe("Asset Service", () => { expect(Math.floor(result.regions[1].points[1].y)).toEqual(800); }); }); + + describe("Tag Update functions", () => { + + function populateProjectAssets(project?: IProject, assetCount = 10) { + if (!project) { + project = MockFactory.createTestProject(); + } + const assets = MockFactory.createTestAssets(assetCount); + assets.forEach((asset) => { + asset.state = AssetState.Tagged; + }); + + project.assets = _.keyBy(assets, (asset) => asset.id); + return project; + } + + beforeAll(() => { + registerMixins(); + }); + + it("Deletes tag from assets", async () => { + const tag1 = "tag1"; + const tag2 = "tag2"; + const region = MockFactory.createTestRegion(undefined, [tag1, tag2]); + const asset: IAsset = { + ...MockFactory.createTestAsset("1"), + state: AssetState.Tagged, + }; + const assetMetadata = MockFactory.createTestAssetMetadata(asset, [region]); + AssetService.prototype.getAssetMetadata = jest.fn((asset: IAsset) => Promise.resolve(assetMetadata)); + + const expectedAssetMetadata: IAssetMetadata = { + ...MockFactory.createTestAssetMetadata( + asset, + [ + { + ...region, + tags: [tag2], + }, + ], + ), + }; + + const project = populateProjectAssets(); + const assetService = new AssetService(project); + const assetUpdates = await assetService.deleteTag(tag1); + + expect(assetUpdates).toHaveLength(1); + expect(assetUpdates[0]).toEqual(expectedAssetMetadata); + }); + + it("Deletes empty regions after deleting only tag from region", async () => { + const tag1 = "tag1"; + const region = MockFactory.createTestRegion(undefined, [tag1]); + const asset: IAsset = { + ...MockFactory.createTestAsset("1"), + state: AssetState.Tagged, + }; + const assetMetadata = MockFactory.createTestAssetMetadata(asset, [region]); + AssetService.prototype.getAssetMetadata = jest.fn((asset: IAsset) => Promise.resolve(assetMetadata)); + + const expectedAssetMetadata: IAssetMetadata = MockFactory.createTestAssetMetadata(asset, []); + const project = populateProjectAssets(); + const assetService = new AssetService(project); + const assetUpdates = await assetService.deleteTag(tag1); + + expect(assetUpdates).toHaveLength(1); + expect(assetUpdates[0]).toEqual(expectedAssetMetadata); + }); + + it("Updates renamed tag within all assets", async () => { + const tag1 = "tag1"; + const newTag = "tag2"; + const region = MockFactory.createTestRegion(undefined, [tag1]); + const asset: IAsset = { + ...MockFactory.createTestAsset("1"), + state: AssetState.Tagged, + }; + const assetMetadata = MockFactory.createTestAssetMetadata(asset, [region]); + AssetService.prototype.getAssetMetadata = jest.fn((asset: IAsset) => Promise.resolve(assetMetadata)); + + const expectedAssetMetadata: IAssetMetadata = { + ...MockFactory.createTestAssetMetadata( + asset, + [ + { + ...region, + tags: [newTag], + }, + ], + ), + }; + + const project = populateProjectAssets(); + const assetService = new AssetService(project); + const assetUpdates = await assetService.renameTag(tag1, newTag); + + expect(assetUpdates).toHaveLength(1); + expect(assetUpdates[0]).toEqual(expectedAssetMetadata); + }); + }); }); diff --git a/src/services/assetService.ts b/src/services/assetService.ts index 58335b606a..2ab0ffabd0 100644 --- a/src/services/assetService.ts +++ b/src/services/assetService.ts @@ -204,10 +204,73 @@ export class AssetService { } } + /** + * Delete a tag from asset metadata files + * @param tagName Name of tag to delete + */ + public async deleteTag(tagName: string): Promise { + const transformer = (tags) => tags.filter((t) => t !== tagName); + return await this.getUpdatedAssets(tagName, transformer); + } + + /** + * Rename a tag within asset metadata files + * @param tagName Name of tag to rename + */ + public async renameTag(tagName: string, newTagName: string): Promise { + const transformer = (tags) => tags.map((t) => (t === tagName) ? newTagName : t); + return await this.getUpdatedAssets(tagName, transformer); + } + + /** + * Update tags within asset metadata files + * @param tagName Name of tag to update within project + * @param transformer Function that accepts array of tags from a region and returns a modified array of tags + */ + private async getUpdatedAssets(tagName: string, transformer: (tags: string[]) => string[]) + : Promise { + // Loop over assets and update if necessary + const updates = await _.values(this.project.assets).mapAsync(async (asset) => { + const assetMetadata = await this.getAssetMetadata(asset); + const isUpdated = this.updateTagInAssetMetadata(assetMetadata, tagName, transformer); + + return isUpdated ? assetMetadata : null; + }); + + return updates.filter((assetMetadata) => !!assetMetadata); + } + + /** + * Update tag within asset metadata object + * @param assetMetadata Asset metadata to update + * @param tagName Name of tag being updated + * @param transformer Function that accepts array of tags from a region and returns a modified array of tags + * @returns Modified asset metadata object or null if object does not need to be modified + */ + private updateTagInAssetMetadata( + assetMetadata: IAssetMetadata, + tagName: string, + transformer: (tags: string[]) => string[]): boolean { + let foundTag = false; + + for (const region of assetMetadata.regions) { + if (region.tags.find((t) => t === tagName)) { + foundTag = true; + region.tags = transformer(region.tags); + } + } + if (foundTag) { + assetMetadata.regions = assetMetadata.regions.filter((region) => region.tags.length > 0); + assetMetadata.asset.state = (assetMetadata.regions.length) ? AssetState.Tagged : AssetState.Visited; + return true; + } + + return false; + } + private async getRegionsFromTFRecord(asset: IAsset): Promise { const objectArray = await this.getTFRecordMetadata(asset); const regions: IRegion[] = []; - const tags: string[] = []; // Add Regions from TFRecord in Regions for (let index = 0; index < objectArray.textArray.length; index++) {