feat: bilingual support (FA/EN), IRANSans typography, RTL layout, and interactive product detail pages
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
export {
|
||||
ASSET_KINDS,
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
COMMENT_ORDERS,
|
||||
COMMENT_TYPES,
|
||||
CUSTOM_FIELD_TYPES,
|
||||
CustomFieldValue,
|
||||
DATE_PRESETS,
|
||||
INBOX_CATEGORIES,
|
||||
INBOX_STATES,
|
||||
LiveError,
|
||||
NOTIFICATION_TYPES,
|
||||
PRIORITIES,
|
||||
PriorityFlag,
|
||||
RichText,
|
||||
SEARCH_KINDS,
|
||||
SEARCH_ORDERS,
|
||||
STATUS_TYPES,
|
||||
StatusBadge,
|
||||
TASK_ORDERS,
|
||||
Tag,
|
||||
clickup,
|
||||
keepPreviousData,
|
||||
useComments,
|
||||
useCustomFields,
|
||||
useFolders,
|
||||
useInbox,
|
||||
useLists,
|
||||
useMutation,
|
||||
useReminders,
|
||||
useSearch,
|
||||
useSpaces,
|
||||
useStatuses,
|
||||
useTaskQuery,
|
||||
useTaskTypes,
|
||||
useTasks,
|
||||
useTeams,
|
||||
useUsers,
|
||||
} from "./index.ts";
|
||||
@@ -0,0 +1,2 @@
|
||||
/** A hash of the kit's sources, written by the snapshot build; `"dev"` in the repo. */
|
||||
export const KIT_BUILD = "0fce9c14";
|
||||
Vendored
+76
@@ -0,0 +1,76 @@
|
||||
import { KIT_BUILD } from "../build";
|
||||
import type { LiveBridge } from "../host/bridge";
|
||||
import type { Store } from "../store";
|
||||
import { hydrate } from "./hydrate";
|
||||
import { persist } from "./persist";
|
||||
import { idbStorage, removeSiblings } from "./storage";
|
||||
|
||||
const KILL_SWITCH_KEY = "live-kit:cache";
|
||||
const KILL_SWITCH_PARAM = "live-kit-cache";
|
||||
const OFF = "off";
|
||||
const PREFIX = "live-kit/";
|
||||
|
||||
/**
|
||||
* Paints the viewer's last visit while this session's data loads, and writes
|
||||
* this session's data back. Nothing here is on the network path: hydration
|
||||
* only fills what the store has not read yet, one tab at a time writes, and
|
||||
* any failure turns the cache off for the session without a trace.
|
||||
*/
|
||||
export function attachCache(store: Store, bridge: LiveBridge): void {
|
||||
if (killed()) {
|
||||
return;
|
||||
}
|
||||
const viewer = viewerOf(bridge);
|
||||
if (viewer === undefined) {
|
||||
return;
|
||||
}
|
||||
const name = `${PREFIX}${KIT_BUILD}/${viewer}`;
|
||||
const storage = idbStorage(name);
|
||||
hydrate(storage, store).then(() => {
|
||||
void removeSiblings(PREFIX, name);
|
||||
void asWriter(name, (release) => persist(storage, store, release));
|
||||
}, noop);
|
||||
}
|
||||
|
||||
/** Runs `start` once this tab holds the database's lock, and holds it until the tab closes or the persister gives up. */
|
||||
async function asWriter(
|
||||
name: string,
|
||||
start: (release: () => void) => () => void,
|
||||
): Promise<void> {
|
||||
const locks = (navigator as { locks?: LockManager }).locks;
|
||||
const hold = () =>
|
||||
new Promise<void>((release) => {
|
||||
const detach = start(release);
|
||||
window.addEventListener("pagehide", () => {
|
||||
detach();
|
||||
release();
|
||||
});
|
||||
});
|
||||
if (locks) {
|
||||
await locks.request(name, hold);
|
||||
} else {
|
||||
await hold();
|
||||
}
|
||||
}
|
||||
|
||||
function killed(): boolean {
|
||||
try {
|
||||
return (
|
||||
localStorage.getItem(KILL_SWITCH_KEY) === OFF ||
|
||||
new URLSearchParams(location.search).get(KILL_SWITCH_PARAM) === OFF
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** The viewer's id; a host without `getViewer` gets no cache. */
|
||||
function viewerOf(bridge: LiveBridge): string | undefined {
|
||||
try {
|
||||
return bridge.viewer().id;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function noop(): void {}
|
||||
Vendored
+96
@@ -0,0 +1,96 @@
|
||||
import type { Thing } from "../things";
|
||||
|
||||
/** A page on the wire: every ref nested in its things written once, referenced by `kind#id`. */
|
||||
interface PageWire {
|
||||
refs: Record<string, object>;
|
||||
data: object[];
|
||||
}
|
||||
|
||||
interface RefNode {
|
||||
$ref: string;
|
||||
}
|
||||
|
||||
export function encodePage(things: Thing[]): string {
|
||||
const refs: Record<string, object> = {};
|
||||
const compact = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(compact);
|
||||
}
|
||||
if (!isObject(value)) {
|
||||
return value;
|
||||
}
|
||||
if (!isRef(value)) {
|
||||
return compactFields(value);
|
||||
}
|
||||
const key = `${value.kind}#${value.id}`;
|
||||
if (!(key in refs)) {
|
||||
refs[key] = compactFields(value);
|
||||
}
|
||||
return { $ref: key } satisfies RefNode;
|
||||
};
|
||||
const compactFields = (value: object): object =>
|
||||
Object.fromEntries(
|
||||
Object.entries(value).map(([field, nested]) => [field, compact(nested)]),
|
||||
);
|
||||
const wire: PageWire = { refs, data: things.map(compactFields) };
|
||||
return JSON.stringify(wire);
|
||||
}
|
||||
|
||||
/** Throws when the text is not a page or a `$ref` names nothing. */
|
||||
export function decodePage(text: string): Thing[] {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (!isObject(parsed)) {
|
||||
throw new Error("Not a page");
|
||||
}
|
||||
const { refs, data } = parsed as PageWire;
|
||||
if (!isObject(refs) || !Array.isArray(data)) {
|
||||
throw new Error("Not a page");
|
||||
}
|
||||
const expand = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
value[index] = expand(value[index]);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (!isObject(value)) {
|
||||
return value;
|
||||
}
|
||||
if (isRefNode(value)) {
|
||||
const ref = refs[value.$ref];
|
||||
if (!ref) {
|
||||
throw new Error(`Unresolvable $ref ${value.$ref}`);
|
||||
}
|
||||
return { ...ref };
|
||||
}
|
||||
for (const field of Object.keys(value)) {
|
||||
(value as Record<string, unknown>)[field] = expand(
|
||||
(value as Record<string, unknown>)[field],
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
for (const key of Object.keys(refs)) {
|
||||
refs[key] = expand(refs[key]) as object;
|
||||
}
|
||||
return data.map((thing) => {
|
||||
const expanded = expand(thing);
|
||||
if (!isObject(expanded) || !isRef(expanded)) {
|
||||
throw new Error("Not a thing");
|
||||
}
|
||||
return expanded;
|
||||
});
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is object {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isRef(value: object): value is Thing {
|
||||
const { kind, id } = value as Partial<Thing>;
|
||||
return typeof kind === "string" && typeof id === "string";
|
||||
}
|
||||
|
||||
function isRefNode(value: object): value is RefNode {
|
||||
return typeof (value as Partial<RefNode>).$ref === "string";
|
||||
}
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
import type { Store } from "../store";
|
||||
import { decodePage } from "./codec";
|
||||
import type { PageStorage } from "./storage";
|
||||
|
||||
/**
|
||||
* Seeds the store from every page on disk, in key order, one page per task.
|
||||
* A page that does not decode is removed and skipped; the rest still land.
|
||||
*/
|
||||
export async function hydrate(
|
||||
storage: PageStorage,
|
||||
store: Store,
|
||||
): Promise<void> {
|
||||
const broken: string[] = [];
|
||||
await storage.readAll((key, text) => {
|
||||
try {
|
||||
store.seedPage(key, decodePage(text));
|
||||
} catch {
|
||||
broken.push(key);
|
||||
}
|
||||
});
|
||||
for (const key of broken) {
|
||||
await storage.remove(key);
|
||||
}
|
||||
}
|
||||
Vendored
+105
@@ -0,0 +1,105 @@
|
||||
import type { Store } from "../store";
|
||||
import { BUDGET_BYTES } from "../things";
|
||||
import { encodePage } from "./codec";
|
||||
import { whenIdle, yieldToMain } from "./schedule";
|
||||
import type { PageStorage } from "./storage";
|
||||
|
||||
const IDLE_TIMEOUT_MS = 2_000;
|
||||
|
||||
/** Dispatched on `document` after every flush, so a test can await one. */
|
||||
const FLUSH_EVENT = "live-kit:flush";
|
||||
|
||||
/**
|
||||
* Writes the dirty pages in idle time after the store changes, and at once
|
||||
* when the page is hidden. One flush runs at a time and changes during it
|
||||
* coalesce into one more; a failure reports through `onError`, after which
|
||||
* nothing more is written.
|
||||
* @returns Detaches from the store and the page.
|
||||
*/
|
||||
export function persist(
|
||||
storage: PageStorage,
|
||||
store: Store,
|
||||
onError: (error: unknown) => void,
|
||||
): () => void {
|
||||
let dirty = false;
|
||||
let scheduled = false;
|
||||
let flushing = false;
|
||||
let detached = false;
|
||||
|
||||
const write = async () => {
|
||||
store.evict(BUDGET_BYTES);
|
||||
const puts: Array<[string, string]> = [];
|
||||
const deletes: string[] = [];
|
||||
for (const [page, things] of store.things.takeDirtyPages()) {
|
||||
if (things.length === 0) {
|
||||
deletes.push(page);
|
||||
continue;
|
||||
}
|
||||
puts.push([page, encodePage(things)]);
|
||||
await yieldToMain();
|
||||
}
|
||||
if (puts.length > 0 || deletes.length > 0) {
|
||||
await storage.write(puts, deletes);
|
||||
}
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(FLUSH_EVENT, {
|
||||
detail: { puts: puts.map(([key]) => key), deletes },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (flushing || detached) {
|
||||
return;
|
||||
}
|
||||
dirty = false;
|
||||
flushing = true;
|
||||
write().then(
|
||||
() => {
|
||||
flushing = false;
|
||||
if (dirty) {
|
||||
schedule();
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
flushing = false;
|
||||
detach();
|
||||
onError(error);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
if (scheduled) {
|
||||
return;
|
||||
}
|
||||
scheduled = true;
|
||||
whenIdle(() => {
|
||||
scheduled = false;
|
||||
if (dirty) {
|
||||
flush();
|
||||
}
|
||||
}, IDLE_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const onHidden = () => {
|
||||
if (document.visibilityState === "hidden" && dirty) {
|
||||
flush();
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = store.onChange(() => {
|
||||
dirty = true;
|
||||
schedule();
|
||||
});
|
||||
document.addEventListener("visibilitychange", onHidden);
|
||||
dirty = true;
|
||||
schedule();
|
||||
|
||||
const detach = () => {
|
||||
detached = true;
|
||||
unsubscribe();
|
||||
document.removeEventListener("visibilitychange", onHidden);
|
||||
};
|
||||
return detach;
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
interface YieldingScheduler {
|
||||
yield?: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Lets the main thread run pending input and paint before the next slice of cache work. */
|
||||
export function yieldToMain(): Promise<void> {
|
||||
const scheduler = (globalThis as { scheduler?: YieldingScheduler }).scheduler;
|
||||
if (scheduler?.yield) {
|
||||
return scheduler.yield();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.onmessage = () => {
|
||||
channel.port1.close();
|
||||
resolve();
|
||||
};
|
||||
channel.port2.postMessage(undefined);
|
||||
});
|
||||
}
|
||||
|
||||
interface IdleWindow {
|
||||
requestIdleCallback?: (
|
||||
callback: () => void,
|
||||
options: { timeout: number },
|
||||
) => number;
|
||||
}
|
||||
|
||||
/** Runs `task` when the page is idle, or after `timeout` at the latest. */
|
||||
export function whenIdle(task: () => void, timeout: number): void {
|
||||
const idle = (globalThis as IdleWindow).requestIdleCallback;
|
||||
if (idle) {
|
||||
idle(task, { timeout });
|
||||
} else {
|
||||
setTimeout(task, timeout);
|
||||
}
|
||||
}
|
||||
Vendored
+126
@@ -0,0 +1,126 @@
|
||||
const STORE = "pages";
|
||||
const OPEN_DEADLINE_MS = 2_000;
|
||||
|
||||
/** Pages by key, each one encoded string; keys mean nothing here beyond their order. */
|
||||
export interface PageStorage {
|
||||
/** Visits every page in key order, one task per page, and resolves once the last has been visited. */
|
||||
readAll(onPage: (key: string, text: string) => void): Promise<void>;
|
||||
/** Applies the puts and deletes in one transaction. */
|
||||
write(puts: Array<[string, string]>, deletes: string[]): Promise<void>;
|
||||
/** Deletes the page, on its own transaction. */
|
||||
remove(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One IndexedDB database holding one object store. Every failure rejects,
|
||||
* including a missing `indexedDB` and an `open` that does not answer within
|
||||
* the deadline, and the caller decides what to do.
|
||||
*/
|
||||
export function idbStorage(name: string): PageStorage {
|
||||
let database: Promise<IDBDatabase> | undefined;
|
||||
const open = () => (database ??= openDatabase(name));
|
||||
|
||||
const storage: PageStorage = {
|
||||
async readAll(onPage) {
|
||||
const db = await open();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = db
|
||||
.transaction(STORE, "readonly")
|
||||
.objectStore(STORE)
|
||||
.openCursor();
|
||||
request.onerror = () =>
|
||||
reject(request.error ?? new Error("IDB read failed"));
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (typeof cursor.value === "string") {
|
||||
onPage(String(cursor.key), cursor.value);
|
||||
}
|
||||
cursor.continue();
|
||||
};
|
||||
});
|
||||
},
|
||||
async write(puts, deletes) {
|
||||
const db = await open();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, "readwrite", {
|
||||
durability: "relaxed",
|
||||
});
|
||||
const store = tx.objectStore(STORE);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error ?? new Error("IDB write failed"));
|
||||
tx.onabort = () => reject(tx.error ?? new Error("IDB write aborted"));
|
||||
for (const key of deletes) {
|
||||
store.delete(key);
|
||||
}
|
||||
const remaining = puts.values();
|
||||
const putNext = () => {
|
||||
const next = remaining.next();
|
||||
if (next.done) {
|
||||
tx.commit?.();
|
||||
return;
|
||||
}
|
||||
const [key, text] = next.value;
|
||||
store.put(text, key).onsuccess = putNext;
|
||||
};
|
||||
putNext();
|
||||
});
|
||||
},
|
||||
remove: (key) => storage.write([], [key]),
|
||||
};
|
||||
return storage;
|
||||
}
|
||||
|
||||
/** Deletes every database whose name starts with `prefix` other than `keep`, where the browser can list them. */
|
||||
export async function removeSiblings(
|
||||
prefix: string,
|
||||
keep: string,
|
||||
): Promise<void> {
|
||||
if (typeof indexedDB === "undefined" || !indexedDB.databases) {
|
||||
return;
|
||||
}
|
||||
for (const { name } of await indexedDB.databases()) {
|
||||
if (name && name !== keep && name.startsWith(prefix)) {
|
||||
indexedDB.deleteDatabase(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openDatabase(name: string): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof indexedDB === "undefined") {
|
||||
reject(new Error("IndexedDB is unavailable"));
|
||||
return;
|
||||
}
|
||||
const deadline = setTimeout(
|
||||
() => reject(new Error("IndexedDB did not open in time")),
|
||||
OPEN_DEADLINE_MS,
|
||||
);
|
||||
let request: IDBOpenDBRequest;
|
||||
try {
|
||||
request = indexedDB.open(name, 1);
|
||||
} catch (error) {
|
||||
clearTimeout(deadline);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
request.onupgradeneeded = () => {
|
||||
request.result.createObjectStore(STORE);
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
clearTimeout(deadline);
|
||||
resolve(request.result);
|
||||
};
|
||||
request.onerror = () => {
|
||||
clearTimeout(deadline);
|
||||
reject(request.error ?? new Error("IndexedDB failed to open"));
|
||||
};
|
||||
request.onblocked = () => {
|
||||
clearTimeout(deadline);
|
||||
reject(new Error("IndexedDB open is blocked"));
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import { attachCache } from "./cache/attach";
|
||||
import { getComments, readComments } from "./comments/read";
|
||||
import {
|
||||
CommentPatchSchema,
|
||||
CommentSelectorSchema,
|
||||
CreateCommentInputSchema,
|
||||
} from "./comments/schema";
|
||||
import { createComment, updateComment } from "./comments/write";
|
||||
import type { LiveClient } from "./core";
|
||||
import { readCustomFields } from "./customFields/read";
|
||||
import { CustomFieldSelectorSchema } from "./customFields/schema";
|
||||
import {
|
||||
getFolders,
|
||||
getLists,
|
||||
getSpaces,
|
||||
readFolders,
|
||||
readLists,
|
||||
readSpaces,
|
||||
} from "./hierarchy/read";
|
||||
import { FolderSelectorSchema, ListSelectorSchema } from "./hierarchy/schema";
|
||||
import type { LiveBridge } from "./host/bridge";
|
||||
import { windowBridge } from "./host/bridge";
|
||||
import { perform } from "./host/perform";
|
||||
import { toHtml } from "./host/quill";
|
||||
import {
|
||||
ARTIFACT_NAVIGATION_CAPABILITIES,
|
||||
ArtifactWriteActionType as Action,
|
||||
} from "./host/wire";
|
||||
import { getInbox, readInbox } from "./inbox/read";
|
||||
import { InboxBundlePatchSchema, InboxSelectorSchema } from "./inbox/schema";
|
||||
import { updateInboxBundle } from "./inbox/write";
|
||||
import type { User } from "./people/people";
|
||||
import {
|
||||
getTeams,
|
||||
getUsers,
|
||||
mapMember,
|
||||
readTeams,
|
||||
readUsers,
|
||||
} from "./people/read";
|
||||
import { getReminders, readReminders } from "./reminders/read";
|
||||
import {
|
||||
CreateReminderInputSchema,
|
||||
ReminderPatchSchema,
|
||||
ReminderSelectorSchema,
|
||||
} from "./reminders/schema";
|
||||
import { createReminder, updateReminder } from "./reminders/write";
|
||||
import { search } from "./search/read";
|
||||
import { SearchQuerySchema } from "./search/schema";
|
||||
import { createSession } from "./session";
|
||||
import { Store } from "./store";
|
||||
import { getTasks, queryTasks, readTasks } from "./tasks/read";
|
||||
import { readStatuses } from "./tasks/statuses";
|
||||
import { readTaskTypes } from "./tasks/taskTypes";
|
||||
import {
|
||||
CreateTaskInputSchema,
|
||||
StatusSelectorSchema,
|
||||
TaskPatchSchema,
|
||||
TaskSelectorSchema,
|
||||
} from "./tasks/schema";
|
||||
import { createTask, updateTask } from "./tasks/write";
|
||||
import { check, delta, refOrId, targets } from "./validate";
|
||||
|
||||
const url = z.string().check(z.minLength(1, "Expected a ClickUp url"));
|
||||
const prompt = z.string().check(z.minLength(1, "Expected a prompt"));
|
||||
const sql = z.string().check(z.minLength(1, "Expected a SELECT statement"));
|
||||
|
||||
let hostClient: LiveClient | undefined;
|
||||
|
||||
/**
|
||||
* Reads and writes outside React: the same reads as the hooks, as promises,
|
||||
* plus all writes. Use it in event handlers and plain modules.
|
||||
*/
|
||||
export const clickup: LiveClient = new Proxy({} as LiveClient, {
|
||||
get: (_, key) => Reflect.get((hostClient ??= createLiveClient()), key),
|
||||
});
|
||||
|
||||
/**
|
||||
* @internal A client over any bridge; `clickup` is one over the window's
|
||||
* host. Every input is checked against its schema here.
|
||||
*/
|
||||
export function createLiveClient(
|
||||
bridge: LiveBridge = windowBridge(),
|
||||
): LiveClient {
|
||||
const store = new Store();
|
||||
const s = createSession(bridge, store);
|
||||
attachCache(store, bridge);
|
||||
let viewer: User | undefined;
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
store.reload();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
store,
|
||||
spaces: {
|
||||
list: async () => readSpaces(s),
|
||||
get: async (spaces) => getSpaces(s, check(targets("space"), spaces)),
|
||||
},
|
||||
folders: {
|
||||
list: async (selector) =>
|
||||
readFolders(s, check(FolderSelectorSchema, selector)),
|
||||
get: async (folders) => getFolders(s, check(targets("folder"), folders)),
|
||||
},
|
||||
lists: {
|
||||
list: async (selector) =>
|
||||
readLists(s, check(ListSelectorSchema, selector)),
|
||||
get: async (lists) => getLists(s, check(targets("list"), lists)),
|
||||
},
|
||||
customFields: {
|
||||
list: async (selector) =>
|
||||
readCustomFields(s, check(CustomFieldSelectorSchema, selector)),
|
||||
},
|
||||
tasks: {
|
||||
list: async (selector) =>
|
||||
readTasks(s, check(TaskSelectorSchema, selector)),
|
||||
get: async (tasks) => getTasks(s, check(targets("task"), tasks)),
|
||||
create: async (input) =>
|
||||
createTask(s, check(CreateTaskInputSchema, input)),
|
||||
update: async (task, patch) =>
|
||||
updateTask(
|
||||
s,
|
||||
check(refOrId("task"), task),
|
||||
check(TaskPatchSchema, patch),
|
||||
),
|
||||
query: async (statement) => queryTasks(s, check(sql, statement)),
|
||||
types: async () => readTaskTypes(s),
|
||||
statuses: async (selector) =>
|
||||
readStatuses(s, check(StatusSelectorSchema, selector)),
|
||||
},
|
||||
comments: {
|
||||
list: async (selector) =>
|
||||
readComments(s, check(CommentSelectorSchema, selector)),
|
||||
get: async (comments) =>
|
||||
getComments(s, check(targets("comment"), comments)),
|
||||
create: async (input) =>
|
||||
createComment(s, check(CreateCommentInputSchema, input)),
|
||||
update: async (comment, patch) =>
|
||||
updateComment(
|
||||
s,
|
||||
check(refOrId("comment"), comment),
|
||||
check(CommentPatchSchema, patch),
|
||||
),
|
||||
},
|
||||
users: {
|
||||
list: async () => readUsers(s),
|
||||
get: async (users) => getUsers(s, check(targets("user"), users)),
|
||||
},
|
||||
teams: {
|
||||
list: async () => readTeams(s),
|
||||
get: async (teams) => getTeams(s, check(targets("team"), teams)),
|
||||
},
|
||||
inbox: {
|
||||
list: async (selector = {}) =>
|
||||
readInbox(s, check(InboxSelectorSchema, selector)),
|
||||
get: async (bundles) =>
|
||||
getInbox(s, check(targets("inboxBundle"), bundles)),
|
||||
update: async (bundle, patch) =>
|
||||
updateInboxBundle(
|
||||
s,
|
||||
check(refOrId("inboxBundle"), bundle),
|
||||
check(InboxBundlePatchSchema, patch),
|
||||
),
|
||||
},
|
||||
reminders: {
|
||||
list: async (selector = {}) =>
|
||||
readReminders(s, check(ReminderSelectorSchema, selector)),
|
||||
get: async (reminders) =>
|
||||
getReminders(s, check(targets("reminder"), reminders)),
|
||||
create: async (input) =>
|
||||
createReminder(s, check(CreateReminderInputSchema, input)),
|
||||
update: async (reminder, patch) =>
|
||||
updateReminder(
|
||||
s,
|
||||
check(refOrId("reminder"), reminder),
|
||||
check(ReminderPatchSchema, patch),
|
||||
),
|
||||
},
|
||||
navigate: async (location) => {
|
||||
await perform(bridge, ARTIFACT_NAVIGATION_CAPABILITIES.open, {
|
||||
url: check(url, location),
|
||||
});
|
||||
},
|
||||
search: async (query) => search(s, check(SearchQuerySchema, query)),
|
||||
askBrain: async (text) => {
|
||||
await perform(bridge, Action.AiPrompt, { prompt: check(prompt, text) });
|
||||
},
|
||||
toHtml: async (body) => toHtml(bridge, check(delta, body)),
|
||||
get viewer() {
|
||||
return (viewer ??= mapMember(bridge.viewer()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && "clickup" in window) {
|
||||
hostClient = createLiveClient();
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type {
|
||||
Delta,
|
||||
IsoDate,
|
||||
Ref,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from "../core";
|
||||
import { listOrGet, useLiveQuery } from "../useQuery";
|
||||
|
||||
export const COMMENT_TYPES = ["message", "ai", "activity"] as const;
|
||||
|
||||
/**
|
||||
* `name` is a one-line preview of the body. The last ancestor is the task,
|
||||
* or for a reply the comment it replies to.
|
||||
*/
|
||||
export interface Comment extends Ref<"comment"> {
|
||||
ancestors: Array<Ref<"space" | "folder" | "list" | "task" | "comment">>;
|
||||
createdAt: IsoDate;
|
||||
updatedAt: IsoDate;
|
||||
type: (typeof COMMENT_TYPES)[number];
|
||||
/** The body; render it with `RichText`. */
|
||||
body: Delta;
|
||||
creator: Ref<"user">;
|
||||
assignee?: Ref<"user">;
|
||||
/** Set once the comment is resolved; there is no separate resolved flag. */
|
||||
resolvedBy?: Ref<"user">;
|
||||
/** One entry per emoji, with the workspace members who reacted. */
|
||||
reactions: Array<{ emoji: string; users: Ref<"user">[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `resolved` resolves or reopens the comment (read it back through
|
||||
* `resolvedBy`), `assignee: null` unassigns, and `myReactions` replaces the
|
||||
* viewer's reactions, leaving everyone else's.
|
||||
*/
|
||||
export interface CommentPatch {
|
||||
/** Replaces the body; read it back as `body`. */
|
||||
bodyMarkdown?: string;
|
||||
assignee?: Ref<"user"> | null;
|
||||
resolved?: boolean;
|
||||
myReactions?: string[];
|
||||
}
|
||||
|
||||
/** `parent` is the task to comment on, or the comment to reply to. */
|
||||
export interface CreateCommentInput {
|
||||
parent: Ref<"task" | "comment">;
|
||||
/** The body, as markdown. */
|
||||
bodyMarkdown: string;
|
||||
assignee?: Ref<"user">;
|
||||
}
|
||||
|
||||
export const COMMENT_ORDERS = ["createdAt"] as const;
|
||||
|
||||
/**
|
||||
* Needs `parent` or `ancestor`. The other fields filter and sort on the
|
||||
* server before `limit` applies.
|
||||
* @example
|
||||
* useComments({ ancestor: task, resolved: false, assignee: "me" });
|
||||
*/
|
||||
export interface CommentSelector {
|
||||
/** A task, for its top-level comments; or a comment from `useComments`, for its replies. */
|
||||
parent?: Ref<"task" | "comment">;
|
||||
/** A task, for all its comments including replies. */
|
||||
ancestor?: Ref<"task"> | string;
|
||||
/** Defaults to 100, at most 500. */
|
||||
limit?: number;
|
||||
resolved?: boolean;
|
||||
/** `"me"` is the viewer. */
|
||||
assignee?: Ref<"user"> | "me";
|
||||
creator?: Ref<"user"> | "me";
|
||||
body?: { contains: string };
|
||||
/** Oldest first when omitted; `direction` defaults to `"asc"`. */
|
||||
orderBy?: {
|
||||
field: (typeof COMMENT_ORDERS)[number];
|
||||
direction?: "asc" | "desc";
|
||||
};
|
||||
}
|
||||
|
||||
export interface CommentsApi {
|
||||
list(selector: CommentSelector): Promise<Comment[]>;
|
||||
/** Comments on tasks; ids from chats or docs are left out. */
|
||||
get(targets: Array<Ref<"comment"> | string>): Promise<Comment[]>;
|
||||
create(input: CreateCommentInput): Promise<Ref<"comment">>;
|
||||
update(comment: Ref<"comment"> | string, patch: CommentPatch): Promise<void>;
|
||||
}
|
||||
|
||||
export function useComments(
|
||||
selector: CommentSelector | Array<Ref<"comment"> | string>,
|
||||
options: UseQueryOptions<Comment[]> = {},
|
||||
): UseQueryResult<Comment[]> {
|
||||
return useLiveQuery(
|
||||
"comment",
|
||||
selector,
|
||||
(client) => listOrGet(client.comments, selector),
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import type { Ref } from "../core";
|
||||
import { asRef, pathOf } from "../core";
|
||||
import type { LiveBridge } from "../host/bridge";
|
||||
import { toIsoOrEpoch } from "../host/dates";
|
||||
import { chunk, pages } from "../host/paging";
|
||||
import { paragraph, toEmoji } from "../host/quill";
|
||||
import type {
|
||||
ArtifactCommentDto,
|
||||
ArtifactCommentReactionDto,
|
||||
ArtifactCommentsQuery,
|
||||
} from "../host/wire";
|
||||
import { ARTIFACT_COMMENTS_MAX_LIMIT } from "../host/wire";
|
||||
import { userOf } from "../people/read";
|
||||
import { byIds, clampLimit } from "../select";
|
||||
import type { Session } from "../session";
|
||||
import { resolve } from "../session";
|
||||
import { getTasks } from "../tasks/read";
|
||||
import { fromHost } from "../validate";
|
||||
import type { Comment, CommentSelector } from "./comments";
|
||||
import { CommentSchema } from "./schema";
|
||||
|
||||
type CommentsInput = Omit<
|
||||
Extract<ArtifactCommentsQuery, { parent: unknown }>,
|
||||
"cursor" | "limit"
|
||||
>;
|
||||
|
||||
/**
|
||||
* The comments under the selector's parent, or every comment under its
|
||||
* ancestor task. A reply read along with its root sits under that root,
|
||||
* named as far as the result lists it.
|
||||
*/
|
||||
export async function readComments(
|
||||
s: Session,
|
||||
selector: CommentSelector,
|
||||
): Promise<Comment[]> {
|
||||
const limit = clampLimit(selector.limit);
|
||||
const parent = await commentParent(s, selector);
|
||||
const wire = await commentPages(
|
||||
s.host,
|
||||
{
|
||||
parent: {
|
||||
kind: parent.kind === "task" ? "task" : "thread",
|
||||
id: parent.id,
|
||||
},
|
||||
includeReplies: selector.ancestor !== undefined,
|
||||
resolved: selector.resolved,
|
||||
assignedTo: selector.assignee && memberId(s, selector.assignee),
|
||||
createdBy: selector.creator && memberId(s, selector.creator),
|
||||
textSearch: selector.body?.contains,
|
||||
orderDirection: selector.orderBy && (selector.orderBy.direction ?? "asc"),
|
||||
},
|
||||
limit,
|
||||
);
|
||||
const parentPath = [...pathOf(parent), asRef(parent)];
|
||||
const roots = new Map(wire.map((dto) => [dto.id, commentRef(dto)]));
|
||||
const comments = await mapComments(s, wire, (dto) =>
|
||||
dto.parentId === parent.id
|
||||
? parentPath
|
||||
: [
|
||||
...parentPath,
|
||||
roots.get(dto.parentId) ?? {
|
||||
kind: "comment",
|
||||
id: dto.parentId,
|
||||
name: "",
|
||||
url: "",
|
||||
},
|
||||
],
|
||||
);
|
||||
return fromHost(z.array(CommentSchema), comments.slice(0, limit), "comments");
|
||||
}
|
||||
|
||||
/** The selector's `parent`, or its `ancestor` task, as a full asset: a bare ref or id is read once for its path. */
|
||||
function commentParent(
|
||||
s: Session,
|
||||
{ parent, ancestor }: CommentSelector,
|
||||
): Promise<Ref<"task" | "comment">> {
|
||||
return parent?.kind === "comment"
|
||||
? resolve(s, "comment", parent, getComments)
|
||||
: resolve(s, "task", parent ?? ancestor!, getTasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly these comments, each under its task and, for a reply, its root
|
||||
* comment. A comment elsewhere (a chat, a doc) or on a task the viewer
|
||||
* cannot see is left out.
|
||||
*/
|
||||
export async function getComments(
|
||||
s: Session,
|
||||
ids: string[],
|
||||
): Promise<Comment[]> {
|
||||
const wire = await commentsByIds(s.host, ids);
|
||||
const byId = await withRoots(s.host, wire);
|
||||
const taskIds = wire.flatMap((dto) => taskIdOf(dto, byId) ?? []);
|
||||
const tasks = new Map(
|
||||
(await getTasks(s, [...new Set(taskIds)])).map((task) => [task.id, task]),
|
||||
);
|
||||
const comments = await mapComments(s, wire, (dto) => {
|
||||
const task = tasks.get(taskIdOf(dto, byId) ?? "");
|
||||
if (!task) {
|
||||
return undefined;
|
||||
}
|
||||
const taskPath = [...pathOf(task), asRef(task)];
|
||||
return dto.parentKind === "thread"
|
||||
? [...taskPath, commentRef(byId.get(dto.parentId)!)]
|
||||
: taskPath;
|
||||
});
|
||||
return fromHost(z.array(CommentSchema), byIds(comments, ids), "comments");
|
||||
}
|
||||
|
||||
/** `wire` by id, plus the root comments its replies hang off, read where `wire` lacks them. */
|
||||
async function withRoots(
|
||||
host: LiveBridge,
|
||||
wire: ArtifactCommentDto[],
|
||||
): Promise<Map<string, ArtifactCommentDto>> {
|
||||
const byId = new Map(wire.map((dto) => [dto.id, dto]));
|
||||
const missing = wire
|
||||
.filter((dto) => dto.parentKind === "thread" && !byId.has(dto.parentId))
|
||||
.map((dto) => dto.parentId);
|
||||
for (const root of await commentsByIds(host, [...new Set(missing)])) {
|
||||
byId.set(root.id, root);
|
||||
}
|
||||
return byId;
|
||||
}
|
||||
|
||||
/** The task a comment sits on, through its root for a reply; none for a comment elsewhere. */
|
||||
function taskIdOf(
|
||||
dto: ArtifactCommentDto,
|
||||
byId: ReadonlyMap<string, ArtifactCommentDto>,
|
||||
): string | undefined {
|
||||
const onTask = dto.parentKind === "thread" ? byId.get(dto.parentId) : dto;
|
||||
return onTask?.parentKind === "task" ? onTask.parentId : undefined;
|
||||
}
|
||||
|
||||
async function commentsByIds(
|
||||
host: LiveBridge,
|
||||
ids: string[],
|
||||
): Promise<ArtifactCommentDto[]> {
|
||||
const results = await Promise.all(
|
||||
chunk(ids, ARTIFACT_COMMENTS_MAX_LIMIT).map((batch) =>
|
||||
host.query("comments", { ids: batch }),
|
||||
),
|
||||
);
|
||||
return results.flatMap((result) => result.comments);
|
||||
}
|
||||
|
||||
function memberId(s: Session, user: Ref<"user"> | "me"): string {
|
||||
return user === "me" ? s.host.viewer().id : user.id;
|
||||
}
|
||||
|
||||
async function commentPages(
|
||||
host: LiveBridge,
|
||||
input: CommentsInput,
|
||||
limit: number,
|
||||
): Promise<ArtifactCommentDto[]> {
|
||||
const comments: ArtifactCommentDto[] = [];
|
||||
for await (const page of pages((cursor) =>
|
||||
host.query("comments", {
|
||||
...input,
|
||||
limit: Math.min(ARTIFACT_COMMENTS_MAX_LIMIT, limit - comments.length),
|
||||
cursor,
|
||||
}),
|
||||
)) {
|
||||
comments.push(...page.comments);
|
||||
if (comments.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return comments;
|
||||
}
|
||||
|
||||
/** Maps `wire` under the ancestors `place` gives each comment; one it places nowhere is left out. */
|
||||
function mapComments(
|
||||
s: Session,
|
||||
wire: ArtifactCommentDto[],
|
||||
place: (dto: ArtifactCommentDto) => Ref[] | undefined,
|
||||
): Promise<Comment[]> {
|
||||
return Promise.all(
|
||||
wire.flatMap((dto) => {
|
||||
const ancestors = place(dto);
|
||||
return ancestors
|
||||
? [mapComment(s, dto, ancestors as Comment["ancestors"])]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function commentRef(dto: ArtifactCommentDto): Ref<"comment"> {
|
||||
return {
|
||||
kind: "comment",
|
||||
id: dto.id,
|
||||
name: preview(dto.bodyText),
|
||||
url: dto.url,
|
||||
};
|
||||
}
|
||||
|
||||
async function mapComment(
|
||||
s: Session,
|
||||
wire: ArtifactCommentDto,
|
||||
parentPath: Comment["ancestors"],
|
||||
): Promise<Comment> {
|
||||
const [creator, assignee, resolvedBy, reactions] = await Promise.all([
|
||||
userOf(s, wire.creator),
|
||||
wire.assigneeId ? s.member(wire.assigneeId) : undefined,
|
||||
wire.resolved && wire.resolvedById
|
||||
? s.member(wire.resolvedById)
|
||||
: undefined,
|
||||
mapReactions(s, wire.reactions),
|
||||
]);
|
||||
return {
|
||||
kind: "comment",
|
||||
id: wire.id,
|
||||
name: preview(wire.bodyText),
|
||||
url: wire.url,
|
||||
ancestors: parentPath,
|
||||
createdAt: toIsoOrEpoch(wire.createdAt),
|
||||
updatedAt: toIsoOrEpoch(wire.updatedAt),
|
||||
type: toCommentType(wire.commentType),
|
||||
body: wire.bodyQuill ?? paragraph(wire.bodyMarkdown || wire.bodyText),
|
||||
creator,
|
||||
assignee,
|
||||
resolvedBy,
|
||||
reactions,
|
||||
};
|
||||
}
|
||||
|
||||
/** One entry per emoji, in first-seen order; reactors the workspace cannot name are left out. */
|
||||
async function mapReactions(
|
||||
s: Session,
|
||||
wire: readonly ArtifactCommentReactionDto[],
|
||||
): Promise<Comment["reactions"]> {
|
||||
const byEmoji = new Map<string, Ref<"user">[]>();
|
||||
for (const reaction of wire) {
|
||||
const emoji = toEmoji(reaction.emoji);
|
||||
const users = byEmoji.get(emoji) ?? [];
|
||||
byEmoji.set(emoji, users);
|
||||
const user = reaction.userId ? await s.member(reaction.userId) : undefined;
|
||||
if (user && !users.some((known) => known.id === user.id)) {
|
||||
users.push(user);
|
||||
}
|
||||
}
|
||||
return [...byEmoji].map(([emoji, users]) => ({ emoji, users }));
|
||||
}
|
||||
const PREVIEW_MAX_LENGTH = 120;
|
||||
|
||||
/** A comment's `name`: its body on one line, cut to a headline. */
|
||||
export function preview(text: string): string {
|
||||
const line = text.replace(/\s+/g, " ").trim();
|
||||
return line.length > PREVIEW_MAX_LENGTH
|
||||
? `${line.slice(0, PREVIEW_MAX_LENGTH - 1)}…`
|
||||
: line;
|
||||
}
|
||||
|
||||
function toCommentType(value: string): Comment["type"] {
|
||||
switch (value) {
|
||||
case "ai":
|
||||
case "activity":
|
||||
return value;
|
||||
default:
|
||||
return "message";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import {
|
||||
contains,
|
||||
delta,
|
||||
isoDate,
|
||||
orderBy,
|
||||
ref,
|
||||
refFields,
|
||||
refOrId,
|
||||
schemaOf,
|
||||
scoped,
|
||||
selection,
|
||||
} from "../validate";
|
||||
import type {
|
||||
Comment,
|
||||
CommentPatch,
|
||||
CommentSelector,
|
||||
CreateCommentInput,
|
||||
} from "./comments";
|
||||
import { COMMENT_ORDERS, COMMENT_TYPES } from "./comments";
|
||||
|
||||
export const CommentSchema = schemaOf<Comment>()(
|
||||
z.object({
|
||||
...refFields(["comment"]),
|
||||
ancestors: z.array(ref(["space", "folder", "list", "task", "comment"])),
|
||||
createdAt: isoDate,
|
||||
updatedAt: isoDate,
|
||||
type: z.enum(COMMENT_TYPES),
|
||||
body: delta,
|
||||
creator: ref(["user"]),
|
||||
assignee: z.optional(ref(["user"])),
|
||||
resolvedBy: z.optional(ref(["user"])),
|
||||
reactions: z.array(
|
||||
z.object({ emoji: z.string(), users: z.array(ref(["user"])) }),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const emoji = z.string().check(z.minLength(1, "Expected an emoji"));
|
||||
|
||||
export const CommentPatchSchema = schemaOf<CommentPatch>()(
|
||||
z.strictObject({
|
||||
bodyMarkdown: z.optional(
|
||||
z.string().check(z.minLength(1, "A comment needs a body")),
|
||||
),
|
||||
assignee: z.optional(z.nullable(ref(["user"]))),
|
||||
resolved: z.optional(z.boolean()),
|
||||
myReactions: z.optional(z.array(emoji)),
|
||||
}),
|
||||
);
|
||||
|
||||
export const CreateCommentInputSchema = schemaOf<CreateCommentInput>()(
|
||||
z.strictObject({
|
||||
parent: ref(["task", "comment"]),
|
||||
bodyMarkdown: z.string().check(z.minLength(1, "A comment needs a body")),
|
||||
assignee: z.optional(ref(["user"])),
|
||||
}),
|
||||
);
|
||||
|
||||
const userOrMe = z.union([ref(["user"]), z.literal("me")], {
|
||||
error: 'Expected a user ref, from useUsers, or "me"',
|
||||
});
|
||||
|
||||
export const CommentSelectorSchema = schemaOf<CommentSelector>()(
|
||||
z
|
||||
.strictObject({
|
||||
parent: z.optional(ref(["task", "comment"])),
|
||||
ancestor: z.optional(refOrId("task")),
|
||||
...selection,
|
||||
resolved: z.optional(z.boolean()),
|
||||
assignee: z.optional(userOrMe),
|
||||
creator: z.optional(userOrMe),
|
||||
body: contains(),
|
||||
orderBy: orderBy(COMMENT_ORDERS),
|
||||
})
|
||||
.check(scoped),
|
||||
);
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Ref } from "../core";
|
||||
import { asRef, LiveError, pathOf } from "../core";
|
||||
import type { WireCall } from "../host/perform";
|
||||
import { createdRef, perform, performAll } from "../host/perform";
|
||||
import { paragraph } from "../host/quill";
|
||||
import { ArtifactWriteActionType as Action } from "../host/wire";
|
||||
import { mapMember } from "../people/read";
|
||||
import type { Session } from "../session";
|
||||
import { resolve } from "../session";
|
||||
import { applyPatch } from "../store";
|
||||
import type { Comment, CommentPatch, CreateCommentInput } from "./comments";
|
||||
import { getComments, preview } from "./read";
|
||||
|
||||
export async function updateComment(
|
||||
s: Session,
|
||||
target: Ref<"comment"> | string,
|
||||
patch: CommentPatch,
|
||||
): Promise<void> {
|
||||
const comment = await resolve<Comment>(s, "comment", target, getComments);
|
||||
const viewer =
|
||||
patch.myReactions === undefined ? undefined : mapMember(s.host.viewer());
|
||||
await s.store.update(
|
||||
comment,
|
||||
(current) => applyCommentPatch(current, patch, viewer),
|
||||
() => performAll(s.host, commentPatchCalls(comment, patch, viewer)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Posts a comment on a task, or a reply under a comment (a `thread` to the host). */
|
||||
export async function createComment(
|
||||
s: Session,
|
||||
input: CreateCommentInput,
|
||||
): Promise<Ref<"comment">> {
|
||||
const { parent } = input;
|
||||
const outcome = await perform(s.host, Action.CreateComment, {
|
||||
parentKind: parent.kind === "task" ? "task" : "thread",
|
||||
parentId: parent.id,
|
||||
commentText: input.bodyMarkdown,
|
||||
});
|
||||
const created = createdRef("comment", outcome, preview(input.bodyMarkdown));
|
||||
if (input.assignee) {
|
||||
await perform(s.host, Action.AssignComment, {
|
||||
...commentTarget(created.id, [...pathOf(parent), asRef(parent)]),
|
||||
assigneeUserId: input.assignee.id,
|
||||
});
|
||||
}
|
||||
await s.store.invalidate("comment");
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered host calls that apply `patch` to `comment`. The host toggles a
|
||||
* reaction, so every emoji the viewer adds or drops is one `react` call.
|
||||
*/
|
||||
export function commentPatchCalls(
|
||||
comment: Comment,
|
||||
patch: CommentPatch,
|
||||
viewer: Ref<"user"> | undefined,
|
||||
): WireCall[] {
|
||||
const target = commentTarget(comment.id, comment.ancestors);
|
||||
const calls: WireCall[] = [];
|
||||
if (patch.bodyMarkdown !== undefined) {
|
||||
calls.push([Action.EditComment, { ...target, text: patch.bodyMarkdown }]);
|
||||
}
|
||||
if (patch.resolved === true) {
|
||||
calls.push([Action.ResolveComment, target]);
|
||||
} else if (patch.resolved === false) {
|
||||
calls.push([Action.ReopenComment, target]);
|
||||
}
|
||||
if (patch.assignee === null) {
|
||||
calls.push([Action.UnassignComment, target]);
|
||||
} else if (patch.assignee) {
|
||||
calls.push([
|
||||
Action.AssignComment,
|
||||
{ ...target, assigneeUserId: patch.assignee.id },
|
||||
]);
|
||||
}
|
||||
if (patch.myReactions !== undefined && viewer) {
|
||||
const current = myReactions(comment, viewer);
|
||||
const next = new Set(patch.myReactions);
|
||||
for (const emoji of [...next, ...current]) {
|
||||
if (next.has(emoji) !== current.has(emoji)) {
|
||||
calls.push([Action.ReactToComment, { ...target, emoji }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
function myReactions(comment: Comment, viewer: Ref<"user">): Set<string> {
|
||||
return new Set(
|
||||
comment.reactions
|
||||
.filter((reaction) =>
|
||||
reaction.users.some((user) => user.id === viewer.id),
|
||||
)
|
||||
.map((reaction) => reaction.emoji),
|
||||
);
|
||||
}
|
||||
|
||||
/** The host addresses a comment by its task, and a reply by its root comment plus the reply id. */
|
||||
function commentTarget(
|
||||
id: string,
|
||||
ancestors: readonly Ref[],
|
||||
): Record<string, string> {
|
||||
const task = ancestors.find((ref) => ref.kind === "task");
|
||||
if (!task) {
|
||||
throw new LiveError("invalid", "Only comments on tasks can be acted on.");
|
||||
}
|
||||
const parent = ancestors[ancestors.length - 1];
|
||||
return parent?.kind === "comment"
|
||||
? { taskId: task.id, commentId: parent.id, threadedCommentId: id }
|
||||
: { taskId: task.id, commentId: id };
|
||||
}
|
||||
|
||||
/** The comment as it will read back once the host has applied `patch`; `resolvedBy` and a rendered body arrive with the refetch that follows. */
|
||||
export function applyCommentPatch(
|
||||
comment: Comment,
|
||||
patch: CommentPatch,
|
||||
viewer: Ref<"user"> | undefined,
|
||||
): Comment {
|
||||
const { bodyMarkdown, resolved, myReactions: mine, ...fields } = patch;
|
||||
const next = applyPatch(comment, fields);
|
||||
if (bodyMarkdown !== undefined) {
|
||||
next.name = preview(bodyMarkdown);
|
||||
next.body = paragraph(bodyMarkdown);
|
||||
}
|
||||
if (resolved === false) {
|
||||
delete next.resolvedBy;
|
||||
}
|
||||
if (mine !== undefined && viewer) {
|
||||
next.reactions = withViewerReactions(comment.reactions, viewer, mine);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** `reactions` with the viewer on exactly the `emojis`; an emoji nobody reacts with anymore goes. */
|
||||
function withViewerReactions(
|
||||
reactions: Comment["reactions"],
|
||||
viewer: Ref<"user">,
|
||||
emojis: string[],
|
||||
): Comment["reactions"] {
|
||||
const wanted = new Set(emojis);
|
||||
const kept = reactions
|
||||
.map(({ emoji, users }) => {
|
||||
const others = users.filter((user) => user.id !== viewer.id);
|
||||
return { emoji, users: wanted.has(emoji) ? [...others, viewer] : others };
|
||||
})
|
||||
.filter((reaction) => reaction.users.length > 0);
|
||||
const known = new Set(kept.map((reaction) => reaction.emoji));
|
||||
return [
|
||||
...kept,
|
||||
...emojis
|
||||
.filter((emoji) => !known.has(emoji))
|
||||
.map((emoji) => ({ emoji, users: [viewer] })),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
||||
import type { Delta, Ref } from "../core";
|
||||
import type { CustomField } from "../customFields/customFields";
|
||||
import type { Status, Task } from "../tasks/tasks";
|
||||
import { useLiveQuery } from "../useQuery";
|
||||
import { CheckIcon, FlagIcon, NoPriorityIcon, PeopleIcon } from "./icons";
|
||||
import {
|
||||
avatarColor,
|
||||
cx,
|
||||
fill,
|
||||
initials,
|
||||
PRIORITY_COLORS,
|
||||
PRIORITY_LABELS,
|
||||
} from "./palette";
|
||||
|
||||
export interface AvatarProps {
|
||||
/** A user or team, e.g. `task.creator` or one of `task.assignees`. */
|
||||
of: Ref<"user" | "team">;
|
||||
/** Diameter in px; 28 by default. */
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_AVATAR_SIZE = 28;
|
||||
const MIN_INITIALS_PX = 12;
|
||||
|
||||
function avatarStyle(size: number): CSSProperties {
|
||||
return {
|
||||
fontSize: Math.max(MIN_INITIALS_PX, Math.round(size * 0.42)),
|
||||
height: size,
|
||||
width: size,
|
||||
};
|
||||
}
|
||||
|
||||
const AVATAR_CLASS =
|
||||
"inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full font-semibold leading-none tracking-tight";
|
||||
|
||||
/** A round avatar: the image, else initials for a user or a people glyph for a team. */
|
||||
export function Avatar({
|
||||
of,
|
||||
size = DEFAULT_AVATAR_SIZE,
|
||||
className,
|
||||
}: AvatarProps): ReactElement {
|
||||
const base = cx(AVATAR_CLASS, className);
|
||||
if (of.image) {
|
||||
return (
|
||||
<span className={base} style={avatarStyle(size)} title={of.name}>
|
||||
<img
|
||||
alt={of.name}
|
||||
className="h-full w-full object-cover"
|
||||
src={of.image}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const { background, text } = fill(avatarColor(of.id))!;
|
||||
return (
|
||||
<span
|
||||
aria-label={of.name}
|
||||
className={base}
|
||||
role="img"
|
||||
style={{ ...avatarStyle(size), backgroundColor: background, color: text }}
|
||||
title={of.name}
|
||||
>
|
||||
{of.kind === "team" ? (
|
||||
<PeopleIcon height="1.25em" width="1.25em" />
|
||||
) : (
|
||||
initials(of.name)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AvatarGroupProps {
|
||||
/** Users and teams, e.g. `task.assignees`. */
|
||||
of: Array<Ref<"user" | "team">>;
|
||||
/** How many to show before the rest collapse into a `+N` bubble; 3 by default. */
|
||||
max?: number;
|
||||
/** Diameter of each avatar in px; 28 by default. */
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Overlapping avatars with a `+N` bubble for the overflow; renders nothing for an empty list. */
|
||||
export function AvatarGroup({
|
||||
of,
|
||||
max = 3,
|
||||
size = DEFAULT_AVATAR_SIZE,
|
||||
className,
|
||||
}: AvatarGroupProps): ReactElement | null {
|
||||
if (of.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const shown = of.length > max ? of.slice(0, max) : of;
|
||||
const hidden = of.length - shown.length;
|
||||
const overlap = { marginLeft: -Math.round(size / 5) };
|
||||
return (
|
||||
<span className={cx("inline-flex items-center", className)}>
|
||||
{shown.map((member, index) => (
|
||||
<span
|
||||
className="inline-flex"
|
||||
key={`${member.kind}:${member.id}`}
|
||||
style={index > 0 ? overlap : undefined}
|
||||
>
|
||||
<Avatar className="ring-2 ring-background" of={member} size={size} />
|
||||
</span>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<span
|
||||
className={cx(
|
||||
AVATAR_CLASS,
|
||||
"bg-muted text-muted-foreground ring-2 ring-background",
|
||||
)}
|
||||
style={{ ...avatarStyle(size), ...overlap }}
|
||||
>
|
||||
+{hidden}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface StatusBadgeProps {
|
||||
/** `task.status`, or one from `useStatuses`. */
|
||||
status: Status;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The status name as ClickUp's filled, uppercase badge. Sized by the surrounding font; `className="text-xs"` makes it compact. */
|
||||
export function StatusBadge({
|
||||
status,
|
||||
className,
|
||||
}: StatusBadgeProps): ReactElement {
|
||||
return (
|
||||
<Pill
|
||||
className={cx(
|
||||
"rounded-md px-[0.7em] py-[0.36em] font-semibold uppercase tracking-wide",
|
||||
className,
|
||||
)}
|
||||
color={status.color}
|
||||
>
|
||||
{status.name}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PriorityFlagProps {
|
||||
/** `task.priority`; `undefined` renders nothing unless `showEmpty`. */
|
||||
priority: Task["priority"];
|
||||
/** Renders a slashed circle for `undefined`; off by default. */
|
||||
showEmpty?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** ClickUp's priority flag, without a label. Sized by the surrounding font. */
|
||||
export function PriorityFlag({
|
||||
priority,
|
||||
showEmpty = false,
|
||||
className,
|
||||
}: PriorityFlagProps): ReactElement | null {
|
||||
if (!priority && !showEmpty) {
|
||||
return null;
|
||||
}
|
||||
const color =
|
||||
priority && priority !== "low" ? PRIORITY_COLORS[priority] : undefined;
|
||||
return (
|
||||
<span
|
||||
aria-label={priority ? PRIORITY_LABELS[priority] : "No priority"}
|
||||
className={cx(
|
||||
"inline-flex items-center",
|
||||
!color && "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
role="img"
|
||||
>
|
||||
{priority ? (
|
||||
<FlagIcon style={color ? { color } : undefined} />
|
||||
) : (
|
||||
<NoPriorityIcon />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TagProps {
|
||||
/** One of `task.tags`. */
|
||||
tag: Task["tags"][number];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** A tag as ClickUp's filled pill. Sized by the surrounding font. */
|
||||
export function Tag({ tag, className }: TagProps): ReactElement {
|
||||
return (
|
||||
<Pill
|
||||
className={cx("rounded-full px-[0.85em] py-[0.32em]", className)}
|
||||
color={tag.color}
|
||||
>
|
||||
{tag.name}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CustomFieldValueProps {
|
||||
/** One of `task.customFields`. */
|
||||
value: Task["customFields"][number];
|
||||
/** The field's definition from `useCustomFields`, matched by `id`; adds the option colors. */
|
||||
field?: CustomField;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PROGRESS_TYPES = new Set<CustomField["type"]>([
|
||||
"manual_progress",
|
||||
"automatic_progress",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A custom field value in the shape its type calls for: a check, a number, a
|
||||
* progress bar, a date, colored option pills, avatars for people, names for
|
||||
* tasks, links for attachments. Sized by the surrounding font.
|
||||
*/
|
||||
export function CustomFieldValue({
|
||||
value,
|
||||
field,
|
||||
className,
|
||||
}: CustomFieldValueProps): ReactElement {
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
"inline-flex items-center gap-1 text-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{renderFieldValue(value, field)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFieldValue(
|
||||
{ type, value }: Task["customFields"][number],
|
||||
field: CustomField | undefined,
|
||||
): ReactNode {
|
||||
if (typeof value === "boolean") {
|
||||
return value ? (
|
||||
<CheckIcon aria-hidden={false} aria-label="Yes" role="img" />
|
||||
) : (
|
||||
<span aria-label="No" className="text-muted-foreground" role="img">
|
||||
–
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return PROGRESS_TYPES.has(type) ? (
|
||||
<Progress percent={value} />
|
||||
) : (
|
||||
value.toLocaleString()
|
||||
);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (type === "date") {
|
||||
return formatDate(value);
|
||||
}
|
||||
if (type === "drop_down") {
|
||||
return <OptionPill field={field} name={value} />;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (type === "attachment") {
|
||||
return (value as string[]).map((url) => (
|
||||
<a
|
||||
className="underline"
|
||||
href={url}
|
||||
key={url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{fileName(url)}
|
||||
</a>
|
||||
));
|
||||
}
|
||||
if (type === "labels") {
|
||||
return (value as string[]).map((name) => (
|
||||
<OptionPill field={field} key={name} name={name} />
|
||||
));
|
||||
}
|
||||
const refs = value as Array<Ref<"user" | "team" | "task">>;
|
||||
if (type === "users") {
|
||||
return <AvatarGroup of={refs as Array<Ref<"user" | "team">>} size={24} />;
|
||||
}
|
||||
return refs.map((ref) => ref.name).join(", ");
|
||||
}
|
||||
|
||||
function fileName(url: string): string {
|
||||
const last = url.split("?")[0]!.split("/").pop();
|
||||
return last ? decodeURIComponent(last) : url;
|
||||
}
|
||||
|
||||
function OptionPill({
|
||||
field,
|
||||
name,
|
||||
}: {
|
||||
field: CustomField | undefined;
|
||||
name: string;
|
||||
}) {
|
||||
const color = field?.options?.find((option) => option.name === name)?.color;
|
||||
return (
|
||||
<Pill className="rounded-full px-[0.75em] py-[0.25em]" color={color}>
|
||||
{name}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
|
||||
function Progress({ percent }: { percent: number }) {
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={clamped}
|
||||
className="inline-block h-1.5 w-24 overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
>
|
||||
<span
|
||||
className="block h-full bg-foreground"
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-muted-foreground">{Math.round(clamped)}%</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime())
|
||||
? iso
|
||||
: date.toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** A filled pill with readable text; an unusable color falls back to the muted token. */
|
||||
function Pill({
|
||||
children,
|
||||
className,
|
||||
color,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className: string;
|
||||
color: string | undefined;
|
||||
}) {
|
||||
const resolved = color ? fill(color) : undefined;
|
||||
const style: CSSProperties | undefined = resolved
|
||||
? { backgroundColor: resolved.background, color: resolved.text }
|
||||
: undefined;
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
"inline-flex items-center whitespace-nowrap leading-none",
|
||||
!resolved && "bg-muted text-foreground",
|
||||
(!resolved || resolved.edge) && "ring-1 ring-inset ring-foreground/15",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RichTextProps {
|
||||
/** A task or comment `body`. */
|
||||
value: Delta;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A body as ClickUp shows it, rendered by the host: mentions, tables, embeds
|
||||
* and the rest. Empty while the HTML loads; plain text if the host cannot
|
||||
* render it.
|
||||
*/
|
||||
export function RichText({ value, className }: RichTextProps): ReactElement {
|
||||
const { data, isError } = useLiveQuery(
|
||||
"html",
|
||||
value,
|
||||
async (client) => [{ html: await client.toHtml(value) }],
|
||||
{ cache: false },
|
||||
);
|
||||
const classes = cx("prose prose-sm max-w-none dark:prose-invert", className);
|
||||
if (isError) {
|
||||
return (
|
||||
<div className={cx(classes, "whitespace-pre-wrap")}>
|
||||
{plainText(value)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
dangerouslySetInnerHTML={{ __html: data?.[0]?.html ?? "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function plainText({ ops }: Delta): string {
|
||||
return ops
|
||||
.map((op) => (typeof op.insert === "string" ? op.insert : ""))
|
||||
.join("");
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
type IconProps = Omit<SVGProps<SVGSVGElement>, "children" | "viewBox">;
|
||||
|
||||
const base = {
|
||||
"aria-hidden": true,
|
||||
fill: "currentColor",
|
||||
height: "1em",
|
||||
viewBox: "0 0 24 24",
|
||||
width: "1em",
|
||||
} as const;
|
||||
|
||||
export function FlagIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base} {...props}>
|
||||
<path d="M5 21a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h9.1a1 1 0 0 1 .98.8L15.4 5.5H20a1 1 0 0 1 1 1V15a1 1 0 0 1-1 1h-5.9a1 1 0 0 1-.98-.8L12.8 13.5H6V20a1 1 0 0 1-1 1Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoPriorityIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base} {...props}>
|
||||
<path d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm0 2a8 8 0 0 0-6.32 12.9L16.9 5.68A7.96 7.96 0 0 0 12 4Zm6.32 3.1L7.1 18.32A8 8 0 0 0 18.32 7.1Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PeopleIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base} {...props}>
|
||||
<path d="M16 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-8 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5C15 14.17 10.33 13 8 13Zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base} {...props}>
|
||||
<path d="M9.55 17.65 4.3 12.4l1.4-1.4 3.85 3.85 8.75-8.75 1.4 1.4Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { Status } from "../tasks/tasks";
|
||||
import type { Task } from "../tasks/tasks";
|
||||
|
||||
export type Rgb = readonly [number, number, number];
|
||||
|
||||
/** A background with a text color that reads on it; `edge` marks fills near white or black. */
|
||||
export interface Fill {
|
||||
background: string;
|
||||
text: string;
|
||||
edge: boolean;
|
||||
}
|
||||
|
||||
/** The APCA Lc body text needs. */
|
||||
export const READABLE_LC = 66;
|
||||
|
||||
const WHITE: Rgb = [255, 255, 255];
|
||||
const BLACK: Rgb = [0, 0, 0];
|
||||
const INK: Rgb = [17, 24, 39];
|
||||
const NUDGE_STEPS = 10;
|
||||
const EDGE_LUMINANCE = { dark: 0.04, light: 0.7 };
|
||||
|
||||
/**
|
||||
* The fill for a workspace color: white or near-black text, whichever reads
|
||||
* better, over the color darkened or lightened just enough for that to hold;
|
||||
* `undefined` when the color cannot be parsed.
|
||||
*/
|
||||
export function fill(color: string): Fill | undefined {
|
||||
const source = parseColor(color);
|
||||
if (!source) {
|
||||
return undefined;
|
||||
}
|
||||
const towards = bestText(source) === WHITE ? BLACK : WHITE;
|
||||
for (let step = 0; step <= NUDGE_STEPS; step++) {
|
||||
const background = mix(source, towards, step / NUDGE_STEPS);
|
||||
const text = bestText(background);
|
||||
if (Math.abs(apcaContrast(text, background)) >= READABLE_LC) {
|
||||
return toFill(background, text);
|
||||
}
|
||||
}
|
||||
return toFill(towards, towards === BLACK ? WHITE : INK);
|
||||
}
|
||||
|
||||
function toFill(background: Rgb, text: Rgb): Fill {
|
||||
const luminance = screenLuminance(background);
|
||||
return {
|
||||
background: toHex(background),
|
||||
text: toHex(text),
|
||||
edge: luminance > EDGE_LUMINANCE.light || luminance < EDGE_LUMINANCE.dark,
|
||||
};
|
||||
}
|
||||
|
||||
function bestText(background: Rgb): Rgb {
|
||||
const white = Math.abs(apcaContrast(WHITE, background));
|
||||
const ink = Math.abs(apcaContrast(INK, background));
|
||||
return white >= ink ? WHITE : INK;
|
||||
}
|
||||
|
||||
function mix(from: Rgb, to: Rgb, amount: number): Rgb {
|
||||
const channel = (index: 0 | 1 | 2) =>
|
||||
Math.round(from[index] + (to[index] - from[index]) * amount);
|
||||
return [channel(0), channel(1), channel(2)];
|
||||
}
|
||||
|
||||
const HEX_PATTERN = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
const RGB_PATTERN =
|
||||
/^rgba?\(\s*(\d{1,3}(?:\.\d+)?)[\s,]+(\d{1,3}(?:\.\d+)?)[\s,]+(\d{1,3}(?:\.\d+)?)(?:\s*[,/]\s*[\d.]+%?)?\s*\)$/i;
|
||||
|
||||
/** `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()` and `rgba()`; alpha is ignored. */
|
||||
export function parseColor(value: string): Rgb | undefined {
|
||||
const color = value.trim();
|
||||
const hex = HEX_PATTERN.exec(color)?.[1];
|
||||
if (hex) {
|
||||
const digits = hex.length <= 4 ? [...hex].map((c) => c + c).join("") : hex;
|
||||
const number = Number.parseInt(digits.slice(0, 6), 16);
|
||||
return [(number >> 16) & 0xff, (number >> 8) & 0xff, number & 0xff];
|
||||
}
|
||||
const rgb = RGB_PATTERN.exec(color);
|
||||
if (rgb) {
|
||||
const [red, green, blue] = rgb.slice(1, 4).map(Number) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
if (Math.max(red, green, blue) <= 255) {
|
||||
return [Math.round(red), Math.round(green), Math.round(blue)];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function toHex([red, green, blue]: Rgb): string {
|
||||
return `#${[red, green, blue]
|
||||
.map((channel) => channel.toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* APCA-W3 0.0.98G-4g lightness contrast: positive for dark-on-light, negative
|
||||
* for light-on-dark; |Lc| runs 0 to about 108, higher reads better.
|
||||
*/
|
||||
export function apcaContrast(text: Rgb, background: Rgb): number {
|
||||
const textY = softClampBlack(screenLuminance(text));
|
||||
const backgroundY = softClampBlack(screenLuminance(background));
|
||||
if (Math.abs(backgroundY - textY) < 0.0005) {
|
||||
return 0;
|
||||
}
|
||||
if (backgroundY > textY) {
|
||||
const sapc = (backgroundY ** 0.56 - textY ** 0.57) * 1.14;
|
||||
return sapc < 0.1 ? 0 : (sapc - 0.027) * 100;
|
||||
}
|
||||
const sapc = (backgroundY ** 0.65 - textY ** 0.62) * 1.14;
|
||||
return sapc > -0.1 ? 0 : (sapc + 0.027) * 100;
|
||||
}
|
||||
|
||||
function screenLuminance([red, green, blue]: Rgb): number {
|
||||
const linear = (channel: number) => (channel / 255) ** 2.4;
|
||||
return (
|
||||
0.2126729 * linear(red) +
|
||||
0.7151522 * linear(green) +
|
||||
0.072175 * linear(blue)
|
||||
);
|
||||
}
|
||||
|
||||
function softClampBlack(luminance: number): number {
|
||||
return luminance > 0.022
|
||||
? luminance
|
||||
: luminance + (0.022 - luminance) ** 1.414;
|
||||
}
|
||||
|
||||
/** ClickUp's priority colors; `low` has none and uses the muted text token. */
|
||||
export const PRIORITY_COLORS: Readonly<
|
||||
Record<Exclude<NonNullable<Task["priority"]>, "low">, string>
|
||||
> = {
|
||||
urgent: "#c62a2f",
|
||||
high: "#ffc53d",
|
||||
normal: "#3e63dd",
|
||||
};
|
||||
|
||||
/** ClickUp's gray, for tags and field options given no color. */
|
||||
export const DEFAULT_COLOR = "#87909e";
|
||||
|
||||
/** ClickUp's default status colors, for statuses whose list gave them none. */
|
||||
export const STATUS_TYPE_COLORS: Readonly<Record<Status["type"], string>> = {
|
||||
open: DEFAULT_COLOR,
|
||||
active: "#5f55ee",
|
||||
done: "#008844",
|
||||
closed: "#656f7d",
|
||||
};
|
||||
|
||||
export const PRIORITY_LABELS: Readonly<
|
||||
Record<NonNullable<Task["priority"]>, string>
|
||||
> = {
|
||||
urgent: "Urgent",
|
||||
high: "High",
|
||||
normal: "Normal",
|
||||
low: "Low",
|
||||
};
|
||||
|
||||
/** ClickUp's avatar color picker; `avatarColor` picks one by id. */
|
||||
export const AVATAR_COLORS: readonly string[] = [
|
||||
"#6647f0",
|
||||
"#3e63dd",
|
||||
"#0091ff",
|
||||
"#12a594",
|
||||
"#248f7d",
|
||||
"#ffc53d",
|
||||
"#f76808",
|
||||
"#e5484d",
|
||||
"#e93d82",
|
||||
"#ab4aba",
|
||||
"#a18072",
|
||||
];
|
||||
|
||||
export function avatarColor(id: string): string {
|
||||
let hash = 5381;
|
||||
for (const char of id) {
|
||||
hash = (hash * 33) ^ char.charCodeAt(0);
|
||||
}
|
||||
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]!;
|
||||
}
|
||||
|
||||
/** The first letter of the first two words; `?` for an empty name. */
|
||||
export function initials(name: string): string {
|
||||
const letters = name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((word) => word[0]!.toUpperCase());
|
||||
return letters.join("") || "?";
|
||||
}
|
||||
|
||||
export function cx(
|
||||
...parts: ReadonlyArray<string | false | null | undefined>
|
||||
): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { CommentsApi } from "./comments/comments";
|
||||
import type { CustomFieldsApi } from "./customFields/customFields";
|
||||
import type { FoldersApi, ListsApi, SpacesApi } from "./hierarchy/hierarchy";
|
||||
import type { InboxApi } from "./inbox/inbox";
|
||||
import type { TeamsApi, User, UsersApi } from "./people/people";
|
||||
import type { RemindersApi } from "./reminders/reminders";
|
||||
import type { SearchHit, SearchQuery } from "./search/search";
|
||||
import type { Store } from "./store";
|
||||
import type { TasksApi } from "./tasks/tasks";
|
||||
|
||||
/**
|
||||
* An ISO 8601 string as `Date#toISOString()` writes it; a plain `YYYY-MM-DD`
|
||||
* is accepted as input and means that day in the viewer's timezone. A
|
||||
* required date the host has no value for reads as the Unix epoch,
|
||||
* `1970-01-01T00:00:00.000Z`.
|
||||
*/
|
||||
export type IsoDate = string;
|
||||
|
||||
/** Rich text as a Quill delta, plain JSON ops; render it with `RichText`. */
|
||||
export interface Delta {
|
||||
ops: ReadonlyArray<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export const ASSET_KINDS = [
|
||||
"space",
|
||||
"folder",
|
||||
"list",
|
||||
"status",
|
||||
"customField",
|
||||
"task",
|
||||
"taskType",
|
||||
"comment",
|
||||
"user",
|
||||
"team",
|
||||
"inboxBundle",
|
||||
"reminder",
|
||||
] as const;
|
||||
export type AssetKind = (typeof ASSET_KINDS)[number];
|
||||
|
||||
/**
|
||||
* A pointer to an asset; every full asset (a `Task`, a `List`, …) is also a
|
||||
* `Ref`, so anything a hook returns can be passed where a ref is expected.
|
||||
* Where a parameter is `Ref | string`, the string is the asset's id.
|
||||
*/
|
||||
export interface Ref<K extends AssetKind = AssetKind> {
|
||||
kind: K;
|
||||
id: string;
|
||||
/** Empty when the viewer cannot see the asset, e.g. a related task in a private list. */
|
||||
name: string;
|
||||
/**
|
||||
* Link to the asset in ClickUp; empty for statuses, custom fields and
|
||||
* reminders. Users and teams link to the workspace's People page.
|
||||
*/
|
||||
url: string;
|
||||
/** Avatar or thumbnail, where the asset has one. */
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function idOf(input: Ref | string): string {
|
||||
return typeof input === "string" ? input : input.id;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function asRef<K extends AssetKind>(asset: Ref<K>): Ref<K> {
|
||||
const { kind, id, name, url, image } = asset;
|
||||
return { kind, id, name, url, image };
|
||||
}
|
||||
|
||||
/** @internal The `ancestors` of a full asset; none for a bare ref. */
|
||||
export function pathOf(ref: Ref): Ref[] {
|
||||
const ancestors = (ref as { ancestors?: unknown }).ancestors;
|
||||
return Array.isArray(ancestors) ? (ancestors as Ref[]) : [];
|
||||
}
|
||||
|
||||
/** `pending` until the first result or failure. */
|
||||
export type QueryStatus = "pending" | "error" | "success";
|
||||
|
||||
/** TanStack Query v5's `useQuery` result. */
|
||||
export interface UseQueryResult<T> {
|
||||
/** Undefined until the first result; kept while refetching. */
|
||||
data: T | undefined;
|
||||
error: LiveError | null;
|
||||
status: QueryStatus;
|
||||
isPending: boolean;
|
||||
/** Pending and fetching: the first result is on its way. */
|
||||
isLoading: boolean;
|
||||
/** True while a request is in flight, including the refresh behind a cached result. */
|
||||
isFetching: boolean;
|
||||
/** Fetching with data already showing. */
|
||||
isRefetching: boolean;
|
||||
/** True while `data` is the option's `placeholderData`, not a result. */
|
||||
isPlaceholderData: boolean;
|
||||
isError: boolean;
|
||||
isSuccess: boolean;
|
||||
/** When `data` was read from ClickUp, as a timestamp; 0 without data. */
|
||||
dataUpdatedAt: number;
|
||||
/** Reloads from ClickUp. Rarely needed, since writes refetch what they change. */
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UseQueryOptions<T = unknown> {
|
||||
/** Pass false to skip fetching, e.g. while an input is still unknown. */
|
||||
enabled?: boolean;
|
||||
/** Shown as `data` until the first result, e.g. `keepPreviousData` to keep the last rows while a changed selector loads. */
|
||||
placeholderData?: T | ((previous: T | undefined) => T | undefined);
|
||||
/** Milliseconds between refetches while the tab is visible; never under 10 000. Rarely needed: writes refetch, and so does returning to the tab. */
|
||||
refetchInterval?: number;
|
||||
/** Pass false to wait for fresh data instead of painting the viewer's last result first. Rarely needed: the data refreshes anyway. */
|
||||
cache?: boolean;
|
||||
/** @internal */
|
||||
client?: LiveClient;
|
||||
}
|
||||
|
||||
/** A `placeholderData` that keeps the previous selector's rows on screen while the new ones load. */
|
||||
export function keepPreviousData<T>(previous: T | undefined): T | undefined {
|
||||
return previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* - `denied`: the viewer declined the approval in ClickUp.
|
||||
* - `forbidden`: the host or viewer is not allowed to; hide or disable the feature.
|
||||
* - `invalid`: the call itself is wrong (selector, id or input); fix the code.
|
||||
* - `failed`: something went wrong; retry when `retryable`.
|
||||
*/
|
||||
export type LiveErrorCode = "denied" | "forbidden" | "invalid" | "failed";
|
||||
|
||||
export class LiveError extends Error {
|
||||
readonly code: LiveErrorCode;
|
||||
/** True for rate limits, timeouts and other transient errors. */
|
||||
readonly retryable: boolean;
|
||||
/** The host's raw reason, for debugging. */
|
||||
readonly reason?: string;
|
||||
|
||||
constructor(
|
||||
code: LiveErrorCode,
|
||||
message: string,
|
||||
options: { retryable?: boolean; reason?: string } = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "LiveError";
|
||||
this.code = code;
|
||||
this.retryable = options.retryable ?? false;
|
||||
if (options.reason !== undefined) {
|
||||
this.reason = options.reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same reads as the hooks, as promises, plus all writes. Each changed
|
||||
* field needs the viewer's approval in ClickUp; a write stops at the first
|
||||
* denial.
|
||||
*/
|
||||
export interface LiveClient {
|
||||
/** @internal */
|
||||
readonly store: Store;
|
||||
spaces: SpacesApi;
|
||||
folders: FoldersApi;
|
||||
lists: ListsApi;
|
||||
customFields: CustomFieldsApi;
|
||||
tasks: TasksApi;
|
||||
comments: CommentsApi;
|
||||
users: UsersApi;
|
||||
teams: TeamsApi;
|
||||
inbox: InboxApi;
|
||||
reminders: RemindersApi;
|
||||
/** Opens a ClickUp url in the app. */
|
||||
navigate(url: string): Promise<void>;
|
||||
/** ClickUp's own search, run as the viewer. */
|
||||
search(query: SearchQuery): Promise<SearchHit[]>;
|
||||
/** Sends a prompt to Brain, ClickUp's AI, on the user's behalf. */
|
||||
askBrain(prompt: string): Promise<void>;
|
||||
/** A task or comment `body` as the HTML ClickUp shows for it; `RichText` renders this. */
|
||||
toHtml(body: Delta): Promise<string>;
|
||||
/** The user viewing the artifact, known to the host up front: no read, no loading state. */
|
||||
readonly viewer: User;
|
||||
}
|
||||
|
||||
export type MutationStatus = "idle" | "pending" | "success" | "error";
|
||||
|
||||
export type MutationFunction<TData, TVariables> = (
|
||||
variables: TVariables,
|
||||
) => Promise<TData>;
|
||||
|
||||
export interface MutationOptions<TData, TVariables> {
|
||||
onSuccess?: (data: TData, variables: TVariables) => void;
|
||||
onError?: (error: Error, variables: TVariables) => void;
|
||||
onSettled?: (
|
||||
data: TData | undefined,
|
||||
error: Error | null,
|
||||
variables: TVariables,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface UseMutationOptions<TData, TVariables> extends MutationOptions<
|
||||
TData,
|
||||
TVariables
|
||||
> {
|
||||
mutationFn: MutationFunction<TData, TVariables>;
|
||||
}
|
||||
|
||||
export interface UseMutationResult<TData, TVariables> {
|
||||
data: TData | undefined;
|
||||
error: Error | null;
|
||||
/** What the last `mutate` was called with; undefined while idle. */
|
||||
variables: TVariables | undefined;
|
||||
isError: boolean;
|
||||
isPending: boolean;
|
||||
isSuccess: boolean;
|
||||
status: MutationStatus;
|
||||
/** Never throws; failures show up in `error`. */
|
||||
mutate: (
|
||||
variables: TVariables,
|
||||
options?: MutationOptions<TData, TVariables>,
|
||||
) => void;
|
||||
mutateAsync: (
|
||||
variables: TVariables,
|
||||
options?: MutationOptions<TData, TVariables>,
|
||||
) => Promise<TData>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
interface MutationState<TData, TVariables> {
|
||||
data: TData | undefined;
|
||||
error: Error | null;
|
||||
variables: TVariables | undefined;
|
||||
status: MutationStatus;
|
||||
}
|
||||
|
||||
const IDLE_STATE: MutationState<never, never> = {
|
||||
data: undefined,
|
||||
error: null,
|
||||
variables: undefined,
|
||||
status: "idle",
|
||||
};
|
||||
|
||||
/**
|
||||
* TanStack Query v5's `useMutation`; a bare function is taken as
|
||||
* `mutationFn`. Call `mutate` from an event handler and leave the rest to
|
||||
* the kit: on-screen queries update optimistically, roll back if the viewer
|
||||
* declines, and refetch on success.
|
||||
* @example
|
||||
* const complete = useMutation({ mutationFn: (status: Status) => clickup.tasks.update(task, { status }) });
|
||||
* <button disabled={complete.isPending} onClick={() => complete.mutate(done)}>Mark done</button>
|
||||
*/
|
||||
export function useMutation<TData, TVariables = void>(
|
||||
options:
|
||||
| UseMutationOptions<TData, TVariables>
|
||||
| MutationFunction<TData, TVariables>,
|
||||
): UseMutationResult<TData, TVariables> {
|
||||
const { mutationFn, ...hookCallbacks } =
|
||||
typeof options === "function" ? { mutationFn: options } : options;
|
||||
const [state, setState] =
|
||||
useState<MutationState<TData, TVariables>>(IDLE_STATE);
|
||||
const latestMutation = useRef(0);
|
||||
const latestCallbacks = useRef(hookCallbacks);
|
||||
latestCallbacks.current = hookCallbacks;
|
||||
|
||||
const mutateAsync = useCallback(
|
||||
async (
|
||||
variables: TVariables,
|
||||
callbacks?: MutationOptions<TData, TVariables>,
|
||||
): Promise<TData> => {
|
||||
const mutationId = ++latestMutation.current;
|
||||
setState({ data: undefined, error: null, variables, status: "pending" });
|
||||
try {
|
||||
const data = await mutationFn(variables);
|
||||
if (mutationId === latestMutation.current) {
|
||||
setState({ data, error: null, variables, status: "success" });
|
||||
const listeners = [latestCallbacks.current, callbacks];
|
||||
listeners.forEach((on) => on?.onSuccess?.(data, variables));
|
||||
listeners.forEach((on) => on?.onSettled?.(data, null, variables));
|
||||
}
|
||||
return data;
|
||||
} catch (cause: unknown) {
|
||||
const error =
|
||||
cause instanceof Error ? cause : new Error("The mutation failed.");
|
||||
if (mutationId === latestMutation.current) {
|
||||
setState({ data: undefined, error, variables, status: "error" });
|
||||
const listeners = [latestCallbacks.current, callbacks];
|
||||
listeners.forEach((on) => on?.onError?.(error, variables));
|
||||
listeners.forEach((on) =>
|
||||
on?.onSettled?.(undefined, error, variables),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[mutationFn],
|
||||
);
|
||||
|
||||
const mutate = useCallback(
|
||||
(
|
||||
variables: TVariables,
|
||||
options?: MutationOptions<TData, TVariables>,
|
||||
): void => {
|
||||
void mutateAsync(variables, options).catch(() => undefined);
|
||||
},
|
||||
[mutateAsync],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
latestMutation.current += 1;
|
||||
setState(IDLE_STATE);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data: state.data,
|
||||
error: state.error,
|
||||
variables: state.variables,
|
||||
isError: state.status === "error",
|
||||
isPending: state.status === "pending",
|
||||
isSuccess: state.status === "success",
|
||||
status: state.status,
|
||||
mutate,
|
||||
mutateAsync,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Ref, UseQueryOptions, UseQueryResult } from "../core";
|
||||
import { useLiveQuery } from "../useQuery";
|
||||
|
||||
export const CUSTOM_FIELD_TYPES = [
|
||||
"short_text",
|
||||
"text",
|
||||
"email",
|
||||
"url",
|
||||
"phone",
|
||||
"number",
|
||||
"currency",
|
||||
"checkbox",
|
||||
"date",
|
||||
"drop_down",
|
||||
"labels",
|
||||
"users",
|
||||
"tasks",
|
||||
"list_relationship",
|
||||
"emoji",
|
||||
"votes",
|
||||
"manual_progress",
|
||||
"automatic_progress",
|
||||
"attachment",
|
||||
"location",
|
||||
"formula",
|
||||
"signature",
|
||||
"button",
|
||||
] as const;
|
||||
|
||||
/** A custom field's definition. Values live on each task in `customFields`, matched by `id`. */
|
||||
export interface CustomField extends Ref<"customField"> {
|
||||
/** Ends at the space, folder or list the field is configured on. */
|
||||
ancestors: Array<Ref<"space" | "folder" | "list">>;
|
||||
type: (typeof CUSTOM_FIELD_TYPES)[number];
|
||||
/** For drop-down and labels fields: the allowed values, in display order. */
|
||||
options?: Array<{ name: string; color: string }>;
|
||||
}
|
||||
|
||||
/** The fields available on a task, or on tasks in a space, folder or list. Inherited fields are included; `ancestors` shows where each is configured. */
|
||||
export interface CustomFieldSelector {
|
||||
parent: Ref<"task" | "space" | "folder" | "list">;
|
||||
}
|
||||
|
||||
export interface CustomFieldsApi {
|
||||
list(selector: CustomFieldSelector): Promise<CustomField[]>;
|
||||
}
|
||||
|
||||
export function useCustomFields(
|
||||
selector: CustomFieldSelector,
|
||||
options: UseQueryOptions<CustomField[]> = {},
|
||||
): UseQueryResult<CustomField[]> {
|
||||
return useLiveQuery(
|
||||
"customField",
|
||||
selector,
|
||||
(client) => client.customFields.list(selector),
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import { DEFAULT_COLOR } from "../components/palette";
|
||||
import { toIsoOrEpoch } from "../host/dates";
|
||||
import type {
|
||||
ArtifactCustomFieldDefinition,
|
||||
ArtifactCustomFieldWireValue,
|
||||
ArtifactTaskCustomFieldDto,
|
||||
} from "../host/wire";
|
||||
import { OBJECT_TYPES, refOf } from "../host/wire";
|
||||
import { usersOf } from "../people/read";
|
||||
import type { Session } from "../session";
|
||||
import type { Task } from "../tasks/tasks";
|
||||
import { fromHost } from "../validate";
|
||||
import type { CustomField, CustomFieldSelector } from "./customFields";
|
||||
import { CustomFieldSchema } from "./schema";
|
||||
|
||||
type TaskCustomFieldEntry = Task["customFields"][number];
|
||||
|
||||
export async function readCustomFields(
|
||||
s: Session,
|
||||
{ parent }: CustomFieldSelector,
|
||||
): Promise<CustomField[]> {
|
||||
const result = await s.host.query(
|
||||
"customFields",
|
||||
parent.kind === "task"
|
||||
? { taskIds: [parent.id] }
|
||||
: { location: { id: parent.id, type: OBJECT_TYPES[parent.kind] } },
|
||||
);
|
||||
return fromHost(
|
||||
z.array(CustomFieldSchema),
|
||||
await Promise.all(
|
||||
result.definitions.map((definition) => mapDefinition(s, definition)),
|
||||
),
|
||||
"custom fields",
|
||||
);
|
||||
}
|
||||
|
||||
async function mapDefinition(
|
||||
s: Session,
|
||||
wire: ArtifactCustomFieldDefinition,
|
||||
): Promise<CustomField> {
|
||||
return {
|
||||
kind: "customField",
|
||||
id: wire.fieldId,
|
||||
name: wire.name,
|
||||
url: "",
|
||||
ancestors: await s.path(wire.location),
|
||||
type: wire.type,
|
||||
options: wire.options?.map((option) => ({
|
||||
name: option.name,
|
||||
color: option.color || DEFAULT_COLOR,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The typed values a task carries, in the order the wire lists them. */
|
||||
export function mapCustomFieldValues(
|
||||
s: Session,
|
||||
wire: readonly ArtifactTaskCustomFieldDto[],
|
||||
): Promise<TaskCustomFieldEntry[]> {
|
||||
return Promise.all(
|
||||
wire.map(async (field) => ({
|
||||
id: field.fieldId,
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
value: await toFieldValue(s, field.value),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async function toFieldValue(
|
||||
s: Session,
|
||||
wire: ArtifactCustomFieldWireValue,
|
||||
): Promise<TaskCustomFieldEntry["value"]> {
|
||||
switch (wire.kind) {
|
||||
case "boolean":
|
||||
case "number":
|
||||
case "text":
|
||||
return wire.value;
|
||||
case "date":
|
||||
return toIsoOrEpoch(wire.timestamp);
|
||||
case "option":
|
||||
return wire.option.name;
|
||||
case "labels":
|
||||
return wire.options.map((option) => option.name);
|
||||
case "users":
|
||||
return usersOf(s, wire.users);
|
||||
case "tasks":
|
||||
return wire.tasks.map((task) => refOf("task", task));
|
||||
case "attachments":
|
||||
return wire.attachments.map((attachment) => attachment.url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import { ref, refFields, schemaOf } from "../validate";
|
||||
import type { CustomField, CustomFieldSelector } from "./customFields";
|
||||
import { CUSTOM_FIELD_TYPES } from "./customFields";
|
||||
|
||||
export const CustomFieldSchema = schemaOf<CustomField>()(
|
||||
z.object({
|
||||
...refFields(["customField"]),
|
||||
ancestors: z.array(ref(["space", "folder", "list"])),
|
||||
type: z.enum(CUSTOM_FIELD_TYPES),
|
||||
options: z.optional(
|
||||
z.array(z.object({ name: z.string(), color: z.string() })),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const CustomFieldSelectorSchema = schemaOf<CustomFieldSelector>()(
|
||||
z.strictObject({
|
||||
parent: ref(["task", "space", "folder", "list"]),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
defineEntity,
|
||||
useTasksByIds,
|
||||
} from "./entities/entities.ts";
|
||||
@@ -0,0 +1,500 @@
|
||||
import type {
|
||||
QueryStatus,
|
||||
Ref,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from "../core";
|
||||
import {
|
||||
clickup,
|
||||
LiveError,
|
||||
useCustomFields,
|
||||
useMutation,
|
||||
useStatuses,
|
||||
useTasks,
|
||||
} from "../index";
|
||||
import type { CustomField } from "../customFields/customFields";
|
||||
import type {
|
||||
CreateTaskInput,
|
||||
Status,
|
||||
Task,
|
||||
TaskPatch,
|
||||
TaskSelector,
|
||||
} from "../tasks/tasks";
|
||||
import type {
|
||||
Entity,
|
||||
EntityClient,
|
||||
EntitySchema,
|
||||
FieldSpec,
|
||||
ListFilter,
|
||||
NativeProperty,
|
||||
RecordBase,
|
||||
} from "./types";
|
||||
|
||||
export type { Entity, EntitySchema, ListFilter, RecordBase } from "./types";
|
||||
|
||||
type FieldValue = Task["customFields"][number]["value"];
|
||||
type Changes = Readonly<Record<string, unknown>>;
|
||||
|
||||
const NUMERIC_TYPES = new Set<CustomField["type"]>([
|
||||
"number",
|
||||
"currency",
|
||||
"emoji",
|
||||
"manual_progress",
|
||||
"votes",
|
||||
]);
|
||||
|
||||
const READ_NATIVE: Record<NativeProperty, (task: Task) => unknown> = {
|
||||
name: (task) => task.name,
|
||||
status: (task) => task.status.name,
|
||||
assignees: (task) => task.assignees,
|
||||
dueDate: (task) => task.dueAt,
|
||||
startDate: (task) => task.startAt,
|
||||
priority: (task) => task.priority,
|
||||
description: (task) => task.body,
|
||||
};
|
||||
|
||||
const nullable = (value: unknown) => (value === null ? null : String(value));
|
||||
const WRITE_NATIVE: Record<
|
||||
Exclude<NativeProperty, "status">,
|
||||
(value: unknown) => TaskPatch
|
||||
> = {
|
||||
name: (value) => ({ name: String(value) }),
|
||||
assignees: (value) => ({
|
||||
assignees: isRefArray(value) ? (value as Task["assignees"]) : [],
|
||||
}),
|
||||
dueDate: (value) => ({ dueAt: nullable(value) }),
|
||||
startDate: (value) => ({ startAt: nullable(value) }),
|
||||
priority: (value) => ({
|
||||
priority: value === null ? null : (value as Task["priority"]),
|
||||
}),
|
||||
description: (value) => ({ bodyMarkdown: String(value) }),
|
||||
};
|
||||
|
||||
/** The entities whose drift check ran this session. */
|
||||
const seen = new Set<string>();
|
||||
|
||||
/**
|
||||
* Typed records over a schema the `solution` CLI provisioned: a record is one
|
||||
* task in the entity's List, its custom fields named by the schema's logical
|
||||
* keys. Reads are shaped like TanStack Query's `useQuery`, writes like
|
||||
* `useMutation`, and every write waits for the viewer's approval.
|
||||
*/
|
||||
export function defineEntity<
|
||||
R extends RecordBase,
|
||||
I extends object,
|
||||
P extends object,
|
||||
S extends string,
|
||||
>(schema: EntitySchema): Entity<R, I, P, S> {
|
||||
const { list, key } = schema;
|
||||
const listRef: Ref<"list"> = { kind: "list", ...list };
|
||||
const readable = schema.fields.filter((field) => field.readable);
|
||||
const listName = JSON.stringify(list.name);
|
||||
const typeId =
|
||||
schema.taskType === undefined ? undefined : String(schema.taskType.id);
|
||||
|
||||
/** Whether the task is one of this entity's records: any task when the schema names no type. */
|
||||
function isRecord(task: Task): boolean {
|
||||
return typeId === undefined || task.type.id === typeId;
|
||||
}
|
||||
|
||||
function decode(task: Task): R {
|
||||
const record: Record<string, unknown> = {
|
||||
id: task.id,
|
||||
url: task.url,
|
||||
task,
|
||||
};
|
||||
for (const native of schema.natives) {
|
||||
record[native.key] = READ_NATIVE[native.native](task);
|
||||
}
|
||||
const values = new Map(task.customFields.map((f) => [f.id, f.value]));
|
||||
for (const spec of readable) {
|
||||
const value = values.get(spec.id);
|
||||
if (spec.type === "list_relationship") {
|
||||
const refs = isRefArray(value) ? value : [];
|
||||
record[`${spec.key}Ids`] = refs.map((ref) => ref.id);
|
||||
record[`${spec.key}Refs`] = refs;
|
||||
} else if (value !== undefined) {
|
||||
record[spec.key] = decodeValue(spec, value);
|
||||
}
|
||||
}
|
||||
return record as unknown as R;
|
||||
}
|
||||
|
||||
/** The custom-field collection with the changed schema fields applied; undefined when none changed. */
|
||||
function mergedFields(
|
||||
current: Task["customFields"],
|
||||
changes: Changes,
|
||||
): Task["customFields"] | undefined {
|
||||
const next = new Map(current.map((field) => [field.id, field]));
|
||||
let touched = false;
|
||||
for (const spec of schema.fields) {
|
||||
const value = changes[spec.key];
|
||||
if (value === undefined) continue;
|
||||
if (spec.readOnly) {
|
||||
throw new LiveError(
|
||||
"invalid",
|
||||
`${JSON.stringify(spec.name)} (${spec.type}) cannot be written through the live kit yet.`,
|
||||
);
|
||||
}
|
||||
touched = true;
|
||||
if (value === null) next.delete(spec.id);
|
||||
else {
|
||||
const { id, name, type } = spec;
|
||||
next.set(id, { id, name, type, value: encodeValue(spec, value) });
|
||||
}
|
||||
}
|
||||
return touched ? [...next.values()] : undefined;
|
||||
}
|
||||
|
||||
async function nativePatch(changes: Changes): Promise<TaskPatch> {
|
||||
const patch: TaskPatch = {};
|
||||
for (const { key: property, native } of schema.natives) {
|
||||
const value = changes[property];
|
||||
if (value === undefined) continue;
|
||||
Object.assign(
|
||||
patch,
|
||||
native === "status"
|
||||
? { status: await statusByName(String(value)) }
|
||||
: WRITE_NATIVE[native](value),
|
||||
);
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
async function statusByName(name: string): Promise<Status> {
|
||||
return findStatus(await clickup.tasks.statuses({ parent: list.id }), name);
|
||||
}
|
||||
|
||||
function findStatus(statuses: readonly Status[], name: string): Status {
|
||||
const wanted = name.toLowerCase();
|
||||
const match = statuses.find((s) => s.name.toLowerCase() === wanted);
|
||||
if (match) return match;
|
||||
const known = statuses.map((s) => s.name).join(", ");
|
||||
throw new LiveError(
|
||||
"invalid",
|
||||
`${JSON.stringify(name)} is not a status of List ${listName}; statuses: ${known}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The task with `id`, whether or not it is in this entity's List. */
|
||||
async function taskById(id: string): Promise<Task> {
|
||||
const [task] = await clickup.tasks.get([id]);
|
||||
if (!task) {
|
||||
throw new LiveError(
|
||||
"invalid",
|
||||
`No task with id ${id}, or it is not visible to you.`,
|
||||
);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
/** What ClickUp is asked for: the ids themselves, or the List with the filters it applies. */
|
||||
function toSelector(
|
||||
filter: ListFilter<R, S>,
|
||||
statusRefs: Ref<"status">[] | undefined,
|
||||
): TaskSelector | string[] {
|
||||
if (filter.ids !== undefined) return [...filter.ids];
|
||||
const selector: TaskSelector = { parent: list.id };
|
||||
if (statusRefs) selector.status = statusRefs;
|
||||
else if (filter.statusType)
|
||||
selector.status = { type: [...filter.statusType] };
|
||||
if (filter.limit !== undefined) selector.limit = filter.limit;
|
||||
if (filter.includeClosed !== undefined)
|
||||
selector.includeClosed = filter.includeClosed;
|
||||
return selector;
|
||||
}
|
||||
|
||||
/** The browser-side part of a filter; all of it when ClickUp only saw ids. */
|
||||
function refine(
|
||||
tasks: Task[],
|
||||
filter: ListFilter<R, S>,
|
||||
byIds: boolean,
|
||||
): R[] {
|
||||
let out = tasks.filter(isRecord).map(decode);
|
||||
if (byIds && filter.status) {
|
||||
const names = new Set(filter.status.map((name) => name.toLowerCase()));
|
||||
out = out.filter((r) => names.has(r.task.status.name.toLowerCase()));
|
||||
}
|
||||
if (filter.statusType && (byIds || filter.status)) {
|
||||
const types = new Set<string>(filter.statusType);
|
||||
out = out.filter((r) => types.has(r.task.status.type));
|
||||
}
|
||||
if (filter.where) out = out.filter(filter.where);
|
||||
if (filter.orderBy) {
|
||||
const { field, direction } = filter.orderBy;
|
||||
const sign = direction === "desc" ? -1 : 1;
|
||||
out = [...out].sort((a, b) => sign * compare(a[field], b[field]));
|
||||
}
|
||||
if (byIds && filter.limit !== undefined) out = out.slice(0, filter.limit);
|
||||
return out;
|
||||
}
|
||||
|
||||
const client: EntityClient<R, I, P, S> = {
|
||||
async list(filter = {}) {
|
||||
const byIds = filter.ids !== undefined;
|
||||
const statusRefs =
|
||||
!byIds && filter.status?.length
|
||||
? await Promise.all(filter.status.map(statusByName))
|
||||
: undefined;
|
||||
const selector = toSelector(filter, statusRefs);
|
||||
const tasks = Array.isArray(selector)
|
||||
? await clickup.tasks.get(selector)
|
||||
: await clickup.tasks.list(selector);
|
||||
return refine(tasks, filter, byIds);
|
||||
},
|
||||
async get(id) {
|
||||
return decode(await taskById(id));
|
||||
},
|
||||
async create(input) {
|
||||
const changes = input as Changes;
|
||||
const {
|
||||
name,
|
||||
bodyMarkdown,
|
||||
status,
|
||||
priority,
|
||||
dueAt,
|
||||
startAt,
|
||||
assignees,
|
||||
} = await nativePatch(changes);
|
||||
if (!name) {
|
||||
throw new LiveError("invalid", `A new ${key} record needs a name.`);
|
||||
}
|
||||
const created = await clickup.tasks.create({
|
||||
list: list.id,
|
||||
name,
|
||||
bodyMarkdown,
|
||||
status,
|
||||
type: typeId,
|
||||
priority: priority ?? undefined,
|
||||
dueAt: dueAt ?? undefined,
|
||||
startAt: startAt ?? undefined,
|
||||
assignees,
|
||||
} satisfies CreateTaskInput);
|
||||
const customFields = mergedFields([], changes);
|
||||
if (customFields?.length) {
|
||||
if (!created) {
|
||||
throw new LiveError(
|
||||
"failed",
|
||||
"ClickUp created the task but did not report its id, so its fields were not written; set them in ClickUp or create the record again.",
|
||||
);
|
||||
}
|
||||
await clickup.tasks.update(created, { customFields });
|
||||
}
|
||||
return created;
|
||||
},
|
||||
async update(id, patch) {
|
||||
const changes = patch as Changes;
|
||||
const task = await taskById(id);
|
||||
const taskPatch = await nativePatch(changes);
|
||||
const customFields = mergedFields(task.customFields, changes);
|
||||
if (customFields !== undefined) taskPatch.customFields = customFields;
|
||||
await clickup.tasks.update(task, taskPatch);
|
||||
},
|
||||
async close(id) {
|
||||
const statuses = await clickup.tasks.statuses({ parent: list.id });
|
||||
const closing =
|
||||
statuses.find((s) => s.type === "closed") ??
|
||||
[...statuses].reverse().find((s) => s.type === "done");
|
||||
if (!closing) {
|
||||
throw new LiveError(
|
||||
"invalid",
|
||||
`List ${listName} has no closing status.`,
|
||||
);
|
||||
}
|
||||
await clickup.tasks.update(id, { status: closing });
|
||||
},
|
||||
};
|
||||
|
||||
/** Once per entity per session; a missing field is a warning, never a failed read. */
|
||||
function useDriftCheck(enabled: boolean): void {
|
||||
const pending = enabled && readable.length > 0 && !seen.has(`drift:${key}`);
|
||||
const fields = useCustomFields({ parent: listRef }, { enabled: pending });
|
||||
if (!pending || fields.data === undefined) return;
|
||||
seen.add(`drift:${key}`);
|
||||
const present = new Set(fields.data.map((field) => field.id));
|
||||
const missing = readable.filter((field) => !present.has(field.id));
|
||||
if (missing.length > 0) {
|
||||
const named = missing.map((f) => `${f.key} (${f.id})`).join(", ");
|
||||
console.warn(
|
||||
`${key}: ${named} ${missing.length === 1 ? "is" : "are"} not on List ${listName} as the viewer sees it; the record leaves them unset. Run solution apply (or solution codegen) to regenerate this directory if the schema changed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function useList(
|
||||
filter: ListFilter<R, S> = {},
|
||||
options: UseQueryOptions<Task[]> = {},
|
||||
): UseQueryResult<R[]> {
|
||||
const enabled = options.enabled !== false;
|
||||
const byIds = filter.ids !== undefined;
|
||||
const wantsStatuses = !byIds && (filter.status?.length ?? 0) > 0;
|
||||
const statuses = useStatuses(
|
||||
{ parent: list.id },
|
||||
{ enabled: enabled && wantsStatuses },
|
||||
);
|
||||
let statusRefs: Ref<"status">[] | undefined;
|
||||
let statusError: LiveError | null = null;
|
||||
if (wantsStatuses && statuses.data) {
|
||||
try {
|
||||
statusRefs = filter.status!.map((name) =>
|
||||
findStatus(statuses.data!, name),
|
||||
);
|
||||
} catch (error) {
|
||||
statusError = error as LiveError;
|
||||
}
|
||||
}
|
||||
const tasks = useTasks(toSelector(filter, statusRefs), {
|
||||
...options,
|
||||
enabled: enabled && (!wantsStatuses || statusRefs !== undefined),
|
||||
});
|
||||
useDriftCheck(enabled);
|
||||
const data = tasks.data && refine(tasks.data, filter, byIds);
|
||||
const error = statusError ?? statuses.error ?? tasks.error;
|
||||
const status: QueryStatus = error
|
||||
? "error"
|
||||
: data !== undefined
|
||||
? "success"
|
||||
: "pending";
|
||||
const isPending = status === "pending";
|
||||
const isFetching = statuses.isFetching || tasks.isFetching;
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
status,
|
||||
isPending,
|
||||
isLoading: isPending && isFetching,
|
||||
isFetching,
|
||||
isRefetching: isFetching && !isPending,
|
||||
isPlaceholderData: tasks.isPlaceholderData,
|
||||
isError: status === "error",
|
||||
isSuccess: status === "success",
|
||||
dataUpdatedAt: tasks.dataUpdatedAt,
|
||||
refetch: tasks.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
function useOne(
|
||||
id: string | undefined,
|
||||
options: UseQueryOptions<Task[]> = {},
|
||||
): UseQueryResult<R | undefined> {
|
||||
return mapResult(
|
||||
useTasksByIds(id === undefined ? [] : [id], options),
|
||||
(tasks) => (tasks[0] ? decode(tasks[0]) : undefined),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
schema,
|
||||
decode,
|
||||
client,
|
||||
useList,
|
||||
useOne,
|
||||
useMany: (ids, options = {}) =>
|
||||
mapResult(useTasksByIds(ids, options), (tasks) => tasks.map(decode)),
|
||||
useCreate: () => useMutation((input: I) => client.create(input)),
|
||||
useUpdate: () =>
|
||||
useMutation((variables: { id: string; patch: P }) =>
|
||||
client.update(variables.id, variables.patch),
|
||||
),
|
||||
useClose: () => useMutation((id: string) => client.close(id)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tasks by id, in the order given. An empty list is a settled `[]` with no
|
||||
* loading state, so a relationship hook fed an unlinked record renders its
|
||||
* empty state at once.
|
||||
*/
|
||||
export function useTasksByIds(
|
||||
ids: readonly string[],
|
||||
options: UseQueryOptions<Task[]> = {},
|
||||
): UseQueryResult<Task[]> {
|
||||
const result = useTasks([...ids], {
|
||||
...options,
|
||||
enabled: options.enabled !== false && ids.length > 0,
|
||||
});
|
||||
return ids.length > 0
|
||||
? result
|
||||
: {
|
||||
...result,
|
||||
data: [],
|
||||
error: null,
|
||||
status: "success",
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
};
|
||||
}
|
||||
|
||||
function mapResult<T, U>(
|
||||
result: UseQueryResult<T>,
|
||||
fn: (data: T) => U,
|
||||
): UseQueryResult<U> {
|
||||
return { ...result, data: result.data && fn(result.data) };
|
||||
}
|
||||
|
||||
/** A ClickUp value as the record expects it: option labels become logical keys. */
|
||||
function decodeValue(spec: FieldSpec, value: FieldValue): unknown {
|
||||
if (spec.type === "drop_down") {
|
||||
return typeof value === "string" ? optionKey(spec, value) : value;
|
||||
}
|
||||
if (spec.type === "labels") {
|
||||
return Array.isArray(value)
|
||||
? value.map((e) => optionKey(spec, typeof e === "string" ? e : e.name))
|
||||
: [optionKey(spec, String(value))];
|
||||
}
|
||||
if (spec.type === "checkbox") return value === true || value === "true";
|
||||
if (NUMERIC_TYPES.has(spec.type)) return Number(value);
|
||||
if (spec.type === "users") return isRefArray(value) ? value : [];
|
||||
if (spec.type === "attachment") {
|
||||
return Array.isArray(value) ? value.filter(isString) : [];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A record value as ClickUp takes it: option keys back to labels. */
|
||||
function encodeValue(spec: FieldSpec, value: unknown): FieldValue {
|
||||
if (spec.type === "drop_down") return optionLabel(spec, String(value));
|
||||
if (spec.type === "labels") {
|
||||
return (Array.isArray(value) ? value : [value]).map((k) =>
|
||||
optionLabel(spec, String(k)),
|
||||
);
|
||||
}
|
||||
if (spec.type === "checkbox") return Boolean(value);
|
||||
if (NUMERIC_TYPES.has(spec.type)) return Number(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** A label the schema never named passes through as its own key. */
|
||||
function optionKey(spec: FieldSpec, label: string): string {
|
||||
const entry = Object.entries(spec.options ?? {}).find(
|
||||
([, option]) => option.label === label,
|
||||
);
|
||||
return entry ? entry[0] : label;
|
||||
}
|
||||
|
||||
function optionLabel(spec: FieldSpec, key: string): string {
|
||||
return spec.options?.[key]?.label ?? key;
|
||||
}
|
||||
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === "string";
|
||||
}
|
||||
|
||||
function isRefArray(value: unknown): value is Ref[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every((e) => typeof e?.id === "string" && typeof e?.kind === "string")
|
||||
);
|
||||
}
|
||||
|
||||
/** Numbers by value, everything else by text; unset values sort last. */
|
||||
function compare(a: unknown, b: unknown): number {
|
||||
if (a === b) return 0;
|
||||
if (a == null) return 1;
|
||||
if (b == null) return -1;
|
||||
if (typeof a === "number" && typeof b === "number") return a - b;
|
||||
return String(a).localeCompare(String(b));
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import type {
|
||||
Delta,
|
||||
LiveErrorCode,
|
||||
Ref,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from "../core";
|
||||
import type { CustomField } from "../customFields/customFields";
|
||||
import type {
|
||||
CreateTaskInput,
|
||||
Status,
|
||||
StatusSelector,
|
||||
Task,
|
||||
TaskPatch,
|
||||
TaskSelector,
|
||||
} from "../tasks/tasks";
|
||||
|
||||
/** A stand-in for the kit barrel that records every call and answers hooks synchronously, for testing the entities engine without a host or a renderer. */
|
||||
export interface FakeKitState {
|
||||
tasks: Map<string, Task>;
|
||||
statuses: Status[];
|
||||
customFields: CustomField[];
|
||||
calls: unknown[][];
|
||||
nextId: number;
|
||||
/** "silent" makes the next create succeed without reporting the task. */
|
||||
created?: "silent";
|
||||
}
|
||||
|
||||
class LiveError extends Error {
|
||||
readonly code: LiveErrorCode;
|
||||
readonly retryable: boolean;
|
||||
readonly reason: string | undefined;
|
||||
|
||||
constructor(
|
||||
code: LiveErrorCode,
|
||||
message: string,
|
||||
options: { retryable?: boolean; reason?: string } = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "LiveError";
|
||||
this.code = code;
|
||||
this.retryable = options.retryable ?? false;
|
||||
this.reason = options.reason;
|
||||
}
|
||||
}
|
||||
|
||||
function idOf(target: Ref | string): string {
|
||||
return typeof target === "string" ? target : target.id;
|
||||
}
|
||||
|
||||
function taskUrl(id: string): string {
|
||||
return `https://app.clickup.com/t/${id}`;
|
||||
}
|
||||
|
||||
function paragraph(text: string): Delta {
|
||||
return { ops: [{ insert: `${text}\n` }] };
|
||||
}
|
||||
|
||||
function result<T>(data: T | undefined, enabled = true): UseQueryResult<T> {
|
||||
const isPending = data === undefined;
|
||||
const isFetching = isPending && enabled;
|
||||
return {
|
||||
data,
|
||||
error: null,
|
||||
status: isPending ? "pending" : "success",
|
||||
isPending,
|
||||
isLoading: isPending && isFetching,
|
||||
isFetching,
|
||||
isRefetching: false,
|
||||
isPlaceholderData: false,
|
||||
isError: false,
|
||||
isSuccess: !isPending,
|
||||
dataUpdatedAt: isPending ? 0 : 1,
|
||||
refetch: () => Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createFakeKit() {
|
||||
const state: FakeKitState = {
|
||||
tasks: new Map(),
|
||||
statuses: [],
|
||||
customFields: [],
|
||||
calls: [],
|
||||
nextId: 100,
|
||||
};
|
||||
|
||||
function getTasks(targets: Array<Ref<"task"> | string>): Task[] {
|
||||
return targets.flatMap((target) => {
|
||||
const task = state.tasks.get(idOf(target));
|
||||
return task ? [task] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function listTasks(selector: TaskSelector): Task[] {
|
||||
let tasks = [...state.tasks.values()].filter(
|
||||
(task) =>
|
||||
task.ancestors[task.ancestors.length - 1]!.id ===
|
||||
idOf(selector.parent!),
|
||||
);
|
||||
if (Array.isArray(selector.status)) {
|
||||
const ids = new Set(selector.status.map((status) => status.id));
|
||||
tasks = tasks.filter((task) => ids.has(task.status.id));
|
||||
} else if (selector.status) {
|
||||
const types = new Set<string>(selector.status.type);
|
||||
tasks = tasks.filter((task) => types.has(task.status.type));
|
||||
}
|
||||
if (!selector.includeClosed) {
|
||||
tasks = tasks.filter((task) => task.status.type !== "closed");
|
||||
}
|
||||
return tasks.slice(0, selector.limit ?? 100);
|
||||
}
|
||||
|
||||
const clickup = {
|
||||
tasks: {
|
||||
async list(selector: TaskSelector): Promise<Task[]> {
|
||||
state.calls.push(["tasks.list", selector]);
|
||||
return listTasks(selector);
|
||||
},
|
||||
async get(targets: Array<Ref<"task"> | string>): Promise<Task[]> {
|
||||
state.calls.push(["tasks.get", targets.map(idOf)]);
|
||||
return getTasks(targets);
|
||||
},
|
||||
async update(
|
||||
target: Ref<"task"> | string,
|
||||
patch: TaskPatch,
|
||||
): Promise<void> {
|
||||
state.calls.push(["tasks.update", idOf(target), patch]);
|
||||
const task = state.tasks.get(idOf(target))!;
|
||||
if (patch.name !== undefined) task.name = patch.name;
|
||||
if (patch.status !== undefined) task.status = patch.status as Status;
|
||||
if (patch.dueAt !== undefined) task.dueAt = patch.dueAt ?? undefined;
|
||||
if (patch.bodyMarkdown !== undefined) {
|
||||
task.body = paragraph(patch.bodyMarkdown);
|
||||
}
|
||||
if (patch.customFields !== undefined) {
|
||||
task.customFields = patch.customFields;
|
||||
}
|
||||
},
|
||||
async create(input: CreateTaskInput): Promise<Ref<"task"> | undefined> {
|
||||
state.calls.push(["tasks.create", input]);
|
||||
const id = String(state.nextId++);
|
||||
const typeId = input.type === undefined ? "0" : idOf(input.type);
|
||||
state.tasks.set(id, {
|
||||
kind: "task",
|
||||
id,
|
||||
name: input.name,
|
||||
url: taskUrl(id),
|
||||
ancestors: [
|
||||
{ kind: "list", id: idOf(input.list), name: "List", url: "" },
|
||||
],
|
||||
otherLists: [],
|
||||
private: false,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
dueAt: input.dueAt,
|
||||
status: (input.status ?? state.statuses[0]) as Status,
|
||||
type: { kind: "taskType", id: typeId, name: "", url: "" },
|
||||
tags: [],
|
||||
customFields: [],
|
||||
body: paragraph(input.bodyMarkdown ?? ""),
|
||||
creator: { kind: "user", id: "u1", name: "Creator", url: "" },
|
||||
assignees: input.assignees ?? [],
|
||||
watchers: [],
|
||||
checklists: [],
|
||||
});
|
||||
return state.created === "silent"
|
||||
? undefined
|
||||
: { kind: "task", id, name: input.name, url: taskUrl(id) };
|
||||
},
|
||||
async statuses(selector: StatusSelector): Promise<Status[]> {
|
||||
state.calls.push(["tasks.statuses", selector]);
|
||||
return state.statuses;
|
||||
},
|
||||
},
|
||||
customFields: {
|
||||
async list(): Promise<CustomField[]> {
|
||||
return state.customFields;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function useTasks(
|
||||
selector: TaskSelector | Array<Ref<"task"> | string>,
|
||||
options: UseQueryOptions = {},
|
||||
): UseQueryResult<Task[]> {
|
||||
state.calls.push(["useTasks", selector, options]);
|
||||
if (options.enabled === false) {
|
||||
return result<Task[]>(undefined, false);
|
||||
}
|
||||
return result(
|
||||
Array.isArray(selector) ? getTasks(selector) : listTasks(selector),
|
||||
);
|
||||
}
|
||||
|
||||
function useStatuses(
|
||||
selector: StatusSelector,
|
||||
options: UseQueryOptions = {},
|
||||
): UseQueryResult<Status[]> {
|
||||
state.calls.push(["useStatuses", selector, options]);
|
||||
return options.enabled === false
|
||||
? result<Status[]>(undefined, false)
|
||||
: result(state.statuses);
|
||||
}
|
||||
|
||||
function useCustomFields(
|
||||
selector: unknown,
|
||||
options: UseQueryOptions = {},
|
||||
): UseQueryResult<CustomField[]> {
|
||||
state.calls.push(["useCustomFields", selector, options]);
|
||||
return options.enabled === false
|
||||
? result<CustomField[]>(undefined, false)
|
||||
: result(state.customFields);
|
||||
}
|
||||
|
||||
function useMutation<TData, TVariables>(
|
||||
mutationFn: (variables: TVariables) => Promise<TData>,
|
||||
): UseMutationResult<TData, TVariables> {
|
||||
return {
|
||||
data: undefined,
|
||||
error: null,
|
||||
variables: undefined,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
status: "idle",
|
||||
mutate(variables: TVariables) {
|
||||
mutationFn(variables).catch(() => undefined);
|
||||
},
|
||||
mutateAsync: mutationFn,
|
||||
reset() {},
|
||||
} as UseMutationResult<TData, TVariables>;
|
||||
}
|
||||
|
||||
return {
|
||||
LiveError,
|
||||
clickup,
|
||||
useTasks,
|
||||
useStatuses,
|
||||
useCustomFields,
|
||||
useMutation,
|
||||
__state: state,
|
||||
};
|
||||
}
|
||||
|
||||
export type FakeKit = ReturnType<typeof createFakeKit>;
|
||||
@@ -0,0 +1,111 @@
|
||||
import type {
|
||||
Ref,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from "../core";
|
||||
import type { CustomField } from "../customFields/customFields";
|
||||
import type { Status, Task } from "../tasks/tasks";
|
||||
|
||||
/** A property every task has, mapped to a record property by the schema. */
|
||||
export type NativeProperty =
|
||||
| "name"
|
||||
| "status"
|
||||
| "assignees"
|
||||
| "dueDate"
|
||||
| "startDate"
|
||||
| "priority"
|
||||
| "description";
|
||||
|
||||
export interface OptionSpec {
|
||||
id: string;
|
||||
/** The option's name in ClickUp; what the kit reads and writes. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface FieldSpec {
|
||||
/** The record property; a relationship becomes <key>Ids and <key>Refs. */
|
||||
key: string;
|
||||
/** The ClickUp custom field id. */
|
||||
id: string;
|
||||
name: string;
|
||||
type: CustomField["type"];
|
||||
required: boolean;
|
||||
/** False keeps a type the kit cannot read off the record and out of the drift check. */
|
||||
readable: boolean;
|
||||
/** The kit cannot write this type yet; writes are rejected. */
|
||||
readOnly: boolean;
|
||||
/** Drop-down and labels only: logical option key to ClickUp option. */
|
||||
options?: Readonly<Record<string, OptionSpec>>;
|
||||
targetEntity?: string;
|
||||
targetListId?: string;
|
||||
}
|
||||
|
||||
/** One entity's frozen ids, as _schema.ts exports them. */
|
||||
export interface EntitySchema {
|
||||
key: string;
|
||||
list: { id: string; name: string; url: string };
|
||||
taskType?: { id: number; name: string };
|
||||
natives: ReadonlyArray<{ key: string; native: NativeProperty }>;
|
||||
fields: readonly FieldSpec[];
|
||||
}
|
||||
|
||||
export interface RecordBase {
|
||||
id: string;
|
||||
/** Link to the task in ClickUp; safe to render for the viewer. */
|
||||
url: string;
|
||||
/** The underlying clickup-kit Task, for anything the schema does not model. */
|
||||
task: Task;
|
||||
}
|
||||
|
||||
/**
|
||||
* status, statusType, limit and includeClosed are applied by ClickUp; the
|
||||
* entity's task type, where and orderBy run in the browser on the decoded
|
||||
* records. With ids, ClickUp returns exactly those tasks and every filter
|
||||
* runs in the browser.
|
||||
*/
|
||||
export interface ListFilter<R, S extends string> {
|
||||
/** Status names as shown in ClickUp, case-insensitive. */
|
||||
status?: readonly S[];
|
||||
statusType?: ReadonlyArray<Status["type"]>;
|
||||
ids?: readonly string[];
|
||||
limit?: number;
|
||||
/** ClickUp hides tasks in the closing status unless this is true. */
|
||||
includeClosed?: boolean;
|
||||
where?: (record: R) => boolean;
|
||||
/** Unset values sort last. */
|
||||
orderBy?: { field: keyof R; direction?: "asc" | "desc" };
|
||||
}
|
||||
|
||||
export interface EntityClient<R, I, P, S extends string> {
|
||||
list(filter?: ListFilter<R, S>): Promise<R[]>;
|
||||
get(id: string): Promise<R>;
|
||||
/** Resolves with the new task's ref when the host reports it. */
|
||||
create(input: I): Promise<Ref<"task"> | undefined>;
|
||||
/** undefined leaves a property alone; null clears one that is optional. */
|
||||
update(id: string, patch: P): Promise<void>;
|
||||
/** Moves the task to the List's closing status. There is no delete. */
|
||||
close(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Entity<R extends RecordBase, I, P, S extends string> {
|
||||
schema: EntitySchema;
|
||||
decode(task: Task): R;
|
||||
client: EntityClient<R, I, P, S>;
|
||||
useList(
|
||||
filter?: ListFilter<R, S>,
|
||||
options?: UseQueryOptions<Task[]>,
|
||||
): UseQueryResult<R[]>;
|
||||
/** data is undefined while loading and when no task has that id; isSuccess tells them apart. */
|
||||
useOne(
|
||||
id: string | undefined,
|
||||
options?: UseQueryOptions<Task[]>,
|
||||
): UseQueryResult<R | undefined>;
|
||||
useMany(
|
||||
ids: readonly string[],
|
||||
options?: UseQueryOptions<Task[]>,
|
||||
): UseQueryResult<R[]>;
|
||||
useCreate(): UseMutationResult<Ref<"task"> | undefined, I>;
|
||||
useUpdate(): UseMutationResult<void, { id: string; patch: P }>;
|
||||
useClose(): UseMutationResult<void, string>;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Ref, UseQueryOptions, UseQueryResult } from "../core";
|
||||
import { listOrGet, useLiveQuery } from "../useQuery";
|
||||
|
||||
export interface Space extends Ref<"space"> {
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
export interface Folder extends Ref<"folder"> {
|
||||
ancestors: Array<Ref<"space" | "folder">>;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
export interface List extends Ref<"list"> {
|
||||
ancestors: Array<Ref<"space" | "folder">>;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
/** Needs `parent` or `ancestor`. */
|
||||
export interface FolderSelector {
|
||||
parent?: Ref<"space" | "folder">;
|
||||
ancestor?: Ref<"space"> | string;
|
||||
/** Defaults to 100, at most 500. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Needs `parent` or `ancestor`. */
|
||||
export interface ListSelector {
|
||||
parent?: Ref<"space" | "folder">;
|
||||
ancestor?: Ref<"space"> | string;
|
||||
/** Defaults to 100, at most 500. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SpacesApi {
|
||||
list(): Promise<Space[]>;
|
||||
get(targets: Array<Ref<"space"> | string>): Promise<Space[]>;
|
||||
}
|
||||
|
||||
export interface FoldersApi {
|
||||
list(selector: FolderSelector): Promise<Folder[]>;
|
||||
get(targets: Array<Ref<"folder"> | string>): Promise<Folder[]>;
|
||||
}
|
||||
|
||||
export interface ListsApi {
|
||||
list(selector: ListSelector): Promise<List[]>;
|
||||
get(targets: Array<Ref<"list"> | string>): Promise<List[]>;
|
||||
}
|
||||
|
||||
/** Every space, or exactly the given ones. */
|
||||
export function useSpaces(
|
||||
targets?: Array<Ref<"space"> | string>,
|
||||
options: UseQueryOptions<Space[]> = {},
|
||||
): UseQueryResult<Space[]> {
|
||||
return useLiveQuery(
|
||||
"space",
|
||||
targets ?? {},
|
||||
(client) => listOrGet(client.spaces, targets ?? {}),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function useFolders(
|
||||
selector: FolderSelector | Array<Ref<"folder"> | string>,
|
||||
options: UseQueryOptions<Folder[]> = {},
|
||||
): UseQueryResult<Folder[]> {
|
||||
return useLiveQuery(
|
||||
"folder",
|
||||
selector,
|
||||
(client) => listOrGet(client.folders, selector),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function useLists(
|
||||
selector: ListSelector | Array<Ref<"list"> | string>,
|
||||
options: UseQueryOptions<List[]> = {},
|
||||
): UseQueryResult<List[]> {
|
||||
return useLiveQuery(
|
||||
"list",
|
||||
selector,
|
||||
(client) => listOrGet(client.lists, selector),
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import type { Ref } from "../core";
|
||||
import { LiveError } from "../core";
|
||||
import type { LiveBridge } from "../host/bridge";
|
||||
import { chunk, pages } from "../host/paging";
|
||||
import type {
|
||||
ArtifactFolderDto,
|
||||
ArtifactHierarchyQuery,
|
||||
ArtifactHierarchyResult,
|
||||
ArtifactListDto,
|
||||
} from "../host/wire";
|
||||
import {
|
||||
ARTIFACT_HIERARCHY_MAX_IDS,
|
||||
ARTIFACT_QUERY_MAX_LIMIT,
|
||||
refOf,
|
||||
} from "../host/wire";
|
||||
import { memo, memoBy } from "../memo";
|
||||
import { byIds, clampLimit, selectLoaded } from "../select";
|
||||
import type { Session } from "../session";
|
||||
import { scopeOf, treeOf } from "../session";
|
||||
import { fromHost } from "../validate";
|
||||
import type {
|
||||
Folder,
|
||||
FolderSelector,
|
||||
List,
|
||||
ListSelector,
|
||||
Space,
|
||||
} from "./hierarchy";
|
||||
import { FolderSchema, ListSchema, SpaceSchema } from "./schema";
|
||||
|
||||
/** The `hierarchy` source, mapped: spaces, folders and lists. */
|
||||
export interface Hierarchy {
|
||||
spaces: Space[];
|
||||
folders: Folder[];
|
||||
lists: List[];
|
||||
}
|
||||
|
||||
export const HIERARCHY_KINDS = ["space", "folder", "list"] as const;
|
||||
|
||||
export type HierarchyKind = "space" | "folder" | "list";
|
||||
|
||||
interface Lookup {
|
||||
kind: HierarchyKind;
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
/** The hierarchy by scope, each scope read once per session. */
|
||||
export interface HierarchyReads {
|
||||
/** Every space, without their folders and lists. */
|
||||
spaces(): Promise<Hierarchy>;
|
||||
/** One space's folders and lists. */
|
||||
tree(spaceId: string): Promise<Hierarchy>;
|
||||
/** Exactly these `kind` assets, with the folders and spaces they sit in. */
|
||||
lookup(kind: HierarchyKind, ids: string[]): Promise<Hierarchy>;
|
||||
/** Forgets everything read, so the next reads reach the host again. */
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export function createHierarchyReads(host: LiveBridge): HierarchyReads {
|
||||
const spaces = memo(async () =>
|
||||
mapHierarchy(await browse(host, { entities: "spaces" })),
|
||||
);
|
||||
const tree = memoBy(async (spaceId: string) =>
|
||||
mapHierarchy(await browse(host, { spaceId })),
|
||||
);
|
||||
const lookups = memoBy(
|
||||
async (target: Lookup) => mapHierarchy(await lookup(host, target)),
|
||||
({ kind, ids }) => `${kind}:${[...ids].sort().join(",")}`,
|
||||
);
|
||||
return {
|
||||
spaces,
|
||||
tree,
|
||||
lookup: (kind, ids) => lookups({ kind, ids }),
|
||||
reset: () => {
|
||||
spaces.reset();
|
||||
tree.reset();
|
||||
lookups.reset();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type BrowseInput = Omit<
|
||||
Exclude<ArtifactHierarchyQuery, { ids: unknown }>,
|
||||
"cursor" | "limit"
|
||||
>;
|
||||
|
||||
async function browse(
|
||||
host: LiveBridge,
|
||||
input: BrowseInput,
|
||||
): Promise<ArtifactHierarchyResult> {
|
||||
const wire: ArtifactHierarchyResult = { spaces: [], folders: [], lists: [] };
|
||||
for await (const page of pages((cursor) =>
|
||||
host.query("hierarchy", {
|
||||
...input,
|
||||
limit: ARTIFACT_QUERY_MAX_LIMIT,
|
||||
cursor,
|
||||
}),
|
||||
)) {
|
||||
wire.spaces.push(...page.spaces);
|
||||
wire.folders.push(...page.folders);
|
||||
wire.lists.push(...page.lists);
|
||||
}
|
||||
return wire;
|
||||
}
|
||||
|
||||
async function lookup(
|
||||
host: LiveBridge,
|
||||
{ kind, ids }: Lookup,
|
||||
): Promise<ArtifactHierarchyResult> {
|
||||
const results = await Promise.all(
|
||||
chunk(ids, ARTIFACT_HIERARCHY_MAX_IDS).map((batch) =>
|
||||
host.query("hierarchy", {
|
||||
ids: batch.map((id) => ({ kind, id })),
|
||||
includeParents: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return {
|
||||
spaces: dedupe(results.flatMap((result) => result.spaces)),
|
||||
folders: dedupe(results.flatMap((result) => result.folders)),
|
||||
lists: dedupe(results.flatMap((result) => result.lists)),
|
||||
};
|
||||
}
|
||||
|
||||
function dedupe<T extends { id: string }>(assets: T[]): T[] {
|
||||
return [...new Map(assets.map((asset) => [asset.id, asset])).values()];
|
||||
}
|
||||
|
||||
function mapHierarchy(wire: ArtifactHierarchyResult): Hierarchy {
|
||||
const spacesById = new Map(wire.spaces.map((space) => [space.id, space]));
|
||||
const foldersById = new Map(
|
||||
wire.folders.map((folder) => [folder.id, folder]),
|
||||
);
|
||||
|
||||
/** The folders enclosing a folder or list, innermost first, starting at `folderId`. */
|
||||
const foldersAbove = (
|
||||
child: ArtifactFolderDto | ArtifactListDto,
|
||||
folderId: string | undefined,
|
||||
): ArtifactFolderDto[] => {
|
||||
const folders: ArtifactFolderDto[] = [];
|
||||
let id = folderId;
|
||||
while (id !== undefined) {
|
||||
const folder = listed(foldersById, id, child);
|
||||
if (folders.includes(folder)) {
|
||||
throw new LiveError(
|
||||
"failed",
|
||||
`The host returned folders nested in a cycle above "${child.name}" (${child.id}).`,
|
||||
);
|
||||
}
|
||||
folders.push(folder);
|
||||
id = folder.parentFolderId;
|
||||
}
|
||||
return folders;
|
||||
};
|
||||
|
||||
/** Root first: the space, then the enclosing folders from the outside in. */
|
||||
const ancestorsOf = (
|
||||
child: ArtifactFolderDto | ArtifactListDto,
|
||||
folderId: string | undefined,
|
||||
): Array<Ref<"space" | "folder">> => [
|
||||
refOf("space", listed(spacesById, child.spaceId, child)),
|
||||
...foldersAbove(child, folderId)
|
||||
.reverse()
|
||||
.map((folder) => refOf("folder", folder)),
|
||||
];
|
||||
|
||||
const spaces = wire.spaces.map(
|
||||
(space): Space => ({ ...refOf("space", space), private: space.private }),
|
||||
);
|
||||
const folders = wire.folders.map(
|
||||
(folder): Folder => ({
|
||||
...refOf("folder", folder),
|
||||
ancestors: ancestorsOf(folder, folder.parentFolderId),
|
||||
private: folder.private,
|
||||
}),
|
||||
);
|
||||
const lists = wire.lists.map(
|
||||
(list): List => ({
|
||||
...refOf("list", list),
|
||||
ancestors: ancestorsOf(list, list.folderId),
|
||||
private: list.private,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
spaces: fromHost(z.array(SpaceSchema), spaces, "spaces"),
|
||||
folders: fromHost(z.array(FolderSchema), folders, "folders"),
|
||||
lists: fromHost(z.array(ListSchema), lists, "lists"),
|
||||
};
|
||||
}
|
||||
|
||||
/** The parent the same hierarchy result lists; the host is broken if it does not. */
|
||||
function listed<T>(
|
||||
parents: Map<string, T>,
|
||||
id: string,
|
||||
child: { id: string; name: string },
|
||||
): T {
|
||||
const parent = parents.get(id);
|
||||
if (!parent) {
|
||||
throw new LiveError(
|
||||
"failed",
|
||||
`The host returned "${child.name}" (${child.id}) under an unlisted parent ${id}.`,
|
||||
);
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
/** The tree of the space a folder or list selector scopes to; a bare `ancestor` is a space id. */
|
||||
function scopedTree(
|
||||
s: Session,
|
||||
selector: FolderSelector | ListSelector,
|
||||
): Promise<Hierarchy> {
|
||||
const scope = scopeOf(selector);
|
||||
return typeof scope === "string" ? s.hierarchy.tree(scope) : treeOf(s, scope);
|
||||
}
|
||||
|
||||
/** A folder or list selector as `selectLoaded` takes it, with a bare `ancestor` read as a space. */
|
||||
function loadedSelector(selector: FolderSelector | ListSelector) {
|
||||
const { ancestor } = selector;
|
||||
return {
|
||||
...selector,
|
||||
ancestor:
|
||||
typeof ancestor === "string"
|
||||
? { kind: "space" as const, id: ancestor }
|
||||
: ancestor,
|
||||
limit: clampLimit(selector.limit),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readSpaces(s: Session): Promise<Space[]> {
|
||||
return (await s.hierarchy.spaces()).spaces;
|
||||
}
|
||||
|
||||
export async function getSpaces(s: Session, ids: string[]): Promise<Space[]> {
|
||||
return byIds((await s.hierarchy.lookup("space", ids)).spaces, ids);
|
||||
}
|
||||
|
||||
export async function readFolders(
|
||||
s: Session,
|
||||
selector: FolderSelector,
|
||||
): Promise<Folder[]> {
|
||||
const { folders } = await scopedTree(s, selector);
|
||||
return selectLoaded(folders, loadedSelector(selector));
|
||||
}
|
||||
|
||||
export async function getFolders(s: Session, ids: string[]): Promise<Folder[]> {
|
||||
return byIds((await s.hierarchy.lookup("folder", ids)).folders, ids);
|
||||
}
|
||||
|
||||
export async function readLists(
|
||||
s: Session,
|
||||
selector: ListSelector,
|
||||
): Promise<List[]> {
|
||||
const { lists } = await scopedTree(s, selector);
|
||||
return selectLoaded(lists, loadedSelector(selector));
|
||||
}
|
||||
|
||||
export async function getLists(s: Session, ids: string[]): Promise<List[]> {
|
||||
return byIds((await s.hierarchy.lookup("list", ids)).lists, ids);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import {
|
||||
ref,
|
||||
refFields,
|
||||
refOrId,
|
||||
schemaOf,
|
||||
scoped,
|
||||
selection,
|
||||
} from "../validate";
|
||||
import type {
|
||||
Folder,
|
||||
FolderSelector,
|
||||
List,
|
||||
ListSelector,
|
||||
Space,
|
||||
} from "./hierarchy";
|
||||
|
||||
export const SpaceSchema = schemaOf<Space>()(
|
||||
z.object({ ...refFields(["space"]), private: z.boolean() }),
|
||||
);
|
||||
|
||||
export const FolderSchema = schemaOf<Folder>()(
|
||||
z.object({
|
||||
...refFields(["folder"]),
|
||||
ancestors: z.array(ref(["space", "folder"])),
|
||||
private: z.boolean(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const ListSchema = schemaOf<List>()(
|
||||
z.object({
|
||||
...refFields(["list"]),
|
||||
ancestors: z.array(ref(["space", "folder"])),
|
||||
private: z.boolean(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const FolderSelectorSchema = schemaOf<FolderSelector>()(
|
||||
z
|
||||
.strictObject({
|
||||
parent: z.optional(ref(["space", "folder"])),
|
||||
ancestor: z.optional(refOrId("space")),
|
||||
...selection,
|
||||
})
|
||||
.check(scoped),
|
||||
);
|
||||
|
||||
export const ListSelectorSchema = schemaOf<ListSelector>()(
|
||||
z
|
||||
.strictObject({
|
||||
parent: z.optional(ref(["space", "folder"])),
|
||||
ancestor: z.optional(refOrId("space")),
|
||||
...selection,
|
||||
})
|
||||
.check(scoped),
|
||||
);
|
||||
@@ -0,0 +1,89 @@
|
||||
import { LiveError } from "../core";
|
||||
import { readError } from "./errors";
|
||||
import type {
|
||||
ArtifactHostBridgeApi,
|
||||
ArtifactMemberDto,
|
||||
ArtifactQuerySourceInputs,
|
||||
ArtifactQuerySourceResults,
|
||||
DataQueryResult,
|
||||
EmbedDataQuery,
|
||||
EmbedSearchRequest,
|
||||
KitQuerySource,
|
||||
SearchUpdate,
|
||||
UserActionOutcome,
|
||||
} from "./wire";
|
||||
|
||||
/** The calls the kit makes to its host; every rejection is a `LiveError`. */
|
||||
export interface LiveBridge {
|
||||
query<S extends KitQuerySource>(
|
||||
source: S,
|
||||
input: ArtifactQuerySourceInputs[S],
|
||||
): Promise<ArtifactQuerySourceResults[S]>;
|
||||
queryData(query: EmbedDataQuery): Promise<DataQueryResult>;
|
||||
requestUserAction(
|
||||
actionType: string,
|
||||
params: Record<string, string>,
|
||||
): Promise<UserActionOutcome>;
|
||||
subscribeSearch(
|
||||
search: EmbedSearchRequest,
|
||||
onUpdate: (update: SearchUpdate) => void,
|
||||
): () => void;
|
||||
/** The current viewer, known to the host without a grant; throws `forbidden` on a host that predates it. */
|
||||
viewer(): ArtifactMemberDto;
|
||||
}
|
||||
|
||||
type BridgeWindow = Window & { clickup?: ArtifactHostBridgeApi };
|
||||
|
||||
const rethrowAsLiveError = (reason: unknown): never => {
|
||||
throw readError(reason);
|
||||
};
|
||||
|
||||
/** The bridge the ClickUp embed shell installs on `window.clickup`. */
|
||||
export function windowBridge(): LiveBridge {
|
||||
const member = <K extends keyof ArtifactHostBridgeApi>(
|
||||
name: K,
|
||||
): NonNullable<ArtifactHostBridgeApi[K]> => {
|
||||
const clickup = (window as BridgeWindow).clickup;
|
||||
if (!clickup) {
|
||||
throw new LiveError(
|
||||
"forbidden",
|
||||
"This artifact is not running inside a ClickUp host.",
|
||||
);
|
||||
}
|
||||
const value = clickup[name];
|
||||
if (!value) {
|
||||
throw new LiveError(
|
||||
"forbidden",
|
||||
`The host does not support ${name}; update the ClickUp app.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return {
|
||||
async query(source, input) {
|
||||
const result = await member("query")(source, input).catch(
|
||||
rethrowAsLiveError,
|
||||
);
|
||||
return result as ArtifactQuerySourceResults[typeof source];
|
||||
},
|
||||
async queryData(query) {
|
||||
return member("queryData")(query).catch(rethrowAsLiveError);
|
||||
},
|
||||
async requestUserAction(actionType, params) {
|
||||
return member("requestUserAction")(actionType, params).catch(
|
||||
rethrowAsLiveError,
|
||||
);
|
||||
},
|
||||
subscribeSearch(search, onUpdate) {
|
||||
try {
|
||||
return member("subscribeSearch")(search, onUpdate);
|
||||
} catch (reason) {
|
||||
return rethrowAsLiveError(reason);
|
||||
}
|
||||
},
|
||||
viewer() {
|
||||
return member("getViewer")();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { IsoDate } from "../core";
|
||||
|
||||
const UNIX_EPOCH: IsoDate = new Date(0).toISOString();
|
||||
|
||||
/** Wire dates are epoch-milliseconds strings or ISO strings; both become ISO. */
|
||||
export function toIso(
|
||||
value: string | number | null | undefined,
|
||||
): IsoDate | undefined {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return undefined;
|
||||
}
|
||||
const numeric = typeof value === "number" ? value : Number(value);
|
||||
const date = Number.isFinite(numeric) ? new Date(numeric) : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
}
|
||||
|
||||
export function toIsoOrEpoch(
|
||||
value: string | number | null | undefined,
|
||||
): IsoDate {
|
||||
return toIso(value) ?? UNIX_EPOCH;
|
||||
}
|
||||
|
||||
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** ISO to epoch milliseconds; a date-only value is noon of that day in the viewer's timezone. */
|
||||
export function toEpochMs(iso: IsoDate): number {
|
||||
const ms = new Date(DATE_ONLY.test(iso) ? `${iso}T12:00:00` : iso).getTime();
|
||||
if (Number.isNaN(ms)) {
|
||||
throw new RangeError(`Not an ISO 8601 date: ${iso}`);
|
||||
}
|
||||
return ms;
|
||||
}
|
||||
|
||||
/** ISO to the minute-aligned epoch-milliseconds string the action verbs take. */
|
||||
export function toEpochMinuteString(iso: IsoDate): string {
|
||||
const ms = toEpochMs(iso);
|
||||
return String(ms - (ms % 60_000));
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { LiveError } from "../core";
|
||||
import type { UserActionOutcome } from "./wire";
|
||||
import {
|
||||
ARTIFACT_ACTION_FAILURE_REASONS as FAILURE,
|
||||
ARTIFACT_READ_ERROR_CODES as READ,
|
||||
ARTIFACT_USER_ACTION_DENY_REASONS as DENY,
|
||||
NOT_DECLARED,
|
||||
NOT_ENABLED,
|
||||
} from "./wire";
|
||||
|
||||
const FORBIDDEN_READS: ReadonlySet<string> = new Set([
|
||||
READ.capabilityRevoked,
|
||||
READ.dataSourceRevoked,
|
||||
READ.documentReplaced,
|
||||
READ.missingOrigin,
|
||||
READ.notGranted,
|
||||
READ.sourceUnavailable,
|
||||
NOT_DECLARED,
|
||||
NOT_ENABLED,
|
||||
]);
|
||||
|
||||
const INVALID_READS: ReadonlySet<string> = new Set([
|
||||
READ.invalidLocation,
|
||||
READ.invalidQuery,
|
||||
READ.invalidSearch,
|
||||
]);
|
||||
|
||||
const RETRYABLE_READS: ReadonlySet<string> = new Set([
|
||||
READ.queryFailed,
|
||||
READ.rateLimited,
|
||||
READ.searchFailed,
|
||||
READ.timeout,
|
||||
READ.tooManySubscriptions,
|
||||
]);
|
||||
|
||||
/** Maps a raw bridge rejection to a `LiveError`. */
|
||||
export function readError(reason: unknown): LiveError {
|
||||
if (reason instanceof LiveError) {
|
||||
return reason;
|
||||
}
|
||||
const raw =
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: typeof reason === "string" && reason
|
||||
? reason
|
||||
: "failed";
|
||||
if (FORBIDDEN_READS.has(raw) || raw.includes("is not granted")) {
|
||||
return new LiveError(
|
||||
"forbidden",
|
||||
`The host does not allow this read (${raw}).`,
|
||||
{ reason: raw },
|
||||
);
|
||||
}
|
||||
if (raw.includes("is unavailable") || raw.includes("not connected")) {
|
||||
return new LiveError(
|
||||
"forbidden",
|
||||
"This artifact is not connected to a ClickUp host.",
|
||||
{ reason: raw },
|
||||
);
|
||||
}
|
||||
if (INVALID_READS.has(raw)) {
|
||||
return new LiveError("invalid", `The host rejected the query (${raw}).`, {
|
||||
reason: raw,
|
||||
});
|
||||
}
|
||||
return new LiveError("failed", `The read failed (${raw}).`, {
|
||||
reason: raw,
|
||||
retryable: RETRYABLE_READS.has(raw),
|
||||
});
|
||||
}
|
||||
|
||||
const FORBIDDEN_DENIALS: ReadonlySet<string> = new Set([
|
||||
DENY.grantRevoked,
|
||||
DENY.notGranted,
|
||||
DENY.unknownAction,
|
||||
NOT_DECLARED,
|
||||
NOT_ENABLED,
|
||||
]);
|
||||
|
||||
const RETRYABLE_FAILURES: ReadonlySet<string> = new Set([
|
||||
FAILURE.stateChanged,
|
||||
FAILURE.executionFailed,
|
||||
]);
|
||||
|
||||
/** Maps a non-executed action outcome to a `LiveError`. */
|
||||
export function actionError(
|
||||
actionType: string,
|
||||
outcome: UserActionOutcome,
|
||||
): LiveError {
|
||||
const reason = outcome.reason;
|
||||
const detail = reason ? ` (${reason})` : "";
|
||||
if (outcome.status === "denied") {
|
||||
if (reason === DENY.userDenied) {
|
||||
return new LiveError(
|
||||
"denied",
|
||||
`The user did not approve ${actionType}.`,
|
||||
{ reason },
|
||||
);
|
||||
}
|
||||
if (reason && FORBIDDEN_DENIALS.has(reason)) {
|
||||
return new LiveError(
|
||||
"forbidden",
|
||||
`The host does not allow ${actionType}${detail}.`,
|
||||
{ reason },
|
||||
);
|
||||
}
|
||||
if (reason === DENY.invalidParams) {
|
||||
return new LiveError(
|
||||
"invalid",
|
||||
`The host rejected the parameters of ${actionType}.`,
|
||||
{ reason },
|
||||
);
|
||||
}
|
||||
return new LiveError("failed", `${actionType} was not accepted${detail}.`, {
|
||||
reason,
|
||||
retryable: true,
|
||||
});
|
||||
}
|
||||
return new LiveError("failed", `${actionType} failed${detail}.`, {
|
||||
reason,
|
||||
retryable: reason !== undefined && RETRYABLE_FAILURES.has(reason),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Walks a cursor-paged source to its last page; `break` out of the loop to stop early. */
|
||||
export async function* pages<P extends { nextCursor?: string }>(
|
||||
fetchPage: (cursor: string | undefined) => Promise<P>,
|
||||
): AsyncGenerator<P> {
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await fetchPage(cursor);
|
||||
yield page;
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor);
|
||||
}
|
||||
|
||||
export function chunk<T>(items: T[], size: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
chunks.push(items.slice(index, index + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Ref } from "../core";
|
||||
import { LiveError } from "../core";
|
||||
import type { LiveBridge } from "./bridge";
|
||||
import { actionError } from "./errors";
|
||||
import type { UserActionOutcome } from "./wire";
|
||||
|
||||
/** One host action: its type and the string parameters the host takes. */
|
||||
export type WireCall = [actionType: string, params: Record<string, string>];
|
||||
|
||||
/** Runs one action through the user's approval; anything but `executed` throws. */
|
||||
export async function perform(
|
||||
host: LiveBridge,
|
||||
actionType: string,
|
||||
params: Record<string, string>,
|
||||
): Promise<UserActionOutcome> {
|
||||
const outcome = await host.requestUserAction(actionType, params);
|
||||
if (outcome.status !== "executed") {
|
||||
throw actionError(actionType, outcome);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
export async function performAll(
|
||||
host: LiveBridge,
|
||||
calls: WireCall[],
|
||||
): Promise<void> {
|
||||
for (const [actionType, params] of calls) {
|
||||
await perform(host, actionType, params);
|
||||
}
|
||||
}
|
||||
|
||||
/** The ref of what a create action made; `name` is what the caller asked for unless the host says otherwise. */
|
||||
export function createdRef<K extends Ref["kind"]>(
|
||||
kind: K,
|
||||
outcome: UserActionOutcome,
|
||||
name: string,
|
||||
): Ref<K> {
|
||||
const { id, url, name: reported } = outcome.result ?? {};
|
||||
if (!id || url === undefined) {
|
||||
throw new LiveError(
|
||||
"failed",
|
||||
`The host did not report the ${kind} it created.`,
|
||||
);
|
||||
}
|
||||
return { kind, id, name: reported ?? name, url };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import type { Delta } from "../core";
|
||||
import { fromHost } from "../validate";
|
||||
import type { LiveBridge } from "./bridge";
|
||||
|
||||
/** `text` as a Delta of one paragraph. */
|
||||
export function paragraph(text: string): Delta {
|
||||
return { ops: [{ insert: `${text}\n` }] };
|
||||
}
|
||||
|
||||
const HtmlSchema = z.object({ html: z.string() });
|
||||
|
||||
/** `delta` as the HTML ClickUp shows for it, rendered by the host. */
|
||||
export async function toHtml(host: LiveBridge, delta: Delta): Promise<string> {
|
||||
const result = await host.query("quillHtml", { content: delta });
|
||||
return fromHost(HtmlSchema, result, "html").html;
|
||||
}
|
||||
|
||||
const UNIFIED_EMOJI = /^[0-9a-f]{2,6}(-[0-9a-f]{2,6})*$/i;
|
||||
|
||||
/** Hosts report a standard emoji as its unified code (`1F44D`, `1F44D-1F3FB`); a custom emoji keeps its name. */
|
||||
export function toEmoji(code: string): string {
|
||||
return UNIFIED_EMOJI.test(code)
|
||||
? String.fromCodePoint(
|
||||
...code.split("-").map((part) => Number.parseInt(part, 16)),
|
||||
)
|
||||
: code;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
ArtifactCommentsQuery,
|
||||
ArtifactCustomFieldsQuery,
|
||||
ArtifactHierarchyQuery,
|
||||
ArtifactInboxQuery,
|
||||
ArtifactMembersQuery,
|
||||
ArtifactQuillDelta,
|
||||
ArtifactRemindersQuery,
|
||||
ArtifactStatusesQuery,
|
||||
ArtifactTasksQuery,
|
||||
ArtifactTaskTypesQuery,
|
||||
ArtifactWorkspaceQuery,
|
||||
EMBED_DATA_QUERY_SORT_FIELDS,
|
||||
EmbedDataQueryFilter,
|
||||
EmbedDataQueryLocationType,
|
||||
EmbedSearchOrderBy,
|
||||
EmbedSearchOrderDirection,
|
||||
} from "@time-loop/artifacts-protocol";
|
||||
import type { AssetKind, Ref } from "../core";
|
||||
import type { Task } from "../tasks/tasks";
|
||||
|
||||
export * from "@time-loop/artifacts-protocol";
|
||||
|
||||
/** The host's refusal of a call the artifact's build did not declare in its capabilities manifest. */
|
||||
export const NOT_DECLARED = "not-declared";
|
||||
/** The host's refusal of a call whose feature is off for the workspace. */
|
||||
export const NOT_ENABLED = "not-enabled";
|
||||
|
||||
export const ARTIFACT_QUERY_MAX_TASK_IDS = 50;
|
||||
export const ARTIFACT_COMMENTS_MAX_LIMIT = 50;
|
||||
export const ARTIFACT_HIERARCHY_MAX_IDS = 100;
|
||||
export const ARTIFACT_REMINDERS_MAX_LIMIT = 100;
|
||||
export const ARTIFACT_QUERY_MAX_LIMIT = 500;
|
||||
|
||||
export type EmbedDataQuerySortField =
|
||||
(typeof EMBED_DATA_QUERY_SORT_FIELDS)[number];
|
||||
|
||||
export interface EmbedDataQuery {
|
||||
entity: "task";
|
||||
location: { id: string; type: EmbedDataQueryLocationType };
|
||||
filters?: EmbedDataQueryFilter[];
|
||||
includeClosed?: boolean;
|
||||
sort?: { field: EmbedDataQuerySortField; dir: "asc" | "desc" };
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface EmbedSearchRequest {
|
||||
keywords?: string;
|
||||
assetTypes?: string[];
|
||||
excludeAssetTypes?: string[];
|
||||
ancestors?: Array<{ externalId: string }>;
|
||||
creatorIds?: string[];
|
||||
orderBy?: EmbedSearchOrderBy;
|
||||
orderDirection?: EmbedSearchOrderDirection;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** The `query()` sources the kit reads, with the protocol's input for each. */
|
||||
export interface ArtifactQuerySourceInputs {
|
||||
comments: ArtifactCommentsQuery;
|
||||
customFields: ArtifactCustomFieldsQuery;
|
||||
hierarchy: ArtifactHierarchyQuery;
|
||||
inbox: ArtifactInboxQuery;
|
||||
members: ArtifactMembersQuery;
|
||||
queryWorkspace: ArtifactWorkspaceQuery;
|
||||
quillHtml: { content: ArtifactQuillDelta };
|
||||
reminders: ArtifactRemindersQuery;
|
||||
statuses: ArtifactStatusesQuery;
|
||||
tasks: ArtifactTasksQuery;
|
||||
taskTypes: ArtifactTaskTypesQuery;
|
||||
}
|
||||
|
||||
export type KitQuerySource = keyof ArtifactQuerySourceInputs;
|
||||
|
||||
/** The ref of a wire entity that names itself. */
|
||||
export function refOf<K extends AssetKind>(
|
||||
kind: K,
|
||||
wire: { id: string; name: string; url: string },
|
||||
): Ref<K> {
|
||||
return { kind, id: wire.id, name: wire.name, url: wire.url };
|
||||
}
|
||||
|
||||
/** Task priorities on the wire are ids: `1` urgent through `4` low. */
|
||||
export const PRIORITY_IDS: Readonly<
|
||||
Record<NonNullable<Task["priority"]>, string>
|
||||
> = {
|
||||
urgent: "1",
|
||||
high: "2",
|
||||
normal: "3",
|
||||
low: "4",
|
||||
};
|
||||
|
||||
/** ClickUp's object type name for each kind that has one. */
|
||||
export const OBJECT_TYPES = {
|
||||
space: "project",
|
||||
folder: "category",
|
||||
list: "subcategory",
|
||||
task: "task",
|
||||
comment: "comment",
|
||||
reminder: "reminder",
|
||||
} as const satisfies Partial<Record<AssetKind, string>>;
|
||||
|
||||
const KINDS_BY_OBJECT_TYPE = new Map(
|
||||
Object.entries(OBJECT_TYPES).map(([kind, type]) => [type, kind]),
|
||||
) as ReadonlyMap<string, keyof typeof OBJECT_TYPES>;
|
||||
|
||||
/** The kind behind one of ClickUp's object type names; undefined for types the model does not have. */
|
||||
export function kindOfObjectType(
|
||||
type: string,
|
||||
): keyof typeof OBJECT_TYPES | undefined {
|
||||
return KINDS_BY_OBJECT_TYPE.get(type);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { IsoDate, Ref, UseQueryOptions, UseQueryResult } from "../core";
|
||||
import { listOrGet, useLiveQuery } from "../useQuery";
|
||||
|
||||
/** `primary` holds messages and mentions, `other` everything else, `reminder` reminders. */
|
||||
export const INBOX_CATEGORIES = ["primary", "other", "reminder"] as const;
|
||||
export const INBOX_STATES = ["active", "snoozed", "cleared"] as const;
|
||||
export const NOTIFICATION_TYPES = [
|
||||
"comment",
|
||||
"mention",
|
||||
"assignment",
|
||||
"status",
|
||||
"reminder",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* One inbox row: the notifications about one asset, whose name and url the
|
||||
* bundle mirrors. For a reminder, `related` is the reminder itself.
|
||||
*/
|
||||
export interface InboxBundle extends Ref<"inboxBundle"> {
|
||||
category: (typeof INBOX_CATEGORIES)[number];
|
||||
state: (typeof INBOX_STATES)[number];
|
||||
unreadCount: number;
|
||||
mentionsMe: boolean;
|
||||
assignedToMe: boolean;
|
||||
/** When a snoozed bundle comes back; for a reminder, when it is due. */
|
||||
snoozedUntil?: IsoDate;
|
||||
clearedAt?: IsoDate;
|
||||
/** The asset the bundle is about. */
|
||||
related?: Ref;
|
||||
/** Newest first. */
|
||||
notifications: Array<{
|
||||
type: (typeof NOTIFICATION_TYPES)[number];
|
||||
at: IsoDate;
|
||||
/** Who acted; absent for automations. */
|
||||
actor?: Ref<"user">;
|
||||
/** What the notification points at, e.g. the comment itself. */
|
||||
related?: Ref;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `state: "cleared"` clears, `"snoozed"` snoozes until `snoozedUntil`,
|
||||
* `"active"` brings a cleared or snoozed bundle back. `read` marks it read or
|
||||
* unread; read the state back through `unreadCount`.
|
||||
*/
|
||||
export interface InboxBundlePatch {
|
||||
state?: InboxBundle["state"];
|
||||
snoozedUntil?: IsoDate;
|
||||
read?: boolean;
|
||||
}
|
||||
|
||||
export interface InboxSelector {
|
||||
/** Defaults to `"active"`. */
|
||||
state?: InboxBundle["state"];
|
||||
category?: InboxBundle["category"];
|
||||
/** Defaults to 100, at most 500. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface InboxApi {
|
||||
list(selector?: InboxSelector): Promise<InboxBundle[]>;
|
||||
/** Any state, cleared included. */
|
||||
get(targets: Array<Ref<"inboxBundle"> | string>): Promise<InboxBundle[]>;
|
||||
update(
|
||||
bundle: Ref<"inboxBundle"> | string,
|
||||
patch: InboxBundlePatch,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export function useInbox(
|
||||
selector: InboxSelector | Array<Ref<"inboxBundle"> | string> = {},
|
||||
options: UseQueryOptions<InboxBundle[]> = {},
|
||||
): UseQueryResult<InboxBundle[]> {
|
||||
return useLiveQuery(
|
||||
"inboxBundle",
|
||||
selector,
|
||||
(client) => listOrGet(client.inbox, selector),
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import type { Ref } from "../core";
|
||||
import { toIso, toIsoOrEpoch } from "../host/dates";
|
||||
import { pages } from "../host/paging";
|
||||
import type {
|
||||
ArtifactFeedResourceDto,
|
||||
ArtifactInboxBundleDto,
|
||||
ArtifactInboxNotificationDto,
|
||||
} from "../host/wire";
|
||||
import { kindOfObjectType } from "../host/wire";
|
||||
import { byIds, clampLimit } from "../select";
|
||||
import type { Session } from "../session";
|
||||
import { fromHost } from "../validate";
|
||||
import type { InboxBundle, InboxSelector } from "./inbox";
|
||||
import { InboxBundleSchema } from "./schema";
|
||||
|
||||
type Notification = InboxBundle["notifications"][number];
|
||||
|
||||
export async function readInbox(
|
||||
s: Session,
|
||||
selector: InboxSelector,
|
||||
): Promise<InboxBundle[]> {
|
||||
const state = selector.state ?? "active";
|
||||
const { category } = selector;
|
||||
const bundles = await loadInbox(
|
||||
s,
|
||||
{ category, showCleared: state === "cleared" },
|
||||
(bundle) =>
|
||||
bundle.state === state && (!category || bundle.category === category),
|
||||
clampLimit(selector.limit),
|
||||
);
|
||||
return fromHost(z.array(InboxBundleSchema), bundles, "inbox bundles");
|
||||
}
|
||||
|
||||
/**
|
||||
* The host serves the uncleared bundles and the cleared ones as two reads;
|
||||
* the second is only made for ids the first did not have.
|
||||
*/
|
||||
export async function getInbox(
|
||||
s: Session,
|
||||
ids: string[],
|
||||
): Promise<InboxBundle[]> {
|
||||
const wanted = new Set(ids);
|
||||
const keep = (bundle: InboxBundle) => wanted.has(bundle.id);
|
||||
const found = await loadInbox(s, { showCleared: false }, keep, ids.length);
|
||||
if (found.length < ids.length) {
|
||||
found.push(
|
||||
...(await loadInbox(
|
||||
s,
|
||||
{ showCleared: true },
|
||||
keep,
|
||||
ids.length - found.length,
|
||||
)),
|
||||
);
|
||||
}
|
||||
return fromHost(
|
||||
z.array(InboxBundleSchema),
|
||||
byIds(found, ids),
|
||||
"inbox bundles",
|
||||
);
|
||||
}
|
||||
|
||||
/** The host's inbox pages, mapped, keeping what `keep` accepts until `limit` are kept. */
|
||||
async function loadInbox(
|
||||
s: Session,
|
||||
query: { category?: InboxBundle["category"]; showCleared: boolean },
|
||||
keep: (bundle: InboxBundle) => boolean,
|
||||
limit: number,
|
||||
): Promise<InboxBundle[]> {
|
||||
const kept: InboxBundle[] = [];
|
||||
for await (const page of pages((cursor) =>
|
||||
s.host.query("inbox", {
|
||||
filter: query.category === "reminder" ? "reminders" : "all",
|
||||
showCleared: query.showCleared,
|
||||
cursor,
|
||||
}),
|
||||
)) {
|
||||
const mapped = await Promise.all(
|
||||
page.bundles.map((dto) => mapInboxBundle(s, dto)),
|
||||
);
|
||||
kept.push(...mapped.filter(keep));
|
||||
if (kept.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return kept.slice(0, limit);
|
||||
}
|
||||
|
||||
async function mapInboxBundle(
|
||||
s: Session,
|
||||
wire: ArtifactInboxBundleDto,
|
||||
): Promise<InboxBundle> {
|
||||
const related = feedResourceRef(wire.root);
|
||||
const notifications = await Promise.all(
|
||||
[...wire.notifications]
|
||||
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt))
|
||||
.map((notification) => mapNotification(s, notification)),
|
||||
);
|
||||
return {
|
||||
kind: "inboxBundle",
|
||||
id: wire.id,
|
||||
name: wire.root.name,
|
||||
url: related?.url ?? "",
|
||||
category: toCategory(wire),
|
||||
state: toState(wire.status),
|
||||
unreadCount: wire.unreadCount,
|
||||
mentionsMe: wire.hasMention,
|
||||
assignedToMe: wire.hasAssignment,
|
||||
snoozedUntil: toIso(wire.snoozedUntil),
|
||||
clearedAt: toIso(wire.clearedAt),
|
||||
related,
|
||||
notifications,
|
||||
};
|
||||
}
|
||||
|
||||
async function mapNotification(
|
||||
s: Session,
|
||||
wire: ArtifactInboxNotificationDto,
|
||||
): Promise<Notification> {
|
||||
return {
|
||||
type: toNotificationType(wire.type),
|
||||
at: toIsoOrEpoch(wire.occurredAt),
|
||||
actor: wire.actorId ? await s.member(wire.actorId) : undefined,
|
||||
related: wire.object ? feedResourceRef(wire.object) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toCategory(wire: ArtifactInboxBundleDto): InboxBundle["category"] {
|
||||
const isReminder =
|
||||
wire.root.type === "reminder" ||
|
||||
wire.notifications.some((notification) =>
|
||||
notification.type.includes("reminder"),
|
||||
);
|
||||
if (isReminder) {
|
||||
return "reminder";
|
||||
}
|
||||
return wire.bundleType === "messages" ? "primary" : "other";
|
||||
}
|
||||
|
||||
function toState(status: string): InboxBundle["state"] {
|
||||
switch (status) {
|
||||
case "cleared":
|
||||
return "cleared";
|
||||
case "snoozed":
|
||||
return "snoozed";
|
||||
default:
|
||||
return "active";
|
||||
}
|
||||
}
|
||||
|
||||
/** Folds ClickUp history item types onto the model's notification types. */
|
||||
function toNotificationType(type: string): Notification["type"] {
|
||||
if (type === "comment" || type === "attachment_comment") {
|
||||
return "comment";
|
||||
}
|
||||
if (type === "tag" || type.endsWith("_tag") || type.includes("mention")) {
|
||||
return "mention";
|
||||
}
|
||||
if (type.includes("assign")) {
|
||||
return "assignment";
|
||||
}
|
||||
if (type === "status") {
|
||||
return "status";
|
||||
}
|
||||
if (type.includes("reminder")) {
|
||||
return "reminder";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
/** Only feed resources the model has a kind and a name for become refs. */
|
||||
function feedResourceRef(resource: ArtifactFeedResourceDto): Ref | undefined {
|
||||
const kind = kindOfObjectType(resource.type);
|
||||
if (!kind || !resource.name) {
|
||||
return undefined;
|
||||
}
|
||||
return { kind, id: resource.id, name: resource.name, url: resource.url };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import {
|
||||
anyRef,
|
||||
isoDate,
|
||||
ref,
|
||||
refFields,
|
||||
schemaOf,
|
||||
selection,
|
||||
} from "../validate";
|
||||
import type { InboxBundle, InboxBundlePatch, InboxSelector } from "./inbox";
|
||||
import { INBOX_CATEGORIES, INBOX_STATES, NOTIFICATION_TYPES } from "./inbox";
|
||||
|
||||
export const InboxBundleSchema = schemaOf<InboxBundle>()(
|
||||
z.object({
|
||||
...refFields(["inboxBundle"]),
|
||||
category: z.enum(INBOX_CATEGORIES),
|
||||
state: z.enum(INBOX_STATES),
|
||||
unreadCount: z.number(),
|
||||
mentionsMe: z.boolean(),
|
||||
assignedToMe: z.boolean(),
|
||||
snoozedUntil: z.optional(isoDate),
|
||||
clearedAt: z.optional(isoDate),
|
||||
related: z.optional(anyRef),
|
||||
notifications: z.array(
|
||||
z.object({
|
||||
type: z.enum(NOTIFICATION_TYPES),
|
||||
at: isoDate,
|
||||
actor: z.optional(ref(["user"])),
|
||||
related: z.optional(anyRef),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const InboxBundlePatchSchema = schemaOf<InboxBundlePatch>()(
|
||||
z
|
||||
.strictObject({
|
||||
state: z.optional(z.enum(INBOX_STATES)),
|
||||
snoozedUntil: z.optional(isoDate),
|
||||
read: z.optional(z.boolean()),
|
||||
})
|
||||
.check(
|
||||
z.refine(
|
||||
(patch) =>
|
||||
patch.state !== "snoozed" || patch.snoozedUntil !== undefined,
|
||||
{ error: "Snoozing needs snoozedUntil" },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
export const InboxSelectorSchema = schemaOf<InboxSelector>()(
|
||||
z.strictObject({
|
||||
state: z.optional(z.enum(INBOX_STATES)),
|
||||
category: z.optional(z.enum(INBOX_CATEGORIES)),
|
||||
...selection,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Ref } from "../core";
|
||||
import { toEpochMinuteString } from "../host/dates";
|
||||
import type { WireCall } from "../host/perform";
|
||||
import { performAll } from "../host/perform";
|
||||
import { ArtifactWriteActionType as Action } from "../host/wire";
|
||||
import type { Session } from "../session";
|
||||
import { resolve } from "../session";
|
||||
import { applyPatch } from "../store";
|
||||
import type { InboxBundle, InboxBundlePatch } from "./inbox";
|
||||
import { getInbox } from "./read";
|
||||
|
||||
export async function updateInboxBundle(
|
||||
s: Session,
|
||||
target: Ref<"inboxBundle"> | string,
|
||||
patch: InboxBundlePatch,
|
||||
): Promise<void> {
|
||||
const bundle = await resolve(s, "inboxBundle", target, getInbox);
|
||||
await s.store.update(
|
||||
bundle,
|
||||
(current) => applyInboxPatch(current, patch),
|
||||
() => performAll(s.host, inboxPatchCalls(bundle, patch)),
|
||||
);
|
||||
}
|
||||
|
||||
/** The ordered host calls that apply `patch` to `bundle`. */
|
||||
export function inboxPatchCalls(
|
||||
bundle: InboxBundle,
|
||||
patch: InboxBundlePatch,
|
||||
): WireCall[] {
|
||||
const bundleId = bundle.id;
|
||||
const calls: WireCall[] = [];
|
||||
const snooze = (until: string): WireCall => [
|
||||
Action.SnoozeInboxBundle,
|
||||
{ bundleId, until: toEpochMinuteString(until) },
|
||||
];
|
||||
if (patch.state !== undefined && patch.state !== bundle.state) {
|
||||
switch (patch.state) {
|
||||
case "active":
|
||||
calls.push([
|
||||
bundle.state === "snoozed"
|
||||
? Action.UnsnoozeInboxBundle
|
||||
: Action.UnclearInboxBundle,
|
||||
{ bundleId },
|
||||
]);
|
||||
break;
|
||||
case "cleared":
|
||||
calls.push([Action.ClearInboxBundle, { bundleId }]);
|
||||
break;
|
||||
case "snoozed":
|
||||
calls.push(snooze(patch.snoozedUntil!));
|
||||
break;
|
||||
}
|
||||
} else if (patch.snoozedUntil !== undefined) {
|
||||
calls.push(snooze(patch.snoozedUntil));
|
||||
}
|
||||
if (patch.read === true) {
|
||||
calls.push([Action.MarkInboxBundleRead, { bundleId }]);
|
||||
} else if (patch.read === false) {
|
||||
calls.push([Action.MarkInboxBundleUnread, { bundleId }]);
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
/** The bundle as it will read back once the host has applied `patch`. */
|
||||
export function applyInboxPatch(
|
||||
bundle: InboxBundle,
|
||||
patch: InboxBundlePatch,
|
||||
): InboxBundle {
|
||||
const { read, ...fields } = patch;
|
||||
const next = applyPatch(bundle, fields);
|
||||
if (read === true) {
|
||||
next.unreadCount = 0;
|
||||
} else if (read === false) {
|
||||
next.unreadCount = Math.max(1, next.unreadCount);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
export type {
|
||||
AssetKind,
|
||||
Delta,
|
||||
IsoDate,
|
||||
LiveClient,
|
||||
LiveErrorCode,
|
||||
MutationFunction,
|
||||
MutationOptions,
|
||||
MutationStatus,
|
||||
QueryStatus,
|
||||
Ref,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from "./core";
|
||||
export { clickup } from "./client";
|
||||
export { ASSET_KINDS, keepPreviousData, LiveError, useMutation } from "./core";
|
||||
|
||||
export type { Folder, List, Space } from "./hierarchy/hierarchy";
|
||||
export { useFolders, useLists, useSpaces } from "./hierarchy/hierarchy";
|
||||
export type { CustomField } from "./customFields/customFields";
|
||||
export {
|
||||
CUSTOM_FIELD_TYPES,
|
||||
useCustomFields,
|
||||
} from "./customFields/customFields";
|
||||
export type {
|
||||
ChecklistItem,
|
||||
CreateTaskInput,
|
||||
DateFilter,
|
||||
DateRange,
|
||||
Status,
|
||||
Task,
|
||||
TaskPatch,
|
||||
TaskSelector,
|
||||
TaskType,
|
||||
} from "./tasks/tasks";
|
||||
export {
|
||||
DATE_PRESETS,
|
||||
PRIORITIES,
|
||||
STATUS_TYPES,
|
||||
TASK_ORDERS,
|
||||
useStatuses,
|
||||
useTaskQuery,
|
||||
useTasks,
|
||||
useTaskTypes,
|
||||
} from "./tasks/tasks";
|
||||
export type {
|
||||
Comment,
|
||||
CommentPatch,
|
||||
CommentSelector,
|
||||
CreateCommentInput,
|
||||
} from "./comments/comments";
|
||||
export {
|
||||
COMMENT_ORDERS,
|
||||
COMMENT_TYPES,
|
||||
useComments,
|
||||
} from "./comments/comments";
|
||||
export type { Team, User } from "./people/people";
|
||||
export { useTeams, useUsers } from "./people/people";
|
||||
export type { InboxBundle, InboxBundlePatch } from "./inbox/inbox";
|
||||
export {
|
||||
INBOX_CATEGORIES,
|
||||
INBOX_STATES,
|
||||
NOTIFICATION_TYPES,
|
||||
useInbox,
|
||||
} from "./inbox/inbox";
|
||||
export type {
|
||||
CreateReminderInput,
|
||||
Reminder,
|
||||
ReminderPatch,
|
||||
} from "./reminders/reminders";
|
||||
export { useReminders } from "./reminders/reminders";
|
||||
export type { SearchHit, SearchQuery } from "./search/search";
|
||||
export { SEARCH_KINDS, SEARCH_ORDERS, useSearch } from "./search/search";
|
||||
export type {
|
||||
AvatarGroupProps,
|
||||
AvatarProps,
|
||||
CustomFieldValueProps,
|
||||
PriorityFlagProps,
|
||||
RichTextProps,
|
||||
StatusBadgeProps,
|
||||
TagProps,
|
||||
} from "./components/components";
|
||||
export {
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
CustomFieldValue,
|
||||
PriorityFlag,
|
||||
RichText,
|
||||
StatusBadge,
|
||||
Tag,
|
||||
} from "./components/components";
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface Memo<T> {
|
||||
(): Promise<T>;
|
||||
/** Forgets the result, so the next call loads again. */
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
/** Caches the first successful result; a rejection is forgotten so the next call retries. */
|
||||
export function memo<T>(load: () => Promise<T>): Memo<T> {
|
||||
let promise: Promise<T> | undefined;
|
||||
const get = (): Promise<T> => {
|
||||
promise ??= load().catch((error: unknown) => {
|
||||
promise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
return Object.assign(get, {
|
||||
reset: () => {
|
||||
promise = undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface MemoBy<A, T> {
|
||||
(arg: A): Promise<T>;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
/** `memo` per argument; `keyOf` names the entry when the argument is not itself a usable key. */
|
||||
export function memoBy<A, T>(
|
||||
load: (arg: A) => Promise<T>,
|
||||
keyOf: (arg: A) => unknown = (arg) => arg,
|
||||
): MemoBy<A, T> {
|
||||
const entries = new Map<unknown, Memo<T>>();
|
||||
const get = (arg: A): Promise<T> => {
|
||||
const key = keyOf(arg);
|
||||
let entry = entries.get(key);
|
||||
if (!entry) {
|
||||
entry = memo(() => load(arg));
|
||||
entries.set(key, entry);
|
||||
}
|
||||
return entry();
|
||||
};
|
||||
return Object.assign(get, { reset: () => entries.clear() });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Ref, UseQueryOptions, UseQueryResult } from "../core";
|
||||
import { listOrGet, useLiveQuery } from "../useQuery";
|
||||
|
||||
/** A workspace member; `image` is the avatar. */
|
||||
export type User = Ref<"user">;
|
||||
|
||||
export interface Team extends Ref<"team"> {
|
||||
members: Array<Ref<"user">>;
|
||||
}
|
||||
|
||||
export interface UsersApi {
|
||||
list(): Promise<User[]>;
|
||||
get(targets: Array<Ref<"user"> | string>): Promise<User[]>;
|
||||
}
|
||||
|
||||
export interface TeamsApi {
|
||||
list(): Promise<Team[]>;
|
||||
get(targets: Array<Ref<"team"> | string>): Promise<Team[]>;
|
||||
}
|
||||
|
||||
/** Every member, or exactly the given ones. */
|
||||
export function useUsers(
|
||||
targets?: Array<Ref<"user"> | string>,
|
||||
options: UseQueryOptions<User[]> = {},
|
||||
): UseQueryResult<User[]> {
|
||||
return useLiveQuery(
|
||||
"user",
|
||||
targets ?? {},
|
||||
(client) => listOrGet(client.users, targets ?? {}),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/** Every team, or exactly the given ones. */
|
||||
export function useTeams(
|
||||
targets?: Array<Ref<"team"> | string>,
|
||||
options: UseQueryOptions<Team[]> = {},
|
||||
): UseQueryResult<Team[]> {
|
||||
return useLiveQuery(
|
||||
"team",
|
||||
targets ?? {},
|
||||
(client) => listOrGet(client.teams, targets ?? {}),
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import type { Ref } from "../core";
|
||||
import type { LiveBridge } from "../host/bridge";
|
||||
import { pages } from "../host/paging";
|
||||
import type {
|
||||
ArtifactGroupDto,
|
||||
ArtifactMemberDto,
|
||||
ArtifactMembersResult,
|
||||
ArtifactUserRef,
|
||||
} from "../host/wire";
|
||||
import { ARTIFACT_QUERY_MAX_LIMIT } from "../host/wire";
|
||||
import { byIds } from "../select";
|
||||
import type { Session } from "../session";
|
||||
import { fromHost } from "../validate";
|
||||
import type { Team, User } from "./people";
|
||||
import { TeamSchema, UserSchema } from "./schema";
|
||||
|
||||
/** The `members` source, mapped: everyone in the workspace and every team. */
|
||||
export interface Members {
|
||||
users: User[];
|
||||
teams: Team[];
|
||||
}
|
||||
|
||||
export async function loadMembers(host: LiveBridge): Promise<Members> {
|
||||
const members = [];
|
||||
const groups = [];
|
||||
for await (const page of pages((cursor) =>
|
||||
host.query("members", {
|
||||
includeGroups: true,
|
||||
limit: ARTIFACT_QUERY_MAX_LIMIT,
|
||||
cursor,
|
||||
}),
|
||||
)) {
|
||||
members.push(...page.members);
|
||||
groups.push(...(page.groups ?? []));
|
||||
}
|
||||
const peopleUrl = members[0]?.url ?? host.viewer().url;
|
||||
return mapMembers({ members, groups }, peopleUrl);
|
||||
}
|
||||
|
||||
export function mapMember(member: ArtifactMemberDto): User {
|
||||
return {
|
||||
kind: "user",
|
||||
id: member.id,
|
||||
name: member.displayName,
|
||||
url: member.url,
|
||||
image: member.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/** Teams list only the members the workspace still knows. */
|
||||
function mapMembers(wire: ArtifactMembersResult, peopleUrl: string): Members {
|
||||
const users = wire.members.map(mapMember);
|
||||
const usersById = new Map(users.map((user) => [user.id, user]));
|
||||
|
||||
const teams = (wire.groups ?? []).map(
|
||||
(group): Team => ({
|
||||
kind: "team",
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
url: peopleUrl,
|
||||
members: group.memberIds.flatMap((id) => {
|
||||
const user = usersById.get(id);
|
||||
return user ? [user] : [];
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
users: fromHost(z.array(UserSchema), users, "users"),
|
||||
teams: fromHost(z.array(TeamSchema), teams, "teams"),
|
||||
};
|
||||
}
|
||||
|
||||
/** The user a task or comment names: the workspace member when known, else the user as the wire names them. */
|
||||
export async function userOf(s: Session, wire: ArtifactUserRef): Promise<User> {
|
||||
return (
|
||||
(await s.member(wire.id)) ?? {
|
||||
kind: "user",
|
||||
id: wire.id,
|
||||
name: wire.username,
|
||||
url: wire.url,
|
||||
image: wire.profilePicture,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function usersOf(
|
||||
s: Session,
|
||||
wire: readonly ArtifactUserRef[],
|
||||
): Promise<User[]> {
|
||||
return Promise.all(wire.map((ref) => userOf(s, ref)));
|
||||
}
|
||||
|
||||
/** The team a task names: the workspace team when known, else the team as the wire names it. */
|
||||
async function teamOf(
|
||||
s: Session,
|
||||
wire: ArtifactGroupDto,
|
||||
): Promise<Ref<"team">> {
|
||||
return (
|
||||
(await s.team(wire.id)) ?? {
|
||||
kind: "team",
|
||||
id: wire.id,
|
||||
name: wire.name,
|
||||
url: "",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function teamsOf(
|
||||
s: Session,
|
||||
wire: readonly ArtifactGroupDto[],
|
||||
): Promise<Ref<"team">[]> {
|
||||
return Promise.all(wire.map((group) => teamOf(s, group)));
|
||||
}
|
||||
|
||||
export async function readUsers(s: Session): Promise<User[]> {
|
||||
return (await s.members()).users;
|
||||
}
|
||||
|
||||
export async function getUsers(s: Session, ids: string[]): Promise<User[]> {
|
||||
return byIds((await s.members()).users, ids);
|
||||
}
|
||||
|
||||
export async function readTeams(s: Session): Promise<Team[]> {
|
||||
return (await s.members()).teams;
|
||||
}
|
||||
|
||||
export async function getTeams(s: Session, ids: string[]): Promise<Team[]> {
|
||||
return byIds((await s.members()).teams, ids);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import { ref, refFields, schemaOf } from "../validate";
|
||||
import type { Team, User } from "./people";
|
||||
|
||||
export const UserSchema = schemaOf<User>()(z.object(refFields(["user"])));
|
||||
|
||||
export const TeamSchema = schemaOf<Team>()(
|
||||
z.object({ ...refFields(["team"]), members: z.array(ref(["user"])) }),
|
||||
);
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import type { Ref } from "../core";
|
||||
import { asRef } from "../core";
|
||||
import { toIso } from "../host/dates";
|
||||
import { pages } from "../host/paging";
|
||||
import type { ArtifactReminder } from "../host/wire";
|
||||
import { ARTIFACT_REMINDERS_MAX_LIMIT } from "../host/wire";
|
||||
import { byIds, clampLimit } from "../select";
|
||||
import type { Session } from "../session";
|
||||
import { getTasks } from "../tasks/read";
|
||||
import { fromHost } from "../validate";
|
||||
import type { Reminder, ReminderSelector } from "./reminders";
|
||||
import { ReminderSchema } from "./schema";
|
||||
|
||||
export async function readReminders(
|
||||
s: Session,
|
||||
selector: ReminderSelector,
|
||||
): Promise<Reminder[]> {
|
||||
const wire = await loadReminders(
|
||||
s,
|
||||
selector.includeCompleted ?? false,
|
||||
clampLimit(selector.limit),
|
||||
);
|
||||
return mapReminders(s, wire);
|
||||
}
|
||||
|
||||
export async function getReminders(
|
||||
s: Session,
|
||||
ids: string[],
|
||||
): Promise<Reminder[]> {
|
||||
const wire = await loadReminders(s, true, Infinity);
|
||||
return mapReminders(s, byIds(wire, ids));
|
||||
}
|
||||
|
||||
/** The viewer's reminders, oldest page first, until `limit` are read or none are left. */
|
||||
async function loadReminders(
|
||||
s: Session,
|
||||
includeCompleted: boolean,
|
||||
limit: number,
|
||||
): Promise<ArtifactReminder[]> {
|
||||
const wire: ArtifactReminder[] = [];
|
||||
for await (const page of pages((cursor) =>
|
||||
s.host.query("reminders", {
|
||||
includeCompleted,
|
||||
limit: ARTIFACT_REMINDERS_MAX_LIMIT,
|
||||
cursor,
|
||||
}),
|
||||
)) {
|
||||
wire.push(...page.reminders);
|
||||
if (wire.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return wire.slice(0, limit);
|
||||
}
|
||||
|
||||
async function mapReminders(
|
||||
s: Session,
|
||||
wire: ArtifactReminder[],
|
||||
): Promise<Reminder[]> {
|
||||
const tasks = await relatedTasks(s, wire);
|
||||
return fromHost(
|
||||
z.array(ReminderSchema),
|
||||
wire.map((reminder) => mapReminder(reminder, tasks)),
|
||||
"reminders",
|
||||
);
|
||||
}
|
||||
|
||||
/** The wire names a reminder's task by id only; one tasks read names the ones the viewer can see. */
|
||||
async function relatedTasks(
|
||||
s: Session,
|
||||
wire: readonly ArtifactReminder[],
|
||||
): Promise<Map<string, Ref<"task">>> {
|
||||
const ids = [...new Set(wire.flatMap((reminder) => reminder.taskId ?? []))];
|
||||
if (ids.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const tasks = await getTasks(s, ids);
|
||||
return new Map(tasks.map((task) => [task.id, asRef(task)]));
|
||||
}
|
||||
|
||||
function mapReminder(
|
||||
wire: ArtifactReminder,
|
||||
tasks: ReadonlyMap<string, Ref<"task">>,
|
||||
): Reminder {
|
||||
return {
|
||||
kind: "reminder",
|
||||
id: wire.id,
|
||||
name: wire.title,
|
||||
url: "",
|
||||
dueAt: toIso(wire.dueDate),
|
||||
completedAt: toIso(wire.completedAt),
|
||||
related: wire.taskId
|
||||
? (tasks.get(wire.taskId) ?? {
|
||||
kind: "task",
|
||||
id: wire.taskId,
|
||||
name: "",
|
||||
url: "",
|
||||
})
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { IsoDate, Ref, UseQueryOptions, UseQueryResult } from "../core";
|
||||
import { listOrGet, useLiveQuery } from "../useQuery";
|
||||
|
||||
export interface Reminder extends Ref<"reminder"> {
|
||||
dueAt?: IsoDate;
|
||||
/** Set once completed; there is no separate flag. */
|
||||
completedAt?: IsoDate;
|
||||
related?: Ref<"task">;
|
||||
}
|
||||
|
||||
/** `completed: true` cannot be undone. */
|
||||
export interface ReminderPatch {
|
||||
dueAt?: IsoDate;
|
||||
completed?: true;
|
||||
}
|
||||
|
||||
export interface CreateReminderInput {
|
||||
name: string;
|
||||
dueAt?: IsoDate;
|
||||
related?: Ref<"task"> | string;
|
||||
}
|
||||
|
||||
export interface ReminderSelector {
|
||||
includeCompleted?: boolean;
|
||||
/** Defaults to 100, at most 500. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface RemindersApi {
|
||||
list(selector?: ReminderSelector): Promise<Reminder[]>;
|
||||
/** Completed ones included. */
|
||||
get(targets: Array<Ref<"reminder"> | string>): Promise<Reminder[]>;
|
||||
create(input: CreateReminderInput): Promise<Ref<"reminder">>;
|
||||
update(
|
||||
reminder: Ref<"reminder"> | string,
|
||||
patch: ReminderPatch,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export function useReminders(
|
||||
selector: ReminderSelector | Array<Ref<"reminder"> | string> = {},
|
||||
options: UseQueryOptions<Reminder[]> = {},
|
||||
): UseQueryResult<Reminder[]> {
|
||||
return useLiveQuery(
|
||||
"reminder",
|
||||
selector,
|
||||
(client) => listOrGet(client.reminders, selector),
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import {
|
||||
isoDate,
|
||||
ref,
|
||||
refFields,
|
||||
refOrId,
|
||||
schemaOf,
|
||||
selection,
|
||||
} from "../validate";
|
||||
import type {
|
||||
CreateReminderInput,
|
||||
Reminder,
|
||||
ReminderPatch,
|
||||
ReminderSelector,
|
||||
} from "./reminders";
|
||||
|
||||
export const ReminderSchema = schemaOf<Reminder>()(
|
||||
z.object({
|
||||
...refFields(["reminder"]),
|
||||
dueAt: z.optional(isoDate),
|
||||
completedAt: z.optional(isoDate),
|
||||
related: z.optional(ref(["task"])),
|
||||
}),
|
||||
);
|
||||
|
||||
export const ReminderPatchSchema = schemaOf<ReminderPatch>()(
|
||||
z.strictObject({
|
||||
dueAt: z.optional(isoDate),
|
||||
completed: z.optional(z.literal(true)),
|
||||
}),
|
||||
);
|
||||
|
||||
export const CreateReminderInputSchema = schemaOf<CreateReminderInput>()(
|
||||
z.strictObject({
|
||||
name: z.string().check(z.minLength(1, "A reminder needs a name")),
|
||||
dueAt: z.optional(isoDate),
|
||||
related: z.optional(refOrId("task")),
|
||||
}),
|
||||
);
|
||||
|
||||
export const ReminderSelectorSchema = schemaOf<ReminderSelector>()(
|
||||
z.strictObject({
|
||||
includeCompleted: z.optional(z.boolean()),
|
||||
...selection,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Ref } from "../core";
|
||||
import { idOf } from "../core";
|
||||
import { toEpochMinuteString } from "../host/dates";
|
||||
import type { WireCall } from "../host/perform";
|
||||
import { createdRef, perform, performAll } from "../host/perform";
|
||||
import { ArtifactWriteActionType as Action } from "../host/wire";
|
||||
import type { Session } from "../session";
|
||||
import { applyPatch } from "../store";
|
||||
import type { CreateReminderInput, Reminder, ReminderPatch } from "./reminders";
|
||||
|
||||
export async function createReminder(
|
||||
s: Session,
|
||||
input: CreateReminderInput,
|
||||
): Promise<Ref<"reminder">> {
|
||||
const outcome = await perform(s.host, Action.CreateReminder, {
|
||||
message: input.name,
|
||||
...(input.dueAt ? { dueDate: toEpochMinuteString(input.dueAt) } : {}),
|
||||
...(input.related ? { taskId: idOf(input.related) } : {}),
|
||||
});
|
||||
await Promise.all([
|
||||
s.store.invalidate("reminder"),
|
||||
s.store.invalidate("inboxBundle"),
|
||||
]);
|
||||
return createdRef("reminder", outcome, input.name);
|
||||
}
|
||||
|
||||
export async function updateReminder(
|
||||
s: Session,
|
||||
target: Ref<"reminder"> | string,
|
||||
patch: ReminderPatch,
|
||||
): Promise<void> {
|
||||
const reminder: Ref<"reminder"> =
|
||||
typeof target === "string"
|
||||
? { kind: "reminder", id: target, name: "", url: "" }
|
||||
: target;
|
||||
await s.store.update<Reminder>(
|
||||
reminder as Reminder,
|
||||
(current) => applyReminderPatch(current, patch),
|
||||
() => performAll(s.host, reminderPatchCalls(reminder.id, patch)),
|
||||
);
|
||||
}
|
||||
|
||||
/** The ordered host calls that apply `patch` to the reminder. */
|
||||
export function reminderPatchCalls(
|
||||
reminderId: string,
|
||||
patch: ReminderPatch,
|
||||
): WireCall[] {
|
||||
const calls: WireCall[] = [];
|
||||
if (patch.dueAt !== undefined) {
|
||||
calls.push([
|
||||
Action.SnoozeReminder,
|
||||
{ reminderId, until: toEpochMinuteString(patch.dueAt) },
|
||||
]);
|
||||
}
|
||||
if (patch.completed) {
|
||||
calls.push([Action.CompleteReminder, { reminderId }]);
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
/** The reminder as it will read back once the host has applied `patch`. */
|
||||
export function applyReminderPatch(
|
||||
reminder: Reminder,
|
||||
patch: ReminderPatch,
|
||||
): Reminder {
|
||||
const { completed, ...fields } = patch;
|
||||
const next = applyPatch(reminder, fields);
|
||||
if (completed) {
|
||||
next.completedAt = new Date().toISOString();
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import { preview } from "../comments/read";
|
||||
import { LiveError } from "../core";
|
||||
import type { LiveBridge } from "../host/bridge";
|
||||
import { toIso, toIsoOrEpoch } from "../host/dates";
|
||||
import { readError } from "../host/errors";
|
||||
import type {
|
||||
EmbedSearchOrderBy,
|
||||
EmbedSearchRequest,
|
||||
EmbedSearchResultItem,
|
||||
} from "../host/wire";
|
||||
import {
|
||||
EMBED_SEARCH_DEFAULT_LIMIT,
|
||||
EMBED_SEARCH_MAX_LIMIT,
|
||||
kindOfObjectType,
|
||||
OBJECT_TYPES,
|
||||
} from "../host/wire";
|
||||
import { clampLimit } from "../select";
|
||||
import type { Session } from "../session";
|
||||
import { fromHost } from "../validate";
|
||||
import { SearchHitSchema } from "./schema";
|
||||
import type { SearchHit, SearchQuery } from "./search";
|
||||
import { SEARCH_KINDS } from "./search";
|
||||
|
||||
const SEARCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
const SEARCH_ORDER_BY: Readonly<
|
||||
Record<NonNullable<SearchQuery["orderBy"]>["field"], EmbedSearchOrderBy>
|
||||
> = {
|
||||
createdAt: "created_date",
|
||||
updatedAt: "updated_date",
|
||||
dueAt: "due_date",
|
||||
};
|
||||
|
||||
export async function search(
|
||||
s: Session,
|
||||
query: SearchQuery,
|
||||
): Promise<SearchHit[]> {
|
||||
const limit = clampLimit(
|
||||
query.limit,
|
||||
EMBED_SEARCH_DEFAULT_LIMIT,
|
||||
EMBED_SEARCH_MAX_LIMIT,
|
||||
);
|
||||
const { ancestor } = query;
|
||||
const items = await collectSearch(s.host, {
|
||||
...(query.keywords ? { keywords: query.keywords } : {}),
|
||||
assetTypes: [...(query.kinds ?? SEARCH_KINDS)],
|
||||
...(ancestor
|
||||
? {
|
||||
ancestors: [
|
||||
{ externalId: `${OBJECT_TYPES[ancestor.kind]}:${ancestor.id}` },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(query.orderBy
|
||||
? {
|
||||
orderBy: SEARCH_ORDER_BY[query.orderBy.field],
|
||||
orderDirection: query.orderBy.direction ?? "desc",
|
||||
}
|
||||
: {}),
|
||||
limit,
|
||||
});
|
||||
return fromHost(
|
||||
z.array(SearchHitSchema),
|
||||
items
|
||||
.flatMap((item) => {
|
||||
const hit = mapSearchHit(item);
|
||||
return hit ? [hit] : [];
|
||||
})
|
||||
.slice(0, limit),
|
||||
"search hits",
|
||||
);
|
||||
}
|
||||
|
||||
/** Runs a streamed host search to completion. */
|
||||
function collectSearch(
|
||||
host: LiveBridge,
|
||||
request: EmbedSearchRequest,
|
||||
): Promise<EmbedSearchResultItem[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let unsubscribe = (): void => {};
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(
|
||||
new LiveError("failed", "The search did not complete in time.", {
|
||||
retryable: true,
|
||||
}),
|
||||
);
|
||||
}, SEARCH_TIMEOUT_MS);
|
||||
const settle = (fn: () => void): void => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
fn();
|
||||
};
|
||||
try {
|
||||
unsubscribe = host.subscribeSearch(request, (update) => {
|
||||
if (update.error) {
|
||||
settle(() => reject(readError(update.error)));
|
||||
} else if (update.done) {
|
||||
settle(() => resolve(update.items));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
settle(() => reject(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const SEARCHABLE: ReadonlySet<string> = new Set(SEARCH_KINDS);
|
||||
|
||||
/** Hits of kinds the kit does not search are dropped. */
|
||||
function mapSearchHit(item: EmbedSearchResultItem): SearchHit | undefined {
|
||||
const kind = kindOfObjectType(item.metadata.objectType);
|
||||
if (!kind || !SEARCHABLE.has(kind)) {
|
||||
return undefined;
|
||||
}
|
||||
const snippet = item.highlights?.snippets[0] ?? item.snippet;
|
||||
return {
|
||||
kind: kind as SearchHit["kind"],
|
||||
id: item.metadata.objectId,
|
||||
name: item.title || preview(snippet ?? ""),
|
||||
url: item.url,
|
||||
image: item.thumbnailUrl,
|
||||
snippet,
|
||||
createdAt: toIsoOrEpoch(item.createdAt),
|
||||
updatedAt: toIsoOrEpoch(item.updatedAt),
|
||||
viewedAt: toIso(item.viewedAt),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import {
|
||||
EMBED_SEARCH_KEYWORDS_MAX_LENGTH,
|
||||
EMBED_SEARCH_MAX_LIMIT,
|
||||
} from "../host/wire";
|
||||
import { isoDate, limit, orderBy, ref, refFields, schemaOf } from "../validate";
|
||||
import type { SearchHit, SearchQuery } from "./search";
|
||||
import { SEARCH_KINDS, SEARCH_ORDERS } from "./search";
|
||||
|
||||
export const SearchHitSchema = schemaOf<SearchHit>()(
|
||||
z.object({
|
||||
...refFields(SEARCH_KINDS),
|
||||
snippet: z.optional(z.string()),
|
||||
createdAt: isoDate,
|
||||
updatedAt: isoDate,
|
||||
viewedAt: z.optional(isoDate),
|
||||
}),
|
||||
);
|
||||
|
||||
export const SearchQuerySchema = schemaOf<SearchQuery>()(
|
||||
z.strictObject({
|
||||
keywords: z.optional(
|
||||
z
|
||||
.string()
|
||||
.check(
|
||||
z.minLength(1),
|
||||
z.maxLength(
|
||||
EMBED_SEARCH_KEYWORDS_MAX_LENGTH,
|
||||
`At most ${EMBED_SEARCH_KEYWORDS_MAX_LENGTH} characters`,
|
||||
),
|
||||
),
|
||||
),
|
||||
kinds: z.optional(
|
||||
z
|
||||
.array(z.enum(SEARCH_KINDS))
|
||||
.check(z.minLength(1, "Expected at least one kind")),
|
||||
),
|
||||
ancestor: z.optional(ref(["space", "folder", "list"])),
|
||||
orderBy: orderBy(SEARCH_ORDERS),
|
||||
limit: limit(EMBED_SEARCH_MAX_LIMIT),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { IsoDate, Ref, UseQueryOptions, UseQueryResult } from "../core";
|
||||
import { useLiveQuery } from "../useQuery";
|
||||
|
||||
export const SEARCH_KINDS = [
|
||||
"task",
|
||||
"list",
|
||||
"folder",
|
||||
"space",
|
||||
"comment",
|
||||
] as const;
|
||||
export const SEARCH_ORDERS = ["createdAt", "updatedAt", "dueAt"] as const;
|
||||
|
||||
/** A ref plus why and when it matched. For the full asset, load it through its kind, e.g. `useTasks([hit.id])`. */
|
||||
export interface SearchHit extends Ref<(typeof SEARCH_KINDS)[number]> {
|
||||
/** A plain-text preview; the passage that matched when `keywords` were given. */
|
||||
snippet?: string;
|
||||
createdAt: IsoDate;
|
||||
updatedAt: IsoDate;
|
||||
/** When the viewer last opened it. */
|
||||
viewedAt?: IsoDate;
|
||||
}
|
||||
|
||||
export interface SearchQuery {
|
||||
/** Matched against names, descriptions and comments. Omit for a plain listing, ordered by `orderBy`. */
|
||||
keywords?: string;
|
||||
/** Every searchable kind by default. */
|
||||
kinds?: Array<SearchHit["kind"]>;
|
||||
ancestor?: Ref<"space" | "folder" | "list">;
|
||||
/** Relevance when omitted; `direction` defaults to `"desc"`. */
|
||||
orderBy?: {
|
||||
field: (typeof SEARCH_ORDERS)[number];
|
||||
direction?: "asc" | "desc";
|
||||
};
|
||||
/** Defaults to 25, at most 50. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** ClickUp's own search, run as the viewer; hits come in ClickUp's order. */
|
||||
export function useSearch(
|
||||
query: SearchQuery,
|
||||
options: UseQueryOptions<SearchHit[]> = {},
|
||||
): UseQueryResult<SearchHit[]> {
|
||||
return useLiveQuery("search", query, (client) => client.search(query), {
|
||||
...options,
|
||||
cache: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Ref } from "./core";
|
||||
import { ARTIFACT_QUERY_MAX_LIMIT } from "./host/wire";
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
export function clampLimit(
|
||||
limit: number | undefined,
|
||||
fallback = DEFAULT_LIMIT,
|
||||
max = ARTIFACT_QUERY_MAX_LIMIT,
|
||||
): number {
|
||||
return Math.min(limit ?? fallback, max);
|
||||
}
|
||||
|
||||
type Identity = Pick<Ref, "kind" | "id">;
|
||||
|
||||
interface LoadedSelector {
|
||||
/** Direct owner: the last ancestor. */
|
||||
parent?: Identity;
|
||||
/** Any ancestor. */
|
||||
ancestor?: Identity;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Applies a selector to an already-loaded collection; `limit` bounds the result only when given. */
|
||||
export function selectLoaded<T extends Ref & { ancestors?: Ref[] }>(
|
||||
assets: T[],
|
||||
selector: LoadedSelector,
|
||||
): T[] {
|
||||
const { parent, ancestor, limit } = selector;
|
||||
const selected = assets.filter((asset) => {
|
||||
const ancestors = asset.ancestors ?? [];
|
||||
return (
|
||||
(!parent || same(ancestors[ancestors.length - 1], parent)) &&
|
||||
(!ancestor || ancestors.some((ref) => same(ref, ancestor)))
|
||||
);
|
||||
});
|
||||
return limit === undefined ? selected : selected.slice(0, limit);
|
||||
}
|
||||
|
||||
function same(a: Identity | undefined, b: Identity): boolean {
|
||||
return a !== undefined && a.kind === b.kind && a.id === b.id;
|
||||
}
|
||||
|
||||
/** The items with these ids, in this order; ids the collection lacks are skipped. */
|
||||
export function byIds<T extends { id: string }>(
|
||||
items: readonly T[],
|
||||
ids: string[],
|
||||
): T[] {
|
||||
const byId = new Map(items.map((item) => [item.id, item]));
|
||||
return ids.flatMap((id) => {
|
||||
const item = byId.get(id);
|
||||
return item ? [item] : [];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Ref } from "./core";
|
||||
import { asRef, idOf, LiveError, pathOf } from "./core";
|
||||
import type { Hierarchy, HierarchyReads } from "./hierarchy/read";
|
||||
import { createHierarchyReads, HIERARCHY_KINDS } from "./hierarchy/read";
|
||||
import type { LiveBridge } from "./host/bridge";
|
||||
import { memo } from "./memo";
|
||||
import type { Team, User } from "./people/people";
|
||||
import type { Members } from "./people/read";
|
||||
import { loadMembers } from "./people/read";
|
||||
import type { Store } from "./store";
|
||||
import { loadTaskTypes } from "./tasks/taskTypes";
|
||||
import type { TaskType } from "./tasks/tasks";
|
||||
|
||||
/** The ids of the space, folder and list an asset sits in. */
|
||||
export interface LocationIds {
|
||||
spaceId?: string;
|
||||
folderId?: string;
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
type LocationRef = Ref<"space" | "folder" | "list">;
|
||||
|
||||
/**
|
||||
* One connection to a host: its bridge, its query store, and the bundles
|
||||
* read once and shared by every query, until a refetch or an invalidation
|
||||
* of one of their kinds drops them.
|
||||
*/
|
||||
export interface Session {
|
||||
host: LiveBridge;
|
||||
store: Store;
|
||||
hierarchy: HierarchyReads;
|
||||
/** The members bundle; rejects when the grant is withheld. */
|
||||
members(): Promise<Members>;
|
||||
/** The workspace member with `id`, if any. */
|
||||
member(id: string): Promise<User | undefined>;
|
||||
/** The workspace team with `id`, if any. */
|
||||
team(id: string): Promise<Team | undefined>;
|
||||
/** The task type catalog; rejects when the grant is withheld. */
|
||||
taskTypes(): Promise<TaskType[]>;
|
||||
/**
|
||||
* The refs of a location's ids, root first, as far as its space's tree
|
||||
* lists them; a level the tree does not list (hidden, inaccessible,
|
||||
* archived, or no hierarchy grant) is left out.
|
||||
*/
|
||||
path(ids: LocationIds): Promise<LocationRef[]>;
|
||||
}
|
||||
|
||||
export function createSession(host: LiveBridge, store: Store): Session {
|
||||
const hierarchy = createHierarchyReads(host);
|
||||
const members = memo(() => loadMembers(host));
|
||||
const users = memo(
|
||||
async () => new Map((await members()).users.map((user) => [user.id, user])),
|
||||
);
|
||||
const teams = memo(
|
||||
async () => new Map((await members()).teams.map((team) => [team.id, team])),
|
||||
);
|
||||
const taskTypes = memo(() => loadTaskTypes(host));
|
||||
store.onRefresh(HIERARCHY_KINDS, hierarchy.reset);
|
||||
store.onRefresh(["user", "team"], () => {
|
||||
members.reset();
|
||||
users.reset();
|
||||
teams.reset();
|
||||
});
|
||||
store.onRefresh(["taskType"], taskTypes.reset);
|
||||
|
||||
return {
|
||||
host,
|
||||
store,
|
||||
hierarchy,
|
||||
members,
|
||||
async member(id) {
|
||||
const known = await users().catch(() => undefined);
|
||||
return known?.get(id);
|
||||
},
|
||||
async team(id) {
|
||||
const known = await teams().catch(() => undefined);
|
||||
return known?.get(id);
|
||||
},
|
||||
taskTypes,
|
||||
async path({ spaceId, folderId, listId }) {
|
||||
if (!spaceId) {
|
||||
return [];
|
||||
}
|
||||
const tree = await hierarchy.tree(spaceId).catch(() => undefined);
|
||||
if (!tree) {
|
||||
return [];
|
||||
}
|
||||
const deepest: LocationRef | undefined =
|
||||
tree.lists.find((list) => list.id === listId) ??
|
||||
tree.folders.find((folder) => folder.id === folderId) ??
|
||||
tree.spaces.find((space) => space.id === spaceId);
|
||||
return deepest
|
||||
? [...(pathOf(deepest) as LocationRef[]), asRef(deepest)]
|
||||
: [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The full asset behind a ref or id: the copy on screen, else one read through the kind's `get`. */
|
||||
export async function resolve<T extends Ref>(
|
||||
s: Session,
|
||||
kind: T["kind"],
|
||||
target: Ref | string,
|
||||
get: (s: Session, ids: string[]) => Promise<T[]>,
|
||||
): Promise<T> {
|
||||
const id = idOf(target);
|
||||
const held = s.store.find<T>(kind, id);
|
||||
if (held) {
|
||||
return held;
|
||||
}
|
||||
const [found] = await get(s, [id]);
|
||||
if (!found) {
|
||||
throw new LiveError(
|
||||
"invalid",
|
||||
`No ${kind} with id ${id}, or it is not visible to you.`,
|
||||
);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The `ancestor` or `parent` a selector names; its schema guarantees one. */
|
||||
export function scopeOf<P, A>(selector: {
|
||||
parent?: P;
|
||||
ancestor?: A;
|
||||
}): NonNullable<A | P> {
|
||||
return (selector.ancestor ?? selector.parent)!;
|
||||
}
|
||||
|
||||
/** The tree of the space a space or folder ref sits in. */
|
||||
export async function treeOf(
|
||||
s: Session,
|
||||
ref: Ref<"space" | "folder">,
|
||||
): Promise<Hierarchy> {
|
||||
return s.hierarchy.tree(await spaceIdOf(s, ref));
|
||||
}
|
||||
|
||||
/** The space a ref sits in: from its path when it is a full asset, else looked up by id. */
|
||||
async function spaceIdOf(
|
||||
s: Session,
|
||||
ref: Ref<"space" | "folder">,
|
||||
): Promise<string> {
|
||||
if (ref.kind === "space") {
|
||||
return ref.id;
|
||||
}
|
||||
const known = pathOf(ref).find((ancestor) => ancestor.kind === "space");
|
||||
if (known) {
|
||||
return known.id;
|
||||
}
|
||||
const { folders } = await s.hierarchy.lookup("folder", [ref.id]);
|
||||
const folder = folders.find((candidate) => candidate.id === ref.id);
|
||||
const space = folder?.ancestors.find((ancestor) => ancestor.kind === "space");
|
||||
if (!space) {
|
||||
throw new LiveError(
|
||||
"invalid",
|
||||
`No folder with id ${ref.id}, or it is not visible to you.`,
|
||||
);
|
||||
}
|
||||
return space.id;
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import type { AssetKind, Ref } from "./core";
|
||||
import { ASSET_KINDS, LiveError } from "./core";
|
||||
import type { QueryThing, Thing } from "./things";
|
||||
import { isQuery, keyOf, Things } from "./things";
|
||||
|
||||
/** What a query lists: assets of one kind, search hits, SQL rows over tasks, or a body's HTML. */
|
||||
export type QueryKind = AssetKind | "search" | "taskRow" | "html";
|
||||
|
||||
/** A stable cache key for one kind + selector; equal selectors share a key. */
|
||||
export function queryKey(kind: QueryKind, selector: object): string {
|
||||
return `${kind}:${stableStringify(selector)}`;
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableStringify).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.kind === "string" && typeof record.id === "string") {
|
||||
return `${record.kind}#${record.id}`;
|
||||
}
|
||||
return `{${Object.keys(record)
|
||||
.sort()
|
||||
.filter((key) => record[key] !== undefined)
|
||||
.map((key) => `${key}:${stableStringify(record[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
const ASSET_KIND_SET: ReadonlySet<string> = new Set(ASSET_KINDS);
|
||||
|
||||
/** What a hook reads for its query. */
|
||||
export interface QueryState<T> {
|
||||
data: T[] | undefined;
|
||||
error: unknown;
|
||||
/** When `data` landed: the host's answer this session, or the cached copy's stamp. */
|
||||
at: number;
|
||||
/** True while a request is in flight. */
|
||||
fetching: boolean;
|
||||
}
|
||||
|
||||
export interface SubscribeOptions {
|
||||
/** False keeps this query off the cache: it neither paints from it nor lands in it. */
|
||||
cache?: boolean;
|
||||
}
|
||||
|
||||
/** The per-query state that lives only for this session. */
|
||||
interface Runtime {
|
||||
kind: QueryKind;
|
||||
fetcher: (() => Promise<object[]>) | undefined;
|
||||
promise: Promise<object[]> | undefined;
|
||||
error: unknown;
|
||||
cache: boolean;
|
||||
listeners: Set<() => void>;
|
||||
memo: { version: number; data: object[] | undefined } | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every query on screen over one map of things: shared fetches, optimistic
|
||||
* writes and invalidation. A hook paints what is held, from this session or
|
||||
* the cache, and every mount asks the host again.
|
||||
*/
|
||||
export class Store {
|
||||
readonly things = new Things();
|
||||
private readonly runtime = new Map<string, Runtime>();
|
||||
private readonly resets = new Map<QueryKind, Set<() => void>>();
|
||||
private readonly changeListeners = new Set<() => void>();
|
||||
|
||||
/** Registers a bundle the queries of `kinds` read through; it is reset before any fresh read of those kinds. */
|
||||
onRefresh(kinds: readonly QueryKind[], reset: () => void): void {
|
||||
for (const kind of kinds) {
|
||||
const resets = this.resets.get(kind) ?? new Set();
|
||||
resets.add(reset);
|
||||
this.resets.set(kind, resets);
|
||||
}
|
||||
}
|
||||
|
||||
private refresh(kind: QueryKind): void {
|
||||
for (const reset of this.resets.get(kind) ?? []) {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribes to a query, creating it on first use; identical keys share one entry and one in-flight request. */
|
||||
subscribe<T extends object>(
|
||||
key: string,
|
||||
kind: QueryKind,
|
||||
fetcher: () => Promise<T[]>,
|
||||
listener: () => void,
|
||||
options: SubscribeOptions = {},
|
||||
): () => void {
|
||||
let entry = this.runtime.get(key);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
kind,
|
||||
fetcher: undefined,
|
||||
promise: undefined,
|
||||
error: undefined,
|
||||
cache: true,
|
||||
listeners: new Set(),
|
||||
memo: undefined,
|
||||
};
|
||||
this.runtime.set(key, entry);
|
||||
}
|
||||
entry.fetcher = fetcher;
|
||||
entry.cache = options.cache !== false;
|
||||
entry.listeners.add(listener);
|
||||
const query = this.things.query(key);
|
||||
if (!entry.cache && query && this.things.hasPage(query)) {
|
||||
this.things.delete(keyOf(query));
|
||||
}
|
||||
const { listeners } = entry;
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
state<T extends object>(key: string): QueryState<T> {
|
||||
const entry = this.runtime.get(key);
|
||||
const query = this.things.query(key);
|
||||
const data = query ? (this.rowsOf(query, entry) as T[]) : undefined;
|
||||
return {
|
||||
data,
|
||||
error: entry?.error,
|
||||
at: data === undefined ? 0 : query!.at,
|
||||
fetching: entry?.promise !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** The rows a query holds, defined only once every listed asset is present; the same array while nothing changed. */
|
||||
private rowsOf(
|
||||
query: QueryThing,
|
||||
entry: Runtime | undefined,
|
||||
): object[] | undefined {
|
||||
const { version } = this.things;
|
||||
const memo = entry?.memo;
|
||||
if (memo?.version === version) {
|
||||
return memo.data;
|
||||
}
|
||||
let data: object[] | undefined = query.rows ?? [];
|
||||
for (const id of query.ids ?? []) {
|
||||
const thing = this.things.get(id);
|
||||
if (!thing) {
|
||||
data = undefined;
|
||||
break;
|
||||
}
|
||||
data.push(thing);
|
||||
}
|
||||
if (data && memo?.data && sameItems(data, memo.data)) {
|
||||
data = memo.data;
|
||||
}
|
||||
if (entry) {
|
||||
entry.memo = { version, data };
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Asks the host for the query's data, sharing any request in flight; `fresh` also resets the bundles its kind reads through. */
|
||||
fetch<T extends object>(key: string, fresh = false): Promise<T[]> {
|
||||
const entry = this.runtime.get(key);
|
||||
if (!entry) {
|
||||
return Promise.reject(new Error(`Unknown query ${key}`));
|
||||
}
|
||||
if (fresh) {
|
||||
this.refresh(entry.kind);
|
||||
}
|
||||
return this.load(key, entry) as Promise<T[]>;
|
||||
}
|
||||
|
||||
private load(key: string, entry: Runtime): Promise<object[]> {
|
||||
if (entry.promise) {
|
||||
return entry.promise;
|
||||
}
|
||||
const { fetcher } = entry;
|
||||
if (!fetcher) {
|
||||
return Promise.reject(
|
||||
new Error(`A ${entry.kind} query has no subscriber`),
|
||||
);
|
||||
}
|
||||
const promise = withRetry(fetcher).then(
|
||||
(rows) => {
|
||||
if (entry.promise === promise) {
|
||||
entry.promise = undefined;
|
||||
this.ingest(key, entry, rows);
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (entry.promise === promise) {
|
||||
entry.promise = undefined;
|
||||
entry.error = error;
|
||||
if (isForbidden(error)) {
|
||||
this.things.deleteQuery(key);
|
||||
this.things.collect();
|
||||
}
|
||||
this.notify(entry.kind);
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
entry.promise = promise;
|
||||
entry.error = undefined;
|
||||
this.notify(entry.kind);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Stores a host answer: the rows become the current things and the query lists them. */
|
||||
private ingest(key: string, entry: Runtime, rows: object[]): void {
|
||||
entry.error = undefined;
|
||||
const query: QueryThing = { kind: "query", id: key, at: Date.now() };
|
||||
if (ASSET_KIND_SET.has(entry.kind)) {
|
||||
for (const asset of rows as Ref[]) {
|
||||
this.things.set(keyOf(asset), asset);
|
||||
}
|
||||
query.ids = (rows as Ref[]).map(keyOf);
|
||||
} else {
|
||||
query.rows = rows;
|
||||
}
|
||||
if (entry.cache) {
|
||||
this.things.set(keyOf(query), query);
|
||||
} else {
|
||||
this.things.hold(keyOf(query), query);
|
||||
}
|
||||
this.things.collect();
|
||||
this.notify(entry.kind);
|
||||
this.changed();
|
||||
}
|
||||
|
||||
/** Drops the least recently used off-screen queries until the things fit `budget`. */
|
||||
evict(budget: number): void {
|
||||
const inUse = (query: QueryThing) =>
|
||||
(this.runtime.get(query.id)?.listeners.size ?? 0) > 0;
|
||||
for (const query of this.things.evict(budget, inUse)) {
|
||||
this.runtime.delete(query.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** The copy of an asset any query holds. */
|
||||
find<T extends Ref>(kind: AssetKind, id: string): T | undefined {
|
||||
return this.things.get(`${kind}#${id}`) as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a write optimistically: cached copies of `asset` show the patched
|
||||
* result at once, the host executes after the user's approval, and every
|
||||
* list of that kind on screen refetches. A denial or failure rolls back.
|
||||
*/
|
||||
async update<T extends Ref>(
|
||||
asset: T,
|
||||
apply: (current: T) => T,
|
||||
perform: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const rollback = this.optimistic(asset, apply);
|
||||
try {
|
||||
await perform();
|
||||
} catch (error) {
|
||||
rollback();
|
||||
throw error;
|
||||
}
|
||||
await this.invalidate(asset.kind);
|
||||
}
|
||||
|
||||
private optimistic<T extends Ref>(
|
||||
asset: Ref,
|
||||
merge: (current: T) => T,
|
||||
): () => void {
|
||||
const key = keyOf(asset);
|
||||
const current = this.things.get(key);
|
||||
if (!current) {
|
||||
return () => undefined;
|
||||
}
|
||||
const next = merge(current as T);
|
||||
const swap = (from: object, to: Ref) => {
|
||||
if (this.things.get(key) === from) {
|
||||
this.things.set(key, to);
|
||||
this.notify(asset.kind);
|
||||
this.changed();
|
||||
}
|
||||
};
|
||||
swap(current, next);
|
||||
return () => swap(next, current as Ref);
|
||||
}
|
||||
|
||||
/** Refetches every query of a kind on screen; the rest refetch when they come back. A task write also refreshes SQL rows. */
|
||||
async invalidate(kind: QueryKind): Promise<void> {
|
||||
this.refresh(kind);
|
||||
const refetches: Array<Promise<unknown>> = [];
|
||||
if (kind === "task") {
|
||||
refetches.push(this.invalidate("taskRow"));
|
||||
}
|
||||
for (const [key, entry] of this.runtime) {
|
||||
if (entry.kind === kind && entry.listeners.size > 0) {
|
||||
refetches.push(this.load(key, entry).catch(() => undefined));
|
||||
}
|
||||
}
|
||||
await Promise.all(refetches);
|
||||
}
|
||||
|
||||
/** Drops every bundle and refetches every query on screen; the rest refetch when they come back. */
|
||||
reload(): void {
|
||||
for (const kind of this.resets.keys()) {
|
||||
this.refresh(kind);
|
||||
}
|
||||
for (const [key, entry] of this.runtime) {
|
||||
if (entry.listeners.size > 0) {
|
||||
this.load(key, entry).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Adopts a page from the cache, except queries the host already refused this session, and wakes every hook. */
|
||||
seedPage(page: string, things: Thing[]): void {
|
||||
const refused = (thing: Thing) =>
|
||||
isQuery(thing) && this.runtime.get(thing.id)?.error !== undefined;
|
||||
this.things.seedPage(
|
||||
page,
|
||||
things.filter((thing) => !refused(thing)),
|
||||
);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Called after any host answer or optimistic change; seeding is not a change. */
|
||||
onChange(listener: () => void): () => void {
|
||||
this.changeListeners.add(listener);
|
||||
return () => {
|
||||
this.changeListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private changed(): void {
|
||||
for (const listener of this.changeListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
/** Wakes the hooks of `kind`, or all of them. */
|
||||
private notify(kind?: QueryKind): void {
|
||||
for (const entry of this.runtime.values()) {
|
||||
if (kind === undefined || entry.kind === kind) {
|
||||
for (const listener of entry.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const RETRIES = 3;
|
||||
const FIRST_RETRY_DELAY_MS = 1_000;
|
||||
|
||||
/** Runs `load`, retrying a `retryable` failure up to three times at 1 s, 2 s and 4 s; any other failure rejects at once. */
|
||||
async function withRetry<T>(load: () => Promise<T>): Promise<T> {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
return await load();
|
||||
} catch (error) {
|
||||
if (attempt >= RETRIES || !isTransient(error)) {
|
||||
throw error;
|
||||
}
|
||||
await wait(FIRST_RETRY_DELAY_MS * 2 ** attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isTransient(error: unknown): boolean {
|
||||
return error instanceof LiveError && error.retryable;
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isForbidden(error: unknown): boolean {
|
||||
return error instanceof LiveError && error.code === "forbidden";
|
||||
}
|
||||
|
||||
function sameItems(a: readonly object[], b: readonly object[]): boolean {
|
||||
return a.length === b.length && a.every((item, index) => item === b[index]);
|
||||
}
|
||||
|
||||
/** The optimistic result of a patch: `undefined` leaves a field alone, `null` clears it, anything else replaces it. */
|
||||
export function applyPatch<T extends object>(asset: T, patch: object): T {
|
||||
const next = { ...asset } as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete next[key];
|
||||
} else if (value !== undefined) {
|
||||
next[key] = value;
|
||||
}
|
||||
}
|
||||
return next as T;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { LiveBridge } from "../host/bridge";
|
||||
import { perform } from "../host/perform";
|
||||
import { ArtifactWriteActionType as Action } from "../host/wire";
|
||||
import type { ChecklistItem, Task } from "./tasks";
|
||||
|
||||
/** The `parent` value that puts a checklist item at the top level (`CHECKLIST_ITEM_NO_PARENT` in the protocol's schemas). */
|
||||
const CHECKLIST_ITEM_NO_PARENT = "none";
|
||||
|
||||
/** Syncs the task's checklists to `next`, diffed by id. */
|
||||
export async function syncChecklists(
|
||||
host: LiveBridge,
|
||||
task: Task,
|
||||
next: ChecklistItem[],
|
||||
): Promise<void> {
|
||||
const taskId = task.id;
|
||||
const current = new Map(task.checklists.map((list) => [list.id, list]));
|
||||
for (const checklist of next) {
|
||||
const existing = current.get(checklist.id);
|
||||
if (!existing) {
|
||||
const outcome = await perform(host, Action.CreateChecklist, {
|
||||
taskId,
|
||||
name: checklist.name,
|
||||
});
|
||||
const checklistId = outcome.result?.id;
|
||||
if (checklistId) {
|
||||
await syncChecklistItems(
|
||||
host,
|
||||
taskId,
|
||||
checklistId,
|
||||
[],
|
||||
checklist.items,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current.delete(checklist.id);
|
||||
if (existing.name !== checklist.name) {
|
||||
await perform(host, Action.SetChecklistName, {
|
||||
taskId,
|
||||
checklistId: checklist.id,
|
||||
name: checklist.name,
|
||||
});
|
||||
}
|
||||
await syncChecklistItems(
|
||||
host,
|
||||
taskId,
|
||||
checklist.id,
|
||||
existing.items,
|
||||
checklist.items,
|
||||
);
|
||||
}
|
||||
for (const removed of current.values()) {
|
||||
await perform(host, Action.DeleteChecklist, {
|
||||
taskId,
|
||||
checklistId: removed.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function syncChecklistItems(
|
||||
host: LiveBridge,
|
||||
taskId: string,
|
||||
checklistId: string,
|
||||
current: ChecklistItem[],
|
||||
next: ChecklistItem[],
|
||||
): Promise<void> {
|
||||
const target = { taskId, checklistId };
|
||||
const existing = new Map(
|
||||
flattenItems(current, CHECKLIST_ITEM_NO_PARENT).map((entry) => [
|
||||
entry.item.id,
|
||||
entry,
|
||||
]),
|
||||
);
|
||||
const kept = new Set<string>();
|
||||
const sync = async (
|
||||
items: ChecklistItem[],
|
||||
parent: string,
|
||||
): Promise<void> => {
|
||||
for (const item of items) {
|
||||
const before = existing.get(item.id);
|
||||
let itemId: string | undefined = item.id;
|
||||
if (before) {
|
||||
kept.add(item.id);
|
||||
if (before.item.name !== item.name) {
|
||||
await perform(host, Action.SetChecklistItemName, {
|
||||
...target,
|
||||
itemId: item.id,
|
||||
name: item.name,
|
||||
});
|
||||
}
|
||||
if (item.done !== undefined && before.item.done !== item.done) {
|
||||
await perform(host, Action.SetChecklistItemResolved, {
|
||||
...target,
|
||||
itemId: item.id,
|
||||
resolved: String(item.done),
|
||||
});
|
||||
}
|
||||
if (before.parent !== parent) {
|
||||
await perform(host, Action.SetChecklistItemParent, {
|
||||
...target,
|
||||
itemId: item.id,
|
||||
parent,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const outcome = await perform(host, Action.CreateChecklistItem, {
|
||||
...target,
|
||||
name: item.name,
|
||||
});
|
||||
itemId = outcome.result?.id;
|
||||
if (itemId && parent !== CHECKLIST_ITEM_NO_PARENT) {
|
||||
await perform(host, Action.SetChecklistItemParent, {
|
||||
...target,
|
||||
itemId,
|
||||
parent,
|
||||
});
|
||||
}
|
||||
if (itemId && item.done) {
|
||||
await perform(host, Action.SetChecklistItemResolved, {
|
||||
...target,
|
||||
itemId,
|
||||
resolved: "true",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (itemId) {
|
||||
await sync(item.items, itemId);
|
||||
}
|
||||
}
|
||||
};
|
||||
await sync(next, CHECKLIST_ITEM_NO_PARENT);
|
||||
for (const itemId of existing.keys()) {
|
||||
if (!kept.has(itemId)) {
|
||||
await perform(host, Action.DeleteChecklistItem, { ...target, itemId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every item at every depth with the id of the item above it. */
|
||||
function flattenItems(
|
||||
items: ChecklistItem[],
|
||||
parent: string,
|
||||
): Array<{ item: ChecklistItem; parent: string }> {
|
||||
return items.flatMap((item) => [
|
||||
{ item, parent },
|
||||
...flattenItems(item.items, item.id),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { Ref } from "../core";
|
||||
import { toEpochMs } from "../host/dates";
|
||||
import type {
|
||||
EmbedDataQuery,
|
||||
EmbedDataQueryFilter,
|
||||
EmbedDataQueryFilterValue,
|
||||
EmbedDataQuerySortField,
|
||||
EmbedDataQueryStatusFilterValue,
|
||||
} from "../host/wire";
|
||||
import { PRIORITY_IDS } from "../host/wire";
|
||||
import type { Status, TaskSelector } from "./tasks";
|
||||
|
||||
/** The wire status types behind each of the kit's; the inverse of `toStatusType`. */
|
||||
const STATUS_BUCKETS: Readonly<
|
||||
Record<Status["type"], Array<EmbedDataQueryStatusFilterValue["type"]>>
|
||||
> = {
|
||||
open: ["open", "unstarted", "placeholder"],
|
||||
active: ["active", "custom"],
|
||||
done: ["done"],
|
||||
closed: ["closed"],
|
||||
};
|
||||
|
||||
const DATE_PRESET_OPS = {
|
||||
overdue: "overdue",
|
||||
today: "today",
|
||||
tomorrow: "tomorrow",
|
||||
next7Days: "next7",
|
||||
} as const;
|
||||
|
||||
type DateField = "startDate" | "dueDate" | "dateCreated" | "dateUpdated";
|
||||
|
||||
type AnyDateFilter = NonNullable<TaskSelector["dueAt"]>;
|
||||
|
||||
/** Open ends of a range; the host treats an epoch of 0 as unset. */
|
||||
const EARLIEST = Date.UTC(1971, 0, 1);
|
||||
const LATEST = Date.UTC(2200, 0, 1);
|
||||
|
||||
const WIRE_FIELDS: Readonly<
|
||||
Record<NonNullable<TaskSelector["orderBy"]>["field"], EmbedDataQuerySortField>
|
||||
> = {
|
||||
name: "name",
|
||||
priority: "priority",
|
||||
status: "status",
|
||||
startAt: "startDate",
|
||||
dueAt: "dueDate",
|
||||
createdAt: "dateCreated",
|
||||
updatedAt: "dateUpdated",
|
||||
};
|
||||
|
||||
/** The `queryData` filters behind a selector, in the host's `{ field, op, values }` form. */
|
||||
export function taskFilters(selector: TaskSelector): EmbedDataQueryFilter[] {
|
||||
const filters: EmbedDataQueryFilter[] = [];
|
||||
if (selector.status) {
|
||||
filters.push({
|
||||
field: "status",
|
||||
op: "EQ",
|
||||
values: Array.isArray(selector.status)
|
||||
? selector.status.map((ref) => ref.name)
|
||||
: selector.status.type.flatMap((type) =>
|
||||
STATUS_BUCKETS[type].map((wireType) => ({ type: wireType })),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (selector.assignees) {
|
||||
filters.push(assigneesFilter(selector.assignees));
|
||||
}
|
||||
if (selector.priority) {
|
||||
filters.push({
|
||||
field: "priority",
|
||||
op: "EQ",
|
||||
values: selector.priority.map((level) => PRIORITY_IDS[level]),
|
||||
});
|
||||
}
|
||||
if (selector.tags) {
|
||||
filters.push({ field: "tag", op: "ANY", values: selector.tags });
|
||||
}
|
||||
if (selector.name) {
|
||||
filters.push({
|
||||
field: "name",
|
||||
op: "EQ",
|
||||
values: [{ method: "contains", value: selector.name.contains }],
|
||||
});
|
||||
}
|
||||
const dates: Array<[DateField, AnyDateFilter | undefined]> = [
|
||||
["startDate", selector.startAt],
|
||||
["dueDate", selector.dueAt],
|
||||
["dateCreated", selector.createdAt],
|
||||
["dateUpdated", selector.updatedAt],
|
||||
];
|
||||
for (const [field, filter] of dates) {
|
||||
if (filter !== undefined) {
|
||||
filters.push(dateFilter(field, filter));
|
||||
}
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
/** The `queryData` sort behind a selector; the host's own order without one. */
|
||||
export function taskSort(selector: TaskSelector): EmbedDataQuery["sort"] {
|
||||
return selector.orderBy
|
||||
? {
|
||||
field: WIRE_FIELDS[selector.orderBy.field],
|
||||
dir: selector.orderBy.direction ?? "asc",
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function assigneesFilter(
|
||||
assignees: NonNullable<TaskSelector["assignees"]>,
|
||||
): EmbedDataQueryFilter {
|
||||
if (assignees === "none") {
|
||||
return { field: "assignee", op: "NOT SET", values: [] };
|
||||
}
|
||||
if (assignees === "any") {
|
||||
return { field: "assignee", op: "IS SET", values: [] };
|
||||
}
|
||||
return { field: "assignee", op: "ANY", values: assignees.map(assigneeValue) };
|
||||
}
|
||||
|
||||
/** Ids are numeric on the wire where they can be; a team is the app's `group_id` token. */
|
||||
function assigneeValue(
|
||||
assignee: Ref<"user" | "team"> | "me",
|
||||
): EmbedDataQueryFilterValue {
|
||||
if (assignee === "me") {
|
||||
return { me: true };
|
||||
}
|
||||
const numeric = Number(assignee.id);
|
||||
const id = Number.isFinite(numeric) ? numeric : assignee.id;
|
||||
return assignee.kind === "team" ? { group_id: id } : id;
|
||||
}
|
||||
|
||||
function dateFilter(
|
||||
field: DateField,
|
||||
filter: AnyDateFilter,
|
||||
): EmbedDataQueryFilter {
|
||||
if (filter === "none") {
|
||||
return { field, op: "NOT SET", values: [] };
|
||||
}
|
||||
if (filter === "any") {
|
||||
return { field, op: "IS SET", values: [] };
|
||||
}
|
||||
if (typeof filter === "string") {
|
||||
return { field, op: "EQ", values: [{ op: DATE_PRESET_OPS[filter] }] };
|
||||
}
|
||||
return {
|
||||
field,
|
||||
op: "EQ",
|
||||
values: [
|
||||
{
|
||||
op: "ra",
|
||||
startDate:
|
||||
filter.from === undefined ? EARLIEST : toEpochMs(filter.from),
|
||||
dueDate: filter.to === undefined ? LATEST : toEpochMs(filter.to),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import { DEFAULT_COLOR } from "../components/palette";
|
||||
import type { Ref } from "../core";
|
||||
import { mapCustomFieldValues } from "../customFields/read";
|
||||
import type { LiveBridge } from "../host/bridge";
|
||||
import { toIso, toIsoOrEpoch } from "../host/dates";
|
||||
import { chunk, pages } from "../host/paging";
|
||||
import { paragraph } from "../host/quill";
|
||||
import type {
|
||||
ArtifactChecklistDto,
|
||||
ArtifactChecklistItemDto,
|
||||
ArtifactTaskDto,
|
||||
} from "../host/wire";
|
||||
import {
|
||||
ARTIFACT_QUERY_MAX_TASK_IDS,
|
||||
EMBED_DATA_QUERY_MAX_LIMIT,
|
||||
OBJECT_TYPES,
|
||||
refOf,
|
||||
} from "../host/wire";
|
||||
import { teamsOf, userOf, usersOf } from "../people/read";
|
||||
import { clampLimit } from "../select";
|
||||
import type { Session } from "../session";
|
||||
import { scopeOf } from "../session";
|
||||
import { fromHost } from "../validate";
|
||||
import { taskFilters, taskSort } from "./filters";
|
||||
import { TaskRowsSchema, TaskSchema } from "./schema";
|
||||
import { mapStatus } from "./statuses";
|
||||
import { taskTypeOf } from "./taskTypes";
|
||||
import type { ChecklistItem, Task, TaskSelector } from "./tasks";
|
||||
import { PRIORITIES } from "./tasks";
|
||||
|
||||
export async function readTasks(
|
||||
s: Session,
|
||||
selector: TaskSelector,
|
||||
): Promise<Task[]> {
|
||||
const limit = clampLimit(selector.limit);
|
||||
const scope = scopeOf(selector);
|
||||
const ids = await listTaskIds(
|
||||
s.host,
|
||||
typeof scope === "string" ? { kind: "list", id: scope } : scope,
|
||||
selector,
|
||||
limit,
|
||||
);
|
||||
return getTasks(s, ids.slice(0, limit));
|
||||
}
|
||||
|
||||
/** Full tasks in the order of `ids`. */
|
||||
export async function getTasks(s: Session, ids: string[]): Promise<Task[]> {
|
||||
const batches = await Promise.all(
|
||||
chunk(ids, ARTIFACT_QUERY_MAX_TASK_IDS).map(async (batch) => {
|
||||
const dtos = await s.host.query("tasks", { ids: batch });
|
||||
const byId = new Map(dtos.map((dto) => [dto.id, dto]));
|
||||
return Promise.all(
|
||||
batch.flatMap((id) => {
|
||||
const dto = byId.get(id);
|
||||
return dto ? [mapTask(s, dto)] : [];
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return fromHost(z.array(TaskSchema), batches.flat(), "tasks");
|
||||
}
|
||||
|
||||
/** The host relays the workspace query's own `{ rows }` under `result`. */
|
||||
export async function queryTasks(
|
||||
s: Session,
|
||||
sql: string,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const { result } = await s.host.query("queryWorkspace", { sql });
|
||||
return fromHost(TaskRowsSchema, result, "rows").rows;
|
||||
}
|
||||
|
||||
/** The ids the host's list view returns for the scope, filtered and sorted server-side. */
|
||||
async function listTaskIds(
|
||||
host: LiveBridge,
|
||||
scope: Pick<Ref<"space" | "folder" | "list">, "kind" | "id">,
|
||||
selector: TaskSelector,
|
||||
limit: number,
|
||||
): Promise<string[]> {
|
||||
const ids: string[] = [];
|
||||
for await (const page of pages((cursor) =>
|
||||
host.queryData({
|
||||
entity: "task",
|
||||
location: { id: scope.id, type: OBJECT_TYPES[scope.kind] },
|
||||
filters: taskFilters(selector),
|
||||
includeClosed: selector.includeClosed ?? false,
|
||||
sort: taskSort(selector),
|
||||
limit: EMBED_DATA_QUERY_MAX_LIMIT,
|
||||
cursor,
|
||||
}),
|
||||
)) {
|
||||
ids.push(...page.rows.map((row) => row.id));
|
||||
if (ids.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function mapTask(s: Session, wire: ArtifactTaskDto): Promise<Task> {
|
||||
const [location, type, creator, users, teams, watchers, customFields] =
|
||||
await Promise.all([
|
||||
s.path(wire.location),
|
||||
taskTypeOf(s, wire.typeId),
|
||||
userOf(s, wire.creator),
|
||||
usersOf(s, wire.assignees),
|
||||
teamsOf(s, wire.groupAssignees),
|
||||
usersOf(s, wire.followers),
|
||||
mapCustomFieldValues(s, wire.customFields ?? []),
|
||||
]);
|
||||
return {
|
||||
kind: "task",
|
||||
id: wire.id,
|
||||
name: wire.name,
|
||||
url: wire.url,
|
||||
ancestors: wire.parent
|
||||
? [...location, refOf("task", wire.parent)]
|
||||
: location,
|
||||
otherLists: wire.locations.map((list) => refOf("list", list)),
|
||||
private: wire.private,
|
||||
createdAt: toIsoOrEpoch(wire.dateCreated),
|
||||
updatedAt: toIsoOrEpoch(wire.dateUpdated),
|
||||
startAt: toIso(wire.startDate),
|
||||
dueAt: toIso(wire.dueDate),
|
||||
status: mapStatus(wire.status),
|
||||
type,
|
||||
priority: toPriority(wire.priority),
|
||||
points: wire.points,
|
||||
tags: wire.tags
|
||||
.map((tag) => ({ name: tag.name, color: tag.tagBg || DEFAULT_COLOR }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
customFields,
|
||||
body: wire.descriptionQuill ?? paragraph(wire.markdownDescription),
|
||||
creator,
|
||||
assignees: [...users, ...teams],
|
||||
watchers,
|
||||
checklists: wire.checklists.map(mapChecklist),
|
||||
};
|
||||
}
|
||||
|
||||
function toPriority(value: string | undefined): Task["priority"] {
|
||||
return (PRIORITIES as readonly string[]).includes(value ?? "")
|
||||
? (value as Task["priority"])
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** A checklist is a heading with no `done`; its items, at any depth, are checkable. */
|
||||
function mapChecklist(wire: ArtifactChecklistDto): ChecklistItem {
|
||||
return {
|
||||
id: wire.id,
|
||||
name: wire.name,
|
||||
items: sortByOrder(wire.items).map(mapChecklistItem),
|
||||
};
|
||||
}
|
||||
|
||||
function mapChecklistItem(wire: ArtifactChecklistItemDto): ChecklistItem {
|
||||
return {
|
||||
id: wire.id,
|
||||
name: wire.name,
|
||||
done: wire.resolved,
|
||||
items: sortByOrder(wire.children).map(mapChecklistItem),
|
||||
};
|
||||
}
|
||||
|
||||
function sortByOrder<T extends { orderindex: number }>(items: T[]): T[] {
|
||||
return [...items].sort((a, b) => a.orderindex - b.orderindex);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import { CUSTOM_FIELD_TYPES } from "../customFields/customFields";
|
||||
import {
|
||||
EMBED_DATA_QUERY_MAX_FILTER_VALUES,
|
||||
EMBED_DATA_QUERY_VALUE_MAX_LENGTH,
|
||||
} from "../host/wire";
|
||||
import type { Schema } from "../validate";
|
||||
import {
|
||||
contains,
|
||||
delta,
|
||||
id,
|
||||
isoDate,
|
||||
orderBy,
|
||||
ref,
|
||||
refFields,
|
||||
refOrId,
|
||||
schemaOf,
|
||||
scoped,
|
||||
selection,
|
||||
} from "../validate";
|
||||
import type {
|
||||
ChecklistItem,
|
||||
CreateTaskInput,
|
||||
DateFilter,
|
||||
DateRange,
|
||||
Status,
|
||||
StatusSelector,
|
||||
Task,
|
||||
TaskPatch,
|
||||
TaskSelector,
|
||||
TaskType,
|
||||
} from "./tasks";
|
||||
import { DATE_PRESETS, PRIORITIES, STATUS_TYPES, TASK_ORDERS } from "./tasks";
|
||||
|
||||
export const StatusSchema = schemaOf<Status>()(
|
||||
z.object({
|
||||
...refFields(["status"]),
|
||||
type: z.enum(STATUS_TYPES),
|
||||
color: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const StatusSelectorSchema = schemaOf<StatusSelector>()(
|
||||
z.strictObject({
|
||||
parent: z.union([id, ref(["space", "folder", "list"])], {
|
||||
error:
|
||||
"Expected a space, folder or list ref, from useSpaces, useFolders or useLists, or a list id",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const priority = z.enum(PRIORITIES);
|
||||
const assignee = ref(["user", "team"]);
|
||||
const tag = z.object({ name: z.string(), color: z.string() });
|
||||
const customFieldValue = z.object({
|
||||
id,
|
||||
name: z.string(),
|
||||
type: z.enum(CUSTOM_FIELD_TYPES),
|
||||
value: z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.array(z.string()),
|
||||
z.array(ref(["user", "team", "task"])),
|
||||
]),
|
||||
});
|
||||
|
||||
export const TaskTypeSchema = schemaOf<TaskType>()(
|
||||
z.object({
|
||||
...refFields(["taskType"]),
|
||||
description: z.optional(z.string()),
|
||||
}),
|
||||
);
|
||||
|
||||
export const ChecklistItemSchema: Schema<ChecklistItem> = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
done: z.optional(z.boolean()),
|
||||
get items() {
|
||||
return z.array(ChecklistItemSchema);
|
||||
},
|
||||
});
|
||||
|
||||
export const TaskSchema = schemaOf<Task>()(
|
||||
z.object({
|
||||
...refFields(["task"]),
|
||||
ancestors: z.array(ref(["space", "folder", "list", "task"])),
|
||||
otherLists: z.array(ref(["list"])),
|
||||
private: z.boolean(),
|
||||
createdAt: isoDate,
|
||||
updatedAt: isoDate,
|
||||
startAt: z.optional(isoDate),
|
||||
dueAt: z.optional(isoDate),
|
||||
status: StatusSchema,
|
||||
type: TaskTypeSchema,
|
||||
priority: z.optional(priority),
|
||||
points: z.optional(z.number()),
|
||||
tags: z.array(tag),
|
||||
customFields: z.array(customFieldValue),
|
||||
body: delta,
|
||||
creator: ref(["user"]),
|
||||
assignees: z.array(assignee),
|
||||
watchers: z.array(ref(["user"])),
|
||||
checklists: z.array(ChecklistItemSchema),
|
||||
}),
|
||||
);
|
||||
|
||||
export const TaskPatchSchema = schemaOf<TaskPatch>()(
|
||||
z.strictObject({
|
||||
name: z.optional(z.string()),
|
||||
bodyMarkdown: z.optional(z.string()),
|
||||
status: z.optional(ref(["status"])),
|
||||
type: z.optional(refOrId("taskType")),
|
||||
priority: z.optional(z.nullable(priority)),
|
||||
points: z.optional(z.nullable(z.number())),
|
||||
startAt: z.optional(z.nullable(isoDate)),
|
||||
dueAt: z.optional(z.nullable(isoDate)),
|
||||
parent: z.optional(refOrId("task")),
|
||||
assignees: z.optional(z.array(assignee)),
|
||||
watchers: z.optional(z.array(ref(["user"]))),
|
||||
tags: z.optional(z.array(tag)),
|
||||
customFields: z.optional(z.array(customFieldValue)),
|
||||
checklists: z.optional(z.array(ChecklistItemSchema)),
|
||||
}),
|
||||
);
|
||||
|
||||
export const CreateTaskInputSchema = schemaOf<CreateTaskInput>()(
|
||||
z.strictObject({
|
||||
list: refOrId("list"),
|
||||
parent: z.optional(refOrId("task")),
|
||||
name: z.string().check(z.minLength(1, "A task needs a name")),
|
||||
bodyMarkdown: z.optional(z.string()),
|
||||
status: z.optional(ref(["status"])),
|
||||
type: z.optional(refOrId("taskType")),
|
||||
priority: z.optional(priority),
|
||||
dueAt: z.optional(isoDate),
|
||||
startAt: z.optional(isoDate),
|
||||
assignees: z.optional(z.array(assignee)),
|
||||
}),
|
||||
);
|
||||
|
||||
const filterValues = [
|
||||
z.minLength(1, "Expected at least one value"),
|
||||
z.maxLength(
|
||||
EMBED_DATA_QUERY_MAX_FILTER_VALUES,
|
||||
`At most ${EMBED_DATA_QUERY_MAX_FILTER_VALUES} values per filter`,
|
||||
),
|
||||
];
|
||||
const dateRange: Schema<DateRange> = z
|
||||
.strictObject({ from: z.optional(isoDate), to: z.optional(isoDate) })
|
||||
.check(
|
||||
z.refine((range) => range.from !== undefined || range.to !== undefined, {
|
||||
error: "A date range needs from or to",
|
||||
}),
|
||||
);
|
||||
function dateFilter<E extends string = never>(
|
||||
...extra: E[]
|
||||
): Schema<DateFilter | E> {
|
||||
const presets = [...DATE_PRESETS, ...extra] as [string, ...string[]];
|
||||
return z.union([z.enum(presets), dateRange], {
|
||||
error: `Expected ${presets.map((preset) => `"${preset}"`).join(", ")}, or { from?, to? } as ISO 8601 dates`,
|
||||
}) as Schema<DateFilter | E>;
|
||||
}
|
||||
|
||||
export const TaskSelectorSchema = schemaOf<TaskSelector>()(
|
||||
z
|
||||
.strictObject({
|
||||
parent: z.optional(refOrId("list")),
|
||||
ancestor: z.optional(ref(["space", "folder", "list"])),
|
||||
...selection,
|
||||
includeClosed: z.optional(z.boolean()),
|
||||
status: z.optional(
|
||||
z.union(
|
||||
[
|
||||
z.array(ref(["status"])).check(...filterValues),
|
||||
z.strictObject({ type: z.array(z.enum(STATUS_TYPES)) }),
|
||||
],
|
||||
{
|
||||
error: `Expected statuses from useStatuses, or { type: [${STATUS_TYPES.map((type) => `"${type}"`).join(" | ")}] }`,
|
||||
},
|
||||
),
|
||||
),
|
||||
assignees: z.optional(
|
||||
z.union(
|
||||
[
|
||||
z
|
||||
.array(z.union([ref(["user", "team"]), z.literal("me")]))
|
||||
.check(...filterValues),
|
||||
z.literal("none"),
|
||||
z.literal("any"),
|
||||
],
|
||||
{
|
||||
error:
|
||||
'Expected user or team refs, as useUsers and useTeams return, or "me"; or "none" or "any"',
|
||||
},
|
||||
),
|
||||
),
|
||||
priority: z.optional(z.array(priority).check(...filterValues)),
|
||||
tags: z.optional(z.array(z.string()).check(...filterValues)),
|
||||
name: contains(EMBED_DATA_QUERY_VALUE_MAX_LENGTH),
|
||||
startAt: z.optional(dateFilter("none", "any")),
|
||||
dueAt: z.optional(dateFilter("none", "any", "overdue")),
|
||||
createdAt: z.optional(dateFilter()),
|
||||
updatedAt: z.optional(dateFilter()),
|
||||
orderBy: orderBy(TASK_ORDERS),
|
||||
})
|
||||
.check(scoped),
|
||||
);
|
||||
|
||||
export const TaskRowsSchema = z.object({
|
||||
rows: z.array(z.record(z.string(), z.unknown())),
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import { STATUS_TYPE_COLORS } from "../components/palette";
|
||||
import type { ArtifactStatusDto, ArtifactStatusType } from "../host/wire";
|
||||
import type { Session } from "../session";
|
||||
import { fromHost } from "../validate";
|
||||
import { StatusSchema } from "./schema";
|
||||
import type { Status, StatusSelector } from "./tasks";
|
||||
|
||||
export async function readStatuses(
|
||||
s: Session,
|
||||
{ parent }: StatusSelector,
|
||||
): Promise<Status[]> {
|
||||
const location =
|
||||
typeof parent === "string"
|
||||
? { kind: "list" as const, id: parent }
|
||||
: { kind: parent.kind, id: parent.id };
|
||||
const { statuses } = await s.host.query("statuses", { location });
|
||||
return fromHost(z.array(StatusSchema), mapStatuses(statuses), "statuses");
|
||||
}
|
||||
|
||||
export function mapStatus(wire: ArtifactStatusDto): Status {
|
||||
const type = toStatusType(wire.type);
|
||||
return {
|
||||
kind: "status",
|
||||
id: wire.id,
|
||||
name: wire.name,
|
||||
url: "",
|
||||
type,
|
||||
color: wire.color || STATUS_TYPE_COLORS[type],
|
||||
};
|
||||
}
|
||||
|
||||
/** A location's statuses in board order. */
|
||||
function mapStatuses(wire: ArtifactStatusDto[]): Status[] {
|
||||
return [...wire].sort((a, b) => a.orderindex - b.orderindex).map(mapStatus);
|
||||
}
|
||||
|
||||
/** Collapses the wire's seven status categories onto the model's four. */
|
||||
function toStatusType(type: ArtifactStatusType | string): Status["type"] {
|
||||
switch (type) {
|
||||
case "done":
|
||||
return "done";
|
||||
case "closed":
|
||||
return "closed";
|
||||
case "active":
|
||||
case "custom":
|
||||
return "active";
|
||||
default:
|
||||
return "open";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import type { LiveBridge } from "../host/bridge";
|
||||
import type { Session } from "../session";
|
||||
import { fromHost } from "../validate";
|
||||
import { TaskTypeSchema } from "./schema";
|
||||
import type { TaskType } from "./tasks";
|
||||
|
||||
/** The built-in Task type, which a task without a `typeId` has. */
|
||||
const DEFAULT_TASK_TYPE: TaskType = {
|
||||
kind: "taskType",
|
||||
id: "0",
|
||||
name: "Task",
|
||||
url: "",
|
||||
};
|
||||
|
||||
/** The `taskTypes` source, mapped: the workspace's whole catalog, unpaged. */
|
||||
export async function loadTaskTypes(host: LiveBridge): Promise<TaskType[]> {
|
||||
const { taskTypes } = await host.query("taskTypes", {});
|
||||
return taskTypes.map((wire) => ({
|
||||
kind: "taskType",
|
||||
id: String(wire.id),
|
||||
name: wire.name,
|
||||
url: "",
|
||||
description: wire.description,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function readTaskTypes(s: Session): Promise<TaskType[]> {
|
||||
return fromHost(z.array(TaskTypeSchema), await s.taskTypes(), "task types");
|
||||
}
|
||||
|
||||
/** The task's type; the built-in Task type when the catalog does not list it. */
|
||||
export async function taskTypeOf(
|
||||
s: Session,
|
||||
typeId: number | undefined,
|
||||
): Promise<TaskType> {
|
||||
if (typeId === undefined) {
|
||||
return DEFAULT_TASK_TYPE;
|
||||
}
|
||||
const id = String(typeId);
|
||||
const entries = await s.taskTypes();
|
||||
return entries.find((candidate) => candidate.id === id) ?? DEFAULT_TASK_TYPE;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import type {
|
||||
Delta,
|
||||
IsoDate,
|
||||
Ref,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from "../core";
|
||||
import type { CustomField } from "../customFields/customFields";
|
||||
import { listOrGet, useLiveQuery } from "../useQuery";
|
||||
|
||||
export const PRIORITIES = ["urgent", "high", "normal", "low"] as const;
|
||||
|
||||
/** `open` is not started, `active` in progress, `done` finished, and `closed` the list's final status. */
|
||||
export const STATUS_TYPES = ["open", "active", "done", "closed"] as const;
|
||||
|
||||
export interface Status extends Ref<"status"> {
|
||||
type: (typeof STATUS_TYPES)[number];
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** The effective statuses of a list, folder or space: what a task created there uses, in board order. */
|
||||
export interface StatusSelector {
|
||||
/** A bare string is a list id. */
|
||||
parent: Ref<"space" | "folder" | "list"> | string;
|
||||
}
|
||||
|
||||
export interface Task extends Ref<"task"> {
|
||||
/** The space, folder and home list, then the parent task if this is a subtask. */
|
||||
ancestors: Array<Ref<"space" | "folder" | "list" | "task">>;
|
||||
/** Other lists the task appears in, besides its home list. */
|
||||
otherLists: Array<Ref<"list">>;
|
||||
private: boolean;
|
||||
createdAt: IsoDate;
|
||||
updatedAt: IsoDate;
|
||||
startAt?: IsoDate;
|
||||
dueAt?: IsoDate;
|
||||
status: Status;
|
||||
type: TaskType;
|
||||
priority?: (typeof PRIORITIES)[number];
|
||||
/** Sprint points. */
|
||||
points?: number;
|
||||
tags: Array<{ name: string; color: string }>;
|
||||
/** Set fields only; definitions are in `useCustomFields`, matched by `id`. `value` follows `type`: dates are `IsoDate`, drop-downs and labels option names, attachments urls, users and tasks refs. */
|
||||
customFields: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: CustomField["type"];
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| string[]
|
||||
| Ref<"user" | "team" | "task">[];
|
||||
}>;
|
||||
/** The description; render it with `RichText`. */
|
||||
body: Delta;
|
||||
creator: Ref<"user">;
|
||||
assignees: Array<Ref<"user" | "team">>;
|
||||
watchers: Array<Ref<"user">>;
|
||||
checklists: ChecklistItem[];
|
||||
}
|
||||
|
||||
/** A task's checklists are headings; the items nested below them are checkable, at any depth. */
|
||||
export interface ChecklistItem {
|
||||
/** Empty for an entry that does not exist yet. */
|
||||
id: string;
|
||||
name: string;
|
||||
/** Absent on headings. */
|
||||
done?: boolean;
|
||||
items: ChecklistItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields to change: `undefined` leaves one alone, `null` clears it.
|
||||
* Collections replace the whole list, so read-modify-write them; checklists
|
||||
* are diffed by `id`, so keep existing ids and give new entries an empty one.
|
||||
* @example
|
||||
* await clickup.tasks.update(task, { dueAt: null, assignees: [...task.assignees, user] });
|
||||
*/
|
||||
export interface TaskPatch {
|
||||
name?: string;
|
||||
/** Replaces the description; read it back as `body`. */
|
||||
bodyMarkdown?: string;
|
||||
/** One of the home list's statuses, from `useStatuses`. */
|
||||
status?: Ref<"status">;
|
||||
type?: Ref<"taskType"> | string;
|
||||
priority?: Task["priority"] | null;
|
||||
points?: number | null;
|
||||
startAt?: IsoDate | null;
|
||||
dueAt?: IsoDate | null;
|
||||
/** Makes the task a subtask of `parent`. */
|
||||
parent?: Ref<"task"> | string;
|
||||
assignees?: Task["assignees"];
|
||||
/** Only the viewer can be added or removed. */
|
||||
watchers?: Task["watchers"];
|
||||
tags?: Task["tags"];
|
||||
/** Users, tasks and attachments fields cannot be written yet and reject with `forbidden`. */
|
||||
customFields?: Task["customFields"];
|
||||
checklists?: Task["checklists"];
|
||||
}
|
||||
|
||||
/** `list` is where the task goes; `parent` makes it a subtask of that task. */
|
||||
export interface CreateTaskInput {
|
||||
list: Ref<"list"> | string;
|
||||
parent?: Ref<"task"> | string;
|
||||
name: string;
|
||||
/** The description, as markdown. */
|
||||
bodyMarkdown?: string;
|
||||
status?: Ref<"status">;
|
||||
type?: Ref<"taskType"> | string;
|
||||
priority?: Task["priority"];
|
||||
dueAt?: IsoDate;
|
||||
startAt?: IsoDate;
|
||||
assignees?: Task["assignees"];
|
||||
}
|
||||
|
||||
/** Relative days, in the viewer's timezone. */
|
||||
export const DATE_PRESETS = ["today", "tomorrow", "next7Days"] as const;
|
||||
|
||||
/** Inclusive day bounds; give one or both. */
|
||||
export interface DateRange {
|
||||
from?: IsoDate;
|
||||
to?: IsoDate;
|
||||
}
|
||||
|
||||
export type DateFilter = (typeof DATE_PRESETS)[number] | DateRange;
|
||||
|
||||
export const TASK_ORDERS = [
|
||||
"name",
|
||||
"priority",
|
||||
"status",
|
||||
"startAt",
|
||||
"dueAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Needs `parent` or `ancestor`. The other fields filter and sort on the
|
||||
* server before `limit` applies.
|
||||
* @example
|
||||
* useTasks({ ancestor: space, assignees: ["me"], dueAt: "overdue" });
|
||||
* useTasks({ parent: list, status: { type: ["active"] }, orderBy: { field: "priority" } });
|
||||
*/
|
||||
export interface TaskSelector {
|
||||
parent?: Ref<"list"> | string;
|
||||
ancestor?: Ref<"space" | "folder" | "list">;
|
||||
/** Defaults to 100, at most 500. */
|
||||
limit?: number;
|
||||
/** Defaults to false. Hides tasks in the list's final status only; `done` tasks still come. */
|
||||
includeClosed?: boolean;
|
||||
/** Statuses from `useStatuses`, or every status of these types. */
|
||||
status?: Ref<"status">[] | { type: Status["type"][] };
|
||||
/** Users and teams, `"me"` for the viewer; or `"none"` for unassigned tasks, `"any"` for assigned ones. */
|
||||
assignees?: Array<Ref<"user" | "team"> | "me"> | "none" | "any";
|
||||
priority?: Array<NonNullable<Task["priority"]>>;
|
||||
tags?: string[];
|
||||
/** Tasks whose name contains this text. Descriptions and comments are `useSearch`'s job. */
|
||||
name?: { contains: string };
|
||||
startAt?: DateFilter | "none" | "any";
|
||||
dueAt?: DateFilter | "none" | "any" | "overdue";
|
||||
createdAt?: DateFilter;
|
||||
updatedAt?: DateFilter;
|
||||
/** The host's own order when omitted; `direction` defaults to `"asc"`. */
|
||||
orderBy?: { field: (typeof TASK_ORDERS)[number]; direction?: "asc" | "desc" };
|
||||
}
|
||||
|
||||
/** One of the workspace's task types, e.g. Task, Milestone, Bug. */
|
||||
export interface TaskType extends Ref<"taskType"> {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TasksApi {
|
||||
list(selector: TaskSelector): Promise<Task[]>;
|
||||
get(targets: Array<Ref<"task"> | string>): Promise<Task[]>;
|
||||
create(input: CreateTaskInput): Promise<Ref<"task">>;
|
||||
update(task: Ref<"task"> | string, patch: TaskPatch): Promise<void>;
|
||||
/**
|
||||
* One read-only `SELECT … FROM tasks`, as the viewer; the rows hold the selected columns by name.
|
||||
* @example
|
||||
* clickup.tasks.query(`SELECT status, COUNT(*) AS n FROM tasks WHERE list = '${LIST_ID}' GROUP BY status`)
|
||||
*/
|
||||
query(sql: string): Promise<Array<Record<string, unknown>>>;
|
||||
/** The workspace's task types. */
|
||||
types(): Promise<TaskType[]>;
|
||||
/** The effective statuses of a list, folder or space. */
|
||||
statuses(selector: StatusSelector): Promise<Status[]>;
|
||||
}
|
||||
|
||||
export function useTasks(
|
||||
selector: TaskSelector | Array<Ref<"task"> | string>,
|
||||
options: UseQueryOptions<Task[]> = {},
|
||||
): UseQueryResult<Task[]> {
|
||||
return useLiveQuery(
|
||||
"task",
|
||||
selector,
|
||||
(client) => listOrGet(client.tasks, selector),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/** The rows of `clickup.tasks.query(sql)`, refetched after every task write. */
|
||||
export function useTaskQuery(
|
||||
sql: string,
|
||||
options: UseQueryOptions<Array<Record<string, unknown>>> = {},
|
||||
): UseQueryResult<Array<Record<string, unknown>>> {
|
||||
return useLiveQuery(
|
||||
"taskRow",
|
||||
{ sql },
|
||||
(client) => client.tasks.query(sql),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function useTaskTypes(
|
||||
options: UseQueryOptions<TaskType[]> = {},
|
||||
): UseQueryResult<TaskType[]> {
|
||||
return useLiveQuery(
|
||||
"taskType",
|
||||
{},
|
||||
(client) => client.tasks.types(),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function useStatuses(
|
||||
selector: StatusSelector,
|
||||
options: UseQueryOptions<Status[]> = {},
|
||||
): UseQueryResult<Status[]> {
|
||||
return useLiveQuery(
|
||||
"status",
|
||||
selector,
|
||||
(client) => client.tasks.statuses(selector),
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { Ref } from "../core";
|
||||
import { idOf, LiveError } from "../core";
|
||||
import { toEpochMinuteString } from "../host/dates";
|
||||
import type { WireCall } from "../host/perform";
|
||||
import { createdRef, perform, performAll } from "../host/perform";
|
||||
import { paragraph } from "../host/quill";
|
||||
import { ArtifactWriteActionType as Action, PRIORITY_IDS } from "../host/wire";
|
||||
import type { Session } from "../session";
|
||||
import { resolve } from "../session";
|
||||
import { applyPatch } from "../store";
|
||||
import { syncChecklists } from "./checklists";
|
||||
import { getTasks } from "./read";
|
||||
import type { CreateTaskInput, Task, TaskPatch } from "./tasks";
|
||||
|
||||
/** Applies `patch` to the task optimistically, then through the host. */
|
||||
export async function updateTask(
|
||||
s: Session,
|
||||
target: Ref<"task"> | string,
|
||||
patch: TaskPatch,
|
||||
): Promise<void> {
|
||||
const task = await resolve(s, "task", target, getTasks);
|
||||
await s.store.update(
|
||||
task,
|
||||
(current) => applyTaskPatch(current, patch),
|
||||
async () => {
|
||||
await performAll(s.host, taskPatchCalls(task, patch));
|
||||
if (patch.checklists !== undefined) {
|
||||
await syncChecklists(s.host, task, patch.checklists);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Creates the task, then adds any team assignees, which the create action itself cannot take. */
|
||||
export async function createTask(
|
||||
s: Session,
|
||||
input: CreateTaskInput,
|
||||
): Promise<Ref<"task">> {
|
||||
const users = input.assignees?.filter((ref) => ref.kind === "user") ?? [];
|
||||
const teams = input.assignees?.filter((ref) => ref.kind === "team") ?? [];
|
||||
const outcome = await perform(s.host, Action.CreateTask, {
|
||||
listId: idOf(input.list),
|
||||
name: input.name,
|
||||
...(input.parent ? { parentTaskId: idOf(input.parent) } : {}),
|
||||
...(input.bodyMarkdown !== undefined
|
||||
? { description: input.bodyMarkdown }
|
||||
: {}),
|
||||
...(input.status ? { status: input.status.name } : {}),
|
||||
...(input.type !== undefined ? { typeId: idOf(input.type) } : {}),
|
||||
...(input.priority ? { priority: PRIORITY_IDS[input.priority] } : {}),
|
||||
...(input.dueAt ? { dueDate: toEpochMinuteString(input.dueAt) } : {}),
|
||||
...(input.startAt ? { startDate: toEpochMinuteString(input.startAt) } : {}),
|
||||
...(users.length > 0
|
||||
? { assigneeUserIds: users.map((ref) => ref.id).join(",") }
|
||||
: {}),
|
||||
});
|
||||
const created = createdRef("task", outcome, input.name);
|
||||
for (const team of teams) {
|
||||
await perform(s.host, Action.AddTaskGroupAssignee, {
|
||||
taskId: created.id,
|
||||
groupId: team.id,
|
||||
});
|
||||
}
|
||||
await s.store.invalidate("task");
|
||||
return created;
|
||||
}
|
||||
|
||||
/** The ordered host calls that apply `patch` to `task`; checklists are synced apart by `syncChecklists`. */
|
||||
export function taskPatchCalls(task: Task, patch: TaskPatch): WireCall[] {
|
||||
const taskId = task.id;
|
||||
const calls: WireCall[] = [];
|
||||
|
||||
if (patch.name !== undefined) {
|
||||
calls.push([Action.SetTaskName, { taskId, name: patch.name }]);
|
||||
}
|
||||
if (patch.status !== undefined) {
|
||||
calls.push([Action.SetTaskStatus, statusParams(taskId, patch.status)]);
|
||||
}
|
||||
if (patch.type !== undefined) {
|
||||
calls.push([Action.SetTaskType, { taskId, typeId: idOf(patch.type) }]);
|
||||
}
|
||||
if (patch.priority !== undefined) {
|
||||
calls.push([
|
||||
Action.SetTaskPriority,
|
||||
{
|
||||
taskId,
|
||||
priority:
|
||||
patch.priority === null ? "none" : PRIORITY_IDS[patch.priority],
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (patch.points !== undefined) {
|
||||
calls.push([
|
||||
Action.SetTaskPoints,
|
||||
{ taskId, points: patch.points === null ? "none" : String(patch.points) },
|
||||
]);
|
||||
}
|
||||
if (patch.dueAt !== undefined) {
|
||||
calls.push(
|
||||
patch.dueAt === null
|
||||
? [Action.ClearTaskDueDate, { taskId }]
|
||||
: [
|
||||
Action.SetTaskDueDate,
|
||||
{ taskId, dueDate: toEpochMinuteString(patch.dueAt) },
|
||||
],
|
||||
);
|
||||
}
|
||||
if (patch.startAt !== undefined) {
|
||||
calls.push(
|
||||
patch.startAt === null
|
||||
? [Action.ClearTaskStartDate, { taskId }]
|
||||
: [
|
||||
Action.SetTaskStartDate,
|
||||
{ taskId, startDate: toEpochMinuteString(patch.startAt) },
|
||||
],
|
||||
);
|
||||
}
|
||||
if (patch.parent !== undefined) {
|
||||
calls.push([
|
||||
Action.SetTaskParent,
|
||||
{ taskId, parentTaskId: idOf(patch.parent) },
|
||||
]);
|
||||
}
|
||||
if (patch.assignees !== undefined) {
|
||||
const { added, removed } = diffById(task.assignees, patch.assignees);
|
||||
for (const ref of added) {
|
||||
calls.push(
|
||||
ref.kind === "team"
|
||||
? [Action.AddTaskGroupAssignee, { taskId, groupId: ref.id }]
|
||||
: [Action.AddTaskAssignee, { taskId, assigneeUserId: ref.id }],
|
||||
);
|
||||
}
|
||||
for (const ref of removed) {
|
||||
calls.push(
|
||||
ref.kind === "team"
|
||||
? [Action.RemoveTaskGroupAssignee, { taskId, groupId: ref.id }]
|
||||
: [Action.RemoveTaskAssignee, { taskId, assigneeUserId: ref.id }],
|
||||
);
|
||||
}
|
||||
}
|
||||
if (patch.watchers !== undefined) {
|
||||
// The host follows and unfollows the viewer only; the refs' ids are not sent.
|
||||
const { added, removed } = diffById(task.watchers, patch.watchers);
|
||||
if (added.length > 0) {
|
||||
calls.push([Action.FollowTask, { taskId }]);
|
||||
}
|
||||
if (removed.length > 0) {
|
||||
calls.push([Action.UnfollowTask, { taskId }]);
|
||||
}
|
||||
}
|
||||
if (patch.tags !== undefined) {
|
||||
const current = new Set(task.tags.map((tag) => tag.name));
|
||||
const next = new Set(patch.tags.map((tag) => tag.name));
|
||||
for (const name of next) {
|
||||
if (!current.has(name)) {
|
||||
calls.push([Action.AddTaskTag, { taskId, tagName: name }]);
|
||||
}
|
||||
}
|
||||
for (const name of current) {
|
||||
if (!next.has(name)) {
|
||||
calls.push([Action.RemoveTaskTag, { taskId, tagName: name }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patch.customFields !== undefined) {
|
||||
const next = new Map(patch.customFields.map((field) => [field.id, field]));
|
||||
for (const field of next.values()) {
|
||||
calls.push([
|
||||
Action.SetTaskCustomField,
|
||||
{ taskId, fieldId: field.id, value: customFieldValueParam(field) },
|
||||
]);
|
||||
}
|
||||
for (const field of task.customFields) {
|
||||
if (!next.has(field.id)) {
|
||||
calls.push([
|
||||
Action.ClearTaskCustomField,
|
||||
{ taskId, fieldId: field.id },
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patch.bodyMarkdown !== undefined) {
|
||||
calls.push([
|
||||
Action.SetTaskDescription,
|
||||
{ taskId, markdownDescription: patch.bodyMarkdown },
|
||||
]);
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
/** Legacy statuses have no id of their own and are addressed by label. */
|
||||
function statusParams(
|
||||
taskId: string,
|
||||
status: Ref<"status">,
|
||||
): Record<string, string> {
|
||||
return status.id && status.id !== status.name
|
||||
? { taskId, statusId: status.id }
|
||||
: { taskId, status: status.name };
|
||||
}
|
||||
|
||||
function diffById<T extends Ref>(
|
||||
current: T[],
|
||||
next: T[],
|
||||
): { added: T[]; removed: T[] } {
|
||||
const currentIds = new Set(current.map((ref) => ref.id));
|
||||
const nextIds = new Set(next.map((ref) => ref.id));
|
||||
return {
|
||||
added: next.filter((ref) => !currentIds.has(ref.id)),
|
||||
removed: current.filter((ref) => !nextIds.has(ref.id)),
|
||||
};
|
||||
}
|
||||
|
||||
const READ_ONLY_FIELD_TYPES: ReadonlySet<Task["customFields"][number]["type"]> =
|
||||
new Set(["users", "tasks", "list_relationship", "attachment"]);
|
||||
|
||||
/** The wire value of a field; labels take comma-separated option names. */
|
||||
function customFieldValueParam({
|
||||
type,
|
||||
value,
|
||||
}: Task["customFields"][number]): string {
|
||||
if (READ_ONLY_FIELD_TYPES.has(type)) {
|
||||
throw new LiveError(
|
||||
"forbidden",
|
||||
`Custom fields of type ${type} cannot be written yet.`,
|
||||
);
|
||||
}
|
||||
return Array.isArray(value) ? value.join(",") : String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The task as it will read back once the host has applied `patch`. A parent
|
||||
* or type given by id alone has no name to show, and a new description shows
|
||||
* as plain text, until the refetch.
|
||||
*/
|
||||
export function applyTaskPatch(task: Task, patch: TaskPatch): Task {
|
||||
const { bodyMarkdown, parent, type, ...fields } = patch;
|
||||
const next = applyPatch(task, fields);
|
||||
if (bodyMarkdown !== undefined) {
|
||||
next.body = paragraph(bodyMarkdown);
|
||||
}
|
||||
if (patch.status) {
|
||||
next.status = { ...task.status, ...patch.status };
|
||||
}
|
||||
if (type !== undefined) {
|
||||
next.type =
|
||||
typeof type === "string"
|
||||
? { kind: "taskType", id: type, name: "", url: "" }
|
||||
: type;
|
||||
}
|
||||
if (parent && typeof parent !== "string") {
|
||||
next.ancestors = [
|
||||
...task.ancestors.filter((ref) => ref.kind !== "task"),
|
||||
parent,
|
||||
];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/** Cached data older than this is not painted. */
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
/** A page is closed once its things pass this many JSON characters. */
|
||||
export const PAGE_BYTES = 500_000;
|
||||
/** The cache is trimmed to about this many JSON characters. */
|
||||
export const BUDGET_BYTES = 10_000_000;
|
||||
|
||||
/** Anything the store holds: an asset, or a query. */
|
||||
export interface Thing {
|
||||
kind: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** A query as a thing: when it landed, and what it lists by key or holds inline. */
|
||||
export interface QueryThing extends Thing {
|
||||
kind: "query";
|
||||
/** The query key. */
|
||||
id: string;
|
||||
at: number;
|
||||
/** The listed assets' keys, for asset kinds. */
|
||||
ids?: string[];
|
||||
/** The rows themselves, for the other kinds. */
|
||||
rows?: object[];
|
||||
}
|
||||
|
||||
export function keyOf(thing: Thing): string {
|
||||
return `${thing.kind}#${thing.id}`;
|
||||
}
|
||||
|
||||
function queryKeyOf(key: string): string {
|
||||
return keyOf({ kind: "query", id: key });
|
||||
}
|
||||
|
||||
export function isQuery(thing: Thing): thing is QueryThing {
|
||||
return thing.kind === "query";
|
||||
}
|
||||
|
||||
/** Roots (queries) and nodes (assets) are paged apart; the prefix sorts roots first on disk. */
|
||||
const LANES = ["0r", "1n"] as const;
|
||||
type Lane = (typeof LANES)[number];
|
||||
|
||||
function laneOf(page: string): Lane | undefined {
|
||||
return LANES.find((lane) => page.startsWith(`${lane}:`));
|
||||
}
|
||||
|
||||
/**
|
||||
* One map of things keyed `kind#id`, tracking which page each is on and
|
||||
* whether it changed since. Queries are the roots: an asset lives while a
|
||||
* query lists it.
|
||||
*/
|
||||
export class Things {
|
||||
private readonly map = new Map<string, Thing>();
|
||||
private readonly pageOf = new Map<string, string>();
|
||||
private readonly dirty = new Set<string>();
|
||||
/** Per thing, `<json length>:<json hash>`, so identical content is recognized without keeping the JSON. */
|
||||
private readonly sig = new Map<string, string>();
|
||||
/** Per lane, the page new things go on: the newest one, until it is full. */
|
||||
private readonly open = new Map<Lane, { key: string; bytes: number }>();
|
||||
/** JSON characters on pages, the measure the budget is in. */
|
||||
bytes = 0;
|
||||
/** Bumped on every change, so a derived value can tell it is current. */
|
||||
version = 0;
|
||||
|
||||
get(key: string): Thing | undefined {
|
||||
return this.map.get(key);
|
||||
}
|
||||
|
||||
query(key: string): QueryThing | undefined {
|
||||
return this.map.get(queryKeyOf(key)) as QueryThing | undefined;
|
||||
}
|
||||
|
||||
deleteQuery(key: string): void {
|
||||
this.delete(queryKeyOf(key));
|
||||
}
|
||||
|
||||
hasPage(thing: Thing): boolean {
|
||||
return this.pageOf.has(keyOf(thing));
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a thing in the map and on a page. Identical content keeps the object
|
||||
* already held and dirties nothing; new content dirties the thing's page.
|
||||
*/
|
||||
set(key: string, thing: Thing): void {
|
||||
const json = JSON.stringify(thing);
|
||||
if (this.map.has(key) && this.sig.get(key) === signature(json)) {
|
||||
return;
|
||||
}
|
||||
this.bytes += json.length - this.pagedSize(key);
|
||||
this.hold(key, thing, json);
|
||||
let page = this.pageOf.get(key);
|
||||
if (!page) {
|
||||
page = this.place(isQuery(thing) ? "0r" : "1n", json.length);
|
||||
this.pageOf.set(key, page);
|
||||
}
|
||||
this.dirty.add(page);
|
||||
}
|
||||
|
||||
/** Holds a thing in memory only; it never reaches a page. */
|
||||
hold(key: string, thing: Thing, json = JSON.stringify(thing)): void {
|
||||
this.sig.set(key, signature(json));
|
||||
this.map.set(key, thing);
|
||||
this.version += 1;
|
||||
}
|
||||
|
||||
private pagedSize(key: string): number {
|
||||
return this.pageOf.has(key) ? sizeOf(this.sig.get(key)) : 0;
|
||||
}
|
||||
|
||||
/** The open page of `lane`, or a new one once the open page is full. */
|
||||
private place(lane: Lane, bytes: number): string {
|
||||
let open = this.open.get(lane);
|
||||
if (!open || open.bytes + bytes > PAGE_BYTES) {
|
||||
open = { key: `${lane}:${pageStamp()}`, bytes: 0 };
|
||||
this.open.set(lane, open);
|
||||
}
|
||||
open.bytes += bytes;
|
||||
return open.key;
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
if (!this.map.delete(key)) {
|
||||
return;
|
||||
}
|
||||
this.bytes -= this.pagedSize(key);
|
||||
this.sig.delete(key);
|
||||
this.version += 1;
|
||||
const page = this.pageOf.get(key);
|
||||
this.pageOf.delete(key);
|
||||
if (page) {
|
||||
this.dirty.add(page);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops every asset no query lists. */
|
||||
collect(): void {
|
||||
const listed = new Set<string>();
|
||||
for (const thing of this.map.values()) {
|
||||
if (isQuery(thing)) {
|
||||
for (const id of thing.ids ?? []) {
|
||||
listed.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [key, thing] of this.map) {
|
||||
if (!isQuery(thing) && !listed.has(key)) {
|
||||
this.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the least recently used queries on disk that `inUse` does not
|
||||
* claim, and what only they listed, until the pages fit `budget`.
|
||||
* @returns The dropped queries.
|
||||
*/
|
||||
evict(budget: number, inUse: (query: QueryThing) => boolean): QueryThing[] {
|
||||
const dropped: QueryThing[] = [];
|
||||
while (this.bytes > budget) {
|
||||
let oldest: QueryThing | undefined;
|
||||
for (const [key, thing] of this.map) {
|
||||
if (
|
||||
isQuery(thing) &&
|
||||
this.pageOf.has(key) &&
|
||||
!inUse(thing) &&
|
||||
(!oldest || thing.at < oldest.at)
|
||||
) {
|
||||
oldest = thing;
|
||||
}
|
||||
}
|
||||
if (!oldest) {
|
||||
break;
|
||||
}
|
||||
this.delete(keyOf(oldest));
|
||||
this.collect();
|
||||
dropped.push(oldest);
|
||||
}
|
||||
return dropped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopts a page read from the cache: things the map does not hold yet, on
|
||||
* the page they came from. A copy held elsewhere or an expired query is
|
||||
* left out and dirties the page; the newest page of each lane stays open.
|
||||
*/
|
||||
seedPage(page: string, things: Thing[]): void {
|
||||
const now = Date.now();
|
||||
let bytes = 0;
|
||||
for (const thing of things) {
|
||||
const key = keyOf(thing);
|
||||
const expired = isQuery(thing) && now - thing.at > MAX_AGE_MS;
|
||||
if (this.map.has(key) || expired) {
|
||||
this.dirty.add(page);
|
||||
continue;
|
||||
}
|
||||
const json = JSON.stringify(thing);
|
||||
this.hold(key, thing, json);
|
||||
this.pageOf.set(key, page);
|
||||
bytes += json.length;
|
||||
}
|
||||
this.bytes += bytes;
|
||||
const lane = laneOf(page);
|
||||
const open = lane && this.open.get(lane);
|
||||
if (lane && (!open || open.key < page)) {
|
||||
this.open.set(lane, { key: page, bytes });
|
||||
}
|
||||
}
|
||||
|
||||
/** The dirty pages with their things, an empty page meaning a deletion; clears the dirty set. */
|
||||
takeDirtyPages(): Map<string, Thing[]> {
|
||||
const pages = new Map<string, Thing[]>();
|
||||
for (const page of this.dirty) {
|
||||
pages.set(page, []);
|
||||
}
|
||||
for (const [key, page] of this.pageOf) {
|
||||
pages.get(page)?.push(this.map.get(key)!);
|
||||
}
|
||||
this.dirty.clear();
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
|
||||
function signature(json: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < json.length; index += 1) {
|
||||
hash ^= json.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return `${json.length}:${hash >>> 0}`;
|
||||
}
|
||||
|
||||
function sizeOf(signature: string | undefined): number {
|
||||
return signature ? Number(signature.slice(0, signature.indexOf(":"))) : 0;
|
||||
}
|
||||
|
||||
/** Page keys sort by creation, and two pages never collide, even across tabs. */
|
||||
function pageStamp(): string {
|
||||
const random = Math.random().toString(36).slice(2, 8);
|
||||
return `${Date.now().toString(36).padStart(9, "0")}-${random}`;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
LiveClient,
|
||||
QueryStatus,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from "./core";
|
||||
import { clickup } from "./client";
|
||||
import { LiveError } from "./core";
|
||||
import type { QueryKind } from "./store";
|
||||
import { queryKey } from "./store";
|
||||
|
||||
/** The shortest `refetchInterval` honoured; a shorter one polls the host at this rate. */
|
||||
const MIN_REFETCH_INTERVAL_MS = 10_000;
|
||||
|
||||
export function listOrGet<T, S extends object, R>(
|
||||
api: { list(selector: S): Promise<T[]>; get(targets: R[]): Promise<T[]> },
|
||||
input: S | R[],
|
||||
): Promise<T[]> {
|
||||
return Array.isArray(input) ? api.get(input) : api.list(input);
|
||||
}
|
||||
|
||||
/** Every `use<Kind>` hook: one cached, shared query per kind and selector. */
|
||||
export function useLiveQuery<T extends object>(
|
||||
kind: QueryKind,
|
||||
selector: object,
|
||||
fetch: (client: LiveClient) => Promise<T[]>,
|
||||
options: UseQueryOptions<T[]>,
|
||||
): UseQueryResult<T[]> {
|
||||
const client = options.client ?? clickup;
|
||||
const { store } = client;
|
||||
const enabled = options.enabled !== false;
|
||||
const { cache } = options;
|
||||
const key = queryKey(kind, selector);
|
||||
const [, rerender] = useState(0);
|
||||
const refetch = useCallback(
|
||||
() =>
|
||||
enabled
|
||||
? store.fetch(key, true).then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
: Promise.resolve(),
|
||||
[store, key, enabled],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const unsubscribe = store.subscribe(
|
||||
key,
|
||||
kind,
|
||||
() => fetch(client),
|
||||
() => rerender((tick) => tick + 1),
|
||||
{ cache },
|
||||
);
|
||||
store.fetch(key).catch(() => undefined);
|
||||
return unsubscribe;
|
||||
// The selector, and so the fetch, is fully determined by the key.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [store, client, key, enabled, cache]);
|
||||
|
||||
const { refetchInterval } = options;
|
||||
useEffect(() => {
|
||||
if (!enabled || !refetchInterval) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(
|
||||
() => {
|
||||
if (document.visibilityState === "visible") {
|
||||
store.fetch(key, true).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
Math.max(refetchInterval, MIN_REFETCH_INTERVAL_MS),
|
||||
);
|
||||
return () => clearInterval(timer);
|
||||
}, [store, key, enabled, refetchInterval]);
|
||||
|
||||
const state = store.state<T>(key);
|
||||
const error =
|
||||
state.error === undefined
|
||||
? null
|
||||
: state.error instanceof LiveError
|
||||
? state.error
|
||||
: new LiveError("failed", String(state.error), { retryable: true });
|
||||
|
||||
const previous = useRef<T[]>();
|
||||
if (state.data !== undefined) {
|
||||
previous.current = state.data;
|
||||
}
|
||||
const placeholder =
|
||||
state.data !== undefined || error
|
||||
? undefined
|
||||
: placeholderOf(options.placeholderData, previous.current);
|
||||
const data = state.data ?? placeholder;
|
||||
const status: QueryStatus = error
|
||||
? "error"
|
||||
: data !== undefined
|
||||
? "success"
|
||||
: "pending";
|
||||
const isPending = status === "pending";
|
||||
const isFetching =
|
||||
state.fetching || (state.data === undefined && !error && enabled);
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
status,
|
||||
isPending,
|
||||
isLoading: isPending && isFetching,
|
||||
isFetching,
|
||||
isRefetching: isFetching && !isPending,
|
||||
isPlaceholderData: placeholder !== undefined,
|
||||
isError: status === "error",
|
||||
isSuccess: status === "success",
|
||||
dataUpdatedAt: state.at,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
function placeholderOf<T>(
|
||||
placeholderData: UseQueryOptions<T>["placeholderData"],
|
||||
previous: T | undefined,
|
||||
): T | undefined {
|
||||
return typeof placeholderData === "function"
|
||||
? (placeholderData as (previous: T | undefined) => T | undefined)(previous)
|
||||
: placeholderData;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import * as z from "zod/v4-mini";
|
||||
import en from "zod/v4/locales/en.js";
|
||||
import type { AssetKind, Delta, IsoDate, Ref } from "./core";
|
||||
import { ASSET_KINDS, idOf, LiveError } from "./core";
|
||||
import { ARTIFACT_QUERY_MAX_LIMIT } from "./host/wire";
|
||||
|
||||
z.config({
|
||||
...en(),
|
||||
customError: (issue) => {
|
||||
if (
|
||||
issue.code === "invalid_type" &&
|
||||
issue.input === undefined &&
|
||||
issue.path?.length
|
||||
) {
|
||||
return "required";
|
||||
}
|
||||
if (issue.code === "too_small" && issue.origin === "string") {
|
||||
return "must not be empty";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
export type Schema<T> = z.ZodMiniType<T>;
|
||||
|
||||
type Equal<A, B> =
|
||||
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
|
||||
? true
|
||||
: false;
|
||||
type Exactly<S extends z.ZodMiniType, T> =
|
||||
Equal<z.output<S>, T> extends true
|
||||
? unknown
|
||||
: { "the schema's output differs from": T };
|
||||
|
||||
/**
|
||||
* Pins a schema to the public type it validates: `schemaOf<Task>()(z.object(…))`
|
||||
* compiles only when the schema accepts exactly the values of `Task`, so the
|
||||
* two cannot drift apart.
|
||||
*/
|
||||
export function schemaOf<T>() {
|
||||
return <S extends z.ZodMiniType>(schema: S & Exactly<S, T>): Schema<T> =>
|
||||
schema as unknown as Schema<T>;
|
||||
}
|
||||
|
||||
export const id = z.string().check(z.minLength(1, "Expected a non-empty id"));
|
||||
|
||||
/** ISO 8601 as `Date#toISOString()` writes it; a plain `YYYY-MM-DD` is the viewer's day. */
|
||||
export const isoDate: Schema<IsoDate> = z.union(
|
||||
[z.iso.datetime({ offset: true, local: true }), z.iso.date()],
|
||||
{ error: "Expected an ISO 8601 date" },
|
||||
);
|
||||
|
||||
export const delta: Schema<Delta> = z.object({
|
||||
ops: z.array(z.record(z.string(), z.unknown())),
|
||||
});
|
||||
|
||||
/** The fields every ref has; spread into an asset schema. */
|
||||
export function refFields<K extends AssetKind>(kinds: readonly [K, ...K[]]) {
|
||||
return {
|
||||
kind: z.enum(kinds),
|
||||
id,
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
image: z.optional(z.string()),
|
||||
};
|
||||
}
|
||||
|
||||
const HOOKS: Readonly<Record<AssetKind, string>> = {
|
||||
space: "useSpaces",
|
||||
folder: "useFolders",
|
||||
list: "useLists",
|
||||
status: "useStatuses",
|
||||
customField: "useCustomFields",
|
||||
task: "useTasks",
|
||||
taskType: "useTaskTypes",
|
||||
comment: "useComments",
|
||||
user: "useUsers",
|
||||
team: "useTeams",
|
||||
inboxBundle: "useInbox",
|
||||
reminder: "useReminders",
|
||||
};
|
||||
|
||||
function sourceOf(kind: AssetKind): string {
|
||||
return HOOKS[kind];
|
||||
}
|
||||
|
||||
/** A ref of one of `kinds`, as its hook returns it; a full asset passes. */
|
||||
export function ref<K extends AssetKind>(
|
||||
kinds: readonly [K, ...K[]],
|
||||
): Schema<Ref<K>> {
|
||||
const fields = z.object(refFields(kinds));
|
||||
return z.custom<Ref<K>>((value) => fields.safeParse(value).success, {
|
||||
error: `Expected a ${either(kinds)} ref, from ${either(kinds.map(sourceOf))}`,
|
||||
});
|
||||
}
|
||||
|
||||
function either(words: readonly string[]): string {
|
||||
return words.length > 1
|
||||
? `${words.slice(0, -1).join(", ")} or ${words[words.length - 1]}`
|
||||
: words[0]!;
|
||||
}
|
||||
|
||||
export const anyRef = ref(ASSET_KINDS);
|
||||
|
||||
/** A ref of `kind`, or its bare id. */
|
||||
export function refOrId<K extends AssetKind>(kind: K): Schema<Ref<K> | string> {
|
||||
return z.union([id, ref([kind])], {
|
||||
error: `Expected a ${kind} ref, from ${sourceOf(kind)}, or a ${kind} id`,
|
||||
});
|
||||
}
|
||||
|
||||
/** A selector's `limit`: a positive whole number up to the kind's cap. */
|
||||
export function limit(max = ARTIFACT_QUERY_MAX_LIMIT) {
|
||||
return z.optional(
|
||||
z.int().check(z.positive(), z.maximum(max, `Expected at most ${max}`)),
|
||||
);
|
||||
}
|
||||
|
||||
/** What every selector takes besides its scope and filters. */
|
||||
export const selection = {
|
||||
limit: limit(),
|
||||
};
|
||||
|
||||
const text = z.string().check(z.minLength(1, "Expected text to look for"));
|
||||
|
||||
/** A selector's `{ contains }` text filter; `max` caps the text where the host does. */
|
||||
export function contains(max?: number) {
|
||||
return z.optional(
|
||||
z.strictObject({
|
||||
contains: max === undefined ? text : text.check(z.maxLength(max)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** A selector's `orderBy`: one of `fields`, with an optional direction. */
|
||||
export function orderBy<F extends string>(fields: readonly [F, ...F[]]) {
|
||||
return z.optional(
|
||||
z.strictObject({
|
||||
field: z.enum(fields),
|
||||
direction: z.optional(z.enum(["asc", "desc"])),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** A selector below the workspace names a `parent` or an `ancestor`, never both; nothing lists everything. */
|
||||
export const scoped = z.refine<{ parent?: unknown; ancestor?: unknown }>(
|
||||
(selector) =>
|
||||
(selector.parent !== undefined) !== (selector.ancestor !== undefined),
|
||||
{ error: "Pass parent or ancestor, not both" },
|
||||
);
|
||||
|
||||
/** The refs or ids a `get` takes, read as distinct ids in the order given. */
|
||||
export function targets<K extends AssetKind>(kind: K): Schema<string[]> {
|
||||
return z.pipe(
|
||||
z.array(refOrId(kind)),
|
||||
z.transform((refs) => [...new Set(refs.map(idOf))]),
|
||||
);
|
||||
}
|
||||
|
||||
/** `input` as `schema` reads it, or `invalid` naming every problem at once. */
|
||||
export function check<T>(schema: Schema<T>, input: unknown): T {
|
||||
const result = schema.safeParse(input);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new LiveError("invalid", describe(result.error.issues));
|
||||
}
|
||||
|
||||
/** `value` as read from the host, or `failed` when it is not the `what` the kit promises. */
|
||||
export function fromHost<T>(
|
||||
schema: Schema<T>,
|
||||
value: unknown,
|
||||
what: string,
|
||||
): T {
|
||||
const result = schema.safeParse(value);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new LiveError(
|
||||
"failed",
|
||||
`The host returned unexpected ${what}: ${describe(result.error.issues)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function describe(issues: readonly z.core.$ZodIssue[]): string {
|
||||
return issues
|
||||
.map((issue) =>
|
||||
issue.path.length > 0
|
||||
? `${issue.path.join(".")}: ${issue.message}`
|
||||
: issue.message,
|
||||
)
|
||||
.join("; ");
|
||||
}
|
||||
Reference in New Issue
Block a user