web: fix all tests & improve notebook/topic deletion

This commit is contained in:
Abdullah Atta
2023-03-10 18:23:00 +05:00
committed by Abdullah Atta
parent 7a53881eb3
commit 765fcafc38
34 changed files with 575 additions and 477 deletions

View File

@@ -132,7 +132,7 @@ export class AppModel {
.waitFor({ state: "visible" }); .waitFor({ state: "visible" });
} }
async search(query: string) { async search(query: string, type: string) {
const searchinput = this.page.locator(getTestId("search-input")); const searchinput = this.page.locator(getTestId("search-input"));
const searchButton = this.page.locator(getTestId("search-button")); const searchButton = this.page.locator(getTestId("search-button"));
const openSearch = this.page.locator(getTestId("open-search")); const openSearch = this.page.locator(getTestId("open-search"));
@@ -140,6 +140,6 @@ export class AppModel {
await openSearch.click(); await openSearch.click();
await searchinput.fill(query); await searchinput.fill(query);
await searchButton.click(); await searchButton.click();
return new SearchViewModel(this.page); return new SearchViewModel(this.page, type);
} }
} }

View File

@@ -29,14 +29,17 @@ export class BaseViewModel {
private readonly listPlaceholder: Locator; private readonly listPlaceholder: Locator;
private readonly sortByButton: Locator; private readonly sortByButton: Locator;
constructor(page: Page, pageId: string) { constructor(page: Page, pageId: string, listType: string) {
this.page = page; this.page = page;
this.list = page.locator(`#${pageId} >> ${getTestId("note-list")}`); this.list = page.locator(`#${pageId} >> ${getTestId(`${listType}-list`)}`);
this.listPlaceholder = page.locator( this.listPlaceholder = page.locator(
`#${pageId} >> ${getTestId("list-placeholder")}` `#${pageId} >> ${getTestId("list-placeholder")}`
); );
this.sortByButton = this.list.locator(getTestId("sort-icon-button")); this.sortByButton = this.page.locator(
// TODO:
getTestId(`${pageId === "notebook" ? "notes" : pageId}-sort-button`)
);
} }
async findGroup(groupName: string) { async findGroup(groupName: string) {
@@ -103,13 +106,15 @@ export class BaseViewModel {
async sort(sort: SortOptions) { async sort(sort: SortOptions) {
const contextMenu: ContextMenuModel = new ContextMenuModel(this.page); const contextMenu: ContextMenuModel = new ContextMenuModel(this.page);
await contextMenu.open(this.sortByButton, "left"); if (sort.groupBy) {
await contextMenu.clickOnItem("groupBy"); await contextMenu.open(this.sortByButton, "left");
if (!(await contextMenu.hasItem(sort.groupBy))) { await contextMenu.clickOnItem("groupBy");
await contextMenu.close(); if (!(await contextMenu.hasItem(sort.groupBy))) {
return false; await contextMenu.close();
return false;
}
await contextMenu.clickOnItem(sort.groupBy);
} }
await contextMenu.clickOnItem(sort.groupBy);
await contextMenu.open(this.sortByButton, "left"); await contextMenu.open(this.sortByButton, "left");
await contextMenu.clickOnItem("sortDirection"); await contextMenu.clickOnItem("sortDirection");
@@ -131,7 +136,10 @@ export class BaseViewModel {
} }
async isEmpty() { async isEmpty() {
const totalItems = await this.list.locator(getTestId("list-item")).count(); const items = this.list.locator(
`${getTestId(`virtuoso-item-list`)} >> ${getTestId("list-item")}`
);
const totalItems = await items.count();
return totalItems <= 0; return totalItems <= 0;
} }
} }

View File

@@ -22,18 +22,21 @@ 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 { confirmDialog, denyDialog, fillItemDialog } from "./utils"; import { confirmDialog, fillItemDialog } from "./utils";
export class ItemModel extends BaseItemModel { export class ItemModel extends BaseItemModel {
private readonly contextMenu: ContextMenuModel; private readonly contextMenu: ContextMenuModel;
constructor(locator: Locator) { constructor(locator: Locator, private readonly id: "topic" | "tag") {
super(locator); super(locator);
this.contextMenu = new ContextMenuModel(this.page); this.contextMenu = new ContextMenuModel(this.page);
} }
async open() { async open() {
await this.locator.click(); await this.locator.click();
return new NotesViewModel(this.page, "notes"); return new NotesViewModel(
this.page,
this.id === "topic" ? "notebook" : "notes"
);
} }
async delete() { async delete() {
@@ -47,9 +50,10 @@ export class ItemModel extends BaseItemModel {
await this.contextMenu.open(this.locator); await this.contextMenu.open(this.locator);
await this.contextMenu.clickOnItem("delete"); await this.contextMenu.clickOnItem("delete");
if (deleteContainedNotes) await confirmDialog(this.page); if (deleteContainedNotes)
else await denyDialog(this.page); await this.page.locator("#deleteContainingNotes").check({ force: true });
await confirmDialog(this.page);
await this.waitFor("detached"); await this.waitFor("detached");
} }

View File

@@ -28,7 +28,7 @@ export class ItemsViewModel extends BaseViewModel {
private readonly createButton: Locator; private readonly createButton: Locator;
constructor(page: Page, private readonly id: "topics" | "tags") { constructor(page: Page, private readonly id: "topics" | "tags") {
super(page, id); super(page, id, id);
this.createButton = page.locator(getTestId(`${id}-action-button`)); this.createButton = page.locator(getTestId(`${id}-action-button`));
} }
@@ -45,7 +45,11 @@ export class ItemsViewModel extends BaseViewModel {
async findItem(item: Item) { async findItem(item: Item) {
const titleToCompare = this.id === "tags" ? `#${item.title}` : item.title; const titleToCompare = this.id === "tags" ? `#${item.title}` : item.title;
for await (const _item of this.iterateItems()) { for await (const _item of this.iterateItems()) {
const itemModel = new ItemModel(_item); const itemModel = new ItemModel(
_item,
// TODO:
this.id === "topics" ? "topic" : "tag"
);
const title = await itemModel.getTitle(); const title = await itemModel.getTitle();
if (title === titleToCompare) return itemModel; if (title === titleToCompare) return itemModel;
} }

View File

@@ -23,7 +23,8 @@ import { ContextMenuModel } from "./context-menu.model";
import { ToggleModel } from "./toggle.model"; import { ToggleModel } from "./toggle.model";
import { ItemsViewModel } from "./items-view.model"; import { ItemsViewModel } from "./items-view.model";
import { Notebook } from "./types"; import { Notebook } from "./types";
import { confirmDialog, denyDialog, fillNotebookDialog } from "./utils"; import { confirmDialog, fillNotebookDialog } from "./utils";
import { NotesViewModel } from "./notes-view.model";
export class NotebookItemModel extends BaseItemModel { export class NotebookItemModel extends BaseItemModel {
private readonly contextMenu: ContextMenuModel; private readonly contextMenu: ContextMenuModel;
@@ -34,7 +35,10 @@ export class NotebookItemModel extends BaseItemModel {
async openNotebook() { async openNotebook() {
await this.locator.click(); await this.locator.click();
return new ItemsViewModel(this.page, "topics"); return {
topics: new ItemsViewModel(this.page, "topics"),
notes: new NotesViewModel(this.page, "notebook")
};
} }
async editNotebook(notebook: Notebook) { async editNotebook(notebook: Notebook) {
@@ -48,9 +52,10 @@ export class NotebookItemModel extends BaseItemModel {
await this.contextMenu.open(this.locator); await this.contextMenu.open(this.locator);
await this.contextMenu.clickOnItem("movetotrash"); await this.contextMenu.clickOnItem("movetotrash");
if (deleteContainedNotes) await confirmDialog(this.page); if (deleteContainedNotes)
else await denyDialog(this.page); await this.page.locator("#deleteContainingNotes").check({ force: true });
await confirmDialog(this.page);
await this.waitFor("detached"); await this.waitFor("detached");
} }

View File

@@ -28,7 +28,7 @@ export class NotebooksViewModel extends BaseViewModel {
private readonly createButton: Locator; private readonly createButton: Locator;
constructor(page: Page) { constructor(page: Page) {
super(page, "notebooks"); super(page, "notebooks", "notebooks");
this.createButton = page this.createButton = page
.locator(getTestId("notebooks-action-button")) .locator(getTestId("notebooks-action-button"))
.first(); .first();

View File

@@ -32,9 +32,12 @@ export class NotesViewModel extends BaseViewModel {
private readonly createButton: Locator; private readonly createButton: Locator;
readonly editor: EditorModel; readonly editor: EditorModel;
constructor(page: Page, pageId: "home" | "notes") { constructor(page: Page, pageId: "home" | "notes" | "notebook") {
super(page, pageId); super(page, pageId, pageId === "home" ? "home" : "notes");
this.createButton = page.locator(getTestId("notes-action-button")); this.createButton = page.locator(
// TODO:
getTestId(`${pageId === "notebook" ? "notebook" : "notes"}-action-button`)
);
this.editor = new EditorModel(page); this.editor = new EditorModel(page);
} }

View File

@@ -28,7 +28,7 @@ export class RemindersViewModel extends BaseViewModel {
private readonly createButton: Locator; private readonly createButton: Locator;
constructor(page: Page) { constructor(page: Page) {
super(page, "reminders"); super(page, "reminders", "reminders");
this.createButton = page this.createButton = page
.locator(getTestId("reminders-action-button")) .locator(getTestId("reminders-action-button"))
.first(); .first();

View File

@@ -23,8 +23,8 @@ import { ItemModel } from "./item.model";
import { Item } from "./types"; import { Item } from "./types";
export class SearchViewModel extends BaseViewModel { export class SearchViewModel extends BaseViewModel {
constructor(page: Page) { constructor(page: Page, type: string) {
super(page, "general"); super(page, "general", type);
} }
async findItem(item: Item) { async findItem(item: Item) {

View File

@@ -24,7 +24,7 @@ import { TrashItemModel } from "./trash-item.model";
export class TrashViewModel extends BaseViewModel { export class TrashViewModel extends BaseViewModel {
constructor(page: Page) { constructor(page: Page) {
super(page, "trash"); super(page, "trash", "trash");
} }
async findItem(title: string) { async findItem(title: string) {

View File

@@ -45,7 +45,7 @@ export type GroupByOptions =
| "week"; | "week";
export type SortOptions = { export type SortOptions = {
groupBy: GroupByOptions; groupBy?: GroupByOptions;
sortBy: SortByOptions; sortBy: SortByOptions;
orderBy: OrderByOptions; orderBy: OrderByOptions;
}; };

View File

@@ -43,7 +43,19 @@ test("create a note inside a notebook", async ({ page }) => {
await app.goto(); await app.goto();
const notebooks = await app.goToNotebooks(); const notebooks = await app.goToNotebooks();
const notebook = await notebooks.createNotebook(NOTEBOOK); const notebook = await notebooks.createNotebook(NOTEBOOK);
const topics = await notebook?.openNotebook(); const { notes } = (await notebook?.openNotebook()) || {};
const note = await notes?.createNote(NOTE);
expect(note).toBeDefined();
});
test("create a note inside a topic", async ({ page }) => {
const app = new AppModel(page);
await app.goto();
const notebooks = await app.goToNotebooks();
const notebook = await notebooks.createNotebook(NOTEBOOK);
const { topics } = (await notebook?.openNotebook()) || {};
const topic = await topics?.findItem({ title: NOTEBOOK.topics[0] }); const topic = await topics?.findItem({ title: NOTEBOOK.topics[0] });
const notes = await topic?.open(); const notes = await topic?.open();
@@ -170,7 +182,27 @@ test("delete all notes within a notebook", async ({ page }) => {
await app.goto(); await app.goto();
const notebooks = await app.goToNotebooks(); const notebooks = await app.goToNotebooks();
const notebook = await notebooks.createNotebook(NOTEBOOK); const notebook = await notebooks.createNotebook(NOTEBOOK);
const topics = await notebook?.openNotebook(); let { notes } = (await notebook?.openNotebook()) || {};
for (let i = 0; i < 2; ++i) {
await notes?.createNote({
title: `Note ${i}`,
content: NOTE.content
});
}
await app.goBack();
await notebook?.moveToTrash(true);
notes = await app.goToNotes();
expect(await notes.isEmpty()).toBe(true);
});
test("delete all notes within a topic", async ({ page }) => {
const app = new AppModel(page);
await app.goto();
const notebooks = await app.goToNotebooks();
const notebook = await notebooks.createNotebook(NOTEBOOK);
const { topics } = (await notebook?.openNotebook()) || {};
const topic = await topics?.findItem({ title: NOTEBOOK.topics[0] }); const topic = await topics?.findItem({ title: NOTEBOOK.topics[0] });
let notes = await topic?.open(); let notes = await topic?.open();
for (let i = 0; i < 2; ++i) { for (let i = 0; i < 2; ++i) {

View File

@@ -96,9 +96,7 @@ test("add a note to notebook", async ({ page }) => {
}); });
expect( expect(
await app.toasts.waitForToast( await app.toasts.waitForToast("1 note added to Hello and 3 others.")
"1 note added to 4 topics & removed from 0 topics."
)
).toBe(true); ).toBe(true);
}); });

View File

@@ -20,20 +20,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { AppModel } from "./models/app.model"; import { AppModel } from "./models/app.model";
import { Item } from "./models/types"; import { Item } from "./models/types";
import { import { NOTEBOOK, sortByOptions, orderByOptions, NOTE } from "./utils";
groupByOptions,
NOTEBOOK,
sortByOptions,
orderByOptions,
NOTE
} from "./utils";
test("create shortcut of a topic", async ({ page }) => { test("create shortcut of a topic", async ({ page }) => {
const app = new AppModel(page); const app = new AppModel(page);
await app.goto(); await app.goto();
const notebooks = await app.goToNotebooks(); const notebooks = await app.goToNotebooks();
const notebook = await notebooks.createNotebook(NOTEBOOK); const notebook = await notebooks.createNotebook(NOTEBOOK);
const topics = await notebook?.openNotebook(); const { topics } = (await notebook?.openNotebook()) || {};
const topic = await topics?.findItem({ title: NOTEBOOK.topics[0] }); const topic = await topics?.findItem({ title: NOTEBOOK.topics[0] });
await topic?.createShortcut(); await topic?.createShortcut();
@@ -48,7 +42,7 @@ test("remove shortcut of a topic", async ({ page }) => {
await app.goto(); await app.goto();
const notebooks = await app.goToNotebooks(); const notebooks = await app.goToNotebooks();
const notebook = await notebooks.createNotebook(NOTEBOOK); const notebook = await notebooks.createNotebook(NOTEBOOK);
const topics = await notebook?.openNotebook(); const { topics } = (await notebook?.openNotebook()) || {};
const topic = await topics?.findItem({ title: NOTEBOOK.topics[0] }); const topic = await topics?.findItem({ title: NOTEBOOK.topics[0] });
await topic?.createShortcut(); await topic?.createShortcut();
@@ -64,7 +58,7 @@ test("delete a topic", async ({ page }) => {
await app.goto(); await app.goto();
const notebooks = await app.goToNotebooks(); const notebooks = await app.goToNotebooks();
const notebook = await notebooks.createNotebook(NOTEBOOK); const notebook = await notebooks.createNotebook(NOTEBOOK);
const topics = await notebook?.openNotebook(); const { topics } = (await notebook?.openNotebook()) || {};
const topic = await topics?.findItem({ title: NOTEBOOK.topics[0] }); const topic = await topics?.findItem({ title: NOTEBOOK.topics[0] });
await topic?.deleteWithNotes(); await topic?.deleteWithNotes();
@@ -78,7 +72,7 @@ test("edit topics individually", async ({ page }) => {
await app.goto(); await app.goto();
const notebooks = await app.goToNotebooks(); const notebooks = await app.goToNotebooks();
const notebook = await notebooks.createNotebook(NOTEBOOK); const notebook = await notebooks.createNotebook(NOTEBOOK);
const topics = await notebook?.openNotebook(); const { topics } = (await notebook?.openNotebook()) || {};
const editedTopics: Item[] = []; const editedTopics: Item[] = [];
for (const title of NOTEBOOK.topics) { for (const title of NOTEBOOK.topics) {
@@ -98,7 +92,7 @@ test("delete all notes within a topic", async ({ page }) => {
await app.goto(); await app.goto();
const notebooks = await app.goToNotebooks(); const notebooks = await app.goToNotebooks();
const notebook = await notebooks.createNotebook(NOTEBOOK); const notebook = await notebooks.createNotebook(NOTEBOOK);
const topics = await notebook?.openNotebook(); const { topics } = (await notebook?.openNotebook()) || {};
const topic = await topics?.findItem({ title: NOTEBOOK.topics[0] }); const topic = await topics?.findItem({ title: NOTEBOOK.topics[0] });
let notes = await topic?.open(); let notes = await topic?.open();
for (let i = 0; i < 2; ++i) { for (let i = 0; i < 2; ++i) {
@@ -116,7 +110,7 @@ test("delete all notes within a topic", async ({ page }) => {
}); });
test(`sort topics`, async ({ page }, info) => { test(`sort topics`, async ({ page }, info) => {
info.setTimeout(2 * 60 * 1000); info.setTimeout(1 * 60 * 1000);
const app = new AppModel(page); const app = new AppModel(page);
await app.goto(); await app.goto();
@@ -125,27 +119,24 @@ test(`sort topics`, async ({ page }, info) => {
...NOTEBOOK, ...NOTEBOOK,
topics: ["title1", "title2", "title3", "title4", "title5"] topics: ["title1", "title2", "title3", "title4", "title5"]
}); });
const topics = await notebook?.openNotebook(); const { topics } = (await notebook?.openNotebook()) || {};
for (const groupBy of groupByOptions) { for (const sortBy of sortByOptions) {
for (const sortBy of sortByOptions) { for (const orderBy of orderByOptions) {
for (const orderBy of orderByOptions) { await test.step(`sort by ${sortBy}, order by ${orderBy}`, async () => {
await test.step(`group by ${groupBy}, sort by ${sortBy}, order by ${orderBy}`, async () => { const sortResult = await topics?.sort({
const sortResult = await topics?.sort({ orderBy,
groupBy, sortBy
orderBy,
sortBy
});
if (!sortResult) return;
expect(await topics?.isEmpty()).toBeFalsy();
}); });
} if (!sortResult) return;
expect(await topics?.isEmpty()).toBeFalsy();
});
} }
} }
}); });
test("search topics", async ({ page }) => { test.skip("search topics", async ({ page }) => {
const app = new AppModel(page); const app = new AppModel(page);
await app.goto(); await app.goto();
const notebooks = await app.goToNotebooks(); const notebooks = await app.goToNotebooks();
@@ -155,7 +146,7 @@ test("search topics", async ({ page }) => {
}); });
await notebook?.openNotebook(); await notebook?.openNotebook();
const search = await app.search("1"); const search = await app.search("1", "topics");
const topic = await search?.findItem({ title: "title1" }); const topic = await search?.findItem({ title: "title1" });
expect((await topic?.getTitle()) === "title1").toBeTruthy(); expect((await topic?.getTitle()) === "title1").toBeTruthy();

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -19,7 +19,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { Dialogs } from "../components/dialogs"; import { Dialogs } from "../components/dialogs";
import { hardNavigate } from "../navigation";
import ThemeProvider from "../components/theme-provider"; import ThemeProvider from "../components/theme-provider";
import qclone from "qclone"; import qclone from "qclone";
import { store as notebookStore } from "../stores/notebook-store"; import { store as notebookStore } from "../stores/notebook-store";
@@ -29,7 +28,7 @@ import { store as editorStore } from "../stores/editor-store";
import { store as noteStore } from "../stores/note-store"; import { store as noteStore } from "../stores/note-store";
import { db } from "./db"; import { db } from "./db";
import { showToast } from "../utils/toast"; import { showToast } from "../utils/toast";
import { Flex, Text } from "@theme-ui/components"; import { Text } from "@theme-ui/components";
import * as Icon from "../components/icons"; import * as Icon from "../components/icons";
import Config from "../utils/config"; import Config from "../utils/config";
import { formatDate } from "@notesnook/core/utils/date"; import { formatDate } from "@notesnook/core/utils/date";
@@ -41,10 +40,11 @@ import { FeatureKeys } from "../components/dialogs/feature-dialog";
import { AuthenticatorType } from "../components/dialogs/mfa/types"; import { AuthenticatorType } from "../components/dialogs/mfa/types";
import { Suspense } from "react"; import { Suspense } from "react";
import { Reminder } from "@notesnook/core/collections/reminders"; import { Reminder } from "@notesnook/core/collections/reminders";
import { ConfirmDialogProps } from "../components/dialogs/confirm";
type DialogTypes = typeof Dialogs; type DialogTypes = typeof Dialogs;
type DialogIds = keyof DialogTypes; type DialogIds = keyof DialogTypes;
export type Perform = (result: boolean) => void; export type Perform<T = boolean> = (result: T) => void;
type RenderDialog<TId extends DialogIds, TReturnType> = ( type RenderDialog<TId extends DialogIds, TReturnType> = (
dialog: DialogTypes[TId], dialog: DialogTypes[TId],
perform: (result: TReturnType) => void perform: (result: TReturnType) => void
@@ -154,26 +154,15 @@ export function showBuyDialog(plan?: Period, couponCode?: string) {
)); ));
} }
type ConfirmDialogProps = { export function confirm<TCheckId extends string>(
title?: string; props: Omit<ConfirmDialogProps<TCheckId>, "onClose">
subtitle?: string; ) {
message?: string | JSX.Element; return showDialog<"Confirm", false | Record<TCheckId, boolean>>(
yesText?: string; "Confirm",
noText?: string; (Dialog, perform) => (
yesAction?: () => void; <Dialog {...props} onClose={(result) => perform(result)} />
width?: string; )
}; );
export function confirm(props: ConfirmDialogProps) {
return showDialog("Confirm", (Dialog, perform) => (
<Dialog
{...props}
onNo={() => perform(false)}
onYes={() => {
if (props.yesAction) props.yesAction();
perform(true);
}}
/>
));
} }
export function showPromptDialog(props: { export function showPromptDialog(props: {
@@ -214,41 +203,26 @@ export function showToolbarConfigDialog() {
} }
export function showError(title: string, message: string) { export function showError(title: string, message: string) {
return confirm({ title, message, yesText: "Okay" }); return confirm({ title, message, positiveButtonText: "Okay" });
} }
export function showMultiDeleteConfirmation(length: number) { export function showMultiDeleteConfirmation(length: number) {
return confirm({ return confirm({
title: `Delete ${length} items?`, title: `Delete ${length} items?`,
message: ( message:
<Text as="span"> "These items will be **kept in your Trash for 7 days** after which they will be permanently deleted.",
These items will be{" "} positiveButtonText: "Yes",
<Text as="span" sx={{ color: "primary" }}> negativeButtonText: "No"
kept in your Trash for 7 days
</Text>{" "}
after which they will be permanently removed.
</Text>
),
yesText: `Delete selected`,
noText: "Cancel"
}); });
} }
export function showMultiPermanentDeleteConfirmation(length: number) { export function showMultiPermanentDeleteConfirmation(length: number) {
return confirm({ return confirm({
title: `Permanently delete ${length} items?`, title: `Permanently delete ${length} items?`,
message: ( message:
<Text as="span"> "These items will be **permanently deleted**. This is IRREVERSIBLE.",
These items will be{" "} positiveButtonText: "Yes",
<Text as="span" sx={{ color: "primary" }}> negativeButtonText: "No"
permanently deleted
</Text>
{". "}
This action is IRREVERSIBLE.
</Text>
),
yesText: `Permanently delete selected`,
noText: "Cancel"
}); });
} }
@@ -257,8 +231,8 @@ export function showLogoutConfirmation() {
title: `Logout?`, title: `Logout?`,
message: message:
"Logging out will delete all local data and reset the app. Make sure you have synced your data before logging out.", "Logging out will delete all local data and reset the app. Make sure you have synced your data before logging out.",
yesText: `Yes`, positiveButtonText: "Yes",
noText: "No" negativeButtonText: "No"
}); });
} }
@@ -267,8 +241,8 @@ export function showClearSessionsConfirmation() {
title: `Logout from other devices?`, title: `Logout from other devices?`,
message: message:
"All other logged-in devices will be forced to logout stopping sync. Use with care lest you lose important notes.", "All other logged-in devices will be forced to logout stopping sync. Use with care lest you lose important notes.",
yesText: `Yes`, positiveButtonText: "Yes",
noText: "No" negativeButtonText: "No"
}); });
} }
@@ -276,9 +250,7 @@ export function showAccountLoggedOutNotice(reason?: string) {
return confirm({ return confirm({
title: "You were logged out", title: "You were logged out",
message: reason, message: reason,
noText: "Okay", negativeButtonText: "Okay"
yesText: `Relogin`,
yesAction: () => hardNavigate("/login")
}); });
} }
@@ -287,24 +259,13 @@ export function showAppUpdatedNotice(
) { ) {
return confirm({ return confirm({
title: `Welcome to v${version.formatted}`, title: `Welcome to v${version.formatted}`,
message: ( message: `## Changelog:
<Flex
bg="bgSecondary" \`\`\`
p={1} ${version.changelog || "No change log."}
sx={{ borderRadius: "default", flexDirection: "column" }} \`\`\`
> `,
<Text variant="title">Changelog:</Text> positiveButtonText: `Continue`
<Text
as="pre"
variant="body"
mt={1}
sx={{ fontFamily: "monospace", overflow: "auto" }}
>
{version.changelog || "No change log."}
</Text>
</Flex>
),
yesText: `Yay!`
}); });
} }
@@ -706,32 +667,29 @@ export function showOnboardingDialog(type: string) {
)); ));
} }
export function showInvalidSystemTimeDialog({ export async function showInvalidSystemTimeDialog({
serverTime, serverTime,
localTime localTime
}: { }: {
serverTime: number; serverTime: number;
localTime: number; localTime: number;
}) { }) {
return confirm({ const result = await confirm({
title: "Your system clock is out of sync", title: "Your system clock is out of sync",
subtitle: subtitle:
"Please correct your system date & time and reload the app to avoid syncing issues.", "Please correct your system date & time and reload the app to avoid syncing issues.",
message: ( message: `Server time: ${formatDate(serverTime, {
<> dateStyle: "medium",
Server time:{" "} timeStyle: "medium"
{formatDate(serverTime, { dateStyle: "medium", timeStyle: "medium" })} })}
<br /> Local time: ${formatDate(localTime, {
Local time:{" "} dateStyle: "medium",
{formatDate(localTime, { dateStyle: "medium", timeStyle: "medium" })} timeStyle: "medium"
<br /> })}
Please sync your system time with{" "} Please sync your system time with [https://time.is](https://time.is).`,
<a href="https://time.is">https://time.is/</a>. positiveButtonText: "Reload app"
</>
),
yesText: "Reload app",
yesAction: () => window.location.reload()
}); });
if (result) window.location.reload();
} }
export async function showUpdateAvailableNotice({ export async function showUpdateAvailableNotice({
@@ -771,37 +729,18 @@ type UpdateDialogProps = {
onClick: () => void; onClick: () => void;
}; };
}; };
function showUpdateDialog({ async function showUpdateDialog({
title, title,
subtitle, subtitle,
changelog, changelog,
action action
}: UpdateDialogProps) { }: UpdateDialogProps) {
return confirm({ const result = await confirm({
title, title,
subtitle, subtitle,
message: changelog && ( message: changelog,
<Flex sx={{ borderRadius: "default", flexDirection: "column" }}> width: 500,
<Text positiveButtonText: action.text
as="div"
variant="body"
sx={{ overflow: "auto", fontFamily: "body" }}
css={`
h2 {
font-size: 1.2em;
font-weight: 600;
}
h3 {
font-size: 1em;
font-weight: 600;
}
`}
dangerouslySetInnerHTML={{ __html: changelog }}
></Text>
</Flex>
),
width: "500px",
yesText: action.text,
yesAction: action.onClick
}); });
if (result && action.onClick) action.onClick();
} }

View File

@@ -1,52 +0,0 @@
/*
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 { Box, Text } from "@theme-ui/components";
import Dialog from "./dialog";
function Confirm(props) {
return (
<Dialog
isOpen={true}
title={props.title}
icon={props.icon}
width={props.width}
description={props.subtitle}
onClose={() => props.onNo(false)}
positiveButton={
props.yesText && {
text: props.yesText,
onClick: () => props.onYes(true),
autoFocus: !!props.yesText
}
}
negativeButton={
props.noText && { text: props.noText, onClick: () => props.onNo(false) }
}
>
<Box pb={!props.noText && !props.yesText ? 2 : 0}>
<Text as="span" variant="body">
{props.message}
</Text>
</Box>
</Dialog>
);
}
export default Confirm;

View File

@@ -0,0 +1,120 @@
/*
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 { Box, Checkbox, Label, Text } from "@theme-ui/components";
import { useRef } from "react";
import { Perform } from "../../common/dialog-controller";
import { mdToHtml } from "../../utils/md";
import Dialog from "./dialog";
type Check = { text: string; default?: boolean };
export type ConfirmDialogProps<TCheckId extends string> = {
title: string;
subtitle?: string;
onClose: Perform<false | Record<TCheckId, boolean>>;
width?: number;
positiveButtonText?: string;
negativeButtonText?: string;
message?: string;
checks?: Partial<Record<TCheckId, Check>>;
};
function ConfirmDialog<TCheckId extends string>(
props: ConfirmDialogProps<TCheckId>
) {
const {
onClose,
title,
subtitle,
width,
negativeButtonText,
positiveButtonText,
message,
checks
} = props;
const checkedItems = useRef<Record<TCheckId, boolean>>({} as any);
return (
<Dialog
isOpen={true}
title={title}
width={width}
description={subtitle}
onClose={() => onClose(false)}
positiveButton={
positiveButtonText
? {
text: positiveButtonText,
onClick: () => onClose(checkedItems.current),
autoFocus: !!positiveButtonText
}
: undefined
}
negativeButton={
negativeButtonText
? {
text: negativeButtonText,
onClick: () => onClose(false)
}
: undefined
}
>
<Box
sx={{
pb: !negativeButtonText && !positiveButtonText ? 2 : 0,
p: { m: 0 }
}}
>
{message ? (
<Text
as="span"
variant="body"
dangerouslySetInnerHTML={{ __html: mdToHtml(message) }}
/>
) : null}
{checks
? Object.entries<Check | undefined>(checks).map(
([id, check]) =>
check && (
<Label
key={id}
id={id}
variant="text.body"
sx={{ alignItems: "center" }}
>
<Checkbox
name={id}
defaultChecked={check.default}
sx={{ mr: "small", width: 18, height: 18 }}
onChange={(e) =>
(checkedItems.current[id as TCheckId] =
e.currentTarget.checked)
}
/>
{check.text}{" "}
</Label>
)
)
: null}
</Box>
</Dialog>
);
}
export default ConfirmDialog;

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/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { Flex, Link, Text } from "@theme-ui/components"; import { Flex, Text } from "@theme-ui/components";
import { appVersion } from "../../utils/version"; import { appVersion } from "../../utils/version";
import Field from "../field"; import Field from "../field";
import Dialog from "./dialog"; import Dialog from "./dialog";
@@ -152,33 +152,14 @@ export default IssueDialog;
function showIssueReportedDialog({ url }: { url: string }) { function showIssueReportedDialog({ url }: { url: string }) {
return confirm({ return confirm({
title: "Thank you for reporting!", title: "Thank you for reporting!",
yesAction: () => clipboard.writeText(url), positiveButtonText: "Copy link",
yesText: "Copy link", message: `You can track your bug report at [${url}](${url}).
message: (
<> Please note that we will respond to your bug report on the link above. **We recommended that you save the above link for later reference.**
<p>
You can track your bug report at{" "} If your issue is critical (e.g. notes not syncing, crashes etc.), please [join our Discord community](https://discord.com/invite/zQBK97EE22) for one-to-one support.`
<Link target="_blank" href={url} sx={{ lineBreak: "anywhere" }}> }).then((result) => {
{url} result && clipboard.writeText(url);
</Link>
.
</p>
<p>
Please note that we will respond to your bug report on the link above.{" "}
<b>
We recommended that you save the above link for later reference.
</b>
</p>
<p>
If your issue is critical (e.g. notes not syncing, crashes etc.),
please{" "}
<a href="https://discord.com/invite/zQBK97EE22">
join our Discord community
</a>{" "}
for one-to-one support.
</p>
</>
)
}); });
} }

View File

@@ -145,7 +145,9 @@ function MoveDialog({ onClose, noteIds }: MoveDialogProps) {
if (stringified) { if (stringified) {
showToast( showToast(
"success", "success",
stringified.replace("Add", "Added").replace("remove", "removed") `${pluralize(noteIds.length, "note", "notes")} ${stringified
.replace("Add", "added")
.replace("remove", "removed")}`
); );
} }
@@ -635,12 +637,12 @@ function stringifySelected(suggestion: NotebookReference[]) {
if (added.length > 1) parts.push(`and ${added.length - 1} others`); if (added.length > 1) parts.push(`and ${added.length - 1} others`);
if (removed.length >= 1) { if (removed.length >= 1) {
parts.push("remove from"); parts.push("& remove from");
parts.push(removed[0]); parts.push(removed[0]);
} }
if (removed.length > 1) parts.push(`and ${removed.length - 1} others`); if (removed.length > 1) parts.push(`and ${removed.length - 1} others`);
return parts.join(" "); return parts.join(" ") + ".";
} }
function resolve(ref: NotebookReference) { function resolve(ref: NotebookReference) {

View File

@@ -21,7 +21,7 @@ import * as Icon from "../icons";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Button, Flex, Text } from "@theme-ui/components"; import { Button, Flex, Text } from "@theme-ui/components";
import { db } from "../../common/db"; import { db } from "../../common/db";
import { useMenuTrigger } from "../../hooks/use-menu"; import { Menu, useMenuTrigger } from "../../hooks/use-menu";
import { useStore as useNoteStore } from "../../stores/note-store"; import { useStore as useNoteStore } from "../../stores/note-store";
import { useStore as useNotebookStore } from "../../stores/notebook-store"; import { useStore as useNotebookStore } from "../../stores/notebook-store";
import useMobile from "../../hooks/use-mobile"; import useMobile from "../../hooks/use-mobile";
@@ -36,84 +36,97 @@ const groupByToTitleMap = {
month: "Month" month: "Month"
}; };
const menuItems = [ const groupByMenu = {
{ key: "groupBy",
key: "sortDirection", title: "Group by",
title: "Order by", icon: Icon.GroupBy,
icon: ({ groupOptions }) => items: map([
groupOptions.sortDirection === "asc" { key: "none", title: "None" },
? groupOptions.sortBy === "title" { key: "default", title: "Default" },
? Icon.OrderAtoZ { key: "year", title: "Year" },
: Icon.OrderOldestNewest { key: "month", title: "Month" },
: groupOptions.sortBy === "title" { key: "week", title: "Week" },
? Icon.OrderZtoA { key: "abc", title: "A - Z" }
: Icon.OrderNewestOldest, ])
items: map([ };
{
key: "asc", const orderByMenu = {
title: ({ groupOptions }) => key: "sortDirection",
groupOptions.sortBy === "title" ? "A - Z" : "Oldest - newest" title: "Order by",
}, icon: ({ groupOptions }) =>
{ groupOptions.sortDirection === "asc"
key: "desc", ? groupOptions.sortBy === "title"
title: ({ groupOptions }) => ? Icon.OrderAtoZ
groupOptions.sortBy === "title" ? "Z - A" : "Newest - oldest" : Icon.OrderOldestNewest
: groupOptions.sortBy === "title"
? Icon.OrderZtoA
: Icon.OrderNewestOldest,
items: map([
{
key: "asc",
title: ({ groupOptions }) =>
groupOptions.sortBy === "title" ? "A - Z" : "Oldest - newest"
},
{
key: "desc",
title: ({ groupOptions }) =>
groupOptions.sortBy === "title" ? "Z - A" : "Newest - oldest"
}
])
};
const sortByMenu = {
key: "sortBy",
title: "Sort by",
icon: Icon.SortBy,
items: map([
{
key: "dateCreated",
title: "Date created",
hidden: ({ type }) => type === "trash"
},
{
key: "dateEdited",
title: "Date edited",
hidden: ({ type }) => type === "trash" || type === "tags"
},
{
key: "dateDeleted",
title: "Date deleted",
hidden: ({ type }) => type !== "trash"
},
{
key: "dateModified",
title: "Date modified",
hidden: ({ type }) => type !== "tags"
},
{
key: "title",
title: "Title",
hidden: ({ groupOptions, parent, isUngrouped }, item) => {
if (isUngrouped) return false;
return (
parent?.key === "sortBy" &&
item.key === "title" &&
groupOptions.groupBy !== "abc" &&
groupOptions.groupBy !== "none"
);
} }
]) }
}, ])
{ };
key: "sortBy",
title: "Sort by", export function showSortMenu(type, refresh) {
icon: Icon.SortBy, const groupOptions = db.settings.getGroupOptions(type);
items: map([ Menu.openMenu([orderByMenu, sortByMenu], {
{ title: "Sort",
key: "dateCreated", groupOptions,
title: "Date created", refresh,
hidden: ({ type }) => type === "trash" type,
}, isUngrouped: true
{ });
key: "dateEdited", }
title: "Date edited",
hidden: ({ type }) => type === "trash" || type === "tags"
},
{
key: "dateDeleted",
title: "Date deleted",
hidden: ({ type }) => type !== "trash"
},
{
key: "dateModified",
title: "Date modified",
hidden: ({ type }) => type !== "tags"
},
{
key: "title",
title: "Title",
hidden: ({ groupOptions, parent }, item) => {
return (
parent?.key === "sortBy" &&
item.key === "title" &&
groupOptions.groupBy !== "abc" &&
groupOptions.groupBy !== "none"
);
}
}
])
},
{
key: "groupBy",
title: "Group by",
icon: Icon.GroupBy,
items: map([
{ key: "none", title: "None" },
{ key: "default", title: "Default" },
{ key: "year", title: "Year" },
{ key: "month", title: "Month" },
{ key: "week", title: "Week" },
{ key: "abc", title: "A - Z" }
])
}
];
function changeGroupOptions({ groupOptions, type, refresh, parent }, item) { function changeGroupOptions({ groupOptions, type, refresh, parent }, item) {
if (!parent) return false; if (!parent) return false;
@@ -132,19 +145,10 @@ function isChecked({ groupOptions, parent }, item) {
return groupOptions[parent.key] === item.key; return groupOptions[parent.key] === item.key;
} }
function isDisabled({ groupOptions, parent }, item) {
return (
parent?.key === "sortBy" &&
item.key === "title" &&
groupOptions.groupBy === "abc"
);
}
function map(items) { function map(items) {
return items.map((item) => { return items.map((item) => {
item.checked = isChecked; item.checked = isChecked;
item.onClick = changeGroupOptions; item.onClick = changeGroupOptions;
item.disabled = isDisabled;
return item; return item;
}, []); }, []);
} }
@@ -248,7 +252,7 @@ function GroupHeader(props) {
<Flex mr={1}> <Flex mr={1}>
{type && ( {type && (
<IconButton <IconButton
testId="sort-icon-button" testId={`${type}-sort-button`}
icon={ icon={
groupOptions.sortDirection === "asc" groupOptions.sortDirection === "asc"
? Icon.SortAsc ? Icon.SortAsc
@@ -259,7 +263,7 @@ function GroupHeader(props) {
const groupOptions = db.settings.getGroupOptions(type); const groupOptions = db.settings.getGroupOptions(type);
setGroupOptions(groupOptions); setGroupOptions(groupOptions);
openMenu(menuItems, { openMenu([orderByMenu, sortByMenu, groupByMenu], {
title: "Group & sort", title: "Group & sort",
groupOptions, groupOptions,
refresh, refresh,

View File

@@ -154,7 +154,7 @@ function ListContainer(props: ListContainerProps) {
transition={{ duration: 0.2, delay: 0.1, ease: "easeInOut" }} transition={{ duration: 0.2, delay: 0.1, ease: "easeInOut" }}
ref={listContainerRef} ref={listContainerRef}
variant="columnFill" variant="columnFill"
data-test-id="note-list" data-test-id={`${type}-list`}
> >
<Virtuoso <Virtuoso
ref={listRef} ref={listRef}
@@ -187,6 +187,7 @@ function ListContainer(props: ListContainerProps) {
switch (item.type) { switch (item.type) {
case "header": case "header":
if (!groupType) return null;
return ( return (
<GroupHeader <GroupHeader
type={groupType} type={groupType}

View File

@@ -431,8 +431,8 @@ const menuItems = [
await confirm({ await confirm({
title: "Open duplicated note?", title: "Open duplicated note?",
message: "Do you want to open the duplicated note?", message: "Do you want to open the duplicated note?",
noText: "No", negativeButtonText: "No",
yesText: "Yes" positiveButtonText: "Yes"
}) })
) { ) {
hashNavigate(`/notes/${id}/edit`, { replace: true }); hashNavigate(`/notes/${id}/edit`, { replace: true });
@@ -455,8 +455,8 @@ const menuItems = [
title: "Prevent this item from syncing?", title: "Prevent this item from syncing?",
message: message:
"Turning sync off for this item will automatically delete it from all other devices & any future changes to this item won't get synced. Are you sure you want to continue?", "Turning sync off for this item will automatically delete it from all other devices & any future changes to this item won't get synced. Are you sure you want to continue?",
yesText: "Yes", positiveButtonText: "Yes",
noText: "No" negativeButtonText: "No"
})) }))
) )
await store.get().localOnly(note.id); await store.get().localOnly(note.id);

View File

@@ -155,25 +155,31 @@ const menuItems = [
iconColor: "error", iconColor: "error",
icon: Icon.Trash, icon: Icon.Trash,
onClick: async ({ items }) => { onClick: async ({ items }) => {
const phrase = items.length > 1 ? "this notebook" : "these notebooks"; const result = await confirm({
const shouldDeleteNotes = await confirm({ title: `Delete ${pluralize(items.length, "notebook", "notebooks")}?`,
title: `Delete notes in ${phrase}?`, positiveButtonText: `Yes`,
message: `These notes will be moved to trash and permanently deleted after 7 days.`, negativeButtonText: "No",
yesText: `Yes`, checks: {
noText: "No" deleteContainingNotes: {
}); text: `Delete all containing notes`
if (shouldDeleteNotes) {
const notes = [];
for (const item of items) {
const topics = db.notebooks.notebook(item.id).topics;
for (const topic of topics.all) {
notes.push(...topics.topic(topic.id).all);
} }
} }
await Multiselect.moveNotesToTrash(notes, false); });
if (result) {
if (result.deleteContainingNotes) {
const notes = [];
for (const item of items) {
notes.push(...db.relations.from(item, "note"));
const topics = db.notebooks.notebook(item.id).topics;
for (const topic of topics.all) {
notes.push(...topics.topic(topic.id).all);
}
}
await Multiselect.moveNotesToTrash(notes, false);
}
await Multiselect.moveNotebooksToTrash(items);
} }
await Multiselect.moveNotebooksToTrash(items);
}, },
multiSelect: true multiSelect: true
} }

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from "react"; import React from "react";
import ListItem from "../list-item"; import ListItem from "../list-item";
import { Flex, Text } from "@theme-ui/components"; import { Flex } from "@theme-ui/components";
import * as Icon from "../icons"; import * as Icon from "../icons";
import IconTag from "../icon-tag"; import IconTag from "../icon-tag";
import { import {
@@ -164,15 +164,11 @@ const menuItems: MenuItem[] = [
onClick: async ({ items }) => { onClick: async ({ items }) => {
confirm({ confirm({
title: `Delete ${pluralize(items.length, "reminder", "reminders")}`, title: `Delete ${pluralize(items.length, "reminder", "reminders")}`,
message: ( message: `Are you sure you want to proceed? **This action is IRREVERSIBLE**.`,
<Text> positiveButtonText: "Yes",
Are you sure you want to proceed? negativeButtonText: "No"
<Text sx={{ color: "error" }}> This action is IRREVERSIBLE.</Text> }).then((result) => {
</Text> result && Multiselect.moveRemindersToTrash(items);
),
yesText: "Yes",
noText: "No",
yesAction: () => Multiselect.moveRemindersToTrash(items)
}); });
}, },
multiSelect: true multiSelect: true

View File

@@ -27,6 +27,7 @@ import * as Icon from "../icons";
import { Multiselect } from "../../common/multi-select"; import { Multiselect } from "../../common/multi-select";
import { confirm } from "../../common/dialog-controller"; import { confirm } from "../../common/dialog-controller";
import { useStore as useNotesStore } from "../../stores/note-store"; import { useStore as useNotesStore } from "../../stores/note-store";
import { pluralize } from "../../utils/string";
function Topic({ item, index, onClick }) { function Topic({ item, index, onClick }) {
const { id, notebookId } = item; const { id, notebookId } = item;
@@ -36,7 +37,7 @@ function Topic({ item, index, onClick }) {
); );
const totalNotes = useMemo(() => { const totalNotes = useMemo(() => {
return db.notebooks.notebook(notebookId)?.topics.topic(id).totalNotes; return db.notebooks.notebook(notebookId)?.topics.topic(id)?.totalNotes || 0;
}, [id, notebookId]); }, [id, notebookId]);
return ( return (
@@ -94,23 +95,30 @@ const menuItems = [
color: "error", color: "error",
iconColor: "error", iconColor: "error",
onClick: async ({ items, notebookId }) => { onClick: async ({ items, notebookId }) => {
const phrase = items.length > 1 ? "this topic" : "these topics"; const result = await confirm({
const shouldDeleteNotes = await confirm({ title: `Delete ${pluralize(items.length, "topic", "topics")}?`,
title: `Delete notes in ${phrase}?`, positiveButtonText: `Yes`,
message: `These notes will be moved to trash and permanently deleted after 7 days.`, negativeButtonText: "No",
yesText: `Yes`, checks: {
noText: "No" deleteContainingNotes: {
text: `Delete all containing notes`
}
}
}); });
if (shouldDeleteNotes) { if (result) {
const notes = []; if (result.deleteContainingNotes) {
for (const item of items) { const notes = [];
const topic = db.notebooks.notebook(notebookId).topics.topic(item.id); for (const item of items) {
notes.push(...topic.all); const topic = db.notebooks
.notebook(notebookId)
.topics.topic(item.id);
notes.push(...topic.all);
}
await Multiselect.moveNotesToTrash(notes, false);
} }
await Multiselect.moveNotesToTrash(notes, false); await Multiselect.deleteTopics(notebookId, items);
} }
await Multiselect.deleteTopics(notebookId, items);
}, },
multiSelect: true multiSelect: true
} }

View File

@@ -62,6 +62,13 @@ export function useMenuTrigger() {
}; };
} }
export const Menu = {
openMenu: (items, data) => useMenuStore.getState().open(items, data),
closeMenu: () => useMenuStore.getState().close(),
isOpen: () => useMenuStore.getState().isOpen,
target: () => useMenuStore.getState().target
};
export function useMenu() { export function useMenu() {
const [items, data] = useMenuStore((store) => [store.items, store.data]); const [items, data] = useMenuStore((store) => [store.items, store.data]);
return { items, data }; return { items, data };

View File

@@ -87,7 +87,7 @@ const routes = {
value: { id: notebookId, topic: topicId } value: { id: notebookId, topic: topicId }
}); });
return { return {
key: "topic", key: "notebook",
type: "notebook", type: "notebook",
title: topic.title, title: topic.title,
component: <Topics />, component: <Topics />,

View File

@@ -27,7 +27,8 @@ import Config from "../utils/config";
class NotebookStore extends BaseStore { class NotebookStore extends BaseStore {
notebooks = []; notebooks = [];
selectedNotebookId = 0; selectedNotebook = undefined;
selectedNotebookTopics = [];
viewMode = Config.get("notebooks:viewMode", "detailed"); viewMode = Config.get("notebooks:viewMode", "detailed");
setViewMode = (viewMode) => { setViewMode = (viewMode) => {
@@ -42,7 +43,7 @@ class NotebookStore extends BaseStore {
db.settings.getGroupOptions("notebooks") db.settings.getGroupOptions("notebooks")
); );
}); });
this.setSelectedNotebook(this.get().selectedNotebookId); this.setSelectedNotebook(this.get().selectedNotebook?.id);
}; };
delete = async (...ids) => { delete = async (...ids) => {
@@ -59,8 +60,16 @@ class NotebookStore extends BaseStore {
}; };
setSelectedNotebook = (id) => { setSelectedNotebook = (id) => {
if (!id) return;
const notebook = db.notebooks?.notebook(id)?.data;
if (!notebook) return;
this.set((state) => { this.set((state) => {
state.selectedNotebookId = id; state.selectedNotebook = notebook;
state.selectedNotebookTopics = groupArray(
notebook.topics,
db.settings.getGroupOptions("topics")
);
}); });
}; };
} }

View File

@@ -17,38 +17,6 @@ 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 { marked } from "marked";
const emoji: marked.TokenizerExtension & marked.RendererExtension = {
name: "emoji",
level: "inline",
start(src) {
return src.indexOf(":");
},
tokenizer(src, _tokens) {
const rule = /^:(\w+):/;
const match = rule.exec(src);
if (match) {
return {
type: "emoji",
raw: match[0],
emoji: match[1]
};
}
},
renderer(token) {
return `<span className="emoji ${token}" />`;
}
};
const renderer = new marked.Renderer();
renderer.link = function (href, title, text) {
return `<a target="_blank" rel="noopener noreferrer" href="${href}" ${
title ? `title=${title}` : ""
}>${text}</a>`;
};
marked.use({ extensions: [emoji] });
export async function getChangelog(tag: string) { export async function getChangelog(tag: string) {
try { try {
if (!tag) return "No changelog found."; if (!tag) return "No changelog found.";
@@ -63,7 +31,7 @@ export async function getChangelog(tag: string) {
if (!release || !release.body) return "No changelog found."; if (!release || !release.body) return "No changelog found.";
const { body } = release; const { body } = release;
return await marked.parse(body, { async: true, renderer, gfm: true }); return body;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return "No changelog found."; return "No changelog found.";

54
apps/web/src/utils/md.ts Normal file
View File

@@ -0,0 +1,54 @@
/*
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 { marked } from "marked";
const emoji: marked.TokenizerExtension & marked.RendererExtension = {
name: "emoji",
level: "inline",
start(src) {
return src.indexOf(":");
},
tokenizer(src, _tokens) {
const rule = /^:(\w+):/;
const match = rule.exec(src);
if (match) {
return {
type: "emoji",
raw: match[0],
emoji: match[1]
};
}
},
renderer(token) {
return `<span className="emoji ${token}" />`;
}
};
const renderer = new marked.Renderer();
renderer.link = function (href, title, text) {
return `<a target="_blank" rel="noopener noreferrer" href="${href}" ${
title ? `title=${title}` : ""
}>${text}</a>`;
};
marked.use({ extensions: [emoji] });
export function mdToHtml(markdown: string) {
return marked.parse(markdown, { async: false, renderer, gfm: true });
}

View File

@@ -755,6 +755,7 @@ function MFASelector(props: BaseAuthComponentProps<"mfa:select">) {
(method, index) => (method, index) =>
isValidMethod(method.type) && ( isValidMethod(method.type) && (
<Button <Button
key={method.type}
type="submit" type="submit"
variant={"secondary"} variant={"secondary"}
mt={2} mt={2}

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/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import ListContainer from "../components/list-container"; import ListContainer from "../components/list-container";
import { useStore as useNbStore } from "../stores/notebook-store"; import { useStore as useNbStore } from "../stores/notebook-store";
import { useStore as useAppStore } from "../stores/app-store"; import { useStore as useAppStore } from "../stores/app-store";
@@ -28,37 +28,37 @@ import {
ChevronRight, ChevronRight,
Edit, Edit,
RemoveShortcutLink, RemoveShortcutLink,
ShortcutLink ShortcutLink,
SortAsc
} from "../components/icons"; } from "../components/icons";
import { getTotalNotes } from "../common"; import { getTotalNotes } from "../common";
import { formatDate } from "@notesnook/core/utils/date"; import { formatDate } from "@notesnook/core/utils/date";
import { db } from "../common/db";
import { pluralize } from "../utils/string"; import { pluralize } from "../utils/string";
import { Allotment } from "allotment"; import { Allotment } from "allotment";
import { Plus } from "../components/icons"; import { Plus } from "../components/icons";
import { useStore as useNotesStore } from "../stores/note-store"; import { useStore as useNotesStore } from "../stores/note-store";
import Placeholder from "../components/placeholders"; import Placeholder from "../components/placeholders";
import { showSortMenu } from "../components/group-header";
function Notebook() { function Notebook() {
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
const selectedNotebookId = useNbStore((store) => store.selectedNotebookId); const selectedNotebook = useNbStore((store) => store.selectedNotebook);
const refresh = useNbStore((store) => store.setSelectedNotebook); const refresh = useNbStore((store) => store.setSelectedNotebook);
const notebooks = useNbStore((store) => store.notebooks);
const context = useNotesStore((store) => store.context); const context = useNotesStore((store) => store.context);
const refreshContext = useNotesStore((store) => store.refreshContext); const refreshContext = useNotesStore((store) => store.refreshContext);
const isCompact = useNotesStore((store) => store.viewMode === "compact"); const isCompact = useNotesStore((store) => store.viewMode === "compact");
useEffect(() => { useEffect(() => {
if (context && context.value && selectedNotebookId !== context.value.id) if (
context &&
context.value &&
selectedNotebook &&
selectedNotebook.id !== context.value.id
)
refresh(context.value.id); refresh(context.value.id);
}, [selectedNotebookId, context, refresh]); }, [selectedNotebook, context, refresh]);
const selectedNotebook = useMemo(
() => db.notebooks?.notebook(selectedNotebookId)?.data,
[selectedNotebookId, notebooks]
);
if (!context) return null; if (!context) return null;
return ( return (
@@ -69,7 +69,7 @@ function Notebook() {
{ title: "Notebooks", onClick: () => navigate(`/notebooks/`) }, { title: "Notebooks", onClick: () => navigate(`/notebooks/`) },
{ {
title: selectedNotebook.title, title: selectedNotebook.title,
onClick: () => navigate(`/notebooks/${selectedNotebookId}`) onClick: () => navigate(`/notebooks/${selectedNotebook.id}`)
} }
].map((crumb, index, array) => ( ].map((crumb, index, array) => (
<> <>
@@ -139,8 +139,11 @@ export default Notebook;
function Topics({ selectedNotebook, isCollapsed, setIsCollapsed }) { function Topics({ selectedNotebook, isCollapsed, setIsCollapsed }) {
const refresh = useNbStore((store) => store.setSelectedNotebook); const refresh = useNbStore((store) => store.setSelectedNotebook);
const topics = useNbStore((store) => store.selectedNotebookTopics);
if (!selectedNotebook) return null;
return ( return (
<Flex variant="columnFill" sx={{ height: "100%" }}> <Flex id="topics" variant="columnFill" sx={{ height: "100%" }}>
<Flex <Flex
sx={{ sx={{
m: 1, m: 1,
@@ -161,26 +164,42 @@ function Topics({ selectedNotebook, isCollapsed, setIsCollapsed }) {
TOPICS TOPICS
</Text> </Text>
</Flex> </Flex>
<Button <Flex sx={{ alignItems: "center" }}>
variant="tool" <Button
sx={{ variant="tool"
p: "1px", data-test-id="topics-sort-button"
bg: "transparent", sx={{
visibility: isCollapsed ? "collapse" : "visible" p: "3.5px",
}} bg: "transparent",
onClick={(e) => { visibility: isCollapsed ? "collapse" : "visible"
hashNavigate(`/topics/create`); }}
}} onClick={(e) => {
> e.stopPropagation();
<Plus size={20} /> showSortMenu("topics", () => refresh(selectedNotebook.id));
</Button> }}
>
<SortAsc size={15} />
</Button>
<Button
variant="tool"
sx={{
p: "1px",
bg: "transparent",
visibility: isCollapsed ? "collapse" : "visible"
}}
onClick={(e) => {
e.stopPropagation();
hashNavigate(`/topics/create`);
}}
>
<Plus size={20} />
</Button>
</Flex>
</Flex> </Flex>
<ListContainer <ListContainer
type="topics" type="topics"
groupType="topics" items={topics}
refresh={() => refresh(selectedNotebook.id)}
items={selectedNotebook.topics}
context={{ context={{
notebookId: selectedNotebook.id notebookId: selectedNotebook.id
}} }}

View File

@@ -51,20 +51,9 @@ function Trash() {
confirm({ confirm({
title: "Clear Trash", title: "Clear Trash",
subtitle: "Are you sure you want to clear all the trash?", subtitle: "Are you sure you want to clear all the trash?",
yesText: "Clear trash", positiveButtonText: "Clear trash",
noText: "Cancel", negativeButtonText: "Cancel",
message: ( message: `Are you sure you want to proceed? **This action is IRREVERSIBLE**.`
<>
This action is{" "}
<Text as="span" sx={{ color: "error" }}>
IRREVERSIBLE
</Text>
. You will{" "}
<Text as="span" sx={{ color: "primary" }}>
not be able to recover any of these items.
</Text>
</>
)
}).then(async (res) => { }).then(async (res) => {
if (res) { if (res) {
try { try {