From 571fbac248cb732e1733359b3c6c6bdd475840fa Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 7 May 2026 15:15:28 -0500 Subject: [PATCH] Add Quick Notes example plugin demonstrating core plugin surfaces Mirrors the Bookmarks example (PAP-3575) with a company-scoped note library: database namespace, scoped API routes, page/sidebar/widget/ settings UI slots. All 7 tests pass. Co-Authored-By: Paperclip --- .../esbuild.config.mjs | 17 + .../migrations/001_notes.sql | 12 + .../plugin-quick-notes-example/package.json | 48 +++ .../src/manifest.ts | 90 +++++ .../src/ui/index.tsx | 325 ++++++++++++++++++ .../plugin-quick-notes-example/src/worker.ts | 284 +++++++++++++++ .../tests/plugin.spec.ts | 290 ++++++++++++++++ .../plugin-quick-notes-example/tsconfig.json | 27 ++ .../vitest.config.ts | 8 + pnpm-workspace.yaml | 2 + 10 files changed, 1103 insertions(+) create mode 100644 packages/plugins/examples/plugin-quick-notes-example/esbuild.config.mjs create mode 100644 packages/plugins/examples/plugin-quick-notes-example/migrations/001_notes.sql create mode 100644 packages/plugins/examples/plugin-quick-notes-example/package.json create mode 100644 packages/plugins/examples/plugin-quick-notes-example/src/manifest.ts create mode 100644 packages/plugins/examples/plugin-quick-notes-example/src/ui/index.tsx create mode 100644 packages/plugins/examples/plugin-quick-notes-example/src/worker.ts create mode 100644 packages/plugins/examples/plugin-quick-notes-example/tests/plugin.spec.ts create mode 100644 packages/plugins/examples/plugin-quick-notes-example/tsconfig.json create mode 100644 packages/plugins/examples/plugin-quick-notes-example/vitest.config.ts diff --git a/packages/plugins/examples/plugin-quick-notes-example/esbuild.config.mjs b/packages/plugins/examples/plugin-quick-notes-example/esbuild.config.mjs new file mode 100644 index 00000000000..b5cfd36ed95 --- /dev/null +++ b/packages/plugins/examples/plugin-quick-notes-example/esbuild.config.mjs @@ -0,0 +1,17 @@ +import esbuild from "esbuild"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); +const watch = process.argv.includes("--watch"); + +const workerCtx = await esbuild.context(presets.esbuild.worker); +const manifestCtx = await esbuild.context(presets.esbuild.manifest); +const uiCtx = await esbuild.context(presets.esbuild.ui); + +if (watch) { + await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]); + console.log("esbuild watch mode enabled for worker, manifest, and ui"); +} else { + await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]); + await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]); +} diff --git a/packages/plugins/examples/plugin-quick-notes-example/migrations/001_notes.sql b/packages/plugins/examples/plugin-quick-notes-example/migrations/001_notes.sql new file mode 100644 index 00000000000..d0c373e0e75 --- /dev/null +++ b/packages/plugins/examples/plugin-quick-notes-example/migrations/001_notes.sql @@ -0,0 +1,12 @@ +CREATE TABLE plugin_quick_notes_b34a9f8617.notes ( + id uuid PRIMARY KEY, + company_id uuid NOT NULL, + title text NOT NULL, + body text NOT NULL DEFAULT '', + tags text[] NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX notes_company_idx + ON plugin_quick_notes_b34a9f8617.notes (company_id, created_at DESC); diff --git a/packages/plugins/examples/plugin-quick-notes-example/package.json b/packages/plugins/examples/plugin-quick-notes-example/package.json new file mode 100644 index 00000000000..fcb8e1a017a --- /dev/null +++ b/packages/plugins/examples/plugin-quick-notes-example/package.json @@ -0,0 +1,48 @@ +{ + "name": "@paperclipai/plugin-quick-notes-example", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "First-party example plugin: company-scoped note library backed by a plugin database namespace.", + "scripts": { + "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", + "build": "node ./esbuild.config.mjs", + "build:rollup": "rollup -c", + "dev": "node ./esbuild.config.mjs --watch", + "dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4179", + "test": "vitest run --config ./vitest.config.ts", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" + }, + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + }, + "keywords": [ + "paperclip", + "plugin", + "example", + "notes", + "knowledge" + ], + "author": "Paperclip", + "license": "MIT", + "dependencies": { + "@paperclipai/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@paperclipai/shared": "workspace:*", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.2", + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + "esbuild": "^0.27.3", + "rollup": "^4.38.0", + "tslib": "^2.8.1", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "peerDependencies": { + "react": ">=18" + } +} diff --git a/packages/plugins/examples/plugin-quick-notes-example/src/manifest.ts b/packages/plugins/examples/plugin-quick-notes-example/src/manifest.ts new file mode 100644 index 00000000000..186d959efe2 --- /dev/null +++ b/packages/plugins/examples/plugin-quick-notes-example/src/manifest.ts @@ -0,0 +1,90 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +export const PLUGIN_ID = "paperclipai.plugin-quick-notes-example"; + +const manifest: PaperclipPluginManifestV1 = { + id: PLUGIN_ID, + apiVersion: 1, + version: "0.1.0", + displayName: "Quick Notes (Example)", + description: + "First-party example plugin: company-scoped note library backed by a plugin database namespace. Demonstrates scoped API routes, a database namespace, dashboard widget, and a plugin page.", + author: "Paperclip", + categories: ["workspace", "ui"], + capabilities: [ + "api.routes.register", + "database.namespace.migrate", + "database.namespace.read", + "database.namespace.write", + "companies.read", + "ui.dashboardWidget.register", + "ui.page.register", + "ui.sidebar.register", + "instance.settings.register", + ], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui", + }, + database: { + namespaceSlug: "quick_notes", + migrationsDir: "migrations", + }, + apiRoutes: [ + { + routeKey: "list", + method: "GET", + path: "/notes", + auth: "board-or-agent", + capability: "api.routes.register", + companyResolution: { from: "query", key: "companyId" }, + }, + { + routeKey: "create", + method: "POST", + path: "/notes", + auth: "board-or-agent", + capability: "api.routes.register", + companyResolution: { from: "body", key: "companyId" }, + }, + { + routeKey: "delete", + method: "DELETE", + path: "/notes/:id", + auth: "board-or-agent", + capability: "api.routes.register", + companyResolution: { from: "query", key: "companyId" }, + }, + ], + ui: { + slots: [ + { + type: "page", + id: "quick-notes-page", + displayName: "Quick Notes", + exportName: "QuickNotesPage", + routePath: "quick-notes", + }, + { + type: "sidebar", + id: "quick-notes-sidebar-link", + displayName: "Quick Notes", + exportName: "QuickNotesSidebarLink", + }, + { + type: "dashboardWidget", + id: "quick-notes-widget", + displayName: "Quick Notes", + exportName: "QuickNotesDashboardWidget", + }, + { + type: "settingsPage", + id: "quick-notes-settings", + displayName: "Quick Notes", + exportName: "QuickNotesSettingsPage", + }, + ], + }, +}; + +export default manifest; diff --git a/packages/plugins/examples/plugin-quick-notes-example/src/ui/index.tsx b/packages/plugins/examples/plugin-quick-notes-example/src/ui/index.tsx new file mode 100644 index 00000000000..f78194e8599 --- /dev/null +++ b/packages/plugins/examples/plugin-quick-notes-example/src/ui/index.tsx @@ -0,0 +1,325 @@ +import { + usePluginAction, + usePluginData, + type PluginPageProps, + type PluginSettingsPageProps, + type PluginSidebarProps, + type PluginWidgetProps, +} from "@paperclipai/plugin-sdk/ui"; +import { useState, useMemo, type CSSProperties, type FormEvent } from "react"; + +interface NoteRecord { + id: string; + companyId: string; + title: string; + body: string; + tags: string[]; + createdAt: string; + updatedAt: string; +} + +interface NoteListResult { + databaseNamespace: string; + notes: NoteRecord[]; +} + +const containerStyle: CSSProperties = { + display: "grid", + gap: 16, + font: "13px system-ui, sans-serif", + color: "#111827", + padding: 16, + maxWidth: 760, +}; + +const cardStyle: CSSProperties = { + border: "1px solid #e5e7eb", + borderRadius: 8, + padding: 12, + background: "#fff", +}; + +const inputStyle: CSSProperties = { + border: "1px solid #d1d5db", + borderRadius: 6, + padding: "6px 8px", + font: "inherit", + width: "100%", +}; + +const buttonStyle: CSSProperties = { + border: "1px solid #1f2937", + background: "#111827", + color: "#fff", + borderRadius: 6, + padding: "6px 10px", + font: "inherit", + cursor: "pointer", +}; + +const subtleButtonStyle: CSSProperties = { + border: "1px solid #d1d5db", + background: "#fff", + color: "#111827", + borderRadius: 6, + padding: "4px 8px", + font: "inherit", + cursor: "pointer", +}; + +const tagStyle: CSSProperties = { + display: "inline-block", + border: "1px solid #cbd5f5", + background: "#eef2ff", + color: "#3730a3", + borderRadius: 999, + padding: "1px 8px", + marginRight: 4, + fontSize: 11, +}; + +function parseTagInput(raw: string): string[] { + return raw + .split(/[\s,]+/) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function formatDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + +function NotesList({ + result, + onDelete, + loading, +}: { + result: NoteListResult | null; + onDelete: (id: string) => Promise; + loading: boolean; +}) { + if (loading) return
Loading notes…
; + if (!result) return null; + if (result.notes.length === 0) { + return
No notes yet.
; + } + return ( +
+ {result.notes.map((note) => ( +
+
+ {note.title} + +
+ {note.tags.length > 0 ? ( +
+ {note.tags.map((tag) => ( + + {tag} + + ))} +
+ ) : null} + {note.body ? ( +
{note.body}
+ ) : null} +
+ {formatDate(note.createdAt)} +
+
+ ))} +
+ ); +} + +function NotesSurface({ companyId }: { companyId: string }) { + const [search, setSearch] = useState(""); + const [tag, setTag] = useState(""); + const [title, setTitle] = useState(""); + const [tagsRaw, setTagsRaw] = useState(""); + const [body, setBody] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + + const listParams = useMemo( + () => ({ companyId, search: search.trim() || null, tag: tag.trim() || null }), + [companyId, search, tag], + ); + + const { data, loading, error, refresh } = usePluginData("list", listParams); + const create = usePluginAction("create"); + const remove = usePluginAction("delete"); + + async function handleCreate(event: FormEvent) { + event.preventDefault(); + setErrorMessage(null); + try { + await create({ + companyId, + title, + body: body || null, + tags: parseTagInput(tagsRaw), + }); + setTitle(""); + setBody(""); + setTagsRaw(""); + refresh(); + } catch (cause) { + setErrorMessage(cause instanceof Error ? cause.message : "Failed to add note"); + } + } + + async function handleDelete(id: string) { + setErrorMessage(null); + try { + await remove({ companyId, id }); + refresh(); + } catch (cause) { + setErrorMessage(cause instanceof Error ? cause.message : "Failed to delete note"); + } + } + + return ( +
+
+

Quick Notes

+
+ Company-scoped note library backed by a plugin database namespace. +
+
+ +
+ Add note + setTitle(event.target.value)} + /> + setTagsRaw(event.target.value)} + /> +