From: Daniel Kutyła Date: Mon, 8 Aug 2022 10:20:06 +0000 (+0200) Subject: 18692: Fixed project not refreshing post freeze X-Git-Tag: 2.5.0~33^2~29 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/36c18f380e2a31b532fbdd94208b559bef8d900f 18692: Fixed project not refreshing post freeze Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła --- diff --git a/src/common/service-provider.ts b/src/common/service-provider.ts index 080916c5..e0504ebf 100644 --- a/src/common/service-provider.ts +++ b/src/common/service-provider.ts @@ -6,6 +6,7 @@ class ServicesProvider { private static instance: ServicesProvider; + private store; private services; private constructor() {} @@ -30,6 +31,20 @@ class ServicesProvider { } return this.services; } + + public setStore(newStore): void { + if (!this.store) { + this.store = newStore; + } + } + + public getStore() { + if (!this.store) { + throw "Please check if store has been set in the index.ts before the app is initiated"; // eslint-disable-line no-throw-literal + } + + return this.store; + } } export default ServicesProvider.getInstance(); diff --git a/src/components/breadcrumbs/breadcrumbs.tsx b/src/components/breadcrumbs/breadcrumbs.tsx index 2cb81205..71796661 100644 --- a/src/components/breadcrumbs/breadcrumbs.tsx +++ b/src/components/breadcrumbs/breadcrumbs.tsx @@ -9,14 +9,15 @@ import { withStyles } from '@material-ui/core'; import { IllegalNamingWarning } from '../warning/warning'; import { IconType, FreezeIcon } from 'components/icon/icon'; import grey from '@material-ui/core/colors/grey'; +import { ResourceBreadcrumb } from 'store/breadcrumbs/breadcrumbs-actions'; +import { ResourcesState } from 'store/resources/resources'; export interface Breadcrumb { label: string; icon?: IconType; - isFrozen?: boolean; } -type CssRules = "item" | "currentItem" | "label" | "icon"; +type CssRules = "item" | "currentItem" | "label" | "icon" | "frozenIcon"; const styles: StyleRulesCallback = theme => ({ item: { @@ -31,18 +32,24 @@ const styles: StyleRulesCallback = theme => ({ icon: { fontSize: 20, color: grey["600"], - marginRight: '10px' + marginRight: '10px', + }, + frozenIcon: { + fontSize: 20, + color: grey["600"], + marginLeft: '10px', }, }); export interface BreadcrumbsProps { - items: Breadcrumb[]; - onClick: (breadcrumb: Breadcrumb) => void; - onContextMenu: (event: React.MouseEvent, breadcrumb: Breadcrumb) => void; + items: ResourceBreadcrumb[]; + resources: ResourcesState; + onClick: (breadcrumb: ResourceBreadcrumb) => void; + onContextMenu: (event: React.MouseEvent, breadcrumb: ResourceBreadcrumb) => void; } export const Breadcrumbs = withStyles(styles)( - ({ classes, onClick, onContextMenu, items }: BreadcrumbsProps & WithStyles) => + ({ classes, onClick, onContextMenu, items, resources }: BreadcrumbsProps & WithStyles) => { items.map((item, index) => { @@ -65,15 +72,15 @@ export const Breadcrumbs = withStyles(styles)( onClick={() => onClick(item)} onContextMenu={event => onContextMenu(event, item)}> - { - item.isFrozen ? : null - } {item.label} + { + (resources[item.uuid] as any)?.frozenByUuid ? : null + } {!isLastItem && } diff --git a/src/components/tree/tree.tsx b/src/components/tree/tree.tsx index fc9dbc74..e3708621 100644 --- a/src/components/tree/tree.tsx +++ b/src/components/tree/tree.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { List, ListItem, ListItemIcon, Checkbox, Radio, Collapse } from "@material-ui/core"; import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles'; -import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, FilterGroupIcon } from 'components/icon/icon'; +import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, FilterGroupIcon, FreezeIcon } from 'components/icon/icon'; import { ReactElement } from "react"; import CircularProgress from '@material-ui/core/CircularProgress'; import classnames from "classnames"; @@ -26,7 +26,8 @@ type CssRules = 'list' | 'toggableIcon' | 'checkbox' | 'childItem' - | 'childItemIcon'; + | 'childItemIcon' + | 'frozenIcon'; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ list: { @@ -83,6 +84,11 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ active: { color: theme.palette.primary.main, }, + frozenIcon: { + fontSize: 20, + color: theme.palette.grey["600"], + marginLeft: '10px', + }, }); export enum TreeItemStatus { @@ -102,6 +108,7 @@ export interface TreeItem { flatTree?: boolean; status: TreeItemStatus; items?: Array>; + isFrozen?: boolean; } export interface TreeProps { @@ -253,6 +260,9 @@ const FlatTree = (props: FlatTreeProps) => {item.data.name} + { + !!item.data.frozenByUuid ? : null + } ) @@ -270,7 +280,6 @@ export const Tree = withStyles(styles)( : () => this.props.showSelection ? true : false; const { levelIndentation = 20, itemRightPadding = 20 } = this.props; - return {items && items.map((it: TreeItem, idx: number) =>
diff --git a/src/index.tsx b/src/index.tsx index f0f75210..d0009541 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -150,6 +150,8 @@ fetchConfig() const store = configureStore(history, services, config); + servicesProvider.setStore(store); + store.subscribe(initListener(history, store, services, config)); store.dispatch(initAuth(config)); store.dispatch(setBuildInfo()); diff --git a/src/store/breadcrumbs/breadcrumbs-middleware.ts b/src/store/breadcrumbs/breadcrumbs-middleware.ts deleted file mode 100644 index df8dd007..00000000 --- a/src/store/breadcrumbs/breadcrumbs-middleware.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import { propertiesActions } from "store/properties/properties-actions"; -import { BREADCRUMBS } from "./breadcrumbs-actions"; - -export const breadcrumbsMiddleware = store => next => action => { - propertiesActions.match(action, { - SET_PROPERTY: () => { - - if (action.payload.key === BREADCRUMBS && Array.isArray(action.payload.value)) { - action.payload.value = action.payload - .value.map((value)=> ({ ...value, isFrozen: !!store.getState().resources[value.uuid]?.frozenByUuid })); - } - - next(action); - }, - default: () => next(action) - }); -}; diff --git a/src/store/context-menu/context-menu-actions.ts b/src/store/context-menu/context-menu-actions.ts index d098823d..f01c1866 100644 --- a/src/store/context-menu/context-menu-actions.ts +++ b/src/store/context-menu/context-menu-actions.ts @@ -22,6 +22,7 @@ import { GroupClass, GroupResource } from 'models/group'; import { GroupContentsResource } from 'services/groups-service/groups-service'; import { LinkResource } from 'models/link'; import { resourceIsFrozen } from 'common/frozen-resources'; +import { ProjectResource } from 'models/project'; export const contextMenuActions = unionize({ OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(), @@ -165,6 +166,7 @@ export const openProjectContextMenu = (event: React.MouseEvent, res description: res.description, ownerUuid: res.ownerUuid, isTrashed: ('isTrashed' in res) ? res.isTrashed : false, + isFrozen: !!(res as ProjectResource).frozenByUuid, })); } }; diff --git a/src/store/projects/project-lock-actions.ts b/src/store/projects/project-lock-actions.ts index f01a6225..98ebb384 100644 --- a/src/store/projects/project-lock-actions.ts +++ b/src/store/projects/project-lock-actions.ts @@ -5,6 +5,7 @@ import { Dispatch } from "redux"; import { ServiceRepository } from "services/services"; import { projectPanelActions } from "store/project-panel/project-panel-action"; +import { loadResource } from "store/resources/resources-actions"; import { RootState } from "store/store"; export const freezeProject = (uuid: string) => @@ -16,6 +17,7 @@ export const freezeProject = (uuid: string) => }); dispatch(projectPanelActions.REQUEST_ITEMS()); + dispatch(loadResource(uuid, false)); return updatedProject; }; @@ -27,5 +29,6 @@ export const unfreezeProject = (uuid: string) => }); dispatch(projectPanelActions.REQUEST_ITEMS()); + dispatch(loadResource(uuid, false)); return updatedProject; }; \ No newline at end of file diff --git a/src/store/store.ts b/src/store/store.ts index 7bc28ff4..94f110a0 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -73,7 +73,6 @@ import { ALL_PROCESSES_PANEL_ID } from './all-processes-panel/all-processes-pane import { Config } from 'common/config'; import { pluginConfig } from 'plugins'; import { MiddlewareListReducer } from 'common/plugintypes'; -import { breadcrumbsMiddleware } from './breadcrumbs/breadcrumbs-middleware'; declare global { interface Window { @@ -175,7 +174,6 @@ export function configureStore(history: History, services: ServiceRepository, co publicFavoritesMiddleware, collectionsContentAddress, subprocessMiddleware, - breadcrumbsMiddleware, ]; const reduceMiddlewaresFn: (a: Middleware[], diff --git a/src/store/tree-picker/tree-picker-actions.ts b/src/store/tree-picker/tree-picker-actions.ts index 06abe39f..fd7cc4ce 100644 --- a/src/store/tree-picker/tree-picker-actions.ts +++ b/src/store/tree-picker/tree-picker-actions.ts @@ -103,10 +103,11 @@ interface LoadProjectParams { includeFiles?: boolean; includeFilterGroups?: boolean; loadShared?: boolean; + options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; }; } export const loadProject = (params: LoadProjectParams) => async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => { - const { id, pickerId, includeCollections = false, includeFiles = false, includeFilterGroups = false, loadShared = false } = params; + const { id, pickerId, includeCollections = false, includeFiles = false, includeFilterGroups = false, loadShared = false, options } = params; dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId })); @@ -126,6 +127,11 @@ export const loadProject = (params: LoadProjectParams) => if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) { return false; } + + if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) { + return false; + } + return true; }), extractNodeData: item => ({ @@ -183,11 +189,11 @@ export const initUserProject = (pickerId: string) => })); } }; -export const loadUserProject = (pickerId: string, includeCollections = false, includeFiles = false) => +export const loadUserProject = (pickerId: string, includeCollections = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean } ) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const uuid = getUserUuid(getState()); if (uuid) { - dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeFiles })); + dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeFiles, options })); } }; @@ -240,6 +246,7 @@ interface LoadFavoritesProjectParams { pickerId: string; includeCollections?: boolean; includeFiles?: boolean; + options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }; } export const loadFavoritesProject = (params: LoadFavoritesProjectParams, @@ -265,6 +272,10 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams, return false; } + if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) { + return false; + } + return true; }), extractNodeData: item => ({ @@ -301,7 +312,13 @@ export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) = dispatch(receiveTreePickerData({ id: 'Public Favorites', pickerId, - data: items, + data: items.filter(item => { + if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) { + return false; + } + + return true; + }), extractNodeData: item => ({ id: item.headUuid, value: item, diff --git a/src/views-components/breadcrumbs/breadcrumbs.ts b/src/views-components/breadcrumbs/breadcrumbs.ts index cb48b38f..c4134aed 100644 --- a/src/views-components/breadcrumbs/breadcrumbs.ts +++ b/src/views-components/breadcrumbs/breadcrumbs.ts @@ -3,26 +3,28 @@ // SPDX-License-Identifier: AGPL-3.0 import { connect } from "react-redux"; -import { Breadcrumbs as BreadcrumbsComponent, BreadcrumbsProps } from 'components/breadcrumbs/breadcrumbs'; +import { Breadcrumb, Breadcrumbs as BreadcrumbsComponent, BreadcrumbsProps } from 'components/breadcrumbs/breadcrumbs'; import { RootState } from 'store/store'; import { Dispatch } from 'redux'; import { navigateTo } from 'store/navigation/navigation-action'; import { getProperty } from '../../store/properties/properties'; import { ResourceBreadcrumb, BREADCRUMBS } from '../../store/breadcrumbs/breadcrumbs-actions'; import { openSidePanelContextMenu } from 'store/context-menu/context-menu-actions'; +import { ProjectResource } from "models/project"; -type BreadcrumbsDataProps = Pick; +type BreadcrumbsDataProps = Pick; type BreadcrumbsActionProps = Pick; -const mapStateToProps = () => ({ properties }: RootState): BreadcrumbsDataProps => ({ - items: getProperty(BREADCRUMBS)(properties) || [] +const mapStateToProps = () => ({ properties, resources }: RootState): BreadcrumbsDataProps => ({ + items: (getProperty(BREADCRUMBS)(properties) || []), + resources, }); const mapDispatchToProps = (dispatch: Dispatch): BreadcrumbsActionProps => ({ - onClick: ({ uuid }: ResourceBreadcrumb) => { + onClick: ({ uuid }: Breadcrumb & ProjectResource) => { dispatch(navigateTo(uuid)); }, - onContextMenu: (event, breadcrumb: ResourceBreadcrumb) => { + onContextMenu: (event, breadcrumb: Breadcrumb & ProjectResource) => { dispatch(openSidePanelContextMenu(event, breadcrumb.uuid)); } }); diff --git a/src/views-components/context-menu/action-sets/project-action-set.ts b/src/views-components/context-menu/action-sets/project-action-set.ts index e68eda96..04edba67 100644 --- a/src/views-components/context-menu/action-sets/project-action-set.ts +++ b/src/views-components/context-menu/action-sets/project-action-set.ts @@ -112,7 +112,6 @@ export const freezeProjectAction = { } else { dispatch(freezeProject(resource.uuid)); } - } } diff --git a/src/views-components/context-menu/actions/lock-action.tsx b/src/views-components/context-menu/actions/lock-action.tsx index 8d141e96..99eb756d 100644 --- a/src/views-components/context-menu/actions/lock-action.tsx +++ b/src/views-components/context-menu/actions/lock-action.tsx @@ -9,28 +9,37 @@ import { connect } from "react-redux"; import { RootState } from "store/store"; import { ProjectResource } from "models/project"; import { withRouter, RouteComponentProps } from "react-router"; +import { resourceIsFrozen } from "common/frozen-resources"; const mapStateToProps = (state: RootState, props: { onClick: () => {} }) => ({ isAdmin: !!state.auth.user?.isAdmin, isLocked: !!(state.resources[state.contextMenu.resource!.uuid] as ProjectResource).frozenByUuid, canManage: (state.resources[state.contextMenu.resource!.uuid] as ProjectResource).canManage, canUnfreeze: !state.auth.remoteHostsConfig[state.auth.homeCluster]?.clusterConfig?.API?.UnfreezeProjectRequiresAdmin, + resource: state.contextMenu.resource, + resources: state.resources, onClick: props.onClick }); -export const ToggleLockAction = withRouter(connect(mapStateToProps)((props: { state: RootState, isAdmin: boolean, isLocked: boolean, canManage: boolean, canUnfreeze: boolean, onClick: () => void } & RouteComponentProps) => +export const ToggleLockAction = withRouter(connect(mapStateToProps)((props: { + resource: any, + resources: any, + onClick: () => void, + state: RootState, isAdmin: boolean, isLocked: boolean, canManage: boolean, canUnfreeze: boolean, +} & RouteComponentProps) => (props.canManage && !props.isLocked) || (props.isLocked && props.canManage && (props.canUnfreeze || props.isAdmin)) ? - < ListItem - button - onClick={props.onClick} > - - {props.isLocked - ? - : } - - - {props.isLocked - ? <>Unfreeze project - : <>Freeze project} - - : null)); + resourceIsFrozen(props.resource, props.resources) ? null : + + + {props.isLocked + ? + : } + + + {props.isLocked + ? <>Unfreeze project + : <>Freeze project} + + : null)); diff --git a/src/views-components/context-menu/context-menu-action-set.ts b/src/views-components/context-menu/context-menu-action-set.ts index 1c9eb99b..abef7ec0 100644 --- a/src/views-components/context-menu/context-menu-action-set.ts +++ b/src/views-components/context-menu/context-menu-action-set.ts @@ -5,9 +5,10 @@ import { Dispatch } from "redux"; import { ContextMenuItem } from "components/context-menu/context-menu"; import { ContextMenuResource } from "store/context-menu/context-menu-actions"; +import { RootState } from "store/store"; export interface ContextMenuAction extends ContextMenuItem { - execute(dispatch: Dispatch, resource: ContextMenuResource): void; + execute(dispatch: Dispatch, resource: ContextMenuResource, state?: any): void; } export type ContextMenuActionSet = Array>; diff --git a/src/views-components/projects-tree-picker/home-tree-picker.tsx b/src/views-components/projects-tree-picker/home-tree-picker.tsx index df5fa9c2..4e8eeda9 100644 --- a/src/views-components/projects-tree-picker/home-tree-picker.tsx +++ b/src/views-components/projects-tree-picker/home-tree-picker.tsx @@ -11,7 +11,7 @@ import { ProjectIcon } from 'components/icon/icon'; export const HomeTreePicker = connect(() => ({ rootItemIcon: ProjectIcon, }), (dispatch: Dispatch): Pick => ({ - loadRootItem: (_, pickerId, includeCollections, includeFiles) => { - dispatch(loadUserProject(pickerId, includeCollections, includeFiles)); + loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => { + dispatch(loadUserProject(pickerId, includeCollections, includeFiles, options)); }, }))(ProjectsTreePicker); \ No newline at end of file diff --git a/src/views-components/projects-tree-picker/public-favorites-tree-picker.tsx b/src/views-components/projects-tree-picker/public-favorites-tree-picker.tsx index d2037af4..91551c9a 100644 --- a/src/views-components/projects-tree-picker/public-favorites-tree-picker.tsx +++ b/src/views-components/projects-tree-picker/public-favorites-tree-picker.tsx @@ -11,7 +11,7 @@ import { loadPublicFavoritesProject } from 'store/tree-picker/tree-picker-action export const PublicFavoritesTreePicker = connect(() => ({ rootItemIcon: PublicFavoriteIcon, }), (dispatch: Dispatch): Pick => ({ - loadRootItem: (_, pickerId, includeCollections, includeFiles) => { - dispatch(loadPublicFavoritesProject({ pickerId, includeCollections, includeFiles })); + loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => { + dispatch(loadPublicFavoritesProject({ pickerId, includeCollections, includeFiles, options })); }, }))(ProjectsTreePicker); \ No newline at end of file diff --git a/src/views-components/projects-tree-picker/shared-tree-picker.tsx b/src/views-components/projects-tree-picker/shared-tree-picker.tsx index d6a59bea..201bd118 100644 --- a/src/views-components/projects-tree-picker/shared-tree-picker.tsx +++ b/src/views-components/projects-tree-picker/shared-tree-picker.tsx @@ -11,7 +11,7 @@ import { loadProject } from 'store/tree-picker/tree-picker-actions'; export const SharedTreePicker = connect(() => ({ rootItemIcon: ShareMeIcon, }), (dispatch: Dispatch): Pick => ({ - loadRootItem: (_, pickerId, includeCollections, includeFiles) => { - dispatch(loadProject({ id: 'Shared with me', pickerId, includeCollections, includeFiles, loadShared: true })); + loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => { + dispatch(loadProject({ id: 'Shared with me', pickerId, includeCollections, includeFiles, loadShared: true, options })); }, }))(ProjectsTreePicker); \ No newline at end of file diff --git a/src/views-components/tree-picker/tree-picker.ts b/src/views-components/tree-picker/tree-picker.ts index 86c76e08..712875b5 100644 --- a/src/views-components/tree-picker/tree-picker.ts +++ b/src/views-components/tree-picker/tree-picker.ts @@ -8,6 +8,7 @@ import { RootState } from "store/store"; import { getNodeChildrenIds, Tree as Ttree, createTree, getNode, TreeNodeStatus } from 'models/tree'; import { Dispatch } from "redux"; import { initTreeNode } from '../../models/tree'; +import { ResourcesState } from "store/resources/resources"; type Callback = (event: React.MouseEvent, item: TreeItem, pickerId: string) => void; export interface TreePickerProps { @@ -34,30 +35,24 @@ const addToItemsIdMap = (item: TreeItem, itemsIdMap: Map { - let prevTree: Ttree; - let mappedProps: Pick, 'items' | 'disableRipple' | 'itemsMap'>; - return (state: RootState, props: TreePickerProps): Pick, 'items' | 'disableRipple' | 'itemsMap'> => { +const mapStateToProps = + (state: RootState, props: TreePickerProps): Pick, 'items' | 'disableRipple' | 'itemsMap'> => { const itemsIdMap: Map> = new Map(); const tree = state.treePicker[props.pickerId] || createTree(); - if (tree !== prevTree) { - prevTree = tree; - mappedProps = { - disableRipple: true, - items: getNodeChildrenIds('')(tree) - .map(treePickerToTreeItems(tree)) - .map(item => addToItemsIdMap(item, itemsIdMap)) - .map(parentItem => ({ - ...parentItem, - flatTree: true, - items: flatTree(itemsIdMap, 2, parentItem.items || []), - })), - itemsMap: itemsIdMap, - }; - } - return mappedProps; + + return { + disableRipple: true, + items: getNodeChildrenIds('')(tree) + .map(treePickerToTreeItems(tree, state.resources)) + .map(item => addToItemsIdMap(item, itemsIdMap)) + .map(parentItem => ({ + ...parentItem, + flatTree: true, + items: flatTree(itemsIdMap, 2, parentItem.items || []), + })), + itemsMap: itemsIdMap, + }; }; -}; const mapDispatchToProps = (_: Dispatch, props: TreePickerProps): Pick, 'onContextMenu' | 'toggleItemOpen' | 'toggleItemActive' | 'toggleItemSelection'> => ({ onContextMenu: (event, item) => props.onContextMenu(event, item, props.pickerId), @@ -66,16 +61,16 @@ const mapDispatchToProps = (_: Dispatch, props: TreePickerProps): Pick props.toggleItemSelection(event, item, props.pickerId), }); -export const TreePicker = connect(memoizedMapStateToProps(), mapDispatchToProps)(Tree); +export const TreePicker = connect(mapStateToProps, mapDispatchToProps)(Tree); -const treePickerToTreeItems = (tree: Ttree) => +const treePickerToTreeItems = (tree: Ttree, resources: ResourcesState) => (id: string): TreeItem => { const node = getNode(id)(tree) || initTreeNode({ id: '', value: 'InvalidNode' }); const items = getNodeChildrenIds(node.id)(tree) - .map(treePickerToTreeItems(tree)); + .map(treePickerToTreeItems(tree, resources)); return { active: node.active, - data: node.value, + data: resources[node.id] || node.value, id: node.id, items: items.length > 0 ? items : undefined, open: node.expanded,