diff --git a/jest.config.js b/jest.config.js index 24170926..f34d472d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,8 @@ module.exports = { preset: "ts-jest", testEnvironment: "jsdom", + testMatch: [ + "**/__tests__/**/*.test.ts?(x)" + ], moduleDirectories: ["node_modules", "src"] }; diff --git a/src/background/init/migrations/__tests__/core-default-values.test.ts b/src/background/init/migrations/__tests__/core-default-values.test.ts index 6274cf54..a05d6048 100644 --- a/src/background/init/migrations/__tests__/core-default-values.test.ts +++ b/src/background/init/migrations/__tests__/core-default-values.test.ts @@ -1,40 +1,32 @@ -import { Storage, NotesObject, Note } from "shared/storage/schema"; -import migrate, { expectedKeys } from "../core"; +import { Storage, NotesObject, Note, NotesOrder } from "shared/storage/schema"; +import migrate from "../core"; +import { expectItems } from "./helpers"; -export const expectItems = (items: Storage): void => { - expect(Object.keys(items).sort()).toEqual(expectedKeys.sort()); -}; - -const expectDefaultValues = (myItems?: Storage) => { - const items = (myItems || migrate({}, {})) as Storage; +const expectDefaultValues = (items: Storage) => { expectItems(items); - // font - expect(items.font).toEqual({ + let counter = 0; + const countedExpect = () => { + counter += 1; + return expect; + }; + + // Appearance + countedExpect()(items.font).toEqual({ id: "helvetica", name: "Helvetica", genericFamily: "sans-serif", fontFamily: "Helvetica, sans-serif", }); + countedExpect()(items.size).toBe(200); + countedExpect()(items.theme).toBe("light"); + countedExpect()(items.customTheme).toBe(""); + countedExpect()(items.sidebar).toBe(true); + countedExpect()(items.toolbar).toBe(true); - // size - expect(items.size).toBe(200); - - // sidebar - expect(items.sidebar).toBe(true); - - // toolbar - expect(items.toolbar).toBe(true); - - // theme - expect(items.theme).toBe("light"); - - // custom theme - expect(items.customTheme).toBe(""); - - // notes + // Notes const notes = items.notes as NotesObject; - expect(Object.keys(notes).length).toBe(3); // "One", "Two", "Three" + countedExpect()(Object.keys(notes).length).toBe(3); // "One", "Two", "Three" ["One", "Two", "Three"].every((noteName: string) => { const note = (notes)[noteName] as Note; @@ -45,39 +37,47 @@ const expectDefaultValues = (myItems?: Storage) => { expect(new Date(note.createdTime).getTime()).toEqual(new Date(note.modifiedTime).getTime()); // valid and equal }); - - // active - expect(items.active).toBe("One"); - - // focus - expect(items.focus).toBe(false); - - // tab - expect(items.tab).toBe(false); - - // tab size - expect(items.tabSize).toBe(-1); - - // openNoteOnMouseHover - expect(items.openNoteOnMouseHover).toBe(false); + countedExpect()(items.order).toEqual([]); + countedExpect()(items.active).toBe("One"); + countedExpect()(items.setBy).toBe(""); + countedExpect()(items.lastEdit).toBe(""); + + // Options + countedExpect()(items.notesOrder).toBe(NotesOrder.Alphabetical); + countedExpect()(items.focus).toBe(false); + countedExpect()(items.tab).toBe(false); + countedExpect()(items.tabSize).toBe(-1); + countedExpect()(items.autoSync).toBe(false); + countedExpect()(items.openNoteOnMouseHover).toBe(false); + + // Expect all keys to be tested + expect(counter).toBe(Object.keys(items).length); }; -it("returns default values", () => { - expectDefaultValues(); +it("migrates to default values for an empty storage", () => { + expectDefaultValues(migrate({}, {})); }); -it("fallbacks to default values", () => { +it("fallbacks to default values for any bad values", () => { const items = migrate({}, { + // Appearance font: { // must be a valid "font" object name: "Droid Sans", }, size: "large", // must be number theme: "green", // must be "light" or "dark" + customTheme: { background: "#ffffff" }, // must be string sidebar: "yes", // must be boolean toolbar: "no", // must be boolean - customTheme: { background: "#ffffff" }, // must be string + + // Notes notes: null, // must be object + order: "AZ", // must be [] active: "Todo", // must be in "notes" + setBy: 1, // must be string + lastEdit: 2, // must be string + + // Options focus: 1, // must be boolean tab: 1, // must be boolean tabSize: "Tab", // must be number diff --git a/src/background/init/migrations/__tests__/core-custom-values.test.ts b/src/background/init/migrations/__tests__/core-existing-values.test.ts similarity index 74% rename from src/background/init/migrations/__tests__/core-custom-values.test.ts rename to src/background/init/migrations/__tests__/core-existing-values.test.ts index 1744b9ab..d16a4ebd 100644 --- a/src/background/init/migrations/__tests__/core-custom-values.test.ts +++ b/src/background/init/migrations/__tests__/core-existing-values.test.ts @@ -1,9 +1,9 @@ -import { NotesOrder, Storage } from "shared/storage/schema"; +import { Storage, NotesOrder } from "shared/storage/schema"; import migrate from "../core"; -import { expectItems } from "./core-default-values.test"; +import { expectItems } from "./helpers"; -it("sets custom values", () => { - const local = { +it("uses existing values and adds any omitted properties set to their defaults to make a complete Storage", () => { + const existing: Partial = { font: { id: "roboto-mono", name: "Roboto Mono", @@ -52,12 +52,15 @@ it("sets custom values", () => { tabSize: 2, }; - const items: Storage = migrate({}, local); - expectItems(Object.assign({}, items)); + const items: Storage = migrate({}, existing); + expectItems(items); - // Compare objects expect(items).toEqual(Object.assign({ - // Automatically added properties to make a complete object (interface Storage) + /** + * Omitted properties below are added and set to their defaults + * to make a complete Storage. + * See {@link Storage} + */ order: [], notesOrder: NotesOrder.Alphabetical, sidebar: true, @@ -66,5 +69,5 @@ it("sets custom values", () => { setBy: "", lastEdit: "", openNoteOnMouseHover: false, - }, local)); + } as Partial, existing)); }); diff --git a/src/background/init/migrations/__tests__/helpers.ts b/src/background/init/migrations/__tests__/helpers.ts new file mode 100644 index 00000000..9a28135a --- /dev/null +++ b/src/background/init/migrations/__tests__/helpers.ts @@ -0,0 +1,4 @@ +import { Storage } from "shared/storage/schema"; +import { localKeys } from "../core"; + +export const expectItems = (items: Storage): void => expect(Object.keys(items)).toEqual(localKeys); diff --git a/src/background/init/migrations/core.ts b/src/background/init/migrations/core.ts index 003308d7..048724c9 100644 --- a/src/background/init/migrations/core.ts +++ b/src/background/init/migrations/core.ts @@ -1,5 +1,5 @@ -import { NotesObject, NotesOrder, Storage } from "shared/storage/schema"; +import { NotesObject, NotesOrder, Storage, StorageKey } from "shared/storage/schema"; import { defaultValuesFactory } from "shared/storage/default-values"; import { validBoolean, @@ -12,7 +12,7 @@ import { validNotesOrder, } from "shared/storage/validations"; -export const expectedKeys = [ +export const localKeys: StorageKey[] = [ // Appearance "font", "size", @@ -88,8 +88,8 @@ export default (sync: { [key: string]: unknown }, local: { [key: string]: unknow notes: notes as NotesObject, // already migrated to [3.x] order: validStringArray(local.order) ? local.order : [], active: tryNote(local.active as string) || tryNote(defaultValues.active as string) || firstAvailableNote, - setBy: local.setBy as string || "", - lastEdit: local.lastEdit as string || "", + setBy: typeof local.setBy === "string" ? local.setBy : defaultValues.setBy, + lastEdit: typeof local.lastEdit === "string" ? local.lastEdit : defaultValues.lastEdit, // Options notesOrder: validNotesOrder(local.notesOrder) ? local.notesOrder : NotesOrder.Alphabetical, diff --git a/src/background/init/migrations/index.ts b/src/background/init/migrations/index.ts index 7d7ca4a0..84a2d08c 100644 --- a/src/background/init/migrations/index.ts +++ b/src/background/init/migrations/index.ts @@ -1,9 +1,9 @@ import { Storage } from "shared/storage/schema"; -import migrate, { expectedKeys } from "./core"; +import migrate, { localKeys } from "./core"; export const runMigrations = (): void => { chrome.storage.sync.get(["newtab", "value", "notes"], sync => { - chrome.storage.local.get(expectedKeys, local => { + chrome.storage.local.get(localKeys, local => { const items: Storage = migrate(sync, local); // migrate notes and options chrome.storage.local.set(items); // store the migrated data diff --git a/src/i18n/en.json b/src/i18n/en.json index 6d82c5a5..d5ff8f9f 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -10,6 +10,8 @@ "Yes": "Yes", "Lock": "Lock", "Unlock": "Unlock", + "Pin": "Pin", + "Unpin": "Unpin", "Duplicate": "Duplicate", "Export": "Export", "Google Drive Sync is disabled (see Options)": "Google Drive Sync is disabled (see Options)", diff --git a/src/notes.tsx b/src/notes.tsx index 4b951e53..bc5dd6e4 100644 --- a/src/notes.tsx +++ b/src/notes.tsx @@ -34,7 +34,7 @@ import renameNote from "notes/state/rename-note"; import deleteNote from "notes/state/delete-note"; import duplicateNote from "notes/state/duplicate-note"; -import { saveNote, setLocked } from "notes/content/save"; +import { saveNote, setLocked, setPinnedTime } from "notes/content/save"; import { sendMessage } from "messages"; import { getActiveFromUrl, getFocusOverride } from "notes/location"; @@ -631,6 +631,11 @@ const Notes = (): h.JSX.Element => { setContextMenuProps(null); tabId && notesRef.current && setLocked(noteName, !(notesProps.notes[noteName].locked ?? false), tabId, notesRef.current); }, + pinned: !!notesProps.notes[noteName].pinnedTime, + onTogglePinnedTime: (noteName) => { + setContextMenuProps(null); + tabId && notesRef.current && setPinnedTime(noteName, (notesProps.notes[noteName].pinnedTime ?? undefined) ? undefined : new Date().toISOString(), tabId, notesRef.current); + }, onDuplicate: (noteName) => { setContextMenuProps(null); duplicateNote(noteName); diff --git a/src/notes/components/ContextMenu.tsx b/src/notes/components/ContextMenu.tsx index b56ef8d5..3237754c 100644 --- a/src/notes/components/ContextMenu.tsx +++ b/src/notes/components/ContextMenu.tsx @@ -11,12 +11,14 @@ export interface ContextMenuProps { onDelete: (noteName: string) => void locked: boolean onToggleLocked: (noteName: string) => void + pinned: boolean + onTogglePinnedTime: (noteName: string) => void onDuplicate: (noteName: string) => void onExport: (noteName: string) => void } const ContextMenu = ({ - noteName, x, y, onRename, onDelete, locked, onToggleLocked, onDuplicate, onExport, + noteName, x, y, onRename, onDelete, locked, onToggleLocked, pinned, onTogglePinnedTime, onDuplicate, onExport, }: ContextMenuProps): h.JSX.Element => { const [offsetHeight, setOffsetHeight] = useState(0); const ref = useRef(null); @@ -44,6 +46,7 @@ const ContextMenu = ({
!locked && onRename(noteName)}>{t("Rename")}
!locked && onDelete(noteName)}>{t("Delete")}
onToggleLocked(noteName)}>{locked ? t("Unlock") : t("Lock")}
+
onTogglePinnedTime(noteName)}>{pinned ? t("Unpin") : t("Pin")}
onDuplicate(noteName)}>{t("Duplicate")}
onExport(noteName)}>{t("Export")}
diff --git a/src/notes/components/Sidebar.tsx b/src/notes/components/Sidebar.tsx index 5e6dcb19..c787c39f 100644 --- a/src/notes/components/Sidebar.tsx +++ b/src/notes/components/Sidebar.tsx @@ -1,8 +1,8 @@ import { h } from "preact"; -import { useRef } from "preact/hooks"; +import { useRef, useState, useMemo } from "preact/hooks"; import { Os, Sync } from "shared/storage/schema"; import { SidebarNote } from "notes/adapters"; -import SidebarNotes from "./SidebarNotes"; +import SidebarNotes, { SidebarNotesProps } from "./SidebarNotes"; import SidebarButtons from "./SidebarButtons"; import Drag from "./Drag"; @@ -24,6 +24,18 @@ const Sidebar = ({ os, notes, canChangeOrder, onChangeOrder, active, width, onActivateNote, onNoteContextMenu, onNewNote, sync, openNoteOnMouseHover, }: SidebarProps): h.JSX.Element => { const sidebarRef = useRef(null); + const [onDraggingNoteOriginator, setOnDraggingNoteOriginator] = useState(undefined); + const commonSidebarNotesProps: Omit = { + active, + onActivateNote, + onNoteContextMenu, + canChangeOrder, + openNoteOnMouseHover, + setOnDraggingNoteOriginator, + }; + + const pinnedNotes = useMemo(() => notes.filter((note) => note.pinnedTime), [notes]); + const unpinnedNotes = useMemo(() => notes.filter((note) => !note.pinnedTime), [notes]); return (
- +