feat: bilingual support (FA/EN), IRANSans typography, RTL layout, and interactive product detail pages
This commit is contained in:
+28
@@ -0,0 +1,28 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Vite / Build outputs
|
||||
.artifact
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ply — Leporello | پانزده محصول، یک صفحه</title>
|
||||
<link rel="preload" href="/fonts/IRANSans-Regular.ttf" as="font" type="font/ttf" crossorigin />
|
||||
<link rel="preload" href="/fonts/IRANSans-Bold.ttf" as="font" type="font/ttf" crossorigin />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
# Live Artifacts
|
||||
|
||||
A live artifact is one that fetches its ClickUp data at view time instead of shipping a copy. Use it for anything that should reflect the workspace as it is now: task boards, dashboards, sprint trackers, inboxes.
|
||||
|
||||
Hardcode what the artifact is about, never what it shows. The ids of the list, space or tasks the user pointed at go in the code; everything about them (names, statuses, assignees, counts) is fetched at view time. Baked-in data goes stale and leaks: every viewer would see what you had access to. Reads and writes run as the viewer, so what they see depends on who they are and can be empty. The artifact-creator skill's safety rules still apply.
|
||||
|
||||
## Read live data
|
||||
|
||||
The kit is in every project as `kits/clickup`; use one hook per kind:
|
||||
|
||||
```jsx
|
||||
import { PriorityFlag, StatusBadge, useLists, useTasks } from "@kits/clickup";
|
||||
|
||||
const SPRINT_LIST_ID = "901234567"; // the list the user asked about
|
||||
|
||||
export default function App() {
|
||||
const list = useLists([SPRINT_LIST_ID]);
|
||||
const tasks = useTasks({
|
||||
parent: SPRINT_LIST_ID,
|
||||
assignees: ["me"],
|
||||
dueAt: "overdue",
|
||||
});
|
||||
return (
|
||||
<main>
|
||||
<h1>{list.data?.[0]?.name ?? "Sprint"}</h1>
|
||||
<Overdue tasks={tasks} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Overdue({ tasks }) {
|
||||
if (tasks.error) {
|
||||
return <p role="alert">Live data could not load: {tasks.error.message}</p>;
|
||||
}
|
||||
if (tasks.isPending) {
|
||||
return <p aria-busy="true">Loading…</p>;
|
||||
}
|
||||
if (tasks.data.length === 0) {
|
||||
return <p>Nothing of yours is overdue.</p>;
|
||||
}
|
||||
return (
|
||||
<ul>
|
||||
{tasks.data.map((task) => (
|
||||
<li key={task.id} className="flex items-center gap-2">
|
||||
<PriorityFlag priority={task.priority} />
|
||||
{task.name}
|
||||
<StatusBadge className="text-xs" status={task.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Kinds
|
||||
|
||||
The kit covers spaces, folders, lists, custom fields, tasks with their statuses and types, comments, users, teams, inbox, reminders and search. Each kind has a `use<Kinds>` hook; `clickup.<kinds>` has the same reads as plain promises, plus the writes. Before using a kind, read its reference file in `kits/clickup/docs/`: fields, selector, filters and writes. Spaces, folders and lists are in `hierarchy.d.ts`, users and teams in `people.d.ts`. `Avatar`, `AvatarGroup`, `StatusBadge`, `PriorityFlag`, `Tag` and `CustomFieldValue` render assignees, statuses, priorities, tags and custom fields; `RichText` renders a task or comment `body` (`docs/components.d.ts`). `kits/clickup/src/` is the kit's source, there when a question outlives the docs; read `docs/` first.
|
||||
|
||||
`clickup.viewer` is the user looking at the artifact; `clickup.navigate(url)` opens a ClickUp url; `clickup.askBrain(prompt)` sends a prompt to Brain.
|
||||
|
||||
## How the kit works
|
||||
|
||||
- Every asset is a `Ref`, `{ kind, id, name, url }`, plus the fields of its kind; full assets also carry `ancestors`, from the space down.
|
||||
- Hooks return TanStack Query v5's `useQuery` result (`data`, `error`, `isPending`, `isFetching`, and the rest) and take its `enabled`, `placeholderData` and `refetchInterval` options. The same selector shares one request across components. A hook first shows what the viewer saw last time, then loads fresh data, so `data` is often set while `isFetching` is true: keep showing it rather than a spinner.
|
||||
- Every failure is a `LiveError` with a `code`. `invalid`: your selector or input is wrong; fix the code. `forbidden`: the viewer is not allowed to; hide the feature. `failed`: something went wrong; retry when `retryable`. `denied`: the viewer declined a write.
|
||||
- Dates are ISO 8601 strings; inputs also take `YYYY-MM-DD`.
|
||||
|
||||
## Writes
|
||||
|
||||
There are no write hooks. Create and update with the `clickup` methods (`clickup.tasks.update(task, patch)`, `clickup.tasks.create(input)`, and so on), wrapped in `useMutation({ mutationFn })` and called from an event handler. Every changed field needs the viewer's approval in ClickUp, so a write stops at the first denial and can stay pending for a while. The kit does the rest: lists on screen update optimistically, roll back if the viewer declines, and refetch once ClickUp confirms. Don't `refetch()` after a write or mirror it in local state.
|
||||
|
||||
```jsx
|
||||
import { clickup, useMutation, useStatuses } from "@kits/clickup";
|
||||
|
||||
export default function TaskRow({ task }) {
|
||||
const list = task.ancestors.find((ref) => ref.kind === "list");
|
||||
const statuses = useStatuses({ parent: list }, { enabled: !!list });
|
||||
const done = statuses.data?.find((status) => status.type === "done");
|
||||
const complete = useMutation({
|
||||
mutationFn: (status) => clickup.tasks.update(task, { status }),
|
||||
});
|
||||
|
||||
return (
|
||||
<li>
|
||||
{task.name}
|
||||
<button
|
||||
disabled={!done || complete.isPending}
|
||||
onClick={() => complete.mutate(done)}
|
||||
>
|
||||
{complete.isPending ? "Waiting for approval…" : "Mark done"}
|
||||
</button>
|
||||
{complete.isError && <p role="alert">{complete.error.message}</p>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Design for data you have not seen
|
||||
|
||||
What you see while authoring is a sample: for another viewer any optional field can be missing, text can be far longer or shorter, and a query can return nothing or hit the limit. Design the loading, empty and error states: keep the layout with a skeleton while loading, say what came up empty, and when live data could not load, say so and keep the rest usable. Never invent replacement data, and render live values as text, never as HTML.
|
||||
|
||||
The sandbox preview does not restrict reads and only pretends to write, so you will never see a `forbidden` state there and nothing changes after a write. Design both anyway.
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"useSpaces": ["hierarchy.read"],
|
||||
"useFolders": ["hierarchy.read"],
|
||||
"useLists": ["hierarchy.read"],
|
||||
"useCustomFields": ["tasks.read"],
|
||||
"useTasks": ["tasks.read"],
|
||||
"useTaskQuery": ["search"],
|
||||
"useStatuses": ["tasks.read"],
|
||||
"useTaskTypes": ["tasks.read"],
|
||||
"useComments": ["comments.read"],
|
||||
"useUsers": ["people.read"],
|
||||
"useTeams": ["people.read"],
|
||||
"useInbox": ["inbox.read"],
|
||||
"useReminders": ["reminders.read"],
|
||||
"useSearch": ["search"],
|
||||
"clickup.spaces": ["hierarchy.read"],
|
||||
"clickup.folders": ["hierarchy.read"],
|
||||
"clickup.lists": ["hierarchy.read"],
|
||||
"clickup.customFields": ["tasks.read"],
|
||||
"clickup.tasks.list": ["tasks.read"],
|
||||
"clickup.tasks.get": ["tasks.read"],
|
||||
"clickup.tasks.query": ["search"],
|
||||
"clickup.tasks.statuses": ["tasks.read"],
|
||||
"clickup.tasks.types": ["tasks.read"],
|
||||
"clickup.tasks.create": ["tasks.write"],
|
||||
"clickup.tasks.update": ["tasks.read", "tasks.write"],
|
||||
"clickup.comments.list": ["comments.read"],
|
||||
"clickup.comments.get": ["comments.read"],
|
||||
"clickup.comments.create": ["comments.write"],
|
||||
"clickup.comments.update": ["comments.read", "comments.write"],
|
||||
"clickup.users": ["people.read"],
|
||||
"clickup.teams": ["people.read"],
|
||||
"clickup.inbox.list": ["inbox.read"],
|
||||
"clickup.inbox.get": ["inbox.read"],
|
||||
"clickup.inbox.update": ["inbox.read", "inbox.write"],
|
||||
"clickup.reminders.list": ["reminders.read"],
|
||||
"clickup.reminders.get": ["reminders.read"],
|
||||
"clickup.reminders.create": ["reminders.write"],
|
||||
"clickup.reminders.update": ["reminders.write"],
|
||||
"clickup.search": ["search"],
|
||||
"clickup.navigate": ["navigate"],
|
||||
"clickup.askBrain": ["brain"],
|
||||
"entities/defineEntity": ["tasks.read", "tasks.write"],
|
||||
"entities/useTasksByIds": ["tasks.read"]
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import type { LiveClient } from "./core";
|
||||
|
||||
/**
|
||||
* 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 declare const clickup: LiveClient;
|
||||
Vendored
+78
@@ -0,0 +1,78 @@
|
||||
import type { Delta, IsoDate, Ref, UseQueryOptions, UseQueryResult } from "./core";
|
||||
|
||||
export declare const COMMENT_TYPES: readonly ["message", "ai", "activity"];
|
||||
/**
|
||||
* `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 declare const COMMENT_ORDERS: readonly ["createdAt"];
|
||||
/**
|
||||
* 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 declare function useComments(selector: CommentSelector | Array<Ref<"comment"> | string>, options?: UseQueryOptions<Comment[]>): UseQueryResult<Comment[]>;
|
||||
Vendored
+72
@@ -0,0 +1,72 @@
|
||||
import type { Delta, Ref } from "./core";
|
||||
import type { CustomField } from "./customFields";
|
||||
import type { Status, Task } from "./tasks";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
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;
|
||||
}
|
||||
/** A round avatar: the image, else initials for a user or a people glyph for a team. */
|
||||
export declare function Avatar({ of, size, className, }: AvatarProps): ReactElement;
|
||||
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 declare function AvatarGroup({ of, max, size, className, }: AvatarGroupProps): ReactElement | null;
|
||||
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 declare function StatusBadge({ status, className, }: StatusBadgeProps): ReactElement;
|
||||
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 declare function PriorityFlag({ priority, showEmpty, className, }: PriorityFlagProps): ReactElement | null;
|
||||
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 declare function Tag({ tag, className }: TagProps): ReactElement;
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* 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 declare function CustomFieldValue({ value, field, className, }: CustomFieldValueProps): ReactElement;
|
||||
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 declare function RichText({ value, className }: RichTextProps): ReactElement;
|
||||
Vendored
+155
@@ -0,0 +1,155 @@
|
||||
import type { CommentsApi } from "./comments";
|
||||
import type { CustomFieldsApi } from "./customFields";
|
||||
import type { FoldersApi, ListsApi, SpacesApi } from "./hierarchy";
|
||||
import type { InboxApi } from "./inbox";
|
||||
import type { TeamsApi, User, UsersApi } from "./people";
|
||||
import type { RemindersApi } from "./reminders";
|
||||
import type { SearchHit, SearchQuery } from "./search";
|
||||
import type { TasksApi } from "./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 declare const ASSET_KINDS: readonly ["space", "folder", "list", "status", "customField", "task", "taskType", "comment", "user", "team", "inboxBundle", "reminder"];
|
||||
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;
|
||||
}
|
||||
/** `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;
|
||||
}
|
||||
/** A `placeholderData` that keeps the previous selector's rows on screen while the new ones load. */
|
||||
export declare function keepPreviousData<T>(previous: T | undefined): T | undefined;
|
||||
/**
|
||||
* - `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 declare 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;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 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 {
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* 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 declare function useMutation<TData, TVariables = void>(options: UseMutationOptions<TData, TVariables> | MutationFunction<TData, TVariables>): UseMutationResult<TData, TVariables>;
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
import type { Ref, UseQueryOptions, UseQueryResult } from "./core";
|
||||
|
||||
export declare const CUSTOM_FIELD_TYPES: readonly ["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"];
|
||||
/** 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 declare function useCustomFields(selector: CustomFieldSelector, options?: UseQueryOptions<CustomField[]>): UseQueryResult<CustomField[]>;
|
||||
Vendored
+113
@@ -0,0 +1,113 @@
|
||||
import type { Ref, UseMutationResult, UseQueryOptions, UseQueryResult } from "./core";
|
||||
import type { CustomField } from "./customFields";
|
||||
import type { Status, Task } from "./tasks";
|
||||
|
||||
/**
|
||||
* 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 declare function defineEntity<R extends RecordBase, I extends object, P extends object, S extends string>(schema: EntitySchema): Entity<R, I, P, S>;
|
||||
/**
|
||||
* 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 declare function useTasksByIds(ids: readonly string[], options?: UseQueryOptions<Task[]>): UseQueryResult<Task[]>;
|
||||
|
||||
/** A property every task has, mapped to a record property by the schema. */
|
||||
type NativeProperty = "name" | "status" | "assignees" | "dueDate" | "startDate" | "priority" | "description";
|
||||
interface OptionSpec {
|
||||
id: string;
|
||||
/** The option's name in ClickUp; what the kit reads and writes. */
|
||||
label: string;
|
||||
}
|
||||
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";
|
||||
};
|
||||
}
|
||||
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>;
|
||||
}
|
||||
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
import type { Ref, UseQueryOptions, UseQueryResult } from "./core";
|
||||
|
||||
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 declare function useSpaces(targets?: Array<Ref<"space"> | string>, options?: UseQueryOptions<Space[]>): UseQueryResult<Space[]>;
|
||||
export declare function useFolders(selector: FolderSelector | Array<Ref<"folder"> | string>, options?: UseQueryOptions<Folder[]>): UseQueryResult<Folder[]>;
|
||||
export declare function useLists(selector: ListSelector | Array<Ref<"list"> | string>, options?: UseQueryOptions<List[]>): UseQueryResult<List[]>;
|
||||
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
import type { IsoDate, Ref, UseQueryOptions, UseQueryResult } from "./core";
|
||||
|
||||
/** `primary` holds messages and mentions, `other` everything else, `reminder` reminders. */
|
||||
export declare const INBOX_CATEGORIES: readonly ["primary", "other", "reminder"];
|
||||
export declare const INBOX_STATES: readonly ["active", "snoozed", "cleared"];
|
||||
export declare const NOTIFICATION_TYPES: readonly ["comment", "mention", "assignment", "status", "reminder", "other"];
|
||||
/**
|
||||
* 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 declare function useInbox(selector?: InboxSelector | Array<Ref<"inboxBundle"> | string>, options?: UseQueryOptions<InboxBundle[]>): UseQueryResult<InboxBundle[]>;
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
import type { Ref, UseQueryOptions, UseQueryResult } from "./core";
|
||||
|
||||
/** 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 declare function useUsers(targets?: Array<Ref<"user"> | string>, options?: UseQueryOptions<User[]>): UseQueryResult<User[]>;
|
||||
/** Every team, or exactly the given ones. */
|
||||
export declare function useTeams(targets?: Array<Ref<"team"> | string>, options?: UseQueryOptions<Team[]>): UseQueryResult<Team[]>;
|
||||
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
import type { IsoDate, Ref, UseQueryOptions, UseQueryResult } from "./core";
|
||||
|
||||
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 declare function useReminders(selector?: ReminderSelector | Array<Ref<"reminder"> | string>, options?: UseQueryOptions<Reminder[]>): UseQueryResult<Reminder[]>;
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
import type { IsoDate, Ref, UseQueryOptions, UseQueryResult } from "./core";
|
||||
|
||||
export declare const SEARCH_KINDS: readonly ["task", "list", "folder", "space", "comment"];
|
||||
export declare const SEARCH_ORDERS: readonly ["createdAt", "updatedAt", "dueAt"];
|
||||
/** 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 declare function useSearch(query: SearchQuery, options?: UseQueryOptions<SearchHit[]>): UseQueryResult<SearchHit[]>;
|
||||
Vendored
+169
@@ -0,0 +1,169 @@
|
||||
import type { Delta, IsoDate, Ref, UseQueryOptions, UseQueryResult } from "./core";
|
||||
import type { CustomField } from "./customFields";
|
||||
|
||||
export declare const PRIORITIES: readonly ["urgent", "high", "normal", "low"];
|
||||
/** `open` is not started, `active` in progress, `done` finished, and `closed` the list's final status. */
|
||||
export declare const STATUS_TYPES: readonly ["open", "active", "done", "closed"];
|
||||
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 declare const DATE_PRESETS: readonly ["today", "tomorrow", "next7Days"];
|
||||
/** Inclusive day bounds; give one or both. */
|
||||
export interface DateRange {
|
||||
from?: IsoDate;
|
||||
to?: IsoDate;
|
||||
}
|
||||
export type DateFilter = (typeof DATE_PRESETS)[number] | DateRange;
|
||||
export declare const TASK_ORDERS: readonly ["name", "priority", "status", "startAt", "dueAt", "createdAt", "updatedAt"];
|
||||
/**
|
||||
* 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 declare function useTasks(selector: TaskSelector | Array<Ref<"task"> | string>, options?: UseQueryOptions<Task[]>): UseQueryResult<Task[]>;
|
||||
/** The rows of `clickup.tasks.query(sql)`, refetched after every task write. */
|
||||
export declare function useTaskQuery(sql: string, options?: UseQueryOptions<Array<Record<string, unknown>>>): UseQueryResult<Array<Record<string, unknown>>>;
|
||||
export declare function useTaskTypes(options?: UseQueryOptions<TaskType[]>): UseQueryResult<TaskType[]>;
|
||||
export declare function useStatuses(selector: StatusSelector, options?: UseQueryOptions<Status[]>): UseQueryResult<Status[]>;
|
||||
@@ -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("; ");
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Components
|
||||
|
||||
`@kits/components` holds the larger building blocks. Today that is a rich-text editor at `@kits/components/editor`.
|
||||
|
||||
To share an editor, give it `doc` and `field`, which name a field on a `@kits/sync` document; the SYNC skill explains documents. The other props are the same either way. Buttons, inputs and dialogs come from `@kits/shadcn`, and ClickUp data and its UI come from `@kits/clickup`.
|
||||
|
||||
## Editor
|
||||
|
||||
The `Editor` edits rich text with bold, italic, strike, code, links, headings, lists, tasks, quotes and code blocks. People type `/` for a block menu and `@` to mention someone. Markdown shortcuts like `**bold**`, `# `, `- ` and `[ ] ` work as they type, and a formatting bubble appears over a selection. When the editor is shared, everyone's edits merge live and each person's caret shows in their avatar colour.
|
||||
|
||||
```jsx
|
||||
import { Editor, richText, sharedRichText } from "@kits/components/editor";
|
||||
import { sharedText, useSharedCollection } from "@kits/sync";
|
||||
|
||||
<Editor value={notes} onChange={setNotes} placeholder="Notes…" />; // A local editor holds its Markdown in value and calls onChange with Markdown after every edit.
|
||||
cards.add({ title: sharedText("New card"), body: sharedRichText("# Notes") }); // To share, first create the field together with its document; the starting Markdown is optional.
|
||||
<Editor doc={`cards/${card.id}`} field="body" placeholder="Details…" />; // Then render the editor on that field. It stays read-only until the field exists or while the viewer cannot edit.
|
||||
|
||||
<Editor doc="notes/main" field="body" toolbar="fixed" className="prose dark:prose-invert min-h-[60vh]" />; // A whole document gets a fixed toolbar and prose typography.
|
||||
<Editor doc={`drafts/${me.user.id}`} field="text" single placeholder="Message…" onSubmit={send} />; // A chat composer keeps one paragraph. Enter calls onSubmit and Shift+Enter breaks the line.
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- To show rich text without editing, render `<Editor doc="…" field="…" readOnly toolbar={false} />`.
|
||||
- To get the plain text, for search or a summary, call `richText(doc, field).text()`. Do not read `card.body` directly; it is not text.
|
||||
- To start a field with content, pass it to the marker: `body: sharedRichText("# Notes")`. `richText(doc, field).setMarkdown(md)` replaces the content later.
|
||||
- Cmd+Z inside an editor undoes typing in that editor. `useUndo()` undoes your other document writes; it does not undo what `setMarkdown()` wrote.
|
||||
- For custom blocks, pass `extensions`; the `.d.ts` shows how.
|
||||
|
||||
The full API is in `kits/components/docs/editor.d.ts`.
|
||||
Vendored
+108
@@ -0,0 +1,108 @@
|
||||
import type { FieldValue } from "@kits/sync";
|
||||
import type { AnyExtension, Editor as TiptapEditor } from "@tiptap/core";
|
||||
import type { BubbleMenuPluginProps } from "@tiptap/extension-bubble-menu";
|
||||
import type { FloatingMenuPluginProps } from "@tiptap/extension-floating-menu";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
|
||||
export type { Editor as TiptapEditor, JSONContent } from "@tiptap/core";
|
||||
export { createBlockMarkdownSpec, createInlineMarkdownSpec, Extension, Mark, mergeAttributes, Node, } from "@tiptap/core";
|
||||
export { EditorContent, NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer, useEditorState, } from "@tiptap/react";
|
||||
|
||||
/** Own the state with `value`/`onChange`, or share it with `doc`/`field` on a `@kits/sync` document. */
|
||||
interface LocalBinding<T> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
doc?: never;
|
||||
field?: never;
|
||||
}
|
||||
interface SharedBinding {
|
||||
/** The document path, like `cards/${card.id}`. */
|
||||
doc: string;
|
||||
field: string;
|
||||
value?: never;
|
||||
onChange?: never;
|
||||
}
|
||||
|
||||
export interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
export type MentionSource = MentionItem[] | ((query: string) => MentionItem[] | Promise<MentionItem[]>);
|
||||
/** An entry of the `/` menu; `run` gets the editor with the slash and query already deleted. */
|
||||
export interface SlashItem {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Extra search words, space-separated. */
|
||||
keywords?: string;
|
||||
/** One line under the label. */
|
||||
description?: string;
|
||||
run: (editor: TiptapEditor) => void;
|
||||
}
|
||||
export interface EditorOptions {
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
/** Forced on while the viewer cannot edit or the shared field is not a `sharedRichText()`. */
|
||||
readOnly?: boolean;
|
||||
/** One paragraph: Enter calls `onSubmit`, Shift+Enter is a soft break, pasted blocks fold into the line. */
|
||||
single?: boolean;
|
||||
/** Enter in `single` mode, Cmd/Ctrl+Enter otherwise. */
|
||||
onSubmit?: (editor: TiptapEditor) => void;
|
||||
/** `false` hides the `/` menu, an array adds entries to it; off in `single`. */
|
||||
slash?: false | SlashItem[];
|
||||
/** The `@` menu; default: the other people in the room when shared, none when local. */
|
||||
mentions?: MentionSource | false;
|
||||
/** Tiptap extensions appended to the kit's; every editor and `richText()` on one field must pass the same ones. A custom block (`Node.create`) needs a Markdown spec (`createBlockMarkdownSpec`) to survive `value`/`onChange` and `markdown()`, and a `/` entry via `slash`. */
|
||||
extensions?: AnyExtension[];
|
||||
onReady?: (editor: TiptapEditor) => void;
|
||||
}
|
||||
|
||||
interface EditorBaseProps extends EditorOptions {
|
||||
/** Classes on the wrapper. Content typography is yours, e.g. `prose dark:prose-invert` for a document; the editor's own chrome follows the shadcn tokens. */
|
||||
className?: string;
|
||||
/** `"bubble"` over the selection (default), `"fixed"` above the content, `false` none; `single` defaults to `false`. */
|
||||
toolbar?: "bubble" | "fixed" | false;
|
||||
/** Rendered above the content, with the editor for `BubbleMenu` / `FloatingMenu`. */
|
||||
children?: (editor: TiptapEditor) => ReactNode;
|
||||
}
|
||||
export type EditorProps = EditorBaseProps & (LocalBinding<string> | SharedBinding);
|
||||
/** Rich text with formatting, links, lists, tasks, a `/` menu and `@` mentions; `value`/`onChange` are Markdown, or bind `doc`/`field` to a `sharedRichText()` for live shared editing. */
|
||||
export declare function Editor(props: EditorProps): ReactElement;
|
||||
|
||||
export interface LocalEditorOptions extends EditorOptions {
|
||||
/** Markdown; `onChange` gets Markdown after every edit. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
/** The editor instance over `value`/`onChange`, to render with `EditorContent`. */
|
||||
export declare function useEditor(options: LocalEditorOptions): TiptapEditor;
|
||||
/** The editor instance over a `sharedRichText()` field, to render with `EditorContent`; suspends until shared state has loaded. */
|
||||
export declare function useSharedEditor(doc: string, field: string, options?: EditorOptions): TiptapEditor;
|
||||
|
||||
/** Creates rich text everyone edits together with the `Editor`: `add({ body: sharedRichText("# Notes") })`. The Markdown is the starting content; `richText(doc, field).setMarkdown()` replaces it later. */
|
||||
export declare function sharedRichText(markdown?: string): FieldValue;
|
||||
export interface RichText {
|
||||
/** Plain text, one line per block; `""` while the field is missing or a plain value. */
|
||||
text(): string;
|
||||
html(): string;
|
||||
markdown(): string;
|
||||
/** Replaces the field's content in one write no undo reverts; a no-op while the field is missing or the viewer cannot edit. */
|
||||
setMarkdown(markdown: string): void;
|
||||
}
|
||||
/** Reads and replaces a `sharedRichText()` field outside render; pass the editor's `extensions` so both use one schema. Throws, naming the kit to use, when another kit's field is there. */
|
||||
export declare function richText(doc: string, field: string, options?: Pick<EditorOptions, "extensions">): RichText;
|
||||
|
||||
type MenuOptions<Props> = Omit<Props, "editor" | "element" | "pluginKey">;
|
||||
export interface BubbleMenuProps extends MenuOptions<BubbleMenuPluginProps> {
|
||||
editor: TiptapEditor;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
export interface FloatingMenuProps extends MenuOptions<FloatingMenuPluginProps> {
|
||||
editor: TiptapEditor;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
/** Your own menu over the selection. */
|
||||
export declare function BubbleMenu(props: BubbleMenuProps): ReactElement | null;
|
||||
/** Your own menu beside an empty line. */
|
||||
export declare function FloatingMenu(props: FloatingMenuProps): ReactElement | null;
|
||||
@@ -0,0 +1,2 @@
|
||||
export {
|
||||
} from "./index.ts";
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { OpaqueField } from "@kits/sync/internal";
|
||||
|
||||
const HELD_BY: Record<string, string> = {
|
||||
prosemirror:
|
||||
"prosemirror rich text; use <Editor> from @kits/components/editor",
|
||||
};
|
||||
|
||||
/** Returns `bound`, or throws naming the kit to use when `field` holds another kit's opaque field. */
|
||||
export function bindOpaque<T extends OpaqueField<unknown>>(
|
||||
field: string,
|
||||
bound: T,
|
||||
): T {
|
||||
const { holds } = bound;
|
||||
if (holds) {
|
||||
throw new Error(
|
||||
`"${field}" holds ${HELD_BY[holds.of] ?? `an opaque ${holds.of} field; bind it with the kit that created it`}.`,
|
||||
);
|
||||
}
|
||||
return bound;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Own the state with `value`/`onChange`, or share it with `doc`/`field` on a `@kits/sync` document. */
|
||||
export interface LocalBinding<T> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
doc?: never;
|
||||
field?: never;
|
||||
}
|
||||
|
||||
export interface SharedBinding {
|
||||
/** The document path, like `cards/${card.id}`. */
|
||||
doc: string;
|
||||
field: string;
|
||||
value?: never;
|
||||
onChange?: never;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
BubbleMenu,
|
||||
Editor,
|
||||
EditorContent,
|
||||
Extension,
|
||||
FloatingMenu,
|
||||
Mark,
|
||||
Node,
|
||||
NodeViewContent,
|
||||
NodeViewWrapper,
|
||||
ReactNodeViewRenderer,
|
||||
createBlockMarkdownSpec,
|
||||
createInlineMarkdownSpec,
|
||||
mergeAttributes,
|
||||
richText,
|
||||
sharedRichText,
|
||||
useEditor,
|
||||
useEditorState,
|
||||
useSharedEditor,
|
||||
} from "./editor/index.ts";
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Editor as TiptapEditor } from "@tiptap/core";
|
||||
import { EditorContent } from "@tiptap/react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import type { LocalBinding, SharedBinding } from "../binding";
|
||||
import type { EditorOptions } from "./options";
|
||||
import { Toolbar } from "./Toolbar";
|
||||
import { useEditor, useSharedEditor } from "./useEditor";
|
||||
|
||||
interface EditorBaseProps extends EditorOptions {
|
||||
/** Classes on the wrapper. Content typography is yours, e.g. `prose dark:prose-invert` for a document; the editor's own chrome follows the shadcn tokens. */
|
||||
className?: string;
|
||||
/** `"bubble"` over the selection (default), `"fixed"` above the content, `false` none; `single` defaults to `false`. */
|
||||
toolbar?: "bubble" | "fixed" | false;
|
||||
/** Rendered above the content, with the editor for `BubbleMenu` / `FloatingMenu`. */
|
||||
children?: (editor: TiptapEditor) => ReactNode;
|
||||
}
|
||||
|
||||
export type EditorProps = EditorBaseProps &
|
||||
(LocalBinding<string> | SharedBinding);
|
||||
|
||||
/** Rich text with formatting, links, lists, tasks, a `/` menu and `@` mentions; `value`/`onChange` are Markdown, or bind `doc`/`field` to a `sharedRichText()` for live shared editing. */
|
||||
export function Editor(props: EditorProps): ReactElement {
|
||||
if (typeof props.doc === "string") {
|
||||
return <Shared {...props} />;
|
||||
}
|
||||
if (typeof props.onChange !== "function") {
|
||||
throw new Error(
|
||||
"Editor needs value and onChange to own its state, or doc and field to share it.",
|
||||
);
|
||||
}
|
||||
return <Local {...props} />;
|
||||
}
|
||||
|
||||
function Local({
|
||||
value,
|
||||
onChange,
|
||||
...rest
|
||||
}: EditorBaseProps & LocalBinding<string>): ReactElement {
|
||||
const editor = useEditor({ ...rest, value, onChange });
|
||||
return <Surface editor={editor} {...rest} />;
|
||||
}
|
||||
|
||||
function Shared({
|
||||
doc,
|
||||
field,
|
||||
...rest
|
||||
}: EditorBaseProps & SharedBinding): ReactElement {
|
||||
const editor = useSharedEditor(doc, field, rest);
|
||||
return <Surface editor={editor} {...rest} />;
|
||||
}
|
||||
|
||||
function Surface({
|
||||
editor,
|
||||
className,
|
||||
toolbar,
|
||||
children,
|
||||
single = false,
|
||||
}: EditorBaseProps & { editor: TiptapEditor }): ReactElement {
|
||||
const placement = toolbar ?? (single ? false : "bubble");
|
||||
return (
|
||||
<div
|
||||
className={className ? `ck-editor ${className}` : "ck-editor"}
|
||||
data-toolbar={placement || undefined}
|
||||
>
|
||||
{children?.(editor)}
|
||||
{placement && (
|
||||
<Toolbar blocks={!single} editor={editor} placement={placement} />
|
||||
)}
|
||||
<EditorContent className="ck-content" editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { colorOf, LABEL } from "@kits/sync/internal";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { yCursorPlugin } from "@tiptap/y-tiptap";
|
||||
import type { Awareness } from "y-protocols/awareness";
|
||||
|
||||
export interface SyncCaretOptions {
|
||||
awareness: Awareness | null;
|
||||
}
|
||||
|
||||
interface WireUser {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
}
|
||||
|
||||
interface WireState {
|
||||
user?: WireUser;
|
||||
}
|
||||
|
||||
/** Everyone else's caret and selection in their avatar colour with their name; my other tabs are not drawn. */
|
||||
export const SyncCaret = Extension.create<SyncCaretOptions>({
|
||||
name: "syncCaret",
|
||||
|
||||
addOptions() {
|
||||
return { awareness: null };
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { awareness } = this.options;
|
||||
if (!awareness) {
|
||||
return [];
|
||||
}
|
||||
const mine = () =>
|
||||
userIdOf(
|
||||
(awareness.getLocalState() as WireState | null)?.user,
|
||||
awareness.clientID,
|
||||
);
|
||||
return [
|
||||
yCursorPlugin(awareness, {
|
||||
awarenessStateFilter: (
|
||||
_current: number,
|
||||
clientId: number,
|
||||
state: WireState | null,
|
||||
) =>
|
||||
clientId !== awareness.clientID &&
|
||||
userIdOf(state?.user, clientId) !== mine(),
|
||||
cursorBuilder: (user: WireUser, clientId: number) =>
|
||||
caretElement(userIdOf(user, clientId), user),
|
||||
selectionBuilder: (user: WireUser, clientId: number) => ({
|
||||
class: "ProseMirror-yjs-selection",
|
||||
style: `background-color: ${colorOf(userIdOf(user, clientId)).backgroundColor}${LABEL.alpha}`,
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
function userIdOf(user: WireUser | undefined, clientId: number): string {
|
||||
const id = user?.id;
|
||||
return id == null || id === "" ? `client:${clientId}` : String(id);
|
||||
}
|
||||
|
||||
function caretElement(id: string, user: WireUser): HTMLElement {
|
||||
const colors = colorOf(id);
|
||||
const caret = document.createElement("span");
|
||||
caret.className = "ProseMirror-yjs-cursor";
|
||||
caret.setAttribute("aria-hidden", "true");
|
||||
caret.dataset.syncCaret = id;
|
||||
caret.style.borderColor = colors.backgroundColor;
|
||||
const label = document.createElement("div");
|
||||
label.style.backgroundColor = colors.backgroundColor;
|
||||
label.style.color = colors.color;
|
||||
label.textContent = typeof user.name === "string" ? user.name : id;
|
||||
caret.append("\u2060", label, "\u2060");
|
||||
return caret;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { Editor as TiptapEditor } from "@tiptap/core";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import {
|
||||
Bold,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Strikethrough,
|
||||
} from "lucide-react";
|
||||
import type { ReactElement } from "react";
|
||||
import { useState } from "react";
|
||||
import { BubbleMenu } from "./menus";
|
||||
|
||||
interface ToolbarProps {
|
||||
editor: TiptapEditor;
|
||||
placement: "bubble" | "fixed";
|
||||
/** Whether the block tools are shown. */
|
||||
blocks: boolean;
|
||||
}
|
||||
|
||||
interface Tool {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: typeof Bold;
|
||||
block?: true;
|
||||
active: (editor: TiptapEditor) => boolean;
|
||||
/** Absent only for the link tool, which opens the URL field. */
|
||||
run?: (editor: TiptapEditor) => void;
|
||||
}
|
||||
|
||||
const TOOLS: readonly Tool[] = [
|
||||
{
|
||||
id: "bold",
|
||||
label: "Bold",
|
||||
icon: Bold,
|
||||
active: (e) => e.isActive("bold"),
|
||||
run: (e) => e.chain().focus().toggleBold().run(),
|
||||
},
|
||||
{
|
||||
id: "italic",
|
||||
label: "Italic",
|
||||
icon: Italic,
|
||||
active: (e) => e.isActive("italic"),
|
||||
run: (e) => e.chain().focus().toggleItalic().run(),
|
||||
},
|
||||
{
|
||||
id: "strike",
|
||||
label: "Strikethrough",
|
||||
icon: Strikethrough,
|
||||
active: (e) => e.isActive("strike"),
|
||||
run: (e) => e.chain().focus().toggleStrike().run(),
|
||||
},
|
||||
{
|
||||
id: "code",
|
||||
label: "Code",
|
||||
icon: Code,
|
||||
active: (e) => e.isActive("code"),
|
||||
run: (e) => e.chain().focus().toggleCode().run(),
|
||||
},
|
||||
{
|
||||
id: "link",
|
||||
label: "Link",
|
||||
icon: Link,
|
||||
active: (e) => e.isActive("link"),
|
||||
},
|
||||
{
|
||||
id: "h1",
|
||||
label: "Heading 1",
|
||||
icon: Heading1,
|
||||
block: true,
|
||||
active: (e) => e.isActive("heading", { level: 1 }),
|
||||
run: (e) => e.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
id: "h2",
|
||||
label: "Heading 2",
|
||||
icon: Heading2,
|
||||
block: true,
|
||||
active: (e) => e.isActive("heading", { level: 2 }),
|
||||
run: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
id: "h3",
|
||||
label: "Heading 3",
|
||||
icon: Heading3,
|
||||
block: true,
|
||||
active: (e) => e.isActive("heading", { level: 3 }),
|
||||
run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
},
|
||||
{
|
||||
id: "bullets",
|
||||
label: "Bullet list",
|
||||
icon: List,
|
||||
block: true,
|
||||
active: (e) => e.isActive("bulletList"),
|
||||
run: (e) => e.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
id: "numbers",
|
||||
label: "Numbered list",
|
||||
icon: ListOrdered,
|
||||
block: true,
|
||||
active: (e) => e.isActive("orderedList"),
|
||||
run: (e) => e.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
id: "tasks",
|
||||
label: "Task list",
|
||||
icon: ListChecks,
|
||||
block: true,
|
||||
active: (e) => e.isActive("taskList"),
|
||||
run: (e) => e.chain().focus().toggleTaskList().run(),
|
||||
},
|
||||
{
|
||||
id: "quote",
|
||||
label: "Quote",
|
||||
icon: Quote,
|
||||
block: true,
|
||||
active: (e) => e.isActive("blockquote"),
|
||||
run: (e) => e.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
];
|
||||
|
||||
export function Toolbar(props: ToolbarProps): ReactElement | null {
|
||||
const { editor, placement } = props;
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
const tools = TOOLS.filter((tool) => props.blocks || !tool.block);
|
||||
const active = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: current }) =>
|
||||
tools.map((tool) => (current.isEditable ? tool.active(current) : false)),
|
||||
});
|
||||
const editable = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: current }) => current.isEditable,
|
||||
});
|
||||
if (!editable) {
|
||||
return null;
|
||||
}
|
||||
const close = () => {
|
||||
setLink(null);
|
||||
editor.commands.focus();
|
||||
};
|
||||
const bar =
|
||||
link === null ? (
|
||||
<div
|
||||
aria-label="Formatting"
|
||||
className="ck-toolbar"
|
||||
data-placement={placement}
|
||||
role="toolbar"
|
||||
>
|
||||
{tools.map((tool, index) => {
|
||||
const Icon = tool.icon;
|
||||
return (
|
||||
<button
|
||||
aria-label={tool.label}
|
||||
aria-pressed={active[index] ?? false}
|
||||
key={tool.id}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() =>
|
||||
tool.run
|
||||
? tool.run(editor)
|
||||
: setLink(
|
||||
(editor.getAttributes("link").href as
|
||||
| string
|
||||
| undefined) ?? "",
|
||||
)
|
||||
}
|
||||
title={tool.label}
|
||||
type="button"
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="ck-toolbar"
|
||||
data-placement={placement}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
applyLink(editor, link);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label="Link URL"
|
||||
autoFocus
|
||||
onChange={(event) => setLink(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
inputMode="url"
|
||||
placeholder="https://"
|
||||
type="text"
|
||||
value={link}
|
||||
/>
|
||||
<button aria-label="Apply link" title="Apply link" type="submit">
|
||||
<Link aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
return placement === "bubble" ? (
|
||||
<BubbleMenu editor={editor} options={{ placement: "top", offset: 8 }}>
|
||||
{bar}
|
||||
</BubbleMenu>
|
||||
) : (
|
||||
bar
|
||||
);
|
||||
}
|
||||
|
||||
function applyLink(editor: TiptapEditor, href: string): void {
|
||||
const chain = editor.chain().focus().extendMarkRange("link");
|
||||
if (href.trim() === "") {
|
||||
chain.unsetLink().run();
|
||||
} else {
|
||||
chain.setLink({ href: href.trim() }).run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { shared } from "@kits/sync";
|
||||
import type { OpaqueField } from "@kits/sync/internal";
|
||||
import {
|
||||
Extension,
|
||||
type AnyExtension,
|
||||
type Editor as TiptapEditor,
|
||||
} from "@tiptap/core";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { TaskItem, TaskList } from "@tiptap/extension-list";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import { TableKit } from "@tiptap/extension-table";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Fragment, Slice, type Node as PMNode } from "@tiptap/pm/model";
|
||||
import { Placeholder } from "@tiptap/extensions";
|
||||
import { Markdown } from "@tiptap/markdown";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import type * as Y from "yjs";
|
||||
import type { EditorOptions, MentionItem, MentionSource } from "./options";
|
||||
import { Slash } from "./slash";
|
||||
import { menuRenderer, popupOptions } from "./suggestions";
|
||||
import { SyncCaret } from "./SyncCaret";
|
||||
|
||||
/** The kit's extensions; every editor and `richText()` reader registers the same nodes and marks, whatever the options. */
|
||||
export function editorExtensions(
|
||||
options: EditorOptions,
|
||||
field?: OpaqueField<Y.XmlFragment>,
|
||||
): AnyExtension[] {
|
||||
const { single = false } = options;
|
||||
const target = field?.value;
|
||||
const extensions: AnyExtension[] = [
|
||||
StarterKit.configure({
|
||||
link: { openOnClick: false },
|
||||
undoRedo: target ? false : undefined,
|
||||
trailingNode: false,
|
||||
}),
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Image,
|
||||
TableKit.configure({ table: { resizable: false } }),
|
||||
mentionExtension(options.mentions ?? (target ? collaborators : false)),
|
||||
Markdown,
|
||||
];
|
||||
if (options.placeholder) {
|
||||
extensions.push(
|
||||
Placeholder.configure({ placeholder: options.placeholder }),
|
||||
);
|
||||
}
|
||||
if (options.onSubmit || single) {
|
||||
extensions.push(Submit.configure({ single, onSubmit: options.onSubmit }));
|
||||
}
|
||||
if (single) {
|
||||
extensions.push(SingleLine);
|
||||
}
|
||||
const slash = options.slash ?? !single;
|
||||
if (slash) {
|
||||
extensions.push(
|
||||
Slash.configure({
|
||||
items: Array.isArray(slash) ? slash : [],
|
||||
blocks: !single,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (target) {
|
||||
extensions.push(
|
||||
Collaboration.configure({ fragment: target }),
|
||||
SyncCaret.configure({ awareness: field?.awareness ?? null }),
|
||||
);
|
||||
}
|
||||
return [...extensions, ...(options.extensions ?? [])];
|
||||
}
|
||||
|
||||
interface SubmitOptions {
|
||||
single: boolean;
|
||||
onSubmit: ((editor: TiptapEditor) => void) | undefined;
|
||||
}
|
||||
|
||||
const Submit = Extension.create<SubmitOptions>({
|
||||
name: "submit",
|
||||
priority: 1000,
|
||||
|
||||
addOptions() {
|
||||
return { single: false, onSubmit: undefined };
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
const submit = () => {
|
||||
this.options.onSubmit?.(this.editor);
|
||||
return true;
|
||||
};
|
||||
const shortcuts: Record<string, () => boolean> = {};
|
||||
if (this.options.onSubmit) {
|
||||
shortcuts["Mod-Enter"] = submit;
|
||||
}
|
||||
if (this.options.single) {
|
||||
shortcuts.Enter = submit;
|
||||
}
|
||||
return shortcuts;
|
||||
},
|
||||
});
|
||||
|
||||
/** Folds pasted blocks into one paragraph, a soft break between them. */
|
||||
const SingleLine = Extension.create({
|
||||
name: "singleLine",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { schema } = this.editor;
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
transformPasted: (slice) => {
|
||||
const lines: Fragment[] = [];
|
||||
slice.content.descendants((node: PMNode) => {
|
||||
if (node.isTextblock) {
|
||||
lines.push(node.content);
|
||||
}
|
||||
return !node.isTextblock;
|
||||
});
|
||||
if (lines.length < 2) {
|
||||
return slice;
|
||||
}
|
||||
const inline = lines.reduce((joined, line) =>
|
||||
joined
|
||||
.append(Fragment.from(schema.nodes.hardBreak!.create()))
|
||||
.append(line),
|
||||
);
|
||||
return new Slice(
|
||||
Fragment.from(schema.nodes.paragraph!.create(null, inline)),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
/** The `@` menu over `source`; with `false` the mention node stays in the schema but the menu never opens. */
|
||||
function mentionExtension(source: MentionSource | false): AnyExtension {
|
||||
const key = new PluginKey("mention");
|
||||
return Mention.extend({
|
||||
renderMarkdown: ({ attrs }) => `@${attrs?.label ?? attrs?.id ?? ""}`,
|
||||
}).configure({
|
||||
HTMLAttributes: { class: "mention" },
|
||||
renderText: ({ node }) => `@${node.attrs.label ?? node.attrs.id}`,
|
||||
suggestion: {
|
||||
...popupOptions,
|
||||
char: "@",
|
||||
pluginKey: key,
|
||||
allow: () => source !== false,
|
||||
items: ({ query }) =>
|
||||
typeof source === "function"
|
||||
? source(query)
|
||||
: matching(source || [], query),
|
||||
render: menuRenderer<MentionItem>(key, "mention"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const collaborators: MentionSource = (query) =>
|
||||
matching(
|
||||
shared.collaborators.map(({ user }) => ({ id: user.id, label: user.name })),
|
||||
query,
|
||||
);
|
||||
|
||||
export function matching<Item extends { label: string }>(
|
||||
items: Item[],
|
||||
query: string,
|
||||
): Item[] {
|
||||
const wanted = query.trim().toLowerCase();
|
||||
return items
|
||||
.filter((item) => item.label.toLowerCase().includes(wanted))
|
||||
.slice(0, 8);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type { EditorProps } from "./Editor";
|
||||
export { Editor } from "./Editor";
|
||||
export type {
|
||||
EditorOptions,
|
||||
MentionItem,
|
||||
MentionSource,
|
||||
SlashItem,
|
||||
} from "./options";
|
||||
export type { LocalEditorOptions } from "./useEditor";
|
||||
export { useEditor, useSharedEditor } from "./useEditor";
|
||||
export type { RichText } from "./richText";
|
||||
export { richText, sharedRichText } from "./richText";
|
||||
export type { BubbleMenuProps, FloatingMenuProps } from "./menus";
|
||||
export { BubbleMenu, FloatingMenu } from "./menus";
|
||||
export type { Editor as TiptapEditor, JSONContent } from "@tiptap/core";
|
||||
export {
|
||||
createBlockMarkdownSpec,
|
||||
createInlineMarkdownSpec,
|
||||
Extension,
|
||||
Mark,
|
||||
mergeAttributes,
|
||||
Node,
|
||||
} from "@tiptap/core";
|
||||
export {
|
||||
EditorContent,
|
||||
NodeViewContent,
|
||||
NodeViewWrapper,
|
||||
ReactNodeViewRenderer,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Editor as TiptapEditor } from "@tiptap/core";
|
||||
import type { BubbleMenuPluginProps } from "@tiptap/extension-bubble-menu";
|
||||
import { BubbleMenuPlugin } from "@tiptap/extension-bubble-menu";
|
||||
import type { FloatingMenuPluginProps } from "@tiptap/extension-floating-menu";
|
||||
import { FloatingMenuPlugin } from "@tiptap/extension-floating-menu";
|
||||
import { PluginKey, type Plugin } from "@tiptap/pm/state";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useEffect, useLayoutEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type MenuOptions<Props> = Omit<Props, "editor" | "element" | "pluginKey">;
|
||||
|
||||
export interface BubbleMenuProps extends MenuOptions<BubbleMenuPluginProps> {
|
||||
editor: TiptapEditor;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface FloatingMenuProps extends MenuOptions<FloatingMenuPluginProps> {
|
||||
editor: TiptapEditor;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Your own menu over the selection. */
|
||||
export function BubbleMenu(props: BubbleMenuProps): ReactElement | null {
|
||||
const { editor, className, children, ...options } = props;
|
||||
const element = useMenuElement(editor, className, (menu) =>
|
||||
BubbleMenuPlugin({
|
||||
...options,
|
||||
shouldShow: options.shouldShow ?? null,
|
||||
editor,
|
||||
element: menu,
|
||||
pluginKey: new PluginKey("bubbleMenu"),
|
||||
}),
|
||||
);
|
||||
return element ? createPortal(children, element) : null;
|
||||
}
|
||||
|
||||
/** Your own menu beside an empty line. */
|
||||
export function FloatingMenu(props: FloatingMenuProps): ReactElement | null {
|
||||
const { editor, className, children, ...options } = props;
|
||||
const element = useMenuElement(editor, className, (menu) =>
|
||||
FloatingMenuPlugin({
|
||||
...options,
|
||||
shouldShow: options.shouldShow ?? null,
|
||||
editor,
|
||||
element: menu,
|
||||
pluginKey: new PluginKey("floatingMenu"),
|
||||
}),
|
||||
);
|
||||
return element ? createPortal(children, element) : null;
|
||||
}
|
||||
|
||||
/** A detached element the plugin positions, registered with the editor while mounted. */
|
||||
function useMenuElement(
|
||||
editor: TiptapEditor,
|
||||
className: string | undefined,
|
||||
plugin: (element: HTMLElement) => Plugin,
|
||||
): HTMLElement | undefined {
|
||||
const [element, setElement] = useState<HTMLElement>();
|
||||
useLayoutEffect(() => {
|
||||
const menu = document.createElement("div");
|
||||
menu.style.visibility = "hidden";
|
||||
menu.style.position = "absolute";
|
||||
setElement(menu);
|
||||
return () => setElement(undefined);
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
if (element) {
|
||||
element.className = className ?? "";
|
||||
}
|
||||
}, [element, className]);
|
||||
useEffect(() => {
|
||||
if (!element || editor.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
const registered = plugin(element);
|
||||
editor.registerPlugin(registered);
|
||||
return () => {
|
||||
if (!editor.isDestroyed) {
|
||||
editor.unregisterPlugin(registered.spec.key!);
|
||||
}
|
||||
element.remove();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- the plugin takes its options once, when registered.
|
||||
}, [editor, element]);
|
||||
return element;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { AnyExtension, Editor as TiptapEditor } from "@tiptap/core";
|
||||
|
||||
export interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type MentionSource =
|
||||
| MentionItem[]
|
||||
| ((query: string) => MentionItem[] | Promise<MentionItem[]>);
|
||||
|
||||
/** An entry of the `/` menu; `run` gets the editor with the slash and query already deleted. */
|
||||
export interface SlashItem {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Extra search words, space-separated. */
|
||||
keywords?: string;
|
||||
/** One line under the label. */
|
||||
description?: string;
|
||||
run: (editor: TiptapEditor) => void;
|
||||
}
|
||||
|
||||
export interface EditorOptions {
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
/** Forced on while the viewer cannot edit or the shared field is not a `sharedRichText()`. */
|
||||
readOnly?: boolean;
|
||||
/** One paragraph: Enter calls `onSubmit`, Shift+Enter is a soft break, pasted blocks fold into the line. */
|
||||
single?: boolean;
|
||||
/** Enter in `single` mode, Cmd/Ctrl+Enter otherwise. */
|
||||
onSubmit?: (editor: TiptapEditor) => void;
|
||||
/** `false` hides the `/` menu, an array adds entries to it; off in `single`. */
|
||||
slash?: false | SlashItem[];
|
||||
/** The `@` menu; default: the other people in the room when shared, none when local. */
|
||||
mentions?: MentionSource | false;
|
||||
/** Tiptap extensions appended to the kit's; every editor and `richText()` on one field must pass the same ones. A custom block (`Node.create`) needs a Markdown spec (`createBlockMarkdownSpec`) to survive `value`/`onChange` and `markdown()`, and a `/` entry via `slash`. */
|
||||
extensions?: AnyExtension[];
|
||||
onReady?: (editor: TiptapEditor) => void;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type * as Y from "yjs";
|
||||
|
||||
type XmlNode = Y.XmlElement | Y.XmlText | Y.XmlHook;
|
||||
|
||||
/** One line per block, formatting dropped; an empty inline element is a soft break. */
|
||||
export function plainTextOf(fragment: Y.XmlFragment): string {
|
||||
return fragment.toArray().map(blockTextOf).join("\n");
|
||||
}
|
||||
|
||||
function blockTextOf(node: XmlNode): string {
|
||||
if (isText(node)) {
|
||||
return inlineTextOf(node);
|
||||
}
|
||||
if (!isElement(node)) {
|
||||
return "";
|
||||
}
|
||||
const children = node.toArray() as XmlNode[];
|
||||
if (!children.some(isText)) {
|
||||
return children.map(blockTextOf).join("\n");
|
||||
}
|
||||
return children
|
||||
.map((child) => {
|
||||
if (isText(child)) {
|
||||
return inlineTextOf(child);
|
||||
}
|
||||
if (!isElement(child)) {
|
||||
return "";
|
||||
}
|
||||
const empty =
|
||||
child.length === 0 && Object.keys(child.getAttributes()).length === 0;
|
||||
return empty ? "\n" : blockTextOf(child);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function inlineTextOf(text: Y.XmlText): string {
|
||||
return (text.toDelta() as Array<{ insert: unknown }>)
|
||||
.map((op) => (typeof op.insert === "string" ? op.insert : ""))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function isText(node: XmlNode): node is Y.XmlText {
|
||||
return "toDelta" in node;
|
||||
}
|
||||
|
||||
function isElement(node: XmlNode): node is Y.XmlElement {
|
||||
return "nodeName" in node;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { FieldValue, JsonValue } from "@kits/sync";
|
||||
import {
|
||||
opaque,
|
||||
opaqueField,
|
||||
type OpaqueNode,
|
||||
type OpaqueTextRun,
|
||||
} from "@kits/sync/internal";
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { Editor as TiptapEditor, getHTMLFromFragment } from "@tiptap/core";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
prosemirrorJSONToYXmlFragment,
|
||||
yXmlFragmentToProsemirrorJSON,
|
||||
} from "@tiptap/y-tiptap";
|
||||
import { bindOpaque } from "../bindOpaque";
|
||||
import { editorExtensions } from "./extensions";
|
||||
import type { EditorOptions } from "./options";
|
||||
import { plainTextOf } from "./plainText";
|
||||
|
||||
/** Creates rich text everyone edits together with the `Editor`: `add({ body: sharedRichText("# Notes") })`. The Markdown is the starting content; `richText(doc, field).setMarkdown()` replaces it later. */
|
||||
export function sharedRichText(markdown?: string): FieldValue {
|
||||
if (markdown !== undefined && typeof markdown !== "string") {
|
||||
throw new Error(
|
||||
`sharedRichText() expects Markdown to start with, got ${typeof markdown}.`,
|
||||
);
|
||||
}
|
||||
if (!markdown) {
|
||||
return opaque("xml", "prosemirror");
|
||||
}
|
||||
return opaque(
|
||||
"xml",
|
||||
"prosemirror",
|
||||
withEditor({}, (editor) =>
|
||||
nodesOf(PMNode.fromJSON(editor.schema, editor.markdown!.parse(markdown))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function nodesOf(parent: PMNode): OpaqueNode[] {
|
||||
const nodes: OpaqueNode[] = [];
|
||||
let runs: OpaqueTextRun[] | undefined;
|
||||
parent.forEach((child) => {
|
||||
if (child.isText) {
|
||||
if (!runs) {
|
||||
runs = [];
|
||||
nodes.push({ text: runs });
|
||||
}
|
||||
runs.push(runOf(child));
|
||||
} else {
|
||||
runs = undefined;
|
||||
nodes.push(elementOf(child));
|
||||
}
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function elementOf(node: PMNode): OpaqueNode {
|
||||
const attrs: Record<string, JsonValue> = {};
|
||||
for (const [name, value] of Object.entries(node.attrs)) {
|
||||
if (value !== null) {
|
||||
attrs[name] = value as JsonValue;
|
||||
}
|
||||
}
|
||||
return { type: node.type.name, attrs, children: nodesOf(node) };
|
||||
}
|
||||
|
||||
function runOf(node: PMNode): OpaqueTextRun {
|
||||
const attributes: Record<string, JsonValue> = {};
|
||||
for (const mark of node.marks) {
|
||||
attributes[mark.type.name] = mark.attrs as Record<string, JsonValue>;
|
||||
}
|
||||
return { insert: node.text!, attributes };
|
||||
}
|
||||
|
||||
function withEditor<T>(
|
||||
options: Pick<EditorOptions, "extensions">,
|
||||
use: (editor: TiptapEditor) => T,
|
||||
): T {
|
||||
const editor = new TiptapEditor({
|
||||
element: null,
|
||||
extensions: editorExtensions(options),
|
||||
});
|
||||
try {
|
||||
return use(editor);
|
||||
} finally {
|
||||
editor.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export interface RichText {
|
||||
/** Plain text, one line per block; `""` while the field is missing or a plain value. */
|
||||
text(): string;
|
||||
html(): string;
|
||||
markdown(): string;
|
||||
/** Replaces the field's content in one write no undo reverts; a no-op while the field is missing or the viewer cannot edit. */
|
||||
setMarkdown(markdown: string): void;
|
||||
}
|
||||
|
||||
/** Reads and replaces a `sharedRichText()` field outside render; pass the editor's `extensions` so both use one schema. Throws, naming the kit to use, when another kit's field is there. */
|
||||
export function richText(
|
||||
doc: string,
|
||||
field: string,
|
||||
options: Pick<EditorOptions, "extensions"> = {},
|
||||
): RichText {
|
||||
const bound = bindOpaque(
|
||||
field,
|
||||
opaqueField(doc, field, "xml", "prosemirror"),
|
||||
);
|
||||
const json = (): JSONContent => {
|
||||
const { value } = bound;
|
||||
return value
|
||||
? (yXmlFragmentToProsemirrorJSON(value) as JSONContent)
|
||||
: { type: "doc", content: [] };
|
||||
};
|
||||
return {
|
||||
text: () => (bound.value ? plainTextOf(bound.value) : ""),
|
||||
html: () =>
|
||||
withEditor(options, (editor) =>
|
||||
getHTMLFromFragment(
|
||||
PMNode.fromJSON(editor.schema, json()).content,
|
||||
editor.schema,
|
||||
),
|
||||
),
|
||||
markdown: () =>
|
||||
withEditor(options, (editor) => editor.markdown!.serialize(json())),
|
||||
setMarkdown: (markdown) =>
|
||||
withEditor(options, (editor) => {
|
||||
const { value } = bound;
|
||||
if (!value || !bound.canEdit) {
|
||||
return;
|
||||
}
|
||||
const content = editor.markdown!.parse(markdown);
|
||||
bound.write(() =>
|
||||
prosemirrorJSONToYXmlFragment(editor.schema, content, value),
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { Extension, type Editor as TiptapEditor } from "@tiptap/core";
|
||||
import { isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||
import { PluginKey, type Transaction } from "@tiptap/pm/state";
|
||||
import { ReplaceStep } from "@tiptap/pm/transform";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import {
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
Minus,
|
||||
Quote,
|
||||
Shapes,
|
||||
Table,
|
||||
Type,
|
||||
} from "lucide-react";
|
||||
import type { SlashItem } from "./options";
|
||||
import { menuRenderer, type MenuItem, popupOptions } from "./suggestions";
|
||||
|
||||
export interface SlashOptions {
|
||||
/** The author's entries, listed under "More". */
|
||||
items: SlashItem[];
|
||||
/** Whether the built-in blocks are listed. */
|
||||
blocks: boolean;
|
||||
}
|
||||
|
||||
interface SlashEntry extends SlashItem, MenuItem {}
|
||||
|
||||
interface SlashState {
|
||||
active: boolean;
|
||||
range: { from: number; to: number };
|
||||
}
|
||||
|
||||
const setBlock =
|
||||
(type: string, attrs?: Record<string, unknown>) => (e: TiptapEditor) =>
|
||||
e.chain().focus().clearNodes().setNode(type, attrs).run();
|
||||
|
||||
const wrapList = (list: string, item: string) => (e: TiptapEditor) => {
|
||||
if (!e.isActive(list)) {
|
||||
e.chain().focus().clearNodes().toggleList(list, item).run();
|
||||
}
|
||||
};
|
||||
|
||||
const BLOCKS: readonly SlashEntry[] = [
|
||||
{
|
||||
id: "text",
|
||||
label: "Text",
|
||||
description: "Plain paragraph text.",
|
||||
keywords: "paragraph plain p",
|
||||
group: "Basic blocks",
|
||||
icon: Type,
|
||||
run: setBlock("paragraph"),
|
||||
},
|
||||
{
|
||||
id: "h1",
|
||||
label: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
keywords: "h1 title",
|
||||
hint: "#",
|
||||
group: "Basic blocks",
|
||||
icon: Heading1,
|
||||
run: setBlock("heading", { level: 1 }),
|
||||
},
|
||||
{
|
||||
id: "h2",
|
||||
label: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
keywords: "h2 subtitle",
|
||||
hint: "##",
|
||||
group: "Basic blocks",
|
||||
icon: Heading2,
|
||||
run: setBlock("heading", { level: 2 }),
|
||||
},
|
||||
{
|
||||
id: "h3",
|
||||
label: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
keywords: "h3",
|
||||
hint: "###",
|
||||
group: "Basic blocks",
|
||||
icon: Heading3,
|
||||
run: setBlock("heading", { level: 3 }),
|
||||
},
|
||||
{
|
||||
id: "quote",
|
||||
label: "Quote",
|
||||
description: "A highlighted quotation.",
|
||||
keywords: "blockquote citation",
|
||||
hint: ">",
|
||||
group: "Basic blocks",
|
||||
icon: Quote,
|
||||
run: (e) => e.chain().focus().clearNodes().wrapIn("blockquote").run(),
|
||||
},
|
||||
{
|
||||
id: "divider",
|
||||
label: "Divider",
|
||||
description: "A line between blocks.",
|
||||
keywords: "hr rule line separator",
|
||||
hint: "---",
|
||||
group: "Basic blocks",
|
||||
icon: Minus,
|
||||
run: (e) => e.chain().focus().clearNodes().setHorizontalRule().run(),
|
||||
},
|
||||
{
|
||||
id: "bullets",
|
||||
label: "Bullet list",
|
||||
description: "A simple bulleted list.",
|
||||
keywords: "bulleted unordered ul",
|
||||
hint: "-",
|
||||
group: "Lists",
|
||||
icon: List,
|
||||
run: wrapList("bulletList", "listItem"),
|
||||
},
|
||||
{
|
||||
id: "numbers",
|
||||
label: "Numbered list",
|
||||
description: "A list with numbering.",
|
||||
keywords: "ordered ol",
|
||||
hint: "1.",
|
||||
group: "Lists",
|
||||
icon: ListOrdered,
|
||||
run: wrapList("orderedList", "listItem"),
|
||||
},
|
||||
{
|
||||
id: "tasks",
|
||||
label: "Task list",
|
||||
description: "Tasks with checkboxes.",
|
||||
keywords: "todo to-do checkbox checklist",
|
||||
hint: "[]",
|
||||
group: "Lists",
|
||||
icon: ListChecks,
|
||||
run: wrapList("taskList", "taskItem"),
|
||||
},
|
||||
{
|
||||
id: "code",
|
||||
label: "Code block",
|
||||
description: "Code in monospace.",
|
||||
keywords: "pre snippet",
|
||||
hint: "```",
|
||||
group: "Advanced",
|
||||
icon: Code,
|
||||
run: setBlock("codeBlock"),
|
||||
},
|
||||
{
|
||||
id: "table",
|
||||
label: "Table",
|
||||
description: "Rows and columns.",
|
||||
keywords: "grid rows columns",
|
||||
group: "Advanced",
|
||||
icon: Table,
|
||||
run: (e) =>
|
||||
e
|
||||
.chain()
|
||||
.focus()
|
||||
.clearNodes()
|
||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
.run(),
|
||||
},
|
||||
];
|
||||
|
||||
/** The `/` menu: opens on a typed `/`, filters as the query grows, replaces `/query` with the pick. */
|
||||
export const Slash = Extension.create<SlashOptions>({
|
||||
name: "slash",
|
||||
|
||||
addOptions() {
|
||||
return { items: [], blocks: true };
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const key = new PluginKey<SlashState>("slash");
|
||||
const entries: SlashEntry[] = [
|
||||
...(this.options.blocks ? BLOCKS : []),
|
||||
...this.options.items.map((item) => ({
|
||||
group: "More",
|
||||
icon: Shapes,
|
||||
...item,
|
||||
})),
|
||||
];
|
||||
return [
|
||||
Suggestion<SlashEntry, SlashEntry>({
|
||||
...popupOptions,
|
||||
editor: this.editor,
|
||||
pluginKey: key,
|
||||
char: "/",
|
||||
allowSpaces: true,
|
||||
allowedPrefixes: null,
|
||||
allow: ({ state, range }) => {
|
||||
const $caret = state.doc.resolve(range.to);
|
||||
return (
|
||||
!$caret.parent.type.spec.code &&
|
||||
!$caret.marks().some((mark) => mark.type.spec.code)
|
||||
);
|
||||
},
|
||||
shouldShow: ({ editor, range, query, transaction }) => {
|
||||
if (/^\s/.test(query) || query.includes("/")) {
|
||||
return false;
|
||||
}
|
||||
const before = key.getState(editor.state);
|
||||
return before?.active && sameSlash(before, range, transaction)
|
||||
? staysOpen(entries, query)
|
||||
: typedSlash(transaction);
|
||||
},
|
||||
items: ({ query }) => rank(entries, query),
|
||||
command: ({ editor, range, props }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
props.run(editor);
|
||||
},
|
||||
render: menuRenderer<SlashEntry>(key, "slash"),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
/** Whether the match is still the `/` the menu opened on. */
|
||||
function sameSlash(
|
||||
before: SlashState,
|
||||
range: { from: number },
|
||||
transaction: Transaction,
|
||||
): boolean {
|
||||
return (
|
||||
isChangeOrigin(transaction) ||
|
||||
transaction.mapping.map(before.range.from) === range.from
|
||||
);
|
||||
}
|
||||
|
||||
function staysOpen(entries: SlashEntry[], query: string): boolean {
|
||||
return !(/\s/.test(query) && rank(entries, query).length === 0);
|
||||
}
|
||||
|
||||
/** Whether this transaction is the person typing a `/`, not a paste, an undo or a remote edit. */
|
||||
function typedSlash(transaction: Transaction): boolean {
|
||||
const [step] = transaction.steps;
|
||||
return (
|
||||
transaction.steps.length === 1 &&
|
||||
step instanceof ReplaceStep &&
|
||||
!transaction.getMeta("uiEvent") &&
|
||||
!isChangeOrigin(transaction) &&
|
||||
step.slice.content.textBetween(0, step.slice.content.size).endsWith("/")
|
||||
);
|
||||
}
|
||||
|
||||
/** The entries matching `query`, groups ordered by their best score and entries by score within a group; ties keep listed order. */
|
||||
function rank(entries: SlashEntry[], query: string): SlashEntry[] {
|
||||
const wanted = query.trim().toLowerCase();
|
||||
if (!wanted) {
|
||||
return entries;
|
||||
}
|
||||
const hits = entries
|
||||
.map((entry, index) => ({ entry, index, score: score(entry, wanted) }))
|
||||
.filter((hit) => hit.score < Infinity);
|
||||
const groups = new Map<string | undefined, { best: number; first: number }>();
|
||||
for (const { entry, index, score: hitScore } of hits) {
|
||||
const group = groups.get(entry.group);
|
||||
if (!group) {
|
||||
groups.set(entry.group, { best: hitScore, first: index });
|
||||
} else {
|
||||
group.best = Math.min(group.best, hitScore);
|
||||
}
|
||||
}
|
||||
return hits
|
||||
.sort((a, b) => {
|
||||
if (a.entry.group !== b.entry.group) {
|
||||
const groupA = groups.get(a.entry.group)!;
|
||||
const groupB = groups.get(b.entry.group)!;
|
||||
return groupA.best - groupB.best || groupA.first - groupB.first;
|
||||
}
|
||||
return a.score - b.score || a.index - b.index;
|
||||
})
|
||||
.map((hit) => hit.entry);
|
||||
}
|
||||
|
||||
function score(entry: SlashEntry, wanted: string): number {
|
||||
const label = entry.label.toLowerCase();
|
||||
const words = [
|
||||
...label.split(/\s+/),
|
||||
...(entry.keywords ?? "").toLowerCase().split(/\s+/),
|
||||
].filter(Boolean);
|
||||
if (label.startsWith(wanted)) {
|
||||
return 0;
|
||||
}
|
||||
if (words.some((word) => word.startsWith(wanted))) {
|
||||
return 1;
|
||||
}
|
||||
if (wanted.length < 2) {
|
||||
return Infinity;
|
||||
}
|
||||
if (label.includes(wanted)) {
|
||||
return 2;
|
||||
}
|
||||
return inOrder(wanted.replace(/\s/g, ""), label.replace(/\s/g, ""))
|
||||
? 3
|
||||
: Infinity;
|
||||
}
|
||||
|
||||
function inOrder(letters: string, text: string): boolean {
|
||||
let at = 0;
|
||||
for (const letter of letters) {
|
||||
at = text.indexOf(letter, at) + 1;
|
||||
if (at === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { LABEL } from "@kits/sync/internal";
|
||||
|
||||
const muted = "var(--muted, hsl(240 4.8% 95.9%))";
|
||||
const mutedForeground = "var(--muted-foreground, hsl(240 3.8% 46.1%))";
|
||||
const border = "var(--border, hsl(240 5.9% 90%))";
|
||||
const ring = "var(--ring, hsl(240 5% 64.9%))";
|
||||
const primary = "var(--primary, hsl(240 5.9% 10%))";
|
||||
const popover = "var(--popover, hsl(0 0% 100%))";
|
||||
const popoverForeground = "var(--popover-foreground, hsl(240 10% 3.9%))";
|
||||
const accent = "var(--accent, hsl(240 4.8% 95.9%))";
|
||||
const accentForeground = "var(--accent-foreground, hsl(240 5.9% 10%))";
|
||||
|
||||
export const EDITOR_CSS = `
|
||||
.ck-editor { position: relative; }
|
||||
.ck-editor[data-toolbar="fixed"] { border: 1px solid ${border}; border-radius: 0.5rem; }
|
||||
.ck-editor[data-toolbar="fixed"]:focus-within { box-shadow: 0 0 0 2px ${ring}; }
|
||||
.ck-editor[data-toolbar="fixed"] .ck-content { padding: 0.5rem 0.75rem; }
|
||||
.ProseMirror { outline: none; min-height: 1.5em; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.ProseMirror > * + * { margin-top: 0.5em; }
|
||||
.ProseMirror p.is-editor-empty:first-child::before { content: attr(data-placeholder); color: ${mutedForeground}; float: left; height: 0; pointer-events: none; }
|
||||
.ProseMirror a { color: ${primary}; text-decoration: underline; }
|
||||
.ProseMirror code { background: ${muted}; border-radius: 0.25rem; padding: 0.1em 0.3em; font-size: 0.9em; }
|
||||
.ProseMirror pre { background: ${muted}; border-radius: 0.375rem; padding: 0.75rem 1rem; overflow-x: auto; }
|
||||
.ProseMirror pre code { background: none; padding: 0; font-size: inherit; }
|
||||
.ProseMirror blockquote { border-left: 3px solid ${border}; padding-left: 1rem; color: ${mutedForeground}; }
|
||||
.ProseMirror hr { border: 0; border-top: 1px solid ${border}; margin: 1rem 0; }
|
||||
.ProseMirror ul, .ProseMirror ol { padding-left: 1.5rem; }
|
||||
.ProseMirror ul { list-style: disc; }
|
||||
.ProseMirror ol { list-style: decimal; }
|
||||
.ProseMirror ul[data-type="taskList"] { list-style: none; padding-left: 0; }
|
||||
.ProseMirror ul[data-type="taskList"] li { display: flex; gap: 0.5rem; align-items: flex-start; }
|
||||
.ProseMirror ul[data-type="taskList"] li > label { display: flex; flex: 0 0 auto; align-items: center; height: 1.5em; height: 1lh; }
|
||||
.ProseMirror ul[data-type="taskList"] li > label input { width: 1em; height: 1em; margin: 0; accent-color: ${primary}; }
|
||||
.ProseMirror ul[data-type="taskList"] li > div { flex: 1 1 auto; min-width: 0; }
|
||||
.ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div { color: ${mutedForeground}; text-decoration: line-through; }
|
||||
.ProseMirror table { border-collapse: collapse; width: 100%; table-layout: fixed; }
|
||||
.ProseMirror td, .ProseMirror th { border: 1px solid ${border}; padding: 0.25rem 0.5rem; vertical-align: top; }
|
||||
.ProseMirror th { background: ${muted}; font-weight: 600; text-align: left; }
|
||||
.ProseMirror img { max-width: 100%; height: auto; }
|
||||
.ProseMirror .mention { color: ${primary}; background: color-mix(in srgb, ${primary} 12%, transparent); border-radius: 0.25rem; padding: 0 0.2em; }
|
||||
.ProseMirror-yjs-cursor { position: relative; margin-left: -1px; margin-right: -1px; border-left: 1px solid; border-right: 1px solid; word-break: normal; pointer-events: none; }
|
||||
.ProseMirror-yjs-cursor > div { position: absolute; bottom: 100%; left: -1px; font-size: ${LABEL.fontSize}; line-height: 1; padding: ${LABEL.padding}; border-radius: ${LABEL.radius} ${LABEL.radius} ${LABEL.radius} ${LABEL.tail}; white-space: nowrap; user-select: none; font-style: normal; font-weight: 500; }
|
||||
.ck-toolbar { display: flex; flex-wrap: wrap; gap: 0.125rem; align-items: center; }
|
||||
.ck-toolbar[data-placement="fixed"] { padding: 0.25rem 0.5rem; border-bottom: 1px solid ${border}; }
|
||||
.ck-toolbar[data-placement="bubble"] { padding: 0.25rem; border: 1px solid ${border}; border-radius: 0.5rem; background: ${popover}; color: ${popoverForeground}; box-shadow: 0 4px 12px rgb(0 0 0 / 0.12); }
|
||||
.ck-toolbar button { display: inline-flex; align-items: center; justify-content: center; width: 1.75rem; height: 1.75rem; border: 0; border-radius: 0.375rem; background: none; color: inherit; cursor: pointer; }
|
||||
.ck-toolbar button:hover { background: ${accent}; color: ${accentForeground}; }
|
||||
.ck-toolbar button[aria-pressed="true"] { background: ${accent}; color: ${primary}; }
|
||||
.ck-toolbar button:focus-visible { outline: 2px solid ${ring}; outline-offset: 1px; }
|
||||
.ck-toolbar button svg { width: 1rem; height: 1rem; }
|
||||
.ck-toolbar input { height: 1.75rem; min-width: 14rem; padding: 0 0.5rem; border: 1px solid ${border}; border-radius: 0.375rem; background: transparent; color: inherit; font: inherit; }
|
||||
.ck-toolbar input:focus-visible { outline: 2px solid ${ring}; outline-offset: 1px; }
|
||||
.ck-popup { position: fixed; top: 0; left: 0; z-index: 60; width: max-content; line-height: 1.3; transform-origin: 0 0; }
|
||||
.ck-popup[data-placement^="top"] { transform-origin: 0 100%; }
|
||||
.ck-popup:not([data-state]) { visibility: hidden; }
|
||||
.ck-popup[data-state="open"] { animation: ck-menu-grow 150ms ease-out, ck-menu-fade 150ms ease-out; }
|
||||
.ck-popup[data-state="closed"] { animation: ck-menu-shrink 120ms ease-in forwards; pointer-events: none; }
|
||||
@keyframes ck-menu-grow { from { transform: scale(0.96); } to { transform: scale(1); } }
|
||||
@keyframes ck-menu-fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes ck-menu-shrink { to { opacity: 0; transform: scale(0.97); } }
|
||||
.ck-menu { box-sizing: border-box; min-width: 12rem; max-width: 22rem; max-height: min(22rem, var(--ck-menu-max, 22rem)); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; padding: 0.375rem; border: 1px solid color-mix(in srgb, ${popoverForeground} 10%, transparent); border-radius: 0.75rem; background: ${popover}; color: ${popoverForeground}; box-shadow: 0 1px 2px rgb(0 0 0 / 0.06), 0 4px 12px rgb(0 0 0 / 0.08), 0 16px 40px -8px rgb(0 0 0 / 0.2); }
|
||||
.ck-popup[data-kind="slash"] .ck-menu { width: 19rem; }
|
||||
.ck-menu [role="group"] + [role="group"] { margin-top: 0.25rem; padding-top: 0.25rem; border-top: 1px solid color-mix(in srgb, ${popoverForeground} 8%, transparent); }
|
||||
.ck-menu-group { padding: 0.375rem 0.5rem 0.25rem; font-size: 0.78em; font-weight: 500; letter-spacing: 0.01em; color: ${mutedForeground}; }
|
||||
.ck-menu button[role="option"] { display: flex; align-items: center; gap: 0.625rem; width: 100%; padding: 0.375rem 0.5rem; border: 0; border-radius: 0.5rem; background: none; color: inherit; font: inherit; line-height: inherit; text-align: left; cursor: pointer; transition: background-color 80ms ease-out; -webkit-tap-highlight-color: transparent; }
|
||||
.ck-menu button[aria-selected="true"] { background: ${accent}; color: ${accentForeground}; }
|
||||
.ck-menu-icon { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 1.875rem; height: 1.875rem; border: 1px solid color-mix(in srgb, ${popoverForeground} 10%, transparent); border-radius: 0.5rem; background: ${popover}; color: ${popoverForeground}; box-shadow: 0 1px 2px rgb(0 0 0 / 0.05); }
|
||||
.ck-menu-icon svg { width: 1.0625rem; height: 1.0625rem; }
|
||||
.ck-menu-text { display: flex; flex: 1 1 auto; flex-direction: column; min-width: 0; gap: 0.0625rem; }
|
||||
.ck-menu-title { overflow: hidden; font-weight: 500; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.ck-menu-description { overflow: hidden; font-size: 0.86em; color: ${mutedForeground}; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.ck-menu-hint { flex: none; min-width: 1.25rem; height: 1.25rem; padding: 0 0.3rem; border-radius: 0.3rem; background: ${muted}; color: ${mutedForeground}; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75em; line-height: 1.25rem; text-align: center; }
|
||||
.ck-menu button[aria-selected="true"] .ck-menu-hint { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||
.ck-menu-empty { padding: 0.875rem 0.5rem; color: ${mutedForeground}; font-size: 0.93em; text-align: center; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ck-popup[data-state] { animation: none; }
|
||||
.ck-menu button[role="option"] { transition: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
let adopted = false;
|
||||
|
||||
export function adoptEditorStyles(): void {
|
||||
if (adopted || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
adopted = true;
|
||||
if (
|
||||
"adoptedStyleSheets" in document &&
|
||||
"replaceSync" in CSSStyleSheet.prototype
|
||||
) {
|
||||
const sheet = new CSSStyleSheet();
|
||||
sheet.replaceSync(EDITOR_CSS);
|
||||
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
|
||||
return;
|
||||
}
|
||||
const style = document.createElement("style");
|
||||
style.textContent = EDITOR_CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { shift, size } from "@floating-ui/dom";
|
||||
import type { PluginKey } from "@tiptap/pm/state";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import type {
|
||||
SuggestionKeyDownProps,
|
||||
SuggestionOptions,
|
||||
SuggestionProps,
|
||||
} from "@tiptap/suggestion";
|
||||
import { exitSuggestion } from "@tiptap/suggestion";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactElement } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/** The Markdown shortcut, drawn as a key at the right edge. */
|
||||
hint?: string;
|
||||
/** Consecutive items with the same group share a heading. */
|
||||
group?: string;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
interface MenuHandle {
|
||||
onKeyDown(props: SuggestionKeyDownProps): boolean;
|
||||
}
|
||||
|
||||
export const popupOptions: Pick<SuggestionOptions, "offset" | "floatingUi"> = {
|
||||
offset: { mainAxis: 6 },
|
||||
floatingUi: {
|
||||
strategy: "fixed",
|
||||
middleware: [
|
||||
shift({ padding: 8 }),
|
||||
size({
|
||||
padding: 8,
|
||||
apply: ({ availableHeight, elements }) =>
|
||||
elements.floating.style.setProperty(
|
||||
"--ck-menu-max",
|
||||
`${Math.max(availableHeight, 96)}px`,
|
||||
),
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** The renderer the `/` and `@` menus share; `kind` names the popup for styling. */
|
||||
export function menuRenderer<Item extends MenuItem>(
|
||||
pluginKey: PluginKey,
|
||||
kind: "slash" | "mention",
|
||||
): NonNullable<SuggestionOptions<Item>["render"]> {
|
||||
return () => {
|
||||
let popup: Popup<Item> | undefined;
|
||||
return {
|
||||
onStart: (props) => {
|
||||
popup?.close();
|
||||
popup = openPopup(props, pluginKey, kind);
|
||||
},
|
||||
onUpdate: (props) => popup?.update(props),
|
||||
onKeyDown: (props) => popup?.keyDown(props) ?? false,
|
||||
onExit: () => {
|
||||
popup?.close();
|
||||
popup = undefined;
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface Popup<Item extends MenuItem> {
|
||||
update(props: SuggestionProps<Item>): void;
|
||||
keyDown(props: SuggestionKeyDownProps): boolean;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/** One open menu, mounted on `document.body` and torn down after its exit animation. */
|
||||
function openPopup<Item extends MenuItem>(
|
||||
props: SuggestionProps<Item>,
|
||||
pluginKey: PluginKey,
|
||||
kind: string,
|
||||
): Popup<Item> {
|
||||
const { editor } = props;
|
||||
const surface = editor.view.dom;
|
||||
const renderer = new ReactRenderer<MenuHandle, MenuProps>(Menu, {
|
||||
editor,
|
||||
props: menuProps(props),
|
||||
className: "ck-popup",
|
||||
});
|
||||
const { element } = renderer;
|
||||
element.dataset.kind = kind;
|
||||
adoptTypography(element, surface);
|
||||
document.body.appendChild(element);
|
||||
const unmount = props.mount(element, {
|
||||
autoUpdate: { animationFrame: true },
|
||||
onPosition: ({ x, y, placement }) => {
|
||||
element.style.left = `${x}px`;
|
||||
element.style.top = `${y}px`;
|
||||
element.dataset.placement = placement;
|
||||
element.dataset.state ||= "open";
|
||||
},
|
||||
});
|
||||
const dismiss = () => exitSuggestion(editor.view, pluginKey);
|
||||
surface.addEventListener("blur", dismiss);
|
||||
|
||||
let closed = false;
|
||||
return {
|
||||
update(next) {
|
||||
renderer.updateProps(menuProps(next));
|
||||
},
|
||||
keyDown(keyProps) {
|
||||
return renderer.ref?.onKeyDown(keyProps) ?? false;
|
||||
},
|
||||
close() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
unmount();
|
||||
surface.removeEventListener("blur", dismiss);
|
||||
const remove = () => {
|
||||
if (element.isConnected) {
|
||||
renderer.destroy();
|
||||
}
|
||||
};
|
||||
if (
|
||||
matchMedia("(prefers-reduced-motion: reduce)").matches ||
|
||||
!element.dataset.state
|
||||
) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
element.dataset.state = "closed";
|
||||
element.addEventListener("animationend", remove);
|
||||
setTimeout(remove, 200);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function menuProps<Item extends MenuItem>(
|
||||
props: SuggestionProps<Item>,
|
||||
): MenuProps {
|
||||
return {
|
||||
items: props.items,
|
||||
command: props.command as (item: MenuItem) => void,
|
||||
loading: props.loading,
|
||||
};
|
||||
}
|
||||
|
||||
function adoptTypography(element: HTMLElement, surface: HTMLElement): void {
|
||||
const { fontFamily, fontSize } = getComputedStyle(surface);
|
||||
element.style.fontFamily = fontFamily;
|
||||
element.style.fontSize = `${Math.min(Math.max(parseFloat(fontSize) || 14, 13), 15)}px`;
|
||||
}
|
||||
|
||||
interface MenuProps {
|
||||
items: MenuItem[];
|
||||
command: (item: MenuItem) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
interface Run {
|
||||
group: string | undefined;
|
||||
entries: Array<[MenuItem, number]>;
|
||||
}
|
||||
|
||||
const Menu = forwardRef<MenuHandle, MenuProps>(function Menu(
|
||||
{ items, command, loading },
|
||||
ref,
|
||||
): ReactElement {
|
||||
const [state, setState] = useState({ items, selected: 0 });
|
||||
if (state.items !== items) {
|
||||
setState({ items, selected: 0 });
|
||||
}
|
||||
const selected = Math.min(state.selected, Math.max(items.length - 1, 0));
|
||||
const select = (index: number) => setState({ items, selected: index });
|
||||
const list = useRef<HTMLDivElement>(null);
|
||||
const scrollTo = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (scrollTo.current) {
|
||||
scrollTo.current = false;
|
||||
list.current
|
||||
?.querySelector(`[data-index="${selected}"]`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}, [selected]);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (items.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||
const step = event.key === "ArrowUp" ? -1 : 1;
|
||||
scrollTo.current = true;
|
||||
select((selected + step + items.length) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(event.key === "Enter" || event.key === "Tab") &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey
|
||||
) {
|
||||
command(items[selected]!);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
[items, selected, command],
|
||||
);
|
||||
const runs: Run[] = [];
|
||||
items.forEach((item, index) => {
|
||||
const last = runs[runs.length - 1];
|
||||
if (last && last.group === item.group) {
|
||||
last.entries.push([item, index]);
|
||||
} else {
|
||||
runs.push({ group: item.group, entries: [[item, index]] });
|
||||
}
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className="ck-menu"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
ref={list}
|
||||
role="listbox"
|
||||
>
|
||||
{items.length === 0 && (
|
||||
<div className="ck-menu-empty">
|
||||
{loading ? "Searching…" : "No results"}
|
||||
</div>
|
||||
)}
|
||||
{runs.map(({ group, entries }) => (
|
||||
<div aria-label={group} key={`${group}:${entries[0]![1]}`} role="group">
|
||||
{group && <div className="ck-menu-group">{group}</div>}
|
||||
{entries.map(([item, index]) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
aria-selected={index === selected}
|
||||
data-index={index}
|
||||
key={item.id}
|
||||
onClick={() => command(item)}
|
||||
onMouseMove={() => index !== selected && select(index)}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
{Icon && (
|
||||
<span className="ck-menu-icon">
|
||||
<Icon aria-hidden="true" strokeWidth={1.75} />
|
||||
</span>
|
||||
)}
|
||||
<span className="ck-menu-text">
|
||||
<span className="ck-menu-title">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="ck-menu-description">
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{item.hint && <kbd className="ck-menu-hint">{item.hint}</kbd>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { shared } from "@kits/sync";
|
||||
import { useOpaqueField } from "@kits/sync/internal";
|
||||
import type { Editor as TiptapEditor } from "@tiptap/core";
|
||||
import { useEditor as useTiptapEditor } from "@tiptap/react";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { bindOpaque } from "../bindOpaque";
|
||||
import { warnOnce } from "../warn";
|
||||
import { editorExtensions, matching } from "./extensions";
|
||||
import type { EditorOptions, SlashItem } from "./options";
|
||||
import { adoptEditorStyles } from "./styles";
|
||||
|
||||
export interface LocalEditorOptions extends EditorOptions {
|
||||
/** Markdown; `onChange` gets Markdown after every edit. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/** The editor instance over `value`/`onChange`, to render with `EditorContent`. */
|
||||
export function useEditor(options: LocalEditorOptions): TiptapEditor {
|
||||
const latest = useLatest(options);
|
||||
const emitted = useRef(options.value);
|
||||
const editor = useTiptapEditor(
|
||||
{
|
||||
...instanceOptions(latest),
|
||||
extensions: editorExtensions(stableCallbacks(options, latest)),
|
||||
content: options.value,
|
||||
contentType: "markdown",
|
||||
editable: !options.readOnly,
|
||||
onUpdate: ({ editor }) => {
|
||||
const value = editor.getMarkdown();
|
||||
emitted.current = value;
|
||||
latest.current.onChange(value);
|
||||
},
|
||||
},
|
||||
signatureOf(options),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (options.value !== emitted.current) {
|
||||
emitted.current = options.value;
|
||||
editor.commands.setContent(options.value, {
|
||||
contentType: "markdown",
|
||||
emitUpdate: false,
|
||||
});
|
||||
}
|
||||
}, [editor, options.value]);
|
||||
useLayoutEffect(adoptEditorStyles, []);
|
||||
useEditable(editor, !options.readOnly);
|
||||
return editor;
|
||||
}
|
||||
|
||||
/** The editor instance over a `sharedRichText()` field, to render with `EditorContent`; suspends until shared state has loaded. */
|
||||
export function useSharedEditor(
|
||||
doc: string,
|
||||
field: string,
|
||||
options: EditorOptions = {},
|
||||
): TiptapEditor {
|
||||
const bound = bindOpaque(
|
||||
field,
|
||||
useOpaqueField(doc, field, "xml", "prosemirror"),
|
||||
);
|
||||
const latest = useLatest(options);
|
||||
const editable = !options.readOnly && bound.canEdit;
|
||||
const editor = useTiptapEditor(
|
||||
{
|
||||
...instanceOptions(latest),
|
||||
extensions: editorExtensions(stableCallbacks(options, latest), bound),
|
||||
editable,
|
||||
},
|
||||
[bound.value, bound.awareness, ...signatureOf(options)],
|
||||
);
|
||||
useLayoutEffect(adoptEditorStyles, []);
|
||||
useEditable(editor, editable);
|
||||
useEffect(() => {
|
||||
if (bound.value === undefined && shared.doc(doc).exists) {
|
||||
warnOnce(
|
||||
`editor:${doc}:${field}`,
|
||||
`"${field}" on "${doc}" holds no rich text; write sharedRichText() to it, then change it only through the Editor or richText().setMarkdown().`,
|
||||
);
|
||||
}
|
||||
}, [bound.value, doc, field]);
|
||||
return editor;
|
||||
}
|
||||
|
||||
function useLatest<T>(value: T) {
|
||||
const latest = useRef(value);
|
||||
latest.current = value;
|
||||
return latest;
|
||||
}
|
||||
|
||||
const INLINE_RULES = ["bold", "italic", "strike", "code", "underline", "link"];
|
||||
|
||||
function instanceOptions(latest: { current: EditorOptions }) {
|
||||
return {
|
||||
immediatelyRender: true as const,
|
||||
autofocus: latest.current.autoFocus ? ("end" as const) : false,
|
||||
enableInputRules: latest.current.single ? INLINE_RULES : true,
|
||||
onCreate: ({ editor }: { editor: TiptapEditor }) =>
|
||||
latest.current.onReady?.(editor),
|
||||
};
|
||||
}
|
||||
|
||||
function stableCallbacks<T extends EditorOptions>(
|
||||
options: T,
|
||||
latest: { current: EditorOptions },
|
||||
): T {
|
||||
const { mentions, slash } = options;
|
||||
return {
|
||||
...options,
|
||||
onSubmit:
|
||||
options.onSubmit &&
|
||||
((editor: TiptapEditor) => latest.current.onSubmit?.(editor)),
|
||||
mentions:
|
||||
mentions === undefined || mentions === false
|
||||
? mentions
|
||||
: (query: string) => {
|
||||
const current = latest.current.mentions;
|
||||
return typeof current === "function"
|
||||
? current(query)
|
||||
: matching(Array.isArray(current) ? current : [], query);
|
||||
},
|
||||
slash: Array.isArray(slash)
|
||||
? slash.map((item) => ({
|
||||
...item,
|
||||
run: (editor: TiptapEditor) =>
|
||||
latestSlashItem(latest.current.slash, item.id)?.run(editor),
|
||||
}))
|
||||
: slash,
|
||||
};
|
||||
}
|
||||
|
||||
function latestSlashItem(
|
||||
slash: EditorOptions["slash"],
|
||||
id: string,
|
||||
): SlashItem | undefined {
|
||||
return Array.isArray(slash)
|
||||
? slash.find((item) => item.id === id)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** The deps that rebuild the editor. */
|
||||
function signatureOf(options: EditorOptions): unknown[] {
|
||||
return [
|
||||
Boolean(options.single),
|
||||
options.placeholder,
|
||||
JSON.stringify(options.slash ?? null),
|
||||
options.mentions === false,
|
||||
Boolean(options.onSubmit),
|
||||
(options.extensions ?? []).map((extension) => extension.name).join(","),
|
||||
];
|
||||
}
|
||||
|
||||
function useEditable(editor: TiptapEditor, editable: boolean) {
|
||||
useEffect(() => {
|
||||
if (editor.isEditable !== editable) {
|
||||
editor.setEditable(editable);
|
||||
}
|
||||
}, [editor, editable]);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type { LocalBinding, SharedBinding } from "./binding";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user