chromium/chrome/browser/resources/bookmarks/reducers.ts

// 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.js';
import type {Action} from 'chrome://resources/js/store.js';

import type {ChangeFolderOpenAction, CreateBookmarkAction, EditBookmarkAction, FinishSearchAction, MoveBookmarkAction, RefreshNodesAction, RemoveBookmarkAction, ReorderChildrenAction, SelectFolderAction, SelectItemsAction, SetPrefAction, StartSearchAction, UpdateAnchorAction} from './actions.js';
import type {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),
  };
}