Compare commits

...

5 Commits

Author SHA1 Message Date
alihamuh
2b15d5d85f web: refactored code in core and web components
Signed-off-by: Muhammad Ali <alihamuh@gmail.com>
2023-01-07 14:25:59 +05:00
alihamuh
171305b83e web: added goback method in app model
Signed-off-by: Muhammad Ali <alihamuh@gmail.com>
2022-12-17 10:22:37 +05:00
alihamuh
794502ec88 web: added move topics test
Signed-off-by: Muhammad Ali <alihamuh@gmail.com>
2022-12-17 10:11:28 +05:00
alihamuh
284c852031 web: added refresh notebooks
Signed-off-by: Muhammad Ali <alihamuh@gmail.com>
2022-12-09 06:52:11 +05:00
alihamuh
c58c138996 web: added code to move topic betwen notebooks (code added in core)
Signed-off-by: Muhammad Ali <alihamuh@gmail.com>
2022-12-08 19:49:01 +05:00
14 changed files with 389 additions and 127 deletions

View File

@@ -120,7 +120,7 @@ test("pressing Enter should open focused note", async ({ page }) => {
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);
await notes.focus();

View File

@@ -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/>.
*/
import { Page } from "@playwright/test";
import { Page, Locator } from "@playwright/test";
import { getTestId } from "../utils";
import { AuthModel } from "./auth.model";
import { CheckoutModel } from "./checkout.model";
@@ -35,6 +35,7 @@ export class AppModel {
readonly navigation: NavigationMenuModel;
readonly auth: AuthModel;
readonly checkout: CheckoutModel;
readonly routeHeader: Locator;
constructor(page: Page) {
this.page = page;
@@ -42,6 +43,7 @@ export class AppModel {
this.navigation = new NavigationMenuModel(page);
this.auth = new AuthModel(page);
this.checkout = new CheckoutModel(page);
this.routeHeader = this.page.locator(getTestId("routeHeader"));
}
async goto() {
@@ -49,6 +51,10 @@ export class AppModel {
await this.getRouteHeader();
}
async goback() {
await this.page.locator(getTestId("go-back")).click();
}
async goToNotes() {
await this.navigateTo("Notes");
return new NotesViewModel(this.page, "home");
@@ -85,14 +91,18 @@ export class AppModel {
}
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);
await item?.click();
await this.page.waitForTimeout(1000);
}
getRouteHeader() {
return this.page.locator(getTestId("routeHeader")).inputValue();
async getRouteHeader() {
return await this.page.locator(getTestId("routeHeader")).inputValue();
}
async isSynced() {

View File

@@ -22,7 +22,7 @@ import { BaseItemModel } from "./base-item.model";
import { ContextMenuModel } from "./context-menu.model";
import { NotesViewModel } from "./notes-view.model";
import { Item } from "./types";
import { fillItemDialog } from "./utils";
import { fillItemDialog, fillMoveTopicDialog } from "./utils";
export class ItemModel extends BaseItemModel {
private readonly contextMenu: ContextMenuModel;
@@ -69,4 +69,10 @@ export class ItemModel extends BaseItemModel {
await this.contextMenu.close();
return state;
}
async moveItem(notebookTitle: string) {
await this.contextMenu.open(this.locator);
await this.contextMenu.clickOnItem("move");
await fillMoveTopicDialog(this.page, notebookTitle);
}
}

View File

@@ -57,6 +57,18 @@ export async function fillNotebookDialog(
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) {
const titleInput = page.locator(getTestId("title-input"));
await titleInput.waitFor({ state: "visible" });

View 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();
});

View File

@@ -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) {
switch (type) {
case "create_vault":

View File

@@ -29,6 +29,7 @@ const ImportDialog = React.lazy(() => import("./import-dialog"));
const LoadingDialog = React.lazy(() => import("./loading-dialog"));
const ProgressDialog = React.lazy(() => import("./progress-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 RecoveryKeyDialog = React.lazy(() => import("./recovery-key-dialog"));
const ItemDialog = React.lazy(() => import("./item-dialog"));
@@ -60,6 +61,7 @@ export const Dialogs = {
EmailVerificationDialog,
LoadingDialog,
MoveDialog,
MoveTopicsDialog,
PasswordDialog,
RecoveryKeyDialog,
ItemDialog,

View File

@@ -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/>.
*/
import {
ChangeEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState
} from "react";
import { useCallback, useMemo, useState } from "react";
import { 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";
import { ThemeUIStyleObject } from "@theme-ui/core";
import { showToast } from "../../utils/toast";
import { pluralize } from "../../utils/string";
import FilteredList, { Item } from "../filtered-list";
type MoveDialogProps = { onClose: Perform; noteIds: string[] };
type NotebookReference = { id: string; topic: string; type: "add" | "remove" };
type Item = {
id: string;
type: "topic" | "notebook" | "header";
title: string;
};
type Notebook = Item & { topics: Item[] };
function MoveDialog({ onClose, noteIds }: MoveDialogProps) {
@@ -197,109 +185,6 @@ function topicHasNotes(topic: Item, noteIds: string[]) {
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;
type TreeNodeProps<T extends Item> = {

View 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;

View 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;

View File

@@ -247,6 +247,7 @@ function GroupHeader(props) {
{index === 0 && (
<Flex mr={1}>
<IconButton
testId={"sort-icon-button"}
icon={
groupOptions.sortDirection === "asc"
? Icon.SortAsc
@@ -292,6 +293,7 @@ function IconButton(props) {
const isMobile = useMobile();
return (
<Button
data-test-id={props.testId}
variant="secondary"
bg="transparent"
title={title}

View File

@@ -26,6 +26,7 @@ import { Flex, Text } from "@theme-ui/components";
import * as Icon from "../icons";
import { Multiselect } from "../../common/multi-select";
import { pluralize } from "../../utils/string";
import { showMoveTopicsDialog } from "../../common/dialog-controller";
function Topic({ item, index, onClick }) {
const { id, notebookId } = item;
@@ -87,6 +88,15 @@ const menuItems = [
icon: Icon.Shortcut,
onClick: ({ topic }) => appStore.addToShortcuts(topic)
},
{
key: "move",
title: "Move Topic",
icon: Icon.Move,
onClick: ({ items, notebookId }) => {
showMoveTopicsDialog(items, notebookId);
},
multiSelect: true
},
{
key: "delete",
title: "Delete",

View File

@@ -155,4 +155,25 @@ export default class Notebooks extends Collection {
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);
}
}

View File

@@ -55,10 +55,11 @@ export default class Topic {
clear() {
const noteIds = this._db.notes.topicReferences.get(this.id);
if (!noteIds.length) return;
return this._db.notes.deleteFromNotebook(
this._notebookId,
this.id,
return this._db.notes.removeFromNotebook(
{
id: this._notebookId,
topic: this.id
},
...noteIds
);
}