| // Copyright 2017 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /** |
| * @fileoverview Module of functions which produce a new page state in response |
| * to an action. Reducers (in the same sense as Array.prototype.reduce) must be |
| * pure functions: they must not modify existing state objects, or make any API |
| * calls. |
| */ |
| |
| import {assert} from 'chrome://resources/js/assert_ts.js'; |
| import {Action} from 'chrome://resources/js/store_ts.js'; |
| |
| import {ChangeFolderOpenAction, CreateBookmarkAction, EditBookmarkAction, FinishSearchAction, MoveBookmarkAction, RefreshNodesAction, RemoveBookmarkAction, ReorderChildrenAction, SelectFolderAction, SelectItemsAction, SetPrefAction, StartSearchAction, UpdateAnchorAction} from './actions.js'; |
| import {BookmarkNode, BookmarksPageState, FolderOpenState, NodeMap, PreferencesState, SearchState, SelectionState} from './types.js'; |
| import {removeIdsFromMap, removeIdsFromObject, removeIdsFromSet} from './util.js'; |
| |
| function selectItems( |
| selectionState: SelectionState, action: SelectItemsAction): SelectionState { |
| let newItems = new Set(); |
| if (!action.clear) { |
| newItems = new Set(selectionState.items); |
| } |
| |
| action.items.forEach(function(id) { |
| let add = true; |
| if (action.toggle) { |
| add = !newItems.has(id); |
| } |
| |
| if (add) { |
| newItems.add(id); |
| } else { |
| newItems.delete(id); |
| } |
| }); |
| |
| return (Object.assign({}, selectionState, { |
| items: newItems, |
| anchor: action.anchor, |
| }) as SelectionState); |
| } |
| |
| function deselectAll(_selectionState: SelectionState): SelectionState { |
| return { |
| items: new Set(), |
| anchor: null, |
| }; |
| } |
| |
| function deselectItems( |
| selectionState: SelectionState, deleted: Set<string>): SelectionState { |
| return /** @type {SelectionState} */ (Object.assign({}, selectionState, { |
| items: removeIdsFromSet(selectionState.items, deleted), |
| anchor: !selectionState.anchor || deleted.has(selectionState.anchor) ? |
| null : |
| selectionState.anchor, |
| })); |
| } |
| |
| function updateAnchor( |
| selectionState: SelectionState, |
| action: UpdateAnchorAction): SelectionState { |
| return (Object.assign({}, selectionState, { |
| anchor: action.anchor, |
| }) as SelectionState); |
| } |
| |
| // Exported for tests. |
| export function updateSelection( |
| selection: SelectionState, action: Action): SelectionState { |
| switch (action.name) { |
| case 'clear-search': |
| case 'finish-search': |
| case 'select-folder': |
| case 'deselect-items': |
| return deselectAll(selection); |
| case 'select-items': |
| return selectItems(selection, action as SelectItemsAction); |
| case 'remove-bookmark': |
| return deselectItems( |
| selection, (action as RemoveBookmarkAction).descendants); |
| case 'move-bookmark': |
| // Deselect items when they are moved to another folder, since they will |
| // no longer be visible on screen (for simplicity, ignores items visible |
| // in search results). |
| const moveAction = action as MoveBookmarkAction; |
| if (moveAction.parentId !== moveAction.oldParentId && |
| selection.items.has(moveAction.id)) { |
| return deselectItems(selection, new Set([moveAction.id])); |
| } |
| return selection; |
| case 'update-anchor': |
| return updateAnchor(selection, action as UpdateAnchorAction); |
| default: |
| return selection; |
| } |
| } |
| |
| function startSearch( |
| search: SearchState, action: StartSearchAction): SearchState { |
| return { |
| term: action.term, |
| inProgress: true, |
| results: search.results, |
| }; |
| } |
| |
| function finishSearch( |
| search: SearchState, action: FinishSearchAction): SearchState { |
| return /** @type {SearchState} */ (Object.assign({}, search, { |
| inProgress: false, |
| results: action.results, |
| })); |
| } |
| |
| function clearSearch(): SearchState { |
| return { |
| term: '', |
| inProgress: false, |
| results: null, |
| }; |
| } |
| |
| function removeDeletedResults( |
| search: SearchState, deletedIds: Set<string>): SearchState { |
| if (!search.results) { |
| return search; |
| } |
| |
| const newResults: string[] = []; |
| search.results.forEach(function(id) { |
| if (!deletedIds.has(id)) { |
| newResults.push(id); |
| } |
| }); |
| return (Object.assign({}, search, { |
| results: newResults, |
| }) as SearchState); |
| } |
| |
| function updateSearch(search: SearchState, action: Action): SearchState { |
| switch (action.name) { |
| case 'start-search': |
| return startSearch(search, action as StartSearchAction); |
| case 'select-folder': |
| case 'clear-search': |
| return clearSearch(); |
| case 'finish-search': |
| return finishSearch(search, action as FinishSearchAction); |
| case 'remove-bookmark': |
| return removeDeletedResults( |
| search, (action as RemoveBookmarkAction).descendants); |
| default: |
| return search; |
| } |
| } |
| |
| function modifyNode( |
| nodes: NodeMap, id: string, |
| callback: (p1: BookmarkNode) => BookmarkNode): NodeMap { |
| const nodeModification: NodeMap = {}; |
| nodeModification[id] = callback(nodes[id]!); |
| return Object.assign({}, nodes, nodeModification); |
| } |
| |
| function createBookmark(nodes: NodeMap, action: CreateBookmarkAction): NodeMap { |
| const nodeModifications: NodeMap = {}; |
| nodeModifications[action.id] = action.node; |
| |
| const parentNode = nodes[action.parentId]!; |
| const newChildren = parentNode.children!.slice(); |
| newChildren.splice(action.parentIndex, 0, action.id); |
| nodeModifications[action.parentId] = Object.assign({}, parentNode, { |
| children: newChildren, |
| }); |
| |
| return Object.assign({}, nodes, nodeModifications); |
| } |
| |
| function editBookmark(nodes: NodeMap, action: EditBookmarkAction): NodeMap { |
| // Do not allow folders to change URL (making them no longer folders). |
| if (!nodes[action.id]!.url && action.changeInfo.url) { |
| delete action.changeInfo.url; |
| } |
| |
| return modifyNode(nodes, action.id, function(node) { |
| return Object.assign({}, node, action.changeInfo); |
| }); |
| } |
| |
| function moveBookmark(nodes: NodeMap, action: MoveBookmarkAction): NodeMap { |
| const nodeModifications: NodeMap = {}; |
| const id = action.id; |
| |
| // Change node's parent. |
| nodeModifications[id] = |
| Object.assign({}, nodes[id], {parentId: action.parentId}); |
| |
| // Remove from old parent. |
| const oldParentId = action.oldParentId; |
| const oldParentChildren = nodes[oldParentId]!.children!.slice(); |
| oldParentChildren.splice(action.oldIndex, 1); |
| nodeModifications[oldParentId] = |
| Object.assign({}, nodes[oldParentId], {children: oldParentChildren}); |
| |
| // Add to new parent. |
| const parentId = action.parentId; |
| const parentChildren = oldParentId === parentId ? |
| oldParentChildren : |
| nodes[parentId]!.children!.slice(); |
| parentChildren.splice(action.index, 0, action.id); |
| nodeModifications[parentId] = |
| Object.assign({}, nodes[parentId], {children: parentChildren}); |
| |
| return Object.assign({}, nodes, nodeModifications); |
| } |
| |
| function removeBookmark(nodes: NodeMap, action: RemoveBookmarkAction): NodeMap { |
| const newState = modifyNode(nodes, action.parentId, function(node) { |
| const newChildren = node.children!.slice(); |
| newChildren.splice(action.index, 1); |
| return /** @type {BookmarkNode} */ ( |
| Object.assign({}, node, {children: newChildren})); |
| }); |
| |
| return removeIdsFromObject(newState, action.descendants); |
| } |
| |
| function reorderChildren( |
| nodes: NodeMap, action: ReorderChildrenAction): NodeMap { |
| return modifyNode(nodes, action.id, function(node) { |
| return /** @type {BookmarkNode} */ ( |
| Object.assign({}, node, {children: action.children})); |
| }); |
| } |
| |
| export function updateNodes(nodes: NodeMap, action: Action): NodeMap { |
| switch (action.name) { |
| case 'create-bookmark': |
| return createBookmark(nodes, action as CreateBookmarkAction); |
| case 'edit-bookmark': |
| return editBookmark(nodes, action as EditBookmarkAction); |
| case 'move-bookmark': |
| return moveBookmark(nodes, action as MoveBookmarkAction); |
| case 'remove-bookmark': |
| return removeBookmark(nodes, action as RemoveBookmarkAction); |
| case 'reorder-children': |
| return reorderChildren(nodes, action as ReorderChildrenAction); |
| case 'refresh-nodes': |
| return (action as RefreshNodesAction).nodes; |
| default: |
| return nodes; |
| } |
| } |
| |
| function isAncestorOf( |
| nodes: NodeMap, ancestorId: string, childId: string): boolean { |
| let currentId: string|undefined = childId; |
| // Work upwards through the tree from child. |
| while (currentId) { |
| if (currentId === ancestorId) { |
| return true; |
| } |
| currentId = nodes[currentId!]!.parentId; |
| } |
| return false; |
| } |
| |
| // Exported for tests. |
| export function updateSelectedFolder( |
| selectedFolder: string, action: Action, nodes: NodeMap): string { |
| switch (action.name) { |
| case 'select-folder': |
| return (action as SelectFolderAction).id; |
| case 'change-folder-open': |
| // When hiding the selected folder by closing its ancestor, select |
| // that ancestor instead. |
| const changeFolderAction = action as ChangeFolderOpenAction; |
| if (!changeFolderAction.open && selectedFolder && |
| isAncestorOf(nodes, changeFolderAction.id, selectedFolder)) { |
| return changeFolderAction.id; |
| } |
| return selectedFolder; |
| case 'remove-bookmark': |
| // When deleting the selected folder (or its ancestor), select the |
| // parent of the deleted node. |
| const id = (action as RemoveBookmarkAction).id; |
| if (selectedFolder && isAncestorOf(nodes, id, selectedFolder)) { |
| const parentId = nodes[id]!.parentId; |
| assert(parentId); |
| return parentId; |
| } |
| return selectedFolder; |
| default: |
| return selectedFolder; |
| } |
| } |
| |
| function openFolderAndAncestors( |
| folderOpenState: FolderOpenState, id: string, nodes: NodeMap): |
| FolderOpenState { |
| const newFolderOpenState = (new Map(folderOpenState) as FolderOpenState); |
| for (let currentId = id; currentId; currentId = nodes[currentId]!.parentId!) { |
| newFolderOpenState.set(currentId, true); |
| } |
| |
| return newFolderOpenState; |
| } |
| |
| function changeFolderOpen( |
| folderOpenState: FolderOpenState, |
| action: ChangeFolderOpenAction): FolderOpenState { |
| const newFolderOpenState = new Map(folderOpenState) as FolderOpenState; |
| newFolderOpenState.set(action.id, action.open); |
| |
| return newFolderOpenState; |
| } |
| |
| export function updateFolderOpenState( |
| folderOpenState: FolderOpenState, action: Action, |
| nodes: NodeMap): FolderOpenState { |
| switch (action.name) { |
| case 'change-folder-open': |
| return changeFolderOpen( |
| folderOpenState, action as ChangeFolderOpenAction); |
| case 'select-folder': |
| return openFolderAndAncestors( |
| folderOpenState, nodes[(action as SelectFolderAction).id]!.parentId!, |
| nodes); |
| case 'move-bookmark': |
| if (!nodes[(action as MoveBookmarkAction).id]!.children) { |
| return folderOpenState; |
| } |
| return openFolderAndAncestors( |
| folderOpenState, (action as MoveBookmarkAction).parentId, nodes); |
| case 'remove-bookmark': |
| return removeIdsFromMap( |
| folderOpenState, (action as RemoveBookmarkAction).descendants); |
| default: |
| return folderOpenState; |
| } |
| } |
| |
| function updatePrefs( |
| prefs: PreferencesState, action: Action): PreferencesState { |
| const prefAction = action as SetPrefAction; |
| switch (prefAction.name) { |
| case 'set-incognito-availability': |
| return /** @type {PreferencesState} */ (Object.assign({}, prefs, { |
| incognitoAvailability: prefAction.value, |
| })); |
| case 'set-can-edit': |
| return /** @type {PreferencesState} */ (Object.assign({}, prefs, { |
| canEdit: prefAction.value, |
| })); |
| default: |
| return prefs; |
| } |
| } |
| |
| export function reduceAction( |
| state: BookmarksPageState, action: Action): BookmarksPageState { |
| return { |
| nodes: updateNodes(state.nodes, action), |
| selectedFolder: |
| updateSelectedFolder(state.selectedFolder, action, state.nodes), |
| folderOpenState: |
| updateFolderOpenState(state.folderOpenState, action, state.nodes), |
| prefs: updatePrefs(state.prefs, action), |
| search: updateSearch(state.search, action), |
| selection: updateSelection(state.selection, action), |
| }; |
| } |