From 09969d5236e63f10a9fcb90ad713b50ea51c3421 Mon Sep 17 00:00:00 2001 From: dibyanshu-pal-kushwaha Date: Thu, 26 Feb 2026 21:00:31 +0530 Subject: [PATCH 1/2] Fixed archive inherits at folders --- .../AssetTree/AssetNode/AssetNode.js | 45 +++++---- app/components/Project/Project.js | 95 ++++++++++++++++--- app/containers/ProjectPage/ProjectPage.js | 2 +- app/services/project.js | 3 + app/utils/asset.js | 44 ++++++++- test/utils/asset.spec.js | 94 ++++++++++++++---- 6 files changed, 228 insertions(+), 55 deletions(-) diff --git a/app/components/AssetTree/AssetNode/AssetNode.js b/app/components/AssetTree/AssetNode/AssetNode.js index a290d7db..21ff03f1 100644 --- a/app/components/AssetTree/AssetNode/AssetNode.js +++ b/app/components/AssetTree/AssetNode/AssetNode.js @@ -22,14 +22,15 @@ const getPaddingLeft = (level) => { return level * 20; }; -const getNodeColor = (node) => { - if (!node || !node.attributes) { +const getNodeColor = (node, ancestorArchived) => { + if (!node) { return '#000'; } - if (node.attributes.archived) { + if ((node.attributes && node.attributes.archived) || ancestorArchived) { return "#777"; } + return '#000'; } const getNodeFontWeight = (node) => { @@ -49,7 +50,7 @@ const StyledTreeNode = styled.div` padding: 5px 8px; padding-left: ${(props) => getPaddingLeft(props.$level)}px; ${(props) => (props.selected ? 'background-color: #eee;' : null)} - color: ${(props) => getNodeColor(props.node)}; + color: ${(props) => getNodeColor(props.node, props.ancestorArchived)}; font-weight: ${(props) => getNodeFontWeight(props.node)}; `; @@ -79,6 +80,7 @@ function AssetNode(props) { onToggle, onRightClick, onClick, + ancestorArchived, } = props; const isOpen = root || openNodes.includes(node.uri); const isChecked = checkboxes && (root || checkedNodes.includes(node.uri)); @@ -104,12 +106,16 @@ function AssetNode(props) { !root && checkboxes ? ( ) : null; + + const isArchived = (node.attributes && node.attributes.archived) || ancestorArchived; + return ( <> { if (onRightClick) { @@ -145,20 +151,21 @@ function AssetNode(props) { (!node.children ? null : node.children.map((childNode) => ( - - )))} + + )))} ); } @@ -175,6 +182,7 @@ AssetNode.propTypes = { openNodes: PropTypes.array, checkedNodes: PropTypes.array, checkboxes: PropTypes.bool, + ancestorArchived: PropTypes.bool, }; AssetNode.defaultProps = { @@ -188,6 +196,7 @@ AssetNode.defaultProps = { openNodes: [], checkedNodes: [], checkboxes: false, + ancestorArchived: false, }; export default AssetNode; diff --git a/app/components/Project/Project.js b/app/components/Project/Project.js index 0cde8ca4..e964a1a0 100644 --- a/app/components/Project/Project.js +++ b/app/components/Project/Project.js @@ -1,5 +1,14 @@ import React, { Component, useEffect } from 'react'; -import { Tab, Tabs } from '@mui/material'; +import { + Tab, + Tabs, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Button, +} from '@mui/material'; import { TabPanel, TabContext } from '@mui/lab'; import { cloneDeep } from 'lodash'; import IconButton from '@mui/material/IconButton'; @@ -22,16 +31,14 @@ import GeneralUtil from '../../utils/general'; import styles from './Project.css'; import UserContext from '../../contexts/User'; -type Props = { - onDirtyStateChange?: (boolean) => void, -}; +type Props = {}; class Project extends Component { props: Props; constructor(props) { super(props); - this.state = { selectedTab: 'about', showLogUpdatesOnly: false }; + this.state = { selectedTab: 'about', showLogUpdatesOnly: false, showUnarchiveConfirmation: false, pendingUnarchive: null }; } changeHandler = (event, id) => { @@ -366,15 +373,32 @@ class Project extends Component { * @param {any} value The value of the updated attribute. Its type depends on the configuration of the attribute. */ assetUpdateAttributeHandler = (asset, name, value) => { - const project = { ...this.props.project }; - const assetsCopy = { ...project.assets }; + // Check for unarchive action + if (name === 'archived' && value === false) { + if (AssetUtil.hasArchivedDescendants(asset)) { + this.setState({ + showUnarchiveConfirmation: true, + pendingUnarchive: { assetUri: asset.uri, name, value } + }); + return; + } + } - const existingAsset = AssetUtil.findDescendantAssetByUri(assetsCopy, asset.uri); + this.performAssetAttributeUpdate(asset.uri, name, value, false); + }; + + performAssetAttributeUpdate = (assetUri, name, value, clearDescendants) => { + + const project = cloneDeep(this.props.project); + const assetsCopy = project.assets; + + const existingAsset = AssetUtil.findDescendantAssetByUri(assetsCopy, assetUri); let actionDescription = ''; if (!existingAsset) { console.warn('Could not find the asset to update its attribute'); + return; } else { - actionDescription = `Updated ${asset.uri} attribute '${name}' to '${value}'`; + actionDescription = `Updated ${assetUri} attribute '${name}' to '${value}'`; } if (!existingAsset.attributes) { @@ -382,20 +406,42 @@ class Project extends Component { } existingAsset.attributes[name] = value; - project.assets = assetsCopy; + + if (clearDescendants) { + AssetUtil.clearArchivedAttributeForDescendants(existingAsset); + } + if (this.props.onUpdated) { this.props.onUpdated( project, ActionType.ATTRIBUTE_UPDATED, EntityType.ASSET, - asset.uri, + assetUri, `Asset ${ActionType.ATTRIBUTE_UPDATED}`, actionDescription, - { name, value } + { name, value, clearDescendants } ); } }; + handleCancelUnarchive = () => { + this.setState({ showUnarchiveConfirmation: false, pendingUnarchive: null }); + }; + + handleConfirmUnarchive = (alsoDescendants) => { + const { pendingUnarchive } = this.state; + if (!pendingUnarchive) return; + + this.performAssetAttributeUpdate( + pendingUnarchive.assetUri, + pendingUnarchive.name, + pendingUnarchive.value, + alsoDescendants + ); + + this.setState({ showUnarchiveConfirmation: false, pendingUnarchive: null }); + }; + /** * Handler when an asset (either the main assets or the externalAssets in a project) has * a note removed. @@ -794,7 +840,6 @@ class Project extends Component { project={this.props.project} updates={this.props.logs ? this.props.logs.updates : null} onClickUpdatesLink={this.clickUpdatesLinkHandler} - onDirtyStateChange={this.props.onDirtyStateChange} /> ) : null; const assets = this.props.project ? ( @@ -941,6 +986,28 @@ class Project extends Component { return (
{content} + + Unarchive Descendants + + + This folder contains files or folders that were explicitly archived. Do you want to unarchive them as well? + + + + + + + +
); } @@ -951,7 +1018,6 @@ Project.propTypes = { onUpdated: PropTypes.func, onAssetSelected: PropTypes.func, onChecklistUpdated: PropTypes.func, - onDirtyStateChange: PropTypes.func, // This object has the following structure: // { // logs: array - the actual log data @@ -979,7 +1045,6 @@ Project.defaultProps = { onUpdated: null, onAssetSelected: null, onChecklistUpdated: null, - onDirtyStateChange: null, logs: null, checklistResponse: null, configuration: null, diff --git a/app/containers/ProjectPage/ProjectPage.js b/app/containers/ProjectPage/ProjectPage.js index e9ba6876..6266889c 100644 --- a/app/containers/ProjectPage/ProjectPage.js +++ b/app/containers/ProjectPage/ProjectPage.js @@ -596,7 +596,7 @@ class ProjectPage extends Component { const { projects } = prevState; const foundIndex = projects.findIndex((x) => x.id === project.id); projects[foundIndex] = project; - return { projects }; + return { projects, selectedProject: project }; }); // The update project request will handle logging if it succeeds. No additional logging call is diff --git a/app/services/project.js b/app/services/project.js index bf0a768a..cfadb224 100644 --- a/app/services/project.js +++ b/app/services/project.js @@ -471,6 +471,9 @@ export default class ProjectService { asset.attributes = {}; } asset.attributes[details.name] = details.value; + if (details.clearDescendants) { + AssetUtil.clearArchivedAttributeForDescendants(asset); + } } else { return null; } diff --git a/app/utils/asset.js b/app/utils/asset.js index dad475d5..45924d1e 100644 --- a/app/utils/asset.js +++ b/app/utils/asset.js @@ -207,8 +207,8 @@ export default class AssetUtil { const notes = asset && asset.notes ? asset.notes.map((n) => { - return { ...n, uri: asset.uri }; - }) + return { ...n, uri: asset.uri }; + }) : []; if (!asset || !asset.children) { return notes.flat(); @@ -493,4 +493,44 @@ export default class AssetUtil { } return !FILE_IGNORE_LIST.includes(fileName); } + + /** + * Determine if an asset has any descendants that are explicitly archived. + * @param {object} asset The asset to check + * @returns true if any descendant is explicitly archived + */ + static hasArchivedDescendants(asset) { + if (!asset || !asset.children) { + return false; + } + + // Check if any direct child is archived + const hasArchivedChild = asset.children.some(child => + child.attributes && child.attributes.archived + ); + + if (hasArchivedChild) { + return true; + } + + // Recursively check children + return asset.children.some(child => AssetUtil.hasArchivedDescendants(child)); + } + + /** + * Recursively clear the archived attribute for all descendants of an asset. + * @param {object} asset The asset to clear descendants for + */ + static clearArchivedAttributeForDescendants(asset) { + if (!asset || !asset.children) { + return; + } + + asset.children.forEach(child => { + if (child.attributes && child.attributes.archived) { + child.attributes.archived = false; + } + AssetUtil.clearArchivedAttributeForDescendants(child); + }); + } } diff --git a/test/utils/asset.spec.js b/test/utils/asset.spec.js index 54898cb4..303ae043 100644 --- a/test/utils/asset.spec.js +++ b/test/utils/asset.spec.js @@ -1446,17 +1446,17 @@ describe('utils', () => { expect(AssetUtil.isExternalAsset(undefined)).toBeFalse(); }); it('should return false for a null/undefined type', () => { - expect(AssetUtil.isExternalAsset({type: null})).toBeFalse(); - expect(AssetUtil.isExternalAsset({type: undefined})).toBeFalse(); + expect(AssetUtil.isExternalAsset({ type: null })).toBeFalse(); + expect(AssetUtil.isExternalAsset({ type: undefined })).toBeFalse(); expect(AssetUtil.isExternalAsset({})).toBeFalse(); }); it('should return true for a URL', () => { - expect(AssetUtil.isExternalAsset({type: Constants.AssetType.URL})).toBeTrue(); + expect(AssetUtil.isExternalAsset({ type: Constants.AssetType.URL })).toBeTrue(); }); it('should return false for other types', () => { - expect(AssetUtil.isExternalAsset({type: Constants.AssetType.FILE})).toBeFalse(); - expect(AssetUtil.isExternalAsset({type: 'not a url'})).toBeFalse(); - expect(AssetUtil.isExternalAsset({type: ''})).toBeFalse(); + expect(AssetUtil.isExternalAsset({ type: Constants.AssetType.FILE })).toBeFalse(); + expect(AssetUtil.isExternalAsset({ type: 'not a url' })).toBeFalse(); + expect(AssetUtil.isExternalAsset({ type: '' })).toBeFalse(); }); }); @@ -1465,17 +1465,17 @@ describe('utils', () => { expect(AssetUtil.getAssetNameForTree(null)).toBe(''); }); it('should return a name normally for a regular asset', () => { - expect(AssetUtil.getAssetNameForTree({type: Constants.AssetType.FOLDER, uri: 'Test'})).toBe('Test'); + expect(AssetUtil.getAssetNameForTree({ type: Constants.AssetType.FOLDER, uri: 'Test' })).toBe('Test'); }); it('should return the URL for an external asset with no name parameter', () => { - expect(AssetUtil.getAssetNameForTree({type: Constants.AssetType.URL, uri: 'http://test.com'})).toBe('http://test.com'); - expect(AssetUtil.getAssetNameForTree({type: Constants.AssetType.URL, uri: 'http://test.com', name: null})).toBe('http://test.com'); - expect(AssetUtil.getAssetNameForTree({type: Constants.AssetType.URL, uri: 'http://test.com', name: undefined})).toBe('http://test.com'); - expect(AssetUtil.getAssetNameForTree({type: Constants.AssetType.URL, uri: 'http://test.com', name: ''})).toBe('http://test.com'); - expect(AssetUtil.getAssetNameForTree({type: Constants.AssetType.URL, uri: 'http://test.com', name: ' '})).toBe('http://test.com'); + expect(AssetUtil.getAssetNameForTree({ type: Constants.AssetType.URL, uri: 'http://test.com' })).toBe('http://test.com'); + expect(AssetUtil.getAssetNameForTree({ type: Constants.AssetType.URL, uri: 'http://test.com', name: null })).toBe('http://test.com'); + expect(AssetUtil.getAssetNameForTree({ type: Constants.AssetType.URL, uri: 'http://test.com', name: undefined })).toBe('http://test.com'); + expect(AssetUtil.getAssetNameForTree({ type: Constants.AssetType.URL, uri: 'http://test.com', name: '' })).toBe('http://test.com'); + expect(AssetUtil.getAssetNameForTree({ type: Constants.AssetType.URL, uri: 'http://test.com', name: ' ' })).toBe('http://test.com'); }); it('should return a formatted name for a URL with a name', () => { - expect(AssetUtil.getAssetNameForTree({type: Constants.AssetType.URL, uri: 'http://test.com', name: 'Test'})).toBe('Test (http://test.com)'); + expect(AssetUtil.getAssetNameForTree({ type: Constants.AssetType.URL, uri: 'http://test.com', name: 'Test' })).toBe('Test (http://test.com)'); }); }); }); @@ -1509,19 +1509,75 @@ describe('utils', () => { it('should return false when the asset has no attributes', () => { expect(AssetUtil.isArchived({})).toBeFalsy(); - expect(AssetUtil.isArchived({attributes: null})).toBeFalsy(); - expect(AssetUtil.isArchived({attributes: undefined})).toBeFalsy(); + expect(AssetUtil.isArchived({ attributes: null })).toBeFalsy(); + expect(AssetUtil.isArchived({ attributes: undefined })).toBeFalsy(); }); it('should return false when the asset is not set as archived', () => { expect(AssetUtil.isArchived({})).toBeFalsy(); - expect(AssetUtil.isArchived({attributes: null})).toBeFalsy(); - expect(AssetUtil.isArchived({attributes: {archived: null}})).toBeFalsy(); - expect(AssetUtil.isArchived({attributes: {archived: false}})).toBeFalsy(); + expect(AssetUtil.isArchived({ attributes: null })).toBeFalsy(); + expect(AssetUtil.isArchived({ attributes: { archived: null } })).toBeFalsy(); + expect(AssetUtil.isArchived({ attributes: { archived: false } })).toBeFalsy(); }); it('should return false when the asset is set as archived', () => { - expect(AssetUtil.isArchived({attributes: {archived: true}})).toBeTrue(); + expect(AssetUtil.isArchived({ attributes: { archived: true } })).toBeTrue(); + }); + }); + + describe('hasArchivedDescendants', () => { + it('returns false for null/undefined', () => { + expect(AssetUtil.hasArchivedDescendants(null)).toBeFalsy(); + expect(AssetUtil.hasArchivedDescendants(undefined)).toBeFalsy(); + expect(AssetUtil.hasArchivedDescendants({})).toBeFalsy(); + }); + + it('returns false when there are no explicitly archived descendants', () => { + const asset = { + children: [ + { attributes: { archived: false } }, + { children: [{ attributes: { archived: false } }] } + ] + }; + expect(AssetUtil.hasArchivedDescendants(asset)).toBeFalsy(); + }); + + it('returns true when a direct child is explicitly archived', () => { + const asset = { + children: [ + { attributes: { archived: true } } + ] + }; + expect(AssetUtil.hasArchivedDescendants(asset)).toBeTrue(); + }); + + it('returns true when a deeper descendant is explicitly archived', () => { + const asset = { + children: [ + { children: [{ attributes: { archived: true } }] } + ] + }; + expect(AssetUtil.hasArchivedDescendants(asset)).toBeTrue(); + }); + }); + + describe('clearArchivedAttributeForDescendants', () => { + it('handles null/undefined gracefully', () => { + expect(() => { AssetUtil.clearArchivedAttributeForDescendants(null); }).not.toThrow(); + expect(() => { AssetUtil.clearArchivedAttributeForDescendants(undefined); }).not.toThrow(); + expect(() => { AssetUtil.clearArchivedAttributeForDescendants({}); }).not.toThrow(); + }); + + it('clears archived attributes on all descendants', () => { + const asset = { + children: [ + { attributes: { archived: true } }, + { children: [{ attributes: { archived: true } }, { attributes: { archived: false } }] } + ] + }; + AssetUtil.clearArchivedAttributeForDescendants(asset); + expect(asset.children[0].attributes.archived).toBeFalsy(); + expect(asset.children[1].children[0].attributes.archived).toBeFalsy(); }); }); }); From 466b704300e7cbe90f76e041503a2f764ba37da9 Mon Sep 17 00:00:00 2001 From: dibyanshu-pal-kushwaha Date: Sat, 28 Feb 2026 10:39:34 +0530 Subject: [PATCH 2/2] Revert onDirtyStateChange --- app/components/Project/Project.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/components/Project/Project.js b/app/components/Project/Project.js index e964a1a0..c245e65f 100644 --- a/app/components/Project/Project.js +++ b/app/components/Project/Project.js @@ -31,7 +31,9 @@ import GeneralUtil from '../../utils/general'; import styles from './Project.css'; import UserContext from '../../contexts/User'; -type Props = {}; +type Props = { + onDirtyStateChange?: (boolean) => void, +}; class Project extends Component { props: Props; @@ -840,6 +842,7 @@ class Project extends Component { project={this.props.project} updates={this.props.logs ? this.props.logs.updates : null} onClickUpdatesLink={this.clickUpdatesLinkHandler} + onDirtyStateChange={this.props.onDirtyStateChange} /> ) : null; const assets = this.props.project ? ( @@ -1018,6 +1021,7 @@ Project.propTypes = { onUpdated: PropTypes.func, onAssetSelected: PropTypes.func, onChecklistUpdated: PropTypes.func, + onDirtyStateChange: PropTypes.func, // This object has the following structure: // { // logs: array - the actual log data @@ -1045,6 +1049,7 @@ Project.defaultProps = { onUpdated: null, onAssetSelected: null, onChecklistUpdated: null, + onDirtyStateChange: null, logs: null, checklistResponse: null, configuration: null,