mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 11:39:21 +02:00
Compare commits
5 Commits
fix-backup
...
move-topic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b15d5d85f | ||
|
|
171305b83e | ||
|
|
794502ec88 | ||
|
|
284c852031 | ||
|
|
c58c138996 |
@@ -120,7 +120,7 @@ test("pressing Enter should open focused note", async ({ page }) => {
|
|||||||
expect(await notes.editor.getTitle()).toBe(await notesList[2].getTitle());
|
expect(await notes.editor.getTitle()).toBe(await notesList[2].getTitle());
|
||||||
});
|
});
|
||||||
|
|
||||||
test.only("pressing Shift+ArrowDown should select next note", async ({ page }) => {
|
test("pressing Shift+ArrowDown should select next note", async ({ page }) => {
|
||||||
const { notesList, notes } = await populateList(page);
|
const { notesList, notes } = await populateList(page);
|
||||||
await notes.focus();
|
await notes.focus();
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Page } from "@playwright/test";
|
import { Page, Locator } from "@playwright/test";
|
||||||
import { getTestId } from "../utils";
|
import { getTestId } from "../utils";
|
||||||
import { AuthModel } from "./auth.model";
|
import { AuthModel } from "./auth.model";
|
||||||
import { CheckoutModel } from "./checkout.model";
|
import { CheckoutModel } from "./checkout.model";
|
||||||
@@ -35,6 +35,7 @@ export class AppModel {
|
|||||||
readonly navigation: NavigationMenuModel;
|
readonly navigation: NavigationMenuModel;
|
||||||
readonly auth: AuthModel;
|
readonly auth: AuthModel;
|
||||||
readonly checkout: CheckoutModel;
|
readonly checkout: CheckoutModel;
|
||||||
|
readonly routeHeader: Locator;
|
||||||
|
|
||||||
constructor(page: Page) {
|
constructor(page: Page) {
|
||||||
this.page = page;
|
this.page = page;
|
||||||
@@ -42,6 +43,7 @@ export class AppModel {
|
|||||||
this.navigation = new NavigationMenuModel(page);
|
this.navigation = new NavigationMenuModel(page);
|
||||||
this.auth = new AuthModel(page);
|
this.auth = new AuthModel(page);
|
||||||
this.checkout = new CheckoutModel(page);
|
this.checkout = new CheckoutModel(page);
|
||||||
|
this.routeHeader = this.page.locator(getTestId("routeHeader"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async goto() {
|
async goto() {
|
||||||
@@ -49,6 +51,10 @@ export class AppModel {
|
|||||||
await this.getRouteHeader();
|
await this.getRouteHeader();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async goback() {
|
||||||
|
await this.page.locator(getTestId("go-back")).click();
|
||||||
|
}
|
||||||
|
|
||||||
async goToNotes() {
|
async goToNotes() {
|
||||||
await this.navigateTo("Notes");
|
await this.navigateTo("Notes");
|
||||||
return new NotesViewModel(this.page, "home");
|
return new NotesViewModel(this.page, "home");
|
||||||
@@ -85,14 +91,18 @@ export class AppModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async navigateTo(title: string) {
|
private async navigateTo(title: string) {
|
||||||
if ((await this.getRouteHeader()) === title) return;
|
if (
|
||||||
|
!(await this.routeHeader.isVisible()) ||
|
||||||
|
(await this.getRouteHeader()) === title
|
||||||
|
)
|
||||||
|
return;
|
||||||
const item = await this.navigation.findItem(title);
|
const item = await this.navigation.findItem(title);
|
||||||
await item?.click();
|
await item?.click();
|
||||||
await this.page.waitForTimeout(1000);
|
await this.page.waitForTimeout(1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
getRouteHeader() {
|
async getRouteHeader() {
|
||||||
return this.page.locator(getTestId("routeHeader")).inputValue();
|
return await this.page.locator(getTestId("routeHeader")).inputValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
async isSynced() {
|
async isSynced() {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { BaseItemModel } from "./base-item.model";
|
|||||||
import { ContextMenuModel } from "./context-menu.model";
|
import { ContextMenuModel } from "./context-menu.model";
|
||||||
import { NotesViewModel } from "./notes-view.model";
|
import { NotesViewModel } from "./notes-view.model";
|
||||||
import { Item } from "./types";
|
import { Item } from "./types";
|
||||||
import { fillItemDialog } from "./utils";
|
import { fillItemDialog, fillMoveTopicDialog } from "./utils";
|
||||||
|
|
||||||
export class ItemModel extends BaseItemModel {
|
export class ItemModel extends BaseItemModel {
|
||||||
private readonly contextMenu: ContextMenuModel;
|
private readonly contextMenu: ContextMenuModel;
|
||||||
@@ -69,4 +69,10 @@ export class ItemModel extends BaseItemModel {
|
|||||||
await this.contextMenu.close();
|
await this.contextMenu.close();
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async moveItem(notebookTitle: string) {
|
||||||
|
await this.contextMenu.open(this.locator);
|
||||||
|
await this.contextMenu.clickOnItem("move");
|
||||||
|
await fillMoveTopicDialog(this.page, notebookTitle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ export async function fillNotebookDialog(
|
|||||||
await confirmDialog(page);
|
await confirmDialog(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fillMoveTopicDialog(page: Page, notebookTitle: string) {
|
||||||
|
const notebookList = page.locator(getTestId("notebook-list"));
|
||||||
|
const notebookTitles = notebookList.locator(getTestId("title"));
|
||||||
|
const dialogConfirm = page.locator(getTestId("dialog-yes"));
|
||||||
|
for await (const title of iterateList(notebookTitles)) {
|
||||||
|
if (notebookTitle === (await title.textContent())) {
|
||||||
|
await title.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await confirmDialog(page);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fillItemDialog(page: Page, item: Item) {
|
export async function fillItemDialog(page: Page, item: Item) {
|
||||||
const titleInput = page.locator(getTestId("title-input"));
|
const titleInput = page.locator(getTestId("title-input"));
|
||||||
await titleInput.waitFor({ state: "visible" });
|
await titleInput.waitFor({ state: "visible" });
|
||||||
|
|||||||
51
apps/web/__e2e__/topics.test.ts
Normal file
51
apps/web/__e2e__/topics.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 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 { test, expect } from "@playwright/test";
|
||||||
|
import { AppModel } from "./models/app.model";
|
||||||
|
import { getTestId, NOTE } from "./utils";
|
||||||
|
|
||||||
|
test.only("Move topic to notebook", async ({ page }) => {
|
||||||
|
const NOTEBOOK1 = {
|
||||||
|
title: "Test notebook 1",
|
||||||
|
description: "This is test notebook 1",
|
||||||
|
topics: ["Topic 1", "Very long topic 2", "Topic 3"]
|
||||||
|
};
|
||||||
|
const NOTEBOOK2 = {
|
||||||
|
title: "Test notebook 2",
|
||||||
|
description: "This is test notebook 2",
|
||||||
|
topics: ["Topic 4", "Very long topic 5", "Topic 6"]
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = new AppModel(page);
|
||||||
|
await app.goto();
|
||||||
|
const notebooks = await app.goToNotebooks();
|
||||||
|
const notebook1 = await notebooks.createNotebook(NOTEBOOK1);
|
||||||
|
const notebook2 = await notebooks.createNotebook(NOTEBOOK2);
|
||||||
|
|
||||||
|
const topics = await notebook1?.openNotebook();
|
||||||
|
const topic = await topics?.findItem({ title: NOTEBOOK1.topics[0] });
|
||||||
|
await topic?.moveItem(NOTEBOOK2.title);
|
||||||
|
|
||||||
|
await app.goback();
|
||||||
|
const topics2 = await notebook2?.openNotebook();
|
||||||
|
const topic2 = await topics2?.findItem({ title: NOTEBOOK1.topics[0] });
|
||||||
|
|
||||||
|
expect((await topic2?.getTitle()) === NOTEBOOK1.topics[0]).toBeTruthy();
|
||||||
|
});
|
||||||
@@ -350,6 +350,12 @@ export function showMoveNoteDialog(noteIds: string[]) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function showMoveTopicsDialog(topics: [], id: String) {
|
||||||
|
return showDialog("MoveTopicsDialog", (Dialog, perform) => (
|
||||||
|
<Dialog topics={topics} id={id} onClose={(res: boolean) => perform(res)} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
function getDialogData(type: string) {
|
function getDialogData(type: string) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "create_vault":
|
case "create_vault":
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const ImportDialog = React.lazy(() => import("./import-dialog"));
|
|||||||
const LoadingDialog = React.lazy(() => import("./loading-dialog"));
|
const LoadingDialog = React.lazy(() => import("./loading-dialog"));
|
||||||
const ProgressDialog = React.lazy(() => import("./progress-dialog"));
|
const ProgressDialog = React.lazy(() => import("./progress-dialog"));
|
||||||
const MoveDialog = React.lazy(() => import("./move-note-dialog"));
|
const MoveDialog = React.lazy(() => import("./move-note-dialog"));
|
||||||
|
const MoveTopicsDialog = React.lazy(() => import("./move-topics-dialog"));
|
||||||
const PasswordDialog = React.lazy(() => import("./password-dialog"));
|
const PasswordDialog = React.lazy(() => import("./password-dialog"));
|
||||||
const RecoveryKeyDialog = React.lazy(() => import("./recovery-key-dialog"));
|
const RecoveryKeyDialog = React.lazy(() => import("./recovery-key-dialog"));
|
||||||
const ItemDialog = React.lazy(() => import("./item-dialog"));
|
const ItemDialog = React.lazy(() => import("./item-dialog"));
|
||||||
@@ -60,6 +61,7 @@ export const Dialogs = {
|
|||||||
EmailVerificationDialog,
|
EmailVerificationDialog,
|
||||||
LoadingDialog,
|
LoadingDialog,
|
||||||
MoveDialog,
|
MoveDialog,
|
||||||
|
MoveTopicsDialog,
|
||||||
PasswordDialog,
|
PasswordDialog,
|
||||||
RecoveryKeyDialog,
|
RecoveryKeyDialog,
|
||||||
ItemDialog,
|
ItemDialog,
|
||||||
|
|||||||
@@ -17,33 +17,21 @@ You should have received a copy of the GNU General Public License
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { useCallback, useMemo, useState } from "react";
|
||||||
ChangeEvent,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState
|
|
||||||
} from "react";
|
|
||||||
import { Button, Flex, Input, Text } from "@theme-ui/components";
|
import { Button, Flex, Input, Text } from "@theme-ui/components";
|
||||||
import * as Icon from "../icons";
|
import * as Icon from "../icons";
|
||||||
import { db } from "../../common/db";
|
import { db } from "../../common/db";
|
||||||
import Dialog from "./dialog";
|
import Dialog from "./dialog";
|
||||||
import Field from "../field";
|
|
||||||
import { useStore, store } from "../../stores/notebook-store";
|
import { useStore, store } from "../../stores/notebook-store";
|
||||||
import { store as notestore } from "../../stores/note-store";
|
import { store as notestore } from "../../stores/note-store";
|
||||||
import { Perform } from "../../common/dialog-controller";
|
import { Perform } from "../../common/dialog-controller";
|
||||||
import { ThemeUIStyleObject } from "@theme-ui/core";
|
import { ThemeUIStyleObject } from "@theme-ui/core";
|
||||||
import { showToast } from "../../utils/toast";
|
import { showToast } from "../../utils/toast";
|
||||||
import { pluralize } from "../../utils/string";
|
import { pluralize } from "../../utils/string";
|
||||||
|
import FilteredList, { Item } from "../filtered-list";
|
||||||
|
|
||||||
type MoveDialogProps = { onClose: Perform; noteIds: string[] };
|
type MoveDialogProps = { onClose: Perform; noteIds: string[] };
|
||||||
type NotebookReference = { id: string; topic: string; type: "add" | "remove" };
|
type NotebookReference = { id: string; topic: string; type: "add" | "remove" };
|
||||||
type Item = {
|
|
||||||
id: string;
|
|
||||||
type: "topic" | "notebook" | "header";
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
type Notebook = Item & { topics: Item[] };
|
type Notebook = Item & { topics: Item[] };
|
||||||
|
|
||||||
function MoveDialog({ onClose, noteIds }: MoveDialogProps) {
|
function MoveDialog({ onClose, noteIds }: MoveDialogProps) {
|
||||||
@@ -197,109 +185,6 @@ function topicHasNotes(topic: Item, noteIds: string[]) {
|
|||||||
return noteIds.some((id) => notes.indexOf(id) > -1);
|
return noteIds.some((id) => notes.indexOf(id) > -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilteredListProps<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) => JSX.Element;
|
|
||||||
};
|
|
||||||
|
|
||||||
function FilteredList<T extends Item>(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: Icon.Plus,
|
|
||||||
onClick: async () => await _createNewItem(query)
|
|
||||||
}
|
|
||||||
: { icon: Icon.Search, onClick: () => _filter(query) }
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Flex mt={1} sx={{ overflowY: "hidden", 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))}
|
|
||||||
</Flex>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
export default MoveDialog;
|
export default MoveDialog;
|
||||||
|
|
||||||
type TreeNodeProps<T extends Item> = {
|
type TreeNodeProps<T extends Item> = {
|
||||||
|
|||||||
121
apps/web/src/components/dialogs/move-topics-dialog.tsx
Normal file
121
apps/web/src/components/dialogs/move-topics-dialog.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 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, 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/notebook-store";
|
||||||
|
import { Perform } from "../../common/dialog-controller";
|
||||||
|
import FilteredList, { Item } from "../filtered-list";
|
||||||
|
|
||||||
|
type MoveDialogProps = { onClose: Perform; topics: []; id: String };
|
||||||
|
type Notebook = Item;
|
||||||
|
|
||||||
|
function MoveDialog({ onClose, topics, id }: MoveDialogProps) {
|
||||||
|
const [selected, setSelected] = useState(String);
|
||||||
|
|
||||||
|
const refreshNotebooks = useStore((store) => store.refresh);
|
||||||
|
const getAllNotebooks = useCallback(() => {
|
||||||
|
refreshNotebooks();
|
||||||
|
return (store.get().notebooks as Notebook[]).filter((a) => {
|
||||||
|
return a.type !== "header" && a.id !== id;
|
||||||
|
});
|
||||||
|
}, [refreshNotebooks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen={true}
|
||||||
|
title={"Move topic(s)"}
|
||||||
|
description={"You can move topics between notebooks"}
|
||||||
|
onClose={onClose}
|
||||||
|
width={"30%"}
|
||||||
|
positiveButton={{
|
||||||
|
text: "Finish",
|
||||||
|
disabled: !selected.length,
|
||||||
|
onClick: async () => {
|
||||||
|
await db?.notebooks?.moveTopics(selected, topics);
|
||||||
|
refreshNotebooks();
|
||||||
|
onClose(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
negativeButton={{
|
||||||
|
text: "Cancel",
|
||||||
|
onClick: onClose
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
mt={1}
|
||||||
|
sx={{ overflowY: "hidden", flexDirection: "column" }}
|
||||||
|
data-test-id="notebook-list"
|
||||||
|
>
|
||||||
|
<FilteredList
|
||||||
|
placeholders={{
|
||||||
|
empty: "Add a new notebook",
|
||||||
|
filter: "Filter notebooks"
|
||||||
|
}}
|
||||||
|
items={getAllNotebooks}
|
||||||
|
filter={(notebooks, query) =>
|
||||||
|
db.lookup?.notebooks(notebooks, query) || []
|
||||||
|
}
|
||||||
|
onCreateNewItem={async (title) =>
|
||||||
|
await db.notebooks?.add({
|
||||||
|
title
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderItem={(notebook, _index, refresh) => {
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
alignItems: "center",
|
||||||
|
p: "3px",
|
||||||
|
cursor: "pointer",
|
||||||
|
":hover": { bg: "hover" },
|
||||||
|
justifyContent: "space-between"
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
setSelected(notebook.id);
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex>
|
||||||
|
{selected === notebook.id ? (
|
||||||
|
<Icon.Checkmark size={18} />
|
||||||
|
) : (
|
||||||
|
<Icon.ChevronRight size={18} />
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
variant={"body"}
|
||||||
|
sx={{ fontSize: "subtitle" }}
|
||||||
|
data-test-id="title"
|
||||||
|
>
|
||||||
|
{notebook.title}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MoveDialog;
|
||||||
135
apps/web/src/components/filtered-list/index.tsx
Normal file
135
apps/web/src/components/filtered-list/index.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 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 { Button, Flex, Text } from "@theme-ui/components";
|
||||||
|
import * as Icon from "../icons";
|
||||||
|
import Field from "../field";
|
||||||
|
|
||||||
|
type FilteredListProps<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) => JSX.Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Item = {
|
||||||
|
id: string;
|
||||||
|
type: "topic" | "notebook" | "header";
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FilteredList<T extends Item>(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: Icon.Plus,
|
||||||
|
onClick: async () => await _createNewItem(query)
|
||||||
|
}
|
||||||
|
: { icon: Icon.Search, onClick: () => _filter(query) }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Flex mt={1} sx={{ overflowY: "hidden", 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))}
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilteredList;
|
||||||
@@ -247,6 +247,7 @@ function GroupHeader(props) {
|
|||||||
{index === 0 && (
|
{index === 0 && (
|
||||||
<Flex mr={1}>
|
<Flex mr={1}>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
testId={"sort-icon-button"}
|
||||||
icon={
|
icon={
|
||||||
groupOptions.sortDirection === "asc"
|
groupOptions.sortDirection === "asc"
|
||||||
? Icon.SortAsc
|
? Icon.SortAsc
|
||||||
@@ -292,6 +293,7 @@ function IconButton(props) {
|
|||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
data-test-id={props.testId}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
bg="transparent"
|
bg="transparent"
|
||||||
title={title}
|
title={title}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { Flex, Text } from "@theme-ui/components";
|
|||||||
import * as Icon from "../icons";
|
import * as Icon from "../icons";
|
||||||
import { Multiselect } from "../../common/multi-select";
|
import { Multiselect } from "../../common/multi-select";
|
||||||
import { pluralize } from "../../utils/string";
|
import { pluralize } from "../../utils/string";
|
||||||
|
import { showMoveTopicsDialog } from "../../common/dialog-controller";
|
||||||
|
|
||||||
function Topic({ item, index, onClick }) {
|
function Topic({ item, index, onClick }) {
|
||||||
const { id, notebookId } = item;
|
const { id, notebookId } = item;
|
||||||
@@ -87,6 +88,15 @@ const menuItems = [
|
|||||||
icon: Icon.Shortcut,
|
icon: Icon.Shortcut,
|
||||||
onClick: ({ topic }) => appStore.addToShortcuts(topic)
|
onClick: ({ topic }) => appStore.addToShortcuts(topic)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "move",
|
||||||
|
title: "Move Topic",
|
||||||
|
icon: Icon.Move,
|
||||||
|
onClick: ({ items, notebookId }) => {
|
||||||
|
showMoveTopicsDialog(items, notebookId);
|
||||||
|
},
|
||||||
|
multiSelect: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "delete",
|
key: "delete",
|
||||||
title: "Delete",
|
title: "Delete",
|
||||||
|
|||||||
@@ -155,4 +155,25 @@ export default class Notebooks extends Collection {
|
|||||||
await this._db.trash.add(notebookData);
|
await this._db.trash.add(notebookData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async moveTopics(notebookId, topics) {
|
||||||
|
if (!notebookId)
|
||||||
|
throw new Error("The destination notebook cannot be undefined.");
|
||||||
|
if (!topics.length) throw new Error("You must select one or more topics");
|
||||||
|
|
||||||
|
for (const topic of topics) {
|
||||||
|
let notesInTopic = [...this._db.notes.topicReferences.get(topic.id)];
|
||||||
|
await this._db.notes.addToNotebook(
|
||||||
|
{ id: notebookId, topic: topic.id },
|
||||||
|
...notesInTopic
|
||||||
|
);
|
||||||
|
await this._db.notebooks
|
||||||
|
.notebook(topic.notebookId)
|
||||||
|
.topics.delete(topic.id);
|
||||||
|
|
||||||
|
topic.notebookId = notebookId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._db.notebooks.notebook(notebookId).topics.add(...topics);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,10 +55,11 @@ export default class Topic {
|
|||||||
clear() {
|
clear() {
|
||||||
const noteIds = this._db.notes.topicReferences.get(this.id);
|
const noteIds = this._db.notes.topicReferences.get(this.id);
|
||||||
if (!noteIds.length) return;
|
if (!noteIds.length) return;
|
||||||
|
return this._db.notes.removeFromNotebook(
|
||||||
return this._db.notes.deleteFromNotebook(
|
{
|
||||||
this._notebookId,
|
id: this._notebookId,
|
||||||
this.id,
|
topic: this.id
|
||||||
|
},
|
||||||
...noteIds
|
...noteIds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user