blob: ea2453167ee475a069067a7aff178df6d3dd2a25 [file] [log] [blame]
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {assert} from 'chrome://resources/js/assert.m.js';
import {BookmarkNode, BookmarksPageState, FolderOpenState, NodeMap, PreferencesState, SearchState, SelectionState} from './types.js';
import {removeIdsFromMap, removeIdsFromObject, removeIdsFromSet} from './util.js';
/**
* @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.
*/
/**
* @param {SelectionState} selectionState
* @param {Object} action
* @return {SelectionState}
*/
function selectItems(selectionState, action) {
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 /** @type {SelectionState} */ (Object.assign({}, selectionState, {
items: newItems,
anchor: action.anchor,
}));
}
/**
* @param {SelectionState} selectionState
* @return {SelectionState}
*/
function deselectAll(selectionState) {
return {
items: new Set(),
anchor: null,
};
}
/**
* @param {SelectionState} selectionState
* @param {!Set<string>} deleted
* @return SelectionState
*/
function deselectItems(selectionState, deleted) {
return /** @type {SelectionState} */ (Object.assign({}, selectionState, {
items: removeIdsFromSet(selectionState.items, deleted),
anchor: !selectionState.anchor || deleted.has(selectionState.anchor) ?
null :
selectionState.anchor,
}));
}
/**
* @param {SelectionState} selectionState
* @param {Object} action
* @return {SelectionState}
*/
function updateAnchor(selectionState, action) {
return /** @type {SelectionState} */ (Object.assign({}, selectionState, {
anchor: action.anchor,
}));
}
/**
* Exported for tests.
* @param {SelectionState} selection
* @param {Object} action
* @return {SelectionState}
*/
export function updateSelection(selection, action) {
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);
case 'remove-bookmark':
return deselectItems(selection, action.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).
if (action.parentId !== action.oldParentId &&
selection.items.has(action.id)) {
return deselectItems(selection, new Set([action.id]));
}
return selection;
case 'update-anchor':
return updateAnchor(selection, action);
default:
return selection;
}
}
/**
* @param {SearchState} search
* @param {Object} action
* @return {SearchState}
*/
function startSearch(search, action) {
return {
term: action.term,
inProgress: true,
results: search.results,
};
}
/**
* @param {SearchState} search
* @param {Object} action
* @return {SearchState}
*/
function finishSearch(search, action) {
return /** @type {SearchState} */ (Object.assign({}, search, {
inProgress: false,
results: action.results,
}));
}
/** @return {SearchState} */
function clearSearch() {
return {
term: '',
inProgress: false,
results: null,
};
}
/**
* @param {SearchState} search
* @param {!Set<string>} deletedIds
* @return {SearchState}
*/
function removeDeletedResults(search, deletedIds) {
if (!search.results) {
return search;
}
const newResults = [];
search.results.forEach(function(id) {
if (!deletedIds.has(id)) {
newResults.push(id);
}
});
return /** @type {SearchState} */ (Object.assign({}, search, {
results: newResults,
}));
}
/**
* @param {SearchState} search
* @param {Object} action
* @return {SearchState}
*/
function updateSearch(search, action) {
switch (action.name) {
case 'start-search':
return startSearch(search, action);
case 'select-folder':
case 'clear-search':
return clearSearch();
case 'finish-search':
return finishSearch(search, action);
case 'remove-bookmark':
return removeDeletedResults(search, action.descendants);
default:
return search;
}
}
/**
* @param {NodeMap} nodes
* @param {string} id
* @param {function(BookmarkNode):BookmarkNode} callback
* @return {NodeMap}
*/
function modifyNode(nodes, id, callback) {
const nodeModification = {};
nodeModification[id] = callback(nodes[id]);
return Object.assign({}, nodes, nodeModification);
}
/**
* @param {NodeMap} nodes
* @param {Object} action
* @return {NodeMap}
*/
function createBookmark(nodes, action) {
const nodeModifications = {};
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);
}
/**
* @param {NodeMap} nodes
* @param {Object} action
* @return {NodeMap}
*/
function editBookmark(nodes, action) {
// 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 /** @type {BookmarkNode} */ (
Object.assign({}, node, action.changeInfo));
});
}
/**
* @param {NodeMap} nodes
* @param {Object} action
* @return {NodeMap}
*/
function moveBookmark(nodes, action) {
const nodeModifications = {};
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);
}
/**
* @param {NodeMap} nodes
* @param {Object} action
* @return {NodeMap}
*/
function removeBookmark(nodes, action) {
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);
}
/**
* @param {NodeMap} nodes
* @param {Object} action
* @return {NodeMap}
*/
function reorderChildren(nodes, action) {
return modifyNode(nodes, action.id, function(node) {
return /** @type {BookmarkNode} */ (
Object.assign({}, node, {children: action.children}));
});
}
/**
* Exported for tests.
* @param {NodeMap} nodes
* @param {Object} action
* @return {NodeMap}
*/
export function updateNodes(nodes, action) {
switch (action.name) {
case 'create-bookmark':
return createBookmark(nodes, action);
case 'edit-bookmark':
return editBookmark(nodes, action);
case 'move-bookmark':
return moveBookmark(nodes, action);
case 'remove-bookmark':
return removeBookmark(nodes, action);
case 'reorder-children':
return reorderChildren(nodes, action);
case 'refresh-nodes':
return action.nodes;
default:
return nodes;
}
}
/**
* @param {NodeMap} nodes
* @param {string} ancestorId
* @param {string} childId
* @return {boolean}
*/
function isAncestorOf(nodes, ancestorId, childId) {
let currentId = childId;
// Work upwards through the tree from child.
while (currentId) {
if (currentId === ancestorId) {
return true;
}
currentId = nodes[currentId].parentId;
}
return false;
}
/**
* Exported for tests.
* @param {string} selectedFolder
* @param {Object} action
* @param {NodeMap} nodes
* @return {string}
*/
export function updateSelectedFolder(selectedFolder, action, nodes) {
switch (action.name) {
case 'select-folder':
return action.id;
case 'change-folder-open':
// When hiding the selected folder by closing its ancestor, select
// that ancestor instead.
if (!action.open && selectedFolder &&
isAncestorOf(nodes, action.id, selectedFolder)) {
return action.id;
}
return selectedFolder;
case 'remove-bookmark':
// When deleting the selected folder (or its ancestor), select the
// parent of the deleted node.
if (selectedFolder && isAncestorOf(nodes, action.id, selectedFolder)) {
return assert(nodes[action.id].parentId);
}
return selectedFolder;
default:
return selectedFolder;
}
}
/**
* @param {FolderOpenState} folderOpenState
* @param {string|undefined} id
* @param {NodeMap} nodes
* @return {FolderOpenState}
*/
function openFolderAndAncestors(folderOpenState, id, nodes) {
const newFolderOpenState =
/** @type {FolderOpenState} */ (new Map(folderOpenState));
for (let currentId = id; currentId; currentId = nodes[currentId].parentId) {
newFolderOpenState.set(currentId, true);
}
return newFolderOpenState;
}
/**
* @param {FolderOpenState} folderOpenState
* @param {Object} action
* @return {FolderOpenState}
*/
function changeFolderOpen(folderOpenState, action) {
const newFolderOpenState =
/** @type {FolderOpenState} */ (new Map(folderOpenState));
newFolderOpenState.set(action.id, action.open);
return newFolderOpenState;
}
/**
* Exported for tests.
* @param {FolderOpenState} folderOpenState
* @param {Object} action
* @param {NodeMap} nodes
* @return {FolderOpenState}
*/
export function updateFolderOpenState(folderOpenState, action, nodes) {
switch (action.name) {
case 'change-folder-open':
return changeFolderOpen(folderOpenState, action);
case 'select-folder':
return openFolderAndAncestors(
folderOpenState, nodes[action.id].parentId, nodes);
case 'move-bookmark':
if (!nodes[action.id].children) {
return folderOpenState;
}
return openFolderAndAncestors(folderOpenState, action.parentId, nodes);
case 'remove-bookmark':
return removeIdsFromMap(folderOpenState, action.descendants);
default:
return folderOpenState;
}
}
/**
* @param {PreferencesState} prefs
* @param {Object} action
* @return {PreferencesState}
*/
function updatePrefs(prefs, action) {
switch (action.name) {
case 'set-incognito-availability':
return /** @type {PreferencesState} */ (Object.assign({}, prefs, {
incognitoAvailability: action.value,
}));
case 'set-can-edit':
return /** @type {PreferencesState} */ (Object.assign({}, prefs, {
canEdit: action.value,
}));
default:
return prefs;
}
}
/**
* Root reducer for the Bookmarks page. This is called by the store in
* response to an action, and the return value is used to update the UI.
* @param {!BookmarksPageState} state
* @param {Object} action
* @return {!BookmarksPageState}
*/
export function reduceAction(state, action) {
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),
};
}