diff --git a/CHANGELOG.md b/CHANGELOG.md index 913db1b..4e24619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Now includes useful links to documentation and GitHub issues. - API token validation is done in the settings page with an option to open the modal if the validation fails. - You can now group by different task properties like: priority, due date, and others. Please see the [documentation](https://jamiebrynes7.github.io/obsidian-todoist-plugin/docs/query-blocks#groupBy) for more details. +- Rebuilt the add task dialog from scratch. This should fix a number of bugs with the date picker, improves the UX, and brings it inline with the native Todoist experience. +- Added a new command 'Add task with current page in task description' which offers the ability to append a link to the current page to the task description when it is created. ## [1.12.0] - 2024-02-09 diff --git a/docs/docs/commands/add-task-modal.png b/docs/docs/commands/add-task-modal.png index e06adff..2f0bcd7 100644 Binary files a/docs/docs/commands/add-task-modal.png and b/docs/docs/commands/add-task-modal.png differ diff --git a/docs/docs/commands/add-task.md b/docs/docs/commands/add-task.md index 403a796..60e9af0 100644 --- a/docs/docs/commands/add-task.md +++ b/docs/docs/commands/add-task.md @@ -2,11 +2,14 @@ sidebar_position: 1 --- -# Add Task +# Add task ![](./add-task-modal.png) -The 'Add Todoist task' command allows you send tasks to Todoist from Obsidian. There are a few utilities to help you set the text content: +The 'Add task' set of commands open up a modal that allows you to configure and send tasks to Todoist from Obsidian. Any text selected will be used to pre-populate the task content. -- Any text selected will be used to pre-populate the task's text -- You can append a link to the currently selected Obsidian page to the task's text by using the 'Add Todoist task with the current page' variant of the command +There are a few variants of the command: + +- 'Add task', the basic version +- 'Add task with current page in task content', this option will append a link to the current page in the task content before it sends it to Obsidian. The modal will inform you it will do this, but the link is not shown to keep the modal clean. +- 'Add task with current page in task description', this option will append a link to the current page in the task description before it sends it to Obsidian. The modal will inform you it will do this, but the link is not shown to keep the modal clean. diff --git a/plugin/package-lock.json b/plugin/package-lock.json index 2606d8c..933d39c 100644 --- a/plugin/package-lock.json +++ b/plugin/package-lock.json @@ -9,6 +9,7 @@ "version": "1.12.0", "license": "ISC", "dependencies": { + "@internationalized/date": "^3.5.2", "camelize-ts": "^3.0.0", "classnames": "^2.5.1", "moment": "^2.29.4", @@ -16,6 +17,7 @@ "react": "^18.2.0", "react-aria-components": "^1.1.1", "react-dom": "^18.2.0", + "react-textarea-autosize": "^8.5.3", "snakify-ts": "^2.3.0", "svelte": "^4.2.10", "svelte-select": "^5.0.1", @@ -224,7 +226,6 @@ "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.0.tgz", "integrity": "sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -5281,6 +5282,22 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" } }, + "node_modules/react-textarea-autosize": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz", + "integrity": "sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -5309,8 +5326,7 @@ "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/regexp.prototype.flags": { "version": "1.5.2", @@ -6052,6 +6068,43 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-composed-ref": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.3.0.tgz", + "integrity": "sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", + "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.2.1.tgz", + "integrity": "sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", diff --git a/plugin/package.json b/plugin/package.json index c7b0c5f..49d34f6 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -13,6 +13,7 @@ "author": "Jamie Brynes", "license": "ISC", "dependencies": { + "@internationalized/date": "^3.5.2", "camelize-ts": "^3.0.0", "classnames": "^2.5.1", "moment": "^2.29.4", @@ -20,6 +21,7 @@ "react": "^18.2.0", "react-aria-components": "^1.1.1", "react-dom": "^18.2.0", + "react-textarea-autosize": "^8.5.3", "snakify-ts": "^2.3.0", "svelte": "^4.2.10", "svelte-select": "^5.0.1", diff --git a/plugin/src/api/domain/project.ts b/plugin/src/api/domain/project.ts index 8db4904..ae89387 100644 --- a/plugin/src/api/domain/project.ts +++ b/plugin/src/api/domain/project.ts @@ -5,4 +5,5 @@ export type Project = { parentId: ProjectId | null; name: string; order: number; + isInboxProject: boolean; }; diff --git a/plugin/src/commands/addTask.ts b/plugin/src/commands/addTask.ts new file mode 100644 index 0000000..a55e530 --- /dev/null +++ b/plugin/src/commands/addTask.ts @@ -0,0 +1,60 @@ +import { MarkdownView, Notice, TFile } from "obsidian"; +import type { MakeCommand } from "."; +import type TodoistPlugin from ".."; +import type { TaskCreationOptions } from "../ui/createTaskModal"; + +export const addTask: MakeCommand = (plugin: TodoistPlugin) => { + return { + name: "Add task", + callback: makeCallback(plugin), + }; +}; + +export const addTaskWithPageInContent: MakeCommand = (plugin: TodoistPlugin) => { + return { + id: "add-task-page-content", + name: "Add task with current page in task content", + callback: makeCallback(plugin, { appendLinkToContent: true }), + }; +}; + +export const addTaskWithPageInDescription: MakeCommand = (plugin: TodoistPlugin) => { + return { + id: "add-task-page-description", + name: "Add task with current page in task description", + callback: makeCallback(plugin, { appendLinkToDescription: true }), + }; +}; + +const makeCallback = (plugin: TodoistPlugin, opts?: Partial) => { + return () => { + if (plugin.options === null) { + new Notice("Failed to load settings, cannot open task creation modal."); + return; + } + + plugin.services.modals.taskCreation({ + initialContent: grabSelection(plugin), + fileContext: getFileContext(plugin), + options: { + appendLinkToContent: false, + appendLinkToDescription: false, + ...(opts ?? {}), + }, + }); + }; +}; + +const grabSelection = (plugin: TodoistPlugin): string => { + const editorView = plugin.app.workspace.getActiveViewOfType(MarkdownView)?.editor; + + if (editorView !== undefined) { + return editorView.getSelection(); + } + + return window.getSelection()?.toString() ?? ""; +}; + +const getFileContext = (plugin: TodoistPlugin): TFile | undefined => { + return plugin.app.workspace.getActiveFile() ?? undefined; +}; diff --git a/plugin/src/commands/index.ts b/plugin/src/commands/index.ts new file mode 100644 index 0000000..98d2559 --- /dev/null +++ b/plugin/src/commands/index.ts @@ -0,0 +1,36 @@ +import { type Command as ObsidianCommand } from "obsidian"; +import type TodoistPlugin from ".."; +import debug from "../log"; +import { addTask, addTaskWithPageInContent, addTaskWithPageInDescription } from "./addTask"; + +export type MakeCommand = (plugin: TodoistPlugin) => Omit; + +const syncCommand: MakeCommand = (plugin: TodoistPlugin) => { + return { + name: "Sync with Todoist", + callback: async () => { + debug("Syncing with Todoist API"); + plugin.services.todoist.sync(); + }, + }; +}; + +const commands = { + "todoist-sync": syncCommand, + "add-task": addTask, + "add-task-page-content": addTaskWithPageInContent, + "add-task-page-description": addTaskWithPageInDescription, +}; + +type CommandId = keyof typeof commands; + +export const registerCommands = (plugin: TodoistPlugin) => { + for (const [id, make] of Object.entries(commands)) { + plugin.addCommand({ id, ...make(plugin) }); + } +}; + +export const fireCommand = (id: K, plugin: TodoistPlugin) => { + const make = commands[id]; + make(plugin).callback?.(); +}; diff --git a/plugin/src/data/index.ts b/plugin/src/data/index.ts index 13fd638..93dce4f 100644 --- a/plugin/src/data/index.ts +++ b/plugin/src/data/index.ts @@ -45,6 +45,8 @@ export class TodoistAdapter { private readonly labels: Repository; private readonly subscriptions: SubscriptionManager; + private hasSynced = false; + constructor() { this.projects = new Repository(() => this.api.withInner((api) => api.getProjects())); this.sections = new Repository(() => this.api.withInner((api) => api.getSections())); @@ -52,6 +54,10 @@ export class TodoistAdapter { this.subscriptions = new SubscriptionManager(); } + public isReady(): boolean { + return this.api.hasValue() && this.hasSynced; + } + public async initialize(api: TodoistApiClient) { this.api.insert(api); await this.sync(); @@ -69,6 +75,8 @@ export class TodoistAdapter { for (const refresh of this.subscriptions.listActive()) { await refresh(); } + + this.hasSynced = true; } public data(): DataAccessor { @@ -150,6 +158,7 @@ const makeUnknownProject = (id: string): Project => { parentId: null, name: "Unknown Project", order: Number.MAX_SAFE_INTEGER, + isInboxProject: false, }; }; diff --git a/plugin/src/data/transformations/grouping.test.ts b/plugin/src/data/transformations/grouping.test.ts index 7adc3ca..9f3b1e5 100644 --- a/plugin/src/data/transformations/grouping.test.ts +++ b/plugin/src/data/transformations/grouping.test.ts @@ -36,6 +36,7 @@ function makeProject(id: string, opts?: Partial): Project { parentId: opts?.parentId ?? null, name: opts?.name ?? "Project", order: opts?.order ?? 1, + isInboxProject: false, }; } diff --git a/plugin/src/data/transformations/relationships.test.ts b/plugin/src/data/transformations/relationships.test.ts index 72f4b4e..24443cd 100644 --- a/plugin/src/data/transformations/relationships.test.ts +++ b/plugin/src/data/transformations/relationships.test.ts @@ -13,7 +13,13 @@ function makeTask(id: string, opts?: Partial): Task { priority: opts?.priority ?? 1, order: opts?.order ?? 0, - project: opts?.project ?? { id: "foobar", name: "Foobar", order: 1, parentId: null }, + project: opts?.project ?? { + id: "foobar", + name: "Foobar", + order: 1, + parentId: null, + isInboxProject: false, + }, section: opts?.section, due: opts?.due, diff --git a/plugin/src/data/transformations/sorting.test.ts b/plugin/src/data/transformations/sorting.test.ts index a5056d6..7d8ccdb 100644 --- a/plugin/src/data/transformations/sorting.test.ts +++ b/plugin/src/data/transformations/sorting.test.ts @@ -14,7 +14,13 @@ function makeTask(id: string, opts?: Partial): Task { priority: opts?.priority ?? 1, order: opts?.order ?? 0, - project: opts?.project ?? { id: "foobar", name: "Foobar", order: 1, parentId: null }, + project: opts?.project ?? { + id: "foobar", + name: "Foobar", + order: 1, + parentId: null, + isInboxProject: false, + }, section: opts?.section, due: opts?.due, diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 188e04f..5568461 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -1,23 +1,16 @@ -import { App, Notice, Plugin } from "obsidian"; +import { App, Plugin } from "obsidian"; import type { PluginManifest } from "obsidian"; import "../styles.css"; import { TodoistApiClient } from "./api"; import { ObsidianFetcher } from "./api/fetcher"; -import { TodoistAdapter } from "./data"; +import { registerCommands } from "./commands"; import debug from "./log"; -import CreateTaskModal from "./modals/createTask/createTaskModal"; import { QueryInjector } from "./query/injector"; +import { type Services, makeServices } from "./services"; import { settings } from "./settings"; import { type ISettings, defaultSettings } from "./settings"; -import { VaultTokenAccessor } from "./token"; -import { OnboardingModal } from "./ui/onboardingModal"; import { SettingsTab } from "./ui/settings"; -type Services = { - todoist: TodoistAdapter; - tokenAccessor: VaultTokenAccessor; -}; - export default class TodoistPlugin extends Plugin { public options: ISettings; @@ -26,10 +19,7 @@ export default class TodoistPlugin extends Plugin { constructor(app: App, pluginManifest: PluginManifest) { super(app, pluginManifest); this.options = { ...defaultSettings }; - this.services = { - todoist: new TodoistAdapter(), - tokenAccessor: new VaultTokenAccessor(this.app.vault), - }; + this.services = makeServices(this); settings.subscribe((value) => { debug({ @@ -42,54 +32,21 @@ export default class TodoistPlugin extends Plugin { } async onload() { - const queryInjector = new QueryInjector(this.services.todoist); + const queryInjector = new QueryInjector(this); this.registerMarkdownCodeBlockProcessor( "todoist", queryInjector.onNewBlock.bind(queryInjector), ); this.addSettingTab(new SettingsTab(this.app, this)); - this.addCommand({ - id: "todoist-sync", - name: "Sync with Todoist", - callback: async () => { - debug("Syncing with Todoist API"); - this.services.todoist.sync(); - }, - }); - - this.addCommand({ - id: "todoist-add-task", - name: "Add Todoist task", - callback: () => { - if (this.options === null) { - new Notice("Failed to load settings, cannot open task creation modal."); - return; - } - - new CreateTaskModal(this.app, this.services.todoist, this.options, false); - }, - }); - - this.addCommand({ - id: "todoist-add-task-current-page", - name: "Add Todoist task with the current page", - callback: () => { - if (this.options === null) { - new Notice("Failed to load settings, cannot open task creation modal."); - return; - } - - new CreateTaskModal(this.app, this.services.todoist, this.options, true); - }, - }); + registerCommands(this); await this.loadOptions(); await this.loadApiClient(); } private async loadApiClient(): Promise { - const accessor = this.services.tokenAccessor; + const accessor = this.services.token; if (await accessor.exists()) { const token = await accessor.read(); @@ -97,10 +54,12 @@ export default class TodoistPlugin extends Plugin { return; } - new OnboardingModal(this.app, async (token) => { - await accessor.write(token); - await this.services.todoist.initialize(new TodoistApiClient(token, new ObsidianFetcher())); - }).open(); + this.services.modals.onboarding({ + onTokenSubmit: async (token) => { + await accessor.write(token); + await this.services.todoist.initialize(new TodoistApiClient(token, new ObsidianFetcher())); + }, + }); } async loadOptions(): Promise { diff --git a/plugin/src/modals/createTask/CalendarPicker.svelte b/plugin/src/modals/createTask/CalendarPicker.svelte deleted file mode 100644 index 02b9f1d..0000000 --- a/plugin/src/modals/createTask/CalendarPicker.svelte +++ /dev/null @@ -1,182 +0,0 @@ - - -
- - - - {moment(`${year}-${month + 1}-01 00:00:00`).format("MMM YYYY")} - - - - -
-
-
- {#each daysOfWeek as dow} -
{dow}
- {/each} -
- {#each monthData as week} -
- {#each week as day} - {#if day} -
dispatch("selectDate", day)} - > - {day.date()} -
- {:else} -
- {/if} - {/each} -
- {/each} -
- - diff --git a/plugin/src/modals/createTask/CreateTaskModalContent.svelte b/plugin/src/modals/createTask/CreateTaskModalContent.svelte deleted file mode 100644 index ece76cc..0000000 --- a/plugin/src/modals/createTask/CreateTaskModalContent.svelte +++ /dev/null @@ -1,157 +0,0 @@ - - - - - -
-
- Project -
- -
-
-
- Labels -
- -
-
-
- Date -
- -
-
-
- Priority -
- -
-
-
- - - diff --git a/plugin/src/modals/createTask/DateSelector.svelte b/plugin/src/modals/createTask/DateSelector.svelte deleted file mode 100644 index 5ff185f..0000000 --- a/plugin/src/modals/createTask/DateSelector.svelte +++ /dev/null @@ -1,184 +0,0 @@ - - - - -
{ - if (!drawerOpen) { - ev.stopPropagation(); - drawerOpen = true; - } - }} -> - {#if selected} - {selected.format("MMM D, YYYY")} - { - setDate(undefined); - }} - > - {:else}No date selected.{/if} -
-
- {#if drawerOpen} -
-
{ - setDate(moment()); - }} - > - Today -
-
{ - setDate(moment().add(1, "day")); - }} - > - Tomorrow -
-
- setDate(ev.detail)} /> -
-
- {/if} -
- - diff --git a/plugin/src/modals/createTask/LabelSelector.svelte b/plugin/src/modals/createTask/LabelSelector.svelte deleted file mode 100644 index a9d3ab3..0000000 --- a/plugin/src/modals/createTask/LabelSelector.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - diff --git a/plugin/src/modals/createTask/createTaskModal.ts b/plugin/src/modals/createTask/createTaskModal.ts deleted file mode 100644 index 5412458..0000000 --- a/plugin/src/modals/createTask/createTaskModal.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { App, MarkdownView, Modal } from "obsidian"; -import type { TodoistAdapter } from "../../data"; -import type { ISettings } from "../../settings"; -import CreateTaskModalContent from "./CreateTaskModalContent.svelte"; - -export default class CreateTaskModal extends Modal { - private readonly modalContent: CreateTaskModalContent; - private readonly settings: ISettings; - - constructor(app: App, adapter: TodoistAdapter, settings: ISettings, withPageLink: boolean) { - super(app); - this.settings = settings; - this.titleEl.innerText = "Create new Todoist task"; - - const [initialValue, initialCursorPosition] = this.getInitialContent(withPageLink); - - this.modalContent = new CreateTaskModalContent({ - target: this.contentEl, - props: { - todoistAdapter: adapter, - close: () => this.close(), - value: initialValue, - initialCursorPosition: initialCursorPosition, - }, - }); - - this.open(); - } - - onClose() { - super.onClose(); - this.modalContent.$destroy(); - } - - private getInitialContent(withPageLink: boolean): [string, number] { - let selection = this.app.workspace.getActiveViewOfType(MarkdownView)?.editor?.getSelection(); - - if (selection == null || selection === "") { - selection = window.getSelection()?.toString() ?? ""; - } - - const link = this.getPageLink(); - - if (!withPageLink || link === null) { - return [selection, 0]; - } - - return [`${selection} ${link}`, selection.length]; - } - - private getPageLink(): string | null { - const file = this.app.workspace.getActiveFile(); - - if (file == null) { - return null; - } - const encodedVault = encodeURIComponent(file.vault.getName()); - const encodedFilepath = encodeURIComponent(file.path); - - const link = `[${file.path}](obsidian://open?vault=${encodedVault}&file=${encodedFilepath})`; - - if (this.settings.shouldWrapLinksInParens) { - return `(${link})`; - } - - return link; - } -} diff --git a/plugin/src/modals/createTask/types.ts b/plugin/src/modals/createTask/types.ts deleted file mode 100644 index 2c4eba8..0000000 --- a/plugin/src/modals/createTask/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface LabelOption { - /** The label ID */ - value: string; - /** The label name, what the user sees on screen */ - label: string; -} - -export interface ProjectOrSectionRef { - id: string; - type: "Project" | "Section"; -} - -export interface ProjectOption { - value: ProjectOrSectionRef; - label: string; -} diff --git a/plugin/src/query/injector.ts b/plugin/src/query/injector.ts index 98e3039..f9d934d 100644 --- a/plugin/src/query/injector.ts +++ b/plugin/src/query/injector.ts @@ -1,7 +1,7 @@ import { MarkdownRenderChild } from "obsidian"; import type { MarkdownPostProcessorContext } from "obsidian"; import type { SvelteComponent } from "svelte"; -import type { TodoistAdapter } from "../data"; +import type TodoistPlugin from ".."; import debug from "../log"; import ErrorDisplay from "../ui/ErrorDisplay.svelte"; import TodoistQuery from "../ui/TodoistQuery.svelte"; @@ -9,10 +9,9 @@ import { parseQuery } from "./parser"; import { applyReplacements } from "./replacements"; export class QueryInjector { - private adapater: TodoistAdapter; - - constructor(adapter: TodoistAdapter) { - this.adapater = adapter; + private readonly plugin: TodoistPlugin; + constructor(plugin: TodoistPlugin) { + this.plugin = plugin; } onNewBlock(source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) { @@ -32,7 +31,10 @@ export class QueryInjector { child = new InjectedQuery(el, (root: HTMLElement) => { return new TodoistQuery({ target: root, - props: { query: query, todoistAdapter: this.adapater }, + props: { + query: query, + plugin: this.plugin, + }, }); }); } catch (e) { diff --git a/plugin/src/services/index.ts b/plugin/src/services/index.ts new file mode 100644 index 0000000..380e5d6 --- /dev/null +++ b/plugin/src/services/index.ts @@ -0,0 +1,18 @@ +import type TodoistPlugin from ".."; +import { TodoistAdapter } from "../data"; +import { ModalHandler } from "./modals"; +import { VaultTokenAccessor } from "./tokenAccessor"; + +export type Services = { + modals: ModalHandler; + token: VaultTokenAccessor; + todoist: TodoistAdapter; +}; + +export const makeServices = (plugin: TodoistPlugin): Services => { + return { + modals: new ModalHandler(plugin), + token: new VaultTokenAccessor(plugin.app.vault), + todoist: new TodoistAdapter(), + }; +}; diff --git a/plugin/src/services/modals.tsx b/plugin/src/services/modals.tsx new file mode 100644 index 0000000..929fed2 --- /dev/null +++ b/plugin/src/services/modals.tsx @@ -0,0 +1,82 @@ +import { Modal, Platform } from "obsidian"; +import React from "react"; +import { type Root, createRoot } from "react-dom/client"; +import type TodoistPlugin from ".."; +import { ModalContext, type ModalInfo } from "../ui/context/modal"; +import { PluginContext } from "../ui/context/plugin"; +import { CreateTaskModal } from "../ui/createTaskModal"; +import { OnboardingModal } from "../ui/onboardingModal"; + +type ModalOptions = { + title?: string; + dontCloseOnExternalClick?: boolean; +}; + +class ReactModal extends Modal { + private readonly reactRoot: Root; + + constructor(plugin: TodoistPlugin, Component: React.FC, props: T, opts: ModalOptions) { + super(plugin.app); + if (opts.title) { + this.titleEl.textContent = opts.title; + } + + this.reactRoot = createRoot(this.contentEl); + + const popoverContainerEl = this.containerEl.createDiv(); + popoverContainerEl.style.position = "relative"; + + const modal: ModalInfo = { + close: () => this.close(), + popoverContainerEl: popoverContainerEl, + }; + + if (opts.dontCloseOnExternalClick ?? false) { + // HACK: In order to suppress the click event, we just re-create the element. This works okay because its simple. + const modalBg = this.containerEl.firstElementChild; + if (modalBg?.classList.contains("modal-bg")) { + this.containerEl.removeChild(modalBg); + createDiv({ + prepend: true, + parent: this.containerEl, + cls: ["modal-bg"], + attr: { + style: "opacity: 0.85;", + }, + }); + } + } + + this.reactRoot.render( + + + + + , + ); + } + + onClose(): void { + this.reactRoot.unmount(); + } +} + +export class ModalHandler { + private readonly plugin: TodoistPlugin; + + constructor(plugin: TodoistPlugin) { + this.plugin = plugin; + } + + public onboarding(props: React.ComponentProps) { + new ReactModal(this.plugin, OnboardingModal, props, { + title: "Sync with Todoist Setup", + }).open(); + } + + public taskCreation(props: React.ComponentProps) { + new ReactModal(this.plugin, CreateTaskModal, props, { + dontCloseOnExternalClick: Platform.isMobileApp, + }).open(); + } +} diff --git a/plugin/src/services/tokenAccessor.ts b/plugin/src/services/tokenAccessor.ts new file mode 100644 index 0000000..28ef1bb --- /dev/null +++ b/plugin/src/services/tokenAccessor.ts @@ -0,0 +1,23 @@ +import type { Vault } from "obsidian"; + +export class VaultTokenAccessor { + private readonly vault: Vault; + private readonly path: string; + + constructor(vault: Vault) { + this.vault = vault; + this.path = `${vault.configDir}/todoist-token`; + } + + exists(): Promise { + return this.vault.adapter.exists(this.path); + } + + read(): Promise { + return this.vault.adapter.read(this.path); + } + + write(token: string): Promise { + return this.vault.adapter.write(this.path, token); + } +} diff --git a/plugin/src/token.ts b/plugin/src/token.ts index 0f6d1d5..94f18f3 100644 --- a/plugin/src/token.ts +++ b/plugin/src/token.ts @@ -1,28 +1,6 @@ -import type { Vault } from "obsidian"; import { TodoistApiClient } from "./api"; import { ObsidianFetcher } from "./api/fetcher"; -export class VaultTokenAccessor { - private readonly vault: Vault; - private readonly path: string; - constructor(vault: Vault) { - this.vault = vault; - this.path = `${vault.configDir}/todoist-token`; - } - - exists(): Promise { - return this.vault.adapter.exists(this.path); - } - - read(): Promise { - return this.vault.adapter.read(this.path); - } - - write(token: string): Promise { - return this.vault.adapter.write(this.path, token); - } -} - export namespace TokenValidation { export type Result = | { kind: "none" } diff --git a/plugin/src/ui/TodoistQuery.svelte b/plugin/src/ui/TodoistQuery.svelte index 338a786..d1493d7 100644 --- a/plugin/src/ui/TodoistQuery.svelte +++ b/plugin/src/ui/TodoistQuery.svelte @@ -2,9 +2,8 @@ import { onMount, onDestroy } from "svelte"; import { settings } from "../settings"; import { GroupVariant, type Query } from "../query/query"; - import CreateTaskModal from "../modals/createTask/createTaskModal"; import NoTaskDisplay from "./NoTaskDisplay.svelte"; - import type { QueryErrorKind, TodoistAdapter } from "../data"; + import type { QueryErrorKind } from "../data"; import type { Task } from "../data/task"; import GroupedTasks from "./GroupedTasks.svelte"; import type { TaskId } from "../api/domain/task"; @@ -13,9 +12,11 @@ import { setQuery, setTaskActions } from "./contexts"; import ObsidianIcon from "../components/ObsidianIcon.svelte"; import QueryErrorDisplay from "./QueryErrorDisplay.svelte"; + import type TodoistPlugin from ".."; + import { fireCommand } from "../commands"; + export let plugin: TodoistPlugin; export let query: Query; - export let todoistAdapter: TodoistAdapter; // Set context items. setQuery(query); @@ -26,7 +27,7 @@ let success = true; try { - await todoistAdapter.actions.closeTask(id); + await plugin.services.todoist.actions.closeTask(id); } catch (error) { console.error(`Failed to mark task as closed: ${error}`); success = false; @@ -50,7 +51,7 @@ let tasksPendingClose: Set = new Set(); let queryError: QueryErrorKind | undefined = undefined; - const [unsubscribeQuery, refreshQuery] = todoistAdapter.subscribe( + const [unsubscribeQuery, refreshQuery] = plugin.services.todoist.subscribe( query.filter, (result) => { switch (result.type) { @@ -105,7 +106,7 @@ }); function callTaskModal() { - new CreateTaskModal(app, todoistAdapter, $settings, true); + fireCommand("add-task-page-content", plugin); } async function forceRefresh() { diff --git a/plugin/src/ui/context/modal.ts b/plugin/src/ui/context/modal.ts new file mode 100644 index 0000000..04f6ef2 --- /dev/null +++ b/plugin/src/ui/context/modal.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from "react"; + +export type ModalInfo = { + close: () => void; + popoverContainerEl: HTMLElement; +}; + +export const ModalContext = createContext(undefined); + +export const useModalContext = () => { + const modal = useContext(ModalContext); + + if (modal === undefined) { + throw new Error("ModalContext provider not found"); + } + + return modal; +}; diff --git a/plugin/src/ui/createTaskModal/DueDateSelector.tsx b/plugin/src/ui/createTaskModal/DueDateSelector.tsx new file mode 100644 index 0000000..2886abe --- /dev/null +++ b/plugin/src/ui/createTaskModal/DueDateSelector.tsx @@ -0,0 +1,175 @@ +import { + CalendarDate, + DateFormatter, + endOfWeek, + getLocalTimeZone, + isToday, + today, +} from "@internationalized/date"; +import React from "react"; +import { + Button, + Calendar, + CalendarCell, + CalendarGrid, + Dialog, + DialogTrigger, + Heading, + type Key, + Menu, + MenuItem, + Section, +} from "react-aria-components"; +import { ObsidianIcon } from "../components/obsidian-icon"; +import { Popover } from "./Popover"; + +// TODO: Locale handling everywhere +const formatter = new DateFormatter("en-US", { + month: "short", + day: "numeric", +}); + +const weekdayFormatter = new DateFormatter("en-US", { + weekday: "short", +}); + +type Props = { + selected: CalendarDate | undefined; + setSelected: (selected: CalendarDate | undefined) => void; +}; + +export const DueDateSelector: React.FC = ({ selected, setSelected }) => { + const label = getLabel(selected); + const suggestions = getSuggestions(); + + const onSelected = (key: Key) => { + const selected = suggestions.find((s) => s.id === key); + if (selected === undefined) { + return; + } + + setSelected(selected.target); + }; + + return ( + + + + + {({ close }) => ( + <> + { + onSelected(key); + close(); + }} + aria-label="Due date suggestions" + > +
+ {suggestions.map((props) => ( + + ))} +
+
+
+ { + setSelected(date); + close(); + }} + minValue={today(getLocalTimeZone())} + > +
+ +
+ + +
+
+ {(date) => } +
+ + )} +
+
+
+ ); +}; + +const getLabel = (selected: CalendarDate | undefined) => { + if (selected === undefined) { + return "Due date"; + } + + if (isToday(selected, getLocalTimeZone())) { + return "Today"; + } + + if (today(getLocalTimeZone()).add({ days: 1 }).compare(selected) === 0) { + return "Tomorrow"; + } + + return formatter.format(selected.toDate(getLocalTimeZone())); +}; + +type DateSuggestionProps = { + id: string; + icon: string; + label: string; + target: CalendarDate | undefined; +}; + +const DateSuggestion: React.FC = ({ id, icon, label, target }) => { + const dayOfWeek = + target !== undefined ? weekdayFormatter.format(target.toDate(getLocalTimeZone())) : ""; + + return ( + +
+
+ + {label} +
+
{dayOfWeek}
+
+
+ ); +}; + +const getSuggestions = (): DateSuggestionProps[] => { + const startOfNextWeek = endOfWeek(today(getLocalTimeZone()), "en-US").add({ days: 1 }); + const suggestions = [ + { + id: "today", + icon: "calendar", + label: "Today", + target: today(getLocalTimeZone()), + }, + { + id: "tomorrow", + icon: "sun", + label: "Tomorrow", + target: today(getLocalTimeZone()).add({ days: 1 }), + }, + { + id: "next-week", + icon: "calendar-clock", + label: "Next week", + target: startOfNextWeek, + }, + { + id: "no-date", + icon: "ban", + label: "No date", + target: undefined, + }, + ]; + + return suggestions; +}; diff --git a/plugin/src/ui/createTaskModal/LabelSelector.tsx b/plugin/src/ui/createTaskModal/LabelSelector.tsx new file mode 100644 index 0000000..d02ac1a --- /dev/null +++ b/plugin/src/ui/createTaskModal/LabelSelector.tsx @@ -0,0 +1,73 @@ +import classNames from "classnames"; +import React, { useMemo } from "react"; +import { Button, DialogTrigger, ListBox, ListBoxItem, type Selection } from "react-aria-components"; +import type { Label } from "../../api/domain/label"; +import { ObsidianIcon } from "../components/obsidian-icon"; +import { usePluginContext } from "../context/plugin"; +import { Popover } from "./Popover"; + +type Props = { + selected: Label[]; + setSelected: (labels: Label[]) => void; +}; + +export const LabelSelector: React.FC = ({ selected, setSelected }) => { + const plugin = usePluginContext(); + + const options = useMemo(() => { + return Array.from(plugin.services.todoist.data().labels.iter()); + }, [plugin]); + + const selectedKeys = selected.map((l) => l.id); + + const onSelectionChange = (selection: Selection) => { + if (selection === "all") { + setSelected([...options]); + return; + } + + setSelected(options.filter((l) => selection.has(l.id))); + }; + + return ( + + + + + {options.map((l) => ( + + ))} + + + + ); +}; + +type LabelItemProps = { + label: Label; + isSelected: boolean; +}; + +const LabelItem: React.FC = ({ label, isSelected }) => { + return ( + + {label.name} + {isSelected && } + + ); +}; diff --git a/plugin/src/ui/createTaskModal/Popover.tsx b/plugin/src/ui/createTaskModal/Popover.tsx new file mode 100644 index 0000000..1b4264c --- /dev/null +++ b/plugin/src/ui/createTaskModal/Popover.tsx @@ -0,0 +1,37 @@ +import { Platform } from "obsidian"; +import type { PropsWithChildren } from "react"; +import React from "react"; +import { Popover as AriaPopover, type PopoverProps } from "react-aria-components"; +import { useModalContext } from "../context/modal"; + +export const Popover: React.FC = ({ children }) => { + const modal = useModalContext(); + + return ( + + {children} + + ); +}; + +type PlacementDetails = Pick; + +export const getPlacementDetails = (): PlacementDetails => { + if (Platform.isMobile) { + return { + placement: "top left", + shouldFlip: false, + }; + } + + return { + placement: "bottom left", + shouldFlip: false, + }; +}; diff --git a/plugin/src/ui/createTaskModal/PrioritySelector.tsx b/plugin/src/ui/createTaskModal/PrioritySelector.tsx new file mode 100644 index 0000000..708fcb2 --- /dev/null +++ b/plugin/src/ui/createTaskModal/PrioritySelector.tsx @@ -0,0 +1,67 @@ +import classNames from "classnames"; +import React from "react"; +import { Button, type Key, Label, Menu, MenuItem, MenuTrigger } from "react-aria-components"; +import { type Priority } from "../../api/domain/task"; +import { ObsidianIcon } from "../components/obsidian-icon"; +import { Popover } from "./Popover"; + +type Props = { + selected: Priority; + setSelected: (selected: Priority) => void; +}; + +const options: Priority[] = [4, 3, 2, 1]; + +export const PrioritySelector: React.FC = ({ selected, setSelected }) => { + const onSelected = (key: Key) => { + if (typeof key === "string") { + throw Error("unexpected key type"); + } + + // Should be a safe cast since we only use valid priorities + // as keys. + setSelected(key as Priority); + }; + + const label = getLabel(selected); + return ( + + + + + {options.map((priority) => { + const label = getLabel(priority); + const isSelected = priority === selected; + const className = classNames("priority-option", { "is-selected": isSelected }); + return ( + + + + ); + })} + + + + ); +}; + +const getLabel = (priority: Priority): string => { + switch (priority) { + case 1: + return "Priority 4"; + case 2: + return "Priority 3"; + case 3: + return "Priority 2"; + case 4: + return "Priority 1"; + } +}; diff --git a/plugin/src/ui/createTaskModal/ProjectSelector.tsx b/plugin/src/ui/createTaskModal/ProjectSelector.tsx new file mode 100644 index 0000000..ea62804 --- /dev/null +++ b/plugin/src/ui/createTaskModal/ProjectSelector.tsx @@ -0,0 +1,330 @@ +import { Platform } from "obsidian"; +import React, { useMemo, useState } from "react"; +import { + Button, + Dialog, + DialogTrigger, + Input, + type Key, + ListBox, + ListBoxItem, + SearchField, +} from "react-aria-components"; +import type TodoistPlugin from "../.."; +import type { Project, ProjectId } from "../../api/domain/project"; +import type { Section, SectionId } from "../../api/domain/section"; +import { ObsidianIcon } from "../components/obsidian-icon"; +import { usePluginContext } from "../context/plugin"; +import { Popover } from "./Popover"; + +export type ProjectIdentifier = { + projectId: ProjectId; + sectionId?: SectionId; +}; + +type Props = { + selected: ProjectIdentifier; + setSelected: (selected: ProjectIdentifier) => void; +}; + +export const ProjectSelector: React.FC = ({ selected, setSelected }) => { + const plugin = usePluginContext(); + const todoistData = plugin.services.todoist.data(); + + const [filter, setFilter] = useState(""); + const hierarchy = useMemo(() => buildProjectHierarchy(plugin), [plugin]); + + const onSelect = (key: Key) => { + if (typeof key === "number") { + throw Error("Unexpected key type: number"); + } + + const [id, isSection] = ItemKey.parse(key); + + if (isSection) { + const section = todoistData.sections.byId(id); + if (section === undefined) { + throw Error("Could not find selected section"); + } + + setSelected({ + projectId: section.projectId, + sectionId: section.id, + }); + return; + } + + setSelected({ projectId: id }); + }; + + return ( + + + + + {({ close }) => ( + <> + {!Platform.isMobile && ( + <> + +
+ + )} + { + onSelect(key); + close(); + }} + > + {hierarchy.map((nested) => ( + + ))} + + + )} +
+
+
+ ); +}; + +type SearchFilterProps = { + filter: string; + setFilter: (filter: string) => void; +}; + +const SearchFilter: React.FC = ({ filter, setFilter }) => { + const onChange = (changeEv: React.ChangeEvent) => { + setFilter(changeEv.target.value.toLowerCase()); + }; + + return ( + + + + ); +}; + +type NestedProjectItemProps = { + nested: NestedProject; + depth: number; + filter: string; +}; + +const NestedProjectItem: React.FC = ({ nested, depth, filter }) => { + return ( + <> + + {nested.sections.map((section) => ( + + ))} + {nested.children.map((nested) => ( + + ))} + + ); +}; + +type ProjectOptionProps = { + project: Project; + depth: number; + filter: string; +}; + +const ProjectOption: React.FC = ({ project, depth, filter }) => { + const key = ItemKey.make(project.id); + + // If there is a filter, we don't want to show the hierarchy + const actualDepth = filter === "" ? depth : 0; + const isFilteredOut = filter !== "" && !project.name.toLowerCase().contains(filter); + + return ( + + + + ); +}; + +type SectionOptionProps = { + section: Section; + depth: number; + filter: string; +}; + +const SectionOption: React.FC = ({ section, depth, filter }) => { + const key = ItemKey.make(section.id, true); + + // If there is a filter, we don't want to show the hierarchy + const actualDepth = filter === "" ? depth + 1 : 0; + const isFilteredOut = filter !== "" && !section.name.toLowerCase().contains(filter); + + return ( + + + + ); +}; + +const ItemKey = { + make: (id: string, isSection = false): string => { + const prefix = isSection ? "section" : "project"; + return `${prefix} : ${id}`; + }, + parse: (key: string): [string, boolean] => { + const isSection = key.startsWith("section"); + const id = key.split(" : ")[1]; + + return [id, isSection]; + }, +}; + +const SectionLabel: React.FC<{ section: Section }> = ({ section }) => { + return ( + <> + +
{section.name}
+ + ); +}; + +const ProjectLabel: React.FC<{ project: Project }> = ({ project }) => { + const projectIcon = project.isInboxProject ? "inbox" : "hash"; + + return ( + <> + +
{project.name}
+ + ); +}; + +const ButtonLabel: React.FC = ({ projectId, sectionId }) => { + const { projects, sections } = usePluginContext().services.todoist.data(); + + const selectedProject = projects.byId(projectId); + if (selectedProject === undefined) { + throw Error("Could not find selected project"); + } + + const selectedSection = sectionId !== undefined ? sections.byId(sectionId) : undefined; + + return ( + <> + + {selectedSection && ( + <> +
/
+ + + )} + + ); +}; + +type NestedProject = { + project: Project; + sections: Section[]; + children: NestedProject[]; +}; + +type ProjectHeirarchy = NestedProject[]; + +function buildProjectHierarchy(plugin: TodoistPlugin): ProjectHeirarchy { + const data = plugin.services.todoist.data(); + const mapped = new Map(); + + // Go through each project and insert it into the map. + for (const project of data.projects.iter()) { + mapped.set(project.id, { project, sections: [], children: [] }); + } + + // Now parent them together. + for (const project of data.projects.iter()) { + if (project.parentId === null) { + continue; + } + + const child = mapped.get(project.id); + const parent = mapped.get(project.parentId); + + if (child === undefined) { + throw Error("Failed to find project in map"); + } + + // In this scenario, we could be in a weird half-way sync state. + if (parent === undefined) { + continue; + } + + parent.children.push(child); + } + + // Now attach sections + for (const section of data.sections.iter()) { + const parent = mapped.get(section.projectId); + + // We could be in a weird half-way sync state, so ignore this. + if (parent === undefined) { + continue; + } + + parent.sections.push(section); + } + + // Sort each element in the map + for (const [_, nested] of mapped) { + nested.sections.sort((a, b) => a.order - b.order); + nested.children.sort((a, b) => a.project.order - b.project.order); + } + + // Find top-level projects, i.e. - have no parents + const roots = Array.from(data.projects.iter()) + .filter((project) => project.parentId === null) + .map((project) => { + const nested = mapped.get(project.id); + if (nested === undefined) { + throw Error("Failed to find root project in map"); + } + + return nested; + }); + + // Sort roots, forcing the inbox to be first. + roots.sort((a, b) => { + if (a.project.isInboxProject) { + return -1; + } + + if (b.project.isInboxProject) { + return 1; + } + + return a.project.order - b.project.order; + }); + + return roots; +} diff --git a/plugin/src/ui/createTaskModal/TaskContentInput.tsx b/plugin/src/ui/createTaskModal/TaskContentInput.tsx new file mode 100644 index 0000000..a35e307 --- /dev/null +++ b/plugin/src/ui/createTaskModal/TaskContentInput.tsx @@ -0,0 +1,52 @@ +import classNames from "classnames"; +import React from "react"; +import { TextArea, TextField } from "react-aria-components"; +import TextareaAutosize from "react-textarea-autosize"; + +type Props = { + className: string; + placeholder: string; + content: string; + onChange: (content: string) => void; + autofocus?: boolean; + onEnterKey?: () => Promise; +}; + +export const TaskContentInput: React.FC = ({ + className, + placeholder, + content, + onChange, + onEnterKey, + autofocus, +}) => { + const onInputChange = (ev: React.ChangeEvent) => { + onChange(ev.target.value); + }; + + const onKeyDown = async (ev: React.KeyboardEvent) => { + if (onEnterKey === undefined) { + return; + } + + if (ev.key === "Enter") { + ev.preventDefault(); + await onEnterKey(); + } + }; + + const classes = classNames("task-content-input", className); + return ( + + + + ); +}; diff --git a/plugin/src/ui/createTaskModal/index.tsx b/plugin/src/ui/createTaskModal/index.tsx new file mode 100644 index 0000000..0aaacf5 --- /dev/null +++ b/plugin/src/ui/createTaskModal/index.tsx @@ -0,0 +1,191 @@ +import { CalendarDate } from "@internationalized/date"; +import { Notice, TFile } from "obsidian"; +import React, { useCallback, useEffect, useState } from "react"; +import { Button } from "react-aria-components"; +import type TodoistPlugin from "../.."; +import type { Label } from "../../api/domain/label"; +import type { Priority } from "../../api/domain/task"; +import { useModalContext } from "../context/modal"; +import { usePluginContext } from "../context/plugin"; +import { DueDateSelector } from "./DueDateSelector"; +import { LabelSelector } from "./LabelSelector"; +import { PrioritySelector } from "./PrioritySelector"; +import { type ProjectIdentifier, ProjectSelector } from "./ProjectSelector"; +import { TaskContentInput } from "./TaskContentInput"; +import "./styles.scss"; + +export type TaskCreationOptions = { + appendLinkToContent: boolean; + appendLinkToDescription: boolean; +}; + +type CreateTaskProps = { + initialContent: string; + fileContext: TFile | undefined; + options: TaskCreationOptions; +}; + +export const CreateTaskModal: React.FC = (props) => { + const plugin = usePluginContext(); + + const [isReady, setIsReady] = useState(plugin.services.todoist.isReady()); + + const refreshIsReady = () => { + if (isReady) { + return; + } + + setIsReady(plugin.services.todoist.isReady()); + }; + + // We don't want to reset this when isReady changes. + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + const id = window.setInterval(refreshIsReady, 500); + return () => window.clearInterval(id); + }, []); + + if (!isReady) { + return
Loading Todoist data...
; + } + + return ; +}; + +const CreateTaskModalContent: React.FC = ({ + initialContent, + fileContext, + options: initialOptions, +}) => { + const plugin = usePluginContext(); + const modal = useModalContext(); + + const [content, setContent] = useState(initialContent); + const [description, setDescription] = useState(""); + const [dueDate, setDueDate] = useState(undefined); + const [priority, setPriority] = useState(1); + const [labels, setLabels] = useState([]); + const [project, setProject] = useState(getDefaultProject(plugin)); + + const [options, setOptions] = useState(initialOptions); + + const isSubmitButtonDisabled = content === "" && !options.appendLinkToContent; + + const buildWithLink = (initial: string, withLink: boolean) => { + const builder = [initial]; + if (withLink && fileContext !== undefined) { + builder.push(" "); + if (plugin.options.shouldWrapLinksInParens) { + builder.push("("); + } + builder.push(getLinkForFile(fileContext)); + if (plugin.options.shouldWrapLinksInParens) { + builder.push(")"); + } + } + + return builder.join(""); + }; + + const createTask = async () => { + if (isSubmitButtonDisabled) { + return; + } + + modal.close(); + + try { + await plugin.services.todoist.actions.createTask( + buildWithLink(content, options.appendLinkToContent), + { + description: buildWithLink(description, options.appendLinkToDescription), + dueDate: dueDate?.toString(), + priority: priority, + labels: labels.map((l) => l.name), + projectId: project.projectId, + sectionId: project.sectionId, + }, + ); + new Notice("Task created successfully"); + } catch (err) { + new Notice("Failed to create task"); + console.error("Failed to create task", err); + } + }; + + return ( +
+ + +
+ + + +
+
+
    + {options.appendLinkToContent && ( +
  • A link to this page will be appended to the task name
  • + )} + {options.appendLinkToDescription && ( +
  • A link to this page will be appended to the task description
  • + )} +
+
+
+
+
+ +
+
+ + +
+
+
+ ); +}; + +const getDefaultProject = (plugin: TodoistPlugin): ProjectIdentifier => { + const { todoist } = plugin.services; + const projects = Array.from(todoist.data().projects.iter()); + + for (const project of projects) { + if (project.isInboxProject) { + return { + projectId: project.id, + }; + } + } + + new Notice("Error: could not find inbox project"); + throw Error("Could not find inbox project"); +}; + +const getLinkForFile = (file: TFile): string => { + const vault = encodeURIComponent(file.vault.getName()); + const filepath = encodeURIComponent(file.path); + + return `[${file.name}](obsidian://open?vault=${vault}&file=${filepath})`; +}; diff --git a/plugin/src/ui/createTaskModal/styles.scss b/plugin/src/ui/createTaskModal/styles.scss new file mode 100644 index 0000000..7a4580d --- /dev/null +++ b/plugin/src/ui/createTaskModal/styles.scss @@ -0,0 +1,309 @@ +.modal-popover { + overflow-y: auto; +} + +.task-creation-modal-root { + .task-content-input textarea { + width: 100%; + border: 0; + background-color: unset; + box-shadow: none; + resize: none; + padding: 0px; + + &:focus, + &:hover { + border: 0; + background-color: unset; + box-shadow: none; + } + } + + .task-name textarea { + font-size: var(--font-ui-large); + font-weight: var(--font-semibold); + } + + .task-description textarea { + font-size: var(--font-ui-small); + } + + .task-creation-selectors { + margin-top: 0.5em; + display: flex; + align-items: center; + + & > * + * { + margin-left: 0.5em; + } + + button { + box-shadow: none; + background-color: unset; + border: 1px solid var(--color-base-25); + color: var(--text-muted); + + .obsidian-icon { + margin-right: 0.5em; + } + + &:hover, + &:focus { + border: 1px solid var(--interactive-accent); + box-shadow: var(--box-shadow-hover); + } + } + } + + .task-creation-notes { + ul { + padding-inline-start: 16px; + font-size: var(--font-smallest); + color: var(--text-muted); + } + } + + .task-creation-controls { + display: flex; + justify-content: space-between; + + .is-mobile & { + flex-direction: column; + + & > * + * { + margin-top: 0.5em; + } + } + + .task-creation-action { + display: flex; + + & > * + * { + margin-left: 1em; + } + + .is-mobile & { + justify-content: end; + } + } + } + + hr { + margin: 1em 0; + border-color: var(--color-base-25); + } +} + +.task-option-dialog { + background-color: var(--modal-background); + border: var(--modal-border-width) solid var(--modal-border-color); + border-radius: 4px; + min-width: 200px; + box-shadow: var(--shadow-s); + padding: 0.5em 0; +} + +.task-date-menu { + .react-aria-MenuItem[data-focused] { + background-color: var(--background-modifier-cover); + } + + .date-suggestion-elem { + padding: 8px 1em; + display: flex; + justify-content: space-between; + align-items: center; + font-size: var(--font-smaller); + .date-suggestion-label { + display: flex; + align-items: center; + font-weight: var(--font-semibold); + + .obsidian-icon { + margin-right: 1em; + } + } + + .date-suggestion-day { + color: var(--text-faint); + } + } + + hr { + width: 100%; + border-color: var(--color-base-25); + margin: 1em 0; + } + + .date-picker { + padding: 0px 1em; + font-size: var(--font-small); + + header { + display: flex; + align-items: center; + justify-content: space-between; + } + + h4 { + margin-left: 0.5em; + font-size: var(--font-small); + font-weight: var(--font-semibold); + } + + .date-picker-controls { + display: flex; + align-items: center; + justify-content: right; + + button { + box-shadow: none; + background-color: unset; + border: 1px solid rgba(0, 0, 0, 0); + color: var(--text-muted); + + &[data-disabled] { + opacity: 0.5; + } + + &:hover:not([data-disabled]) { + border: 1px solid var(--interactive-accent); + box-shadow: var(--box-shadow-hover); + } + } + } + + .react-aria-CalendarCell { + text-align: center; + padding: 6px; + border-radius: 2px; + + &:hover { + background-color: var(--background-modifier-cover); + } + + &[data-outside-month] { + display: none; + } + + &[data-disabled] { + color: var(--text-faint); + } + + &[data-selected] { + background-color: var(--interactive-accent); + color: var(--text-on-accent); + } + } + } +} + +.task-priority-menu { + .priority-option { + padding: 8px 1em; + font-size: var(--font-smaller); + + &:hover { + background-color: var(--background-modifier-cover); + } + + &.is-selected { + background-color: var(--interactive-accent); + color: var(--text-on-accent); + } + } +} + +.task-label-menu { + .label-option { + padding: 8px 1em; + font-size: var(--font-smaller); + + display: flex; + align-items: center; + justify-content: space-between; + + &:hover { + background-color: var(--background-modifier-cover); + } + } +} + +button.project-selector { + box-shadow: none; + background-color: unset; + border: 1px solid var(--color-base-25); + color: var(--text-muted); + display: flex; + align-items: center; + + & > * + * { + margin-left: 0.5em; + } + + &:hover, + &:focus { + border: 1px solid var(--interactive-accent); + box-shadow: var(--box-shadow-hover); + } +} + +.task-project-menu { + .search-filter-container { + display: flex; + align-items: center; + justify-content: center; + + input { + flex-grow: 1; + margin: 0 4px; + + &:hover { + box-shadow: none; + border: 1px solid var(--interactive-accent); + } + } + } + + hr { + margin: 0.5em 0; + border-color: var(--color-base-10); + } + + .project-option { + padding: 8px 1em; + font-size: var(--font-smaller); + + display: flex; + align-items: center; + + --project-padding: 8px; + padding-left: var(--project-padding); + + // Each level of depth we add 24 px to the padding + &[data-depth="1"] { + --project-padding: 32px; + } + &[data-depth="2"] { + --project-padding: 56px; + } + &[data-depth="3"] { + --project-padding: 80px; + } + &[data-depth="3"] { + --project-padding: 104px; + } + + & > * + * { + margin-left: 0.5em; + } + + &:hover { + background-color: var(--background-modifier-cover); + } + + &[data-filtered="true"] { + display: none; + } + } +} diff --git a/plugin/src/ui/onboardingModal/index.tsx b/plugin/src/ui/onboardingModal/index.tsx index ee1b735..c9e5ab3 100644 --- a/plugin/src/ui/onboardingModal/index.tsx +++ b/plugin/src/ui/onboardingModal/index.tsx @@ -1,47 +1,27 @@ -import { App, Modal, Notice } from "obsidian"; +import { Notice } from "obsidian"; import React from "react"; -import { type Root, createRoot } from "react-dom/client"; import { TokenValidation } from "../../token"; +import { useModalContext } from "../context/modal"; import { TokenInputForm } from "./TokenInputForm"; import "./styles.scss"; type OnTokenSubmitted = (token: string) => Promise; -export class OnboardingModal extends Modal { - private readonly onTokenSubmitted: OnTokenSubmitted; - private readonly reactRoot: Root; - - constructor(app: App, onTokenSubmitted: OnTokenSubmitted) { - super(app); - this.onTokenSubmitted = onTokenSubmitted; - this.titleEl.textContent = "Sync with Todoist Setup"; - - const { contentEl } = this; - contentEl.empty(); - this.reactRoot = createRoot(contentEl); - - const callback = (token: string) => { - this.close(); - this.onTokenSubmitted(token).catch((e) => { - console.error("Failed to save API token", e); - new Notice("Failed to save API token"); - }); - }; - - this.reactRoot.render(); - } +type OnboardingProps = { + onTokenSubmit: OnTokenSubmitted; +}; - onClose(): void { - super.onClose(); - this.reactRoot.unmount(); - } -} +export const OnboardingModal: React.FC = ({ onTokenSubmit }) => { + const modal = useModalContext(); -type Props = { - onTokenSubmit: (token: string) => void; -}; + const callback = (token: string) => { + modal.close(); + onTokenSubmit(token).catch((e) => { + console.error("Failed to save API token", e); + new Notice("Failed to save API token"); + }); + }; -const ModalRoot: React.FC = ({ onTokenSubmit }) => { return (

@@ -55,7 +35,7 @@ const ModalRoot: React.FC = ({ onTokenSubmit }) => { {" "} on finding your API token.

- +
); }; diff --git a/plugin/src/ui/settings/TokenChecker.tsx b/plugin/src/ui/settings/TokenChecker.tsx index 7009d32..9ddd4fd 100644 --- a/plugin/src/ui/settings/TokenChecker.tsx +++ b/plugin/src/ui/settings/TokenChecker.tsx @@ -4,7 +4,6 @@ import { ObsidianFetcher } from "../../api/fetcher"; import { TokenValidation } from "../../token"; import { TokenValidationIcon } from "../components/token-validation-icon"; import { usePluginContext } from "../context/plugin"; -import { OnboardingModal } from "../onboardingModal"; import { Setting } from "./SettingItem"; type Props = { @@ -13,7 +12,7 @@ type Props = { export const TokenChecker: React.FC = ({ tester }) => { const plugin = usePluginContext(); - const { tokenAccessor, todoist } = plugin.services; + const { token: tokenAccessor, todoist, modals } = plugin.services; const [tokenState, setTokenState] = useState({ kind: "in-progress" }); const [tokenValidationCount, setTokenValidationCount] = useState(0); @@ -33,12 +32,14 @@ export const TokenChecker: React.FC = ({ tester }) => { }, [plugin, tester, tokenValidationCount]); const openModal = () => { - new OnboardingModal(plugin.app, async (token) => { - setTokenValidationCount((old) => old + 1); - - await tokenAccessor.write(token); - await todoist.initialize(new TodoistApiClient(token, new ObsidianFetcher())); - }).open(); + modals.onboarding({ + onTokenSubmit: async (token) => { + setTokenValidationCount((old) => old + 1); + + await tokenAccessor.write(token); + await todoist.initialize(new TodoistApiClient(token, new ObsidianFetcher())); + }, + }); }; return (