web: add support for setting tags in bulk (#2328)

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
This commit is contained in:
Muhammad Ali
2023-05-25 14:10:32 +05:00
committed by GitHub
parent 2761222b36
commit 59b081704b
8 changed files with 454 additions and 127 deletions

View File

@@ -88,6 +88,12 @@ export function closeOpenedDialog() {
dialogs.forEach((elem) => elem.remove());
}
export function showAddTagsDialog(noteIds: string[]) {
return showDialog("AddTagsDialog", (Dialog, perform) => (
<Dialog onClose={(res) => perform(res)} noteIds={noteIds} />
));
}
export function showAddNotebookDialog() {
return showDialog("AddNotebookDialog", (Dialog, perform) => (
<Dialog

View File

@@ -0,0 +1,220 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useCallback, useEffect, useState } from "react";
import { Flex, Text } from "@theme-ui/components";
import * as Icon from "../icons";
import { db } from "../../common/db";
import Dialog from "./dialog";
import { useStore, store } from "../../stores/tag-store";
import { store as notestore } from "../../stores/note-store";
import { Perform } from "../../common/dialog-controller";
import { FilteredList } from "../filtered-list";
type SelectedReference = {
id: string;
new: boolean;
op: "add" | "remove";
};
type Item = {
id: string;
type: "tag" | "header";
title: string;
};
type Tag = Item & { noteIds: string[] };
export type AddTagsDialogProps = {
onClose: Perform;
noteIds: string[];
};
function AddTagsDialog(props: AddTagsDialogProps) {
const { onClose, noteIds } = props;
const refreshTags = useStore((store) => store.refresh);
const tags = useStore((store) => store.tags);
useEffect(() => {
refreshTags();
}, [refreshTags]);
const [selected, setSelected] = useState<SelectedReference[]>([]);
const getAllTags = useCallback(() => {
refreshTags();
return (store.get().tags as Item[]).filter((a) => a.type !== "header");
}, [refreshTags]);
useEffect(() => {
if (!tags) return;
setSelected((s) => {
const selected = s.slice();
for (const tag of tags as Tag[]) {
if (tag.type === "header") continue;
if (selected.findIndex((a) => a.id === tag.id) > -1) continue;
if (tagHasNotes(tag, noteIds)) {
selected.push({
id: tag.id,
op: "add",
new: false
});
}
}
return selected;
});
}, [noteIds, tags, setSelected]);
return (
<Dialog
isOpen={true}
title={"Add tags"}
description={`Add tags to multiple notes at once`}
onClose={() => onClose(false)}
width={450}
positiveButton={{
text: "Done",
onClick: async () => {
for (const id of noteIds) {
for (const item of selected) {
if (item.op === "add") await db.notes?.note(id).tag(item.id);
else await db.notes?.note(id).untag(item.id);
}
}
notestore.refresh();
onClose(true);
}
}}
negativeButton={{
text: "Cancel",
onClick: () => onClose(false)
}}
>
<Flex
mt={1}
sx={{ overflowY: "hidden", flexDirection: "column" }}
data-test-id="tag-list"
>
<FilteredList
items={getAllTags}
placeholders={{
empty: "Add a new tag",
filter: "Search or add a new tag"
}}
filter={(tags, query) => db.lookup?.tags(tags, query) || []}
onCreateNewItem={async (title) => {
const tag = await db.tags?.add(title);
setSelected((selected) => [
...selected,
{ id: tag.id, new: true, op: "add" }
]);
}}
renderItem={(tag, _index) => {
const selectedTag = selected.find((item) => item.id === tag.id);
return (
<TagItem
key={tag.id}
tag={tag}
selected={selectedTag ? selectedTag.op : false}
onSelect={() => {
setSelected((selected) => {
const copy = selected.slice();
const index = copy.findIndex((item) => item.id === tag.id);
const isNew = copy[index] && copy[index].new;
if (isNew) {
copy.splice(index, 1);
} else if (index > -1) {
copy[index] = {
...copy[index],
op: copy[index].op === "add" ? "remove" : "add"
};
} else {
copy.push({ id: tag.id, new: true, op: "add" });
}
return copy;
});
}}
/>
);
}}
/>
</Flex>
</Dialog>
);
}
function TagItem(props: {
tag: Item;
selected: boolean | SelectedReference["op"];
onSelect: () => void;
}) {
const { tag, selected, onSelect } = props;
return (
<Flex
as="li"
data-test-id="tag"
sx={{
cursor: "pointer",
justifyContent: "space-between",
alignItems: "center",
bg: "bgSecondary",
borderRadius: "default",
p: 1,
background: "bgSecondary"
}}
onClick={onSelect}
>
<Flex sx={{ alignItems: "center" }}>
<SelectedCheck size={20} selected={selected} />
<Text
className="title"
data-test-id="notebook-title"
variant="subtitle"
sx={{ fontWeight: "body", color: "text" }}
>
#{tag.title}
</Text>
</Flex>
</Flex>
);
}
export default AddTagsDialog;
function SelectedCheck({
selected,
size = 20
}: {
selected: SelectedReference["op"] | boolean;
size?: number;
}) {
return selected === "add" ? (
<Icon.CheckCircleOutline size={size} sx={{ mr: 1 }} color="primary" />
) : selected === "remove" ? (
<Icon.CheckRemove size={size} sx={{ mr: 1 }} color="error" />
) : (
<Icon.CircleEmpty size={size} sx={{ mr: 1, opacity: 0.4 }} />
);
}
function tagHasNotes(tag: Tag, noteIds: string[]) {
return tag.noteIds.some((id) => noteIds.indexOf(id) > -1);
}

View File

@@ -60,6 +60,7 @@ const LanguageSelectorDialog = React.lazy(
const BillingHistoryDialog = React.lazy(
() => import("./billing-history-dialog")
);
const AddTagsDialog = React.lazy(() => import("./add-tags-dialog"));
export const Dialogs = {
AddNotebookDialog,
@@ -89,5 +90,6 @@ export const Dialogs = {
ReminderPreviewDialog,
EmailChangeDialog,
LanguageSelectorDialog,
BillingHistoryDialog
BillingHistoryDialog,
AddTagsDialog
};

View File

@@ -17,12 +17,11 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Box, Button, Flex, Input, Text } from "@theme-ui/components";
import * as Icon from "../icons";
import { db } from "../../common/db";
import Dialog from "./dialog";
import Field from "../field";
import { useStore, store } from "../../stores/notebook-store";
import { store as notestore } from "../../stores/note-store";
import { Perform } from "../../common/dialog-controller";
@@ -30,6 +29,7 @@ import { showToast } from "../../utils/toast";
import { pluralize } from "../../utils/string";
import { isMac } from "../../utils/platform";
import create from "zustand";
import { FilteredList } from "../filtered-list";
type MoveDialogProps = { onClose: Perform; noteIds: string[] };
type NotebookReference = {
@@ -193,7 +193,7 @@ function MoveDialog({ onClose, noteIds }: MoveDialogProps) {
sx={{ overflowY: "hidden", flexDirection: "column" }}
data-test-id="notebook-list"
>
<FilteredTree
<FilteredList
placeholders={{
empty: "Add a new notebook",
filter: "Search or add a new notebook"
@@ -423,127 +423,6 @@ function TopicItem(props: { topic: Topic }) {
export default MoveDialog;
type FilteredTreeProps<T extends Item> = {
placeholders: { filter: string; empty: string };
items: () => T[];
filter: (items: T[], query: string) => T[];
onCreateNewItem: (title: string) => Promise<void>;
renderItem: (
item: T,
index: number,
refresh: () => void,
isSearching: boolean
) => JSX.Element;
};
function FilteredTree<T extends Item>(props: FilteredTreeProps<T>) {
const {
items: _items,
filter,
onCreateNewItem,
placeholders,
renderItem
} = props;
const [items, setItems] = useState<T[]>([]);
const [query, setQuery] = useState<string>();
const noItemsFound = items.length <= 0 && query && query.length > 0;
const inputRef = useRef<HTMLInputElement>(null);
const refresh = useCallback(() => {
setItems(_items());
}, [_items]);
useEffect(() => {
refresh();
}, [refresh]);
const _filter = useCallback(
(query) => {
setItems(() => {
const items = _items();
if (!query) {
return items;
}
return filter(items, query);
});
setQuery(query);
},
[_items, filter]
);
const _createNewItem = useCallback(
async (title) => {
await onCreateNewItem(title);
refresh();
setQuery(undefined);
if (inputRef.current) inputRef.current.value = "";
},
[inputRef, refresh, onCreateNewItem]
);
return (
<>
<Field
inputRef={inputRef}
data-test-id={"filter-input"}
autoFocus
placeholder={
items.length <= 0 ? placeholders.empty : placeholders.filter
}
onChange={(e: ChangeEvent) =>
_filter((e.target as HTMLInputElement).value)
}
onKeyUp={async (e: KeyboardEvent) => {
if (e.key === "Enter" && noItemsFound) {
await _createNewItem(query);
}
}}
action={
items.length <= 0
? {
icon: Icon.Plus,
onClick: async () => await _createNewItem(query)
}
: { icon: Icon.Search, onClick: () => _filter(query) }
}
/>
<Flex
as="ul"
mt={1}
sx={{
overflowY: "hidden",
listStyle: "none",
m: 0,
p: 0,
gap: 1,
display: "flex",
flexDirection: "column"
}}
>
{noItemsFound && (
<Button
variant={"secondary"}
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
py: 2
}}
onClick={async () => {
await _createNewItem(query);
}}
>
<Text variant={"body"}>{`Add "${query}"`}</Text>
<Icon.Plus size={16} color="primary" />
</Button>
)}
{items.map((item, index) => renderItem(item, index, refresh, !!query))}
</Flex>
</>
);
}
function SelectedCheck({
item,
size = 20

View File

@@ -72,7 +72,7 @@ function Header({ readonly }) {
}
export default Header;
function Autosuggest({
export function Autosuggest({
sessionId,
filter,
onRemove,

View File

@@ -0,0 +1,151 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
import Field from "../field";
import { Plus, Search } from "../icons";
import { Button, Flex, Text } from "@theme-ui/components";
type FilterableItem = {
id: string;
title: string;
};
type FilteredListProps<T extends FilterableItem> = {
placeholders: { filter: string; empty: string };
items: () => T[];
filter: (items: T[], query: string) => T[];
onCreateNewItem: (title: string) => Promise<void>;
renderItem: (
item: T,
index: number,
refresh: () => void,
isSearching: boolean
) => JSX.Element;
};
export function FilteredList<T extends FilterableItem>(
props: FilteredListProps<T>
) {
const {
items: _items,
filter,
onCreateNewItem,
placeholders,
renderItem
} = props;
const [items, setItems] = useState<T[]>([]);
const [query, setQuery] = useState<string>();
const noItemsFound = items.length <= 0 && query && query.length > 0;
const inputRef = useRef<HTMLInputElement>(null);
const refresh = useCallback(() => {
setItems(_items());
}, [_items]);
useEffect(() => {
refresh();
}, [refresh]);
const _filter = useCallback(
(query) => {
setItems(() => {
const items = _items();
if (!query) {
return items;
}
return filter(items, query);
});
setQuery(query);
},
[_items, filter]
);
const _createNewItem = useCallback(
async (title) => {
await onCreateNewItem(title);
refresh();
setQuery(undefined);
if (inputRef.current) inputRef.current.value = "";
},
[inputRef, refresh, onCreateNewItem]
);
return (
<>
<Field
inputRef={inputRef}
data-test-id={"filter-input"}
autoFocus
placeholder={
items.length <= 0 ? placeholders.empty : placeholders.filter
}
onChange={(e: ChangeEvent) =>
_filter((e.target as HTMLInputElement).value)
}
onKeyUp={async (e: KeyboardEvent) => {
if (e.key === "Enter" && noItemsFound) {
await _createNewItem(query);
}
}}
action={
items.length <= 0
? {
icon: Plus,
onClick: async () => await _createNewItem(query)
}
: { icon: Search, onClick: () => _filter(query) }
}
/>
<Flex
as="ul"
mt={1}
sx={{
overflowY: "hidden",
listStyle: "none",
m: 0,
p: 0,
gap: 1,
display: "flex",
flexDirection: "column"
}}
>
{noItemsFound && (
<Button
variant={"secondary"}
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
py: 2
}}
onClick={async () => {
await _createNewItem(query);
}}
>
<Text variant={"body"}>{`Add "${query}"`}</Text>
<Plus size={16} color="primary" />
</Button>
)}
{items.map((item, index) => renderItem(item, index, refresh, !!query))}
</Flex>
</>
);
}

View File

@@ -25,6 +25,7 @@ import ListItem from "../list-item";
import {
confirm,
showAddReminderDialog,
showAddTagsDialog,
showMoveNoteDialog
} from "../../common/dialog-controller";
import { store, useStore } from "../../stores/note-store";
@@ -372,6 +373,16 @@ const menuItems = [
icon: Icon.Colors,
items: colorsToMenuItems()
},
{
key: "add-tags",
title: "Tags",
icon: Icon.Tag2,
multiSelect: true,
items: tagsMenuItems
// onClick: async ({ items }) => {
// await showAddTagsDialog(items.map((i) => i.id));
// }
},
{ key: "sep2", type: "separator" },
{
key: "print",
@@ -598,3 +609,60 @@ function notebooksMenuItems({ items }) {
return menuItems;
}
function tagsMenuItems({ items }) {
const noteIds = items.map((i) => i.id);
const menuItems = [];
menuItems.push({
key: "assign-tags",
title: "Assign to...",
icon: Icon.Plus,
onClick: async () => {
await showAddTagsDialog(noteIds);
}
});
const tags = items.map((note) => note.tags).flat();
if (tags?.length > 0) {
menuItems.push(
{
key: "remove-from-all-tags",
title: "Remove from all",
icon: Icon.RemoveShortcutLink,
onClick: async () => {
for (const note of items) {
for (const tag of tags) {
if (!note.tags.includes(tag)) continue;
await db.notes.note(note).untag(tag);
}
}
store.refresh();
}
},
{ key: "sep", type: "separator" }
);
tags?.forEach((tag) => {
if (menuItems.find((item) => item.key === tag)) return;
menuItems.push({
key: tag,
title: db.tags.alias(tag),
icon: Icon.Tag,
checked: true,
tooltip: "Click to remove from this tag",
onClick: async () => {
for (const note of items) {
if (!note.tags.includes(tag)) continue;
await db.notes.note(note).untag(tag);
}
store.refresh();
}
});
});
}
return menuItems;
}

View File

@@ -194,7 +194,8 @@ export default class Note {
}
async untag(tag) {
if (deleteItem(this._note.tags, tag)) {
const tagItem = this._db.tags.tag(tag);
if (tagItem && deleteItem(this._note.tags, tagItem.title)) {
await this._db.notes.add(this._note);
} else console.error("This note is not tagged by the specified tag.", tag);
await this._db.tags.untag(tag, this._note.id);