@@ -0,0 +1,183 @@
|
||||
"use client"
|
||||
|
||||
// A note's attachments: the list with a remove button on each, and the upload.
|
||||
//
|
||||
// An attachment is two steps, both the API's own: the browser sends the file to the files module
|
||||
// (the kit's `uploadFile`: `files.file.beginUpload`, one `files.file.putPart` per piece, and
|
||||
// `files.file.completeUpload`, which answer before anything that follows an upload has run), and
|
||||
// then links the stored file to the note (`notes.attachment.add`). A file uploaded and never linked
|
||||
// is an ordinary file of the project.
|
||||
//
|
||||
// The list is live: the page's view hands over the attachments it rendered as the seed, and this reads
|
||||
// them again through `notes.note.get` whenever the live stream announces a change of the note -- a link
|
||||
// or an unlink moves the note's version (core/notes.ts `touchNote`), whoever made it. A write resolves
|
||||
// once the stream has seen it, so the list already shows it; nothing reloads the page.
|
||||
|
||||
import { formatMessage } from "@conusai-kit/i18n/format"
|
||||
import { downloadHref, type ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useRead, useUpload, useWrite } from "@conusai-kit/ui/data"
|
||||
import {
|
||||
ConfirmDialog,
|
||||
FormLayout,
|
||||
PageSection,
|
||||
type ResourceColumn,
|
||||
ResourceList,
|
||||
TextField,
|
||||
} from "@conusai-kit/ui/patterns"
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
import type { ReactElement } from "react"
|
||||
import { type AttachmentView, NOTE_COLLECTION } from "../contract/index.js"
|
||||
|
||||
export interface AttachmentRow {
|
||||
readonly attachmentId: string
|
||||
readonly fileId: string
|
||||
readonly filename: string
|
||||
readonly contentType: string
|
||||
/** Already formatted from the `sizeKb` message. */
|
||||
readonly size: string
|
||||
}
|
||||
|
||||
/** A size in whole kilobytes, at least one: the number the `sizeKb` message is filled with. */
|
||||
export const kilobytes = (bytes: number): number => Math.max(1, Math.ceil(bytes / 1024))
|
||||
|
||||
export interface AttachmentLabels {
|
||||
readonly title: string
|
||||
/** `{count, plural, ...}`, with its placeholder left in: only the browser knows the live count. */
|
||||
readonly count: string
|
||||
readonly empty: string
|
||||
readonly file: string
|
||||
readonly type: string
|
||||
readonly sizeHeading: string
|
||||
/** `{size} KB`, with its placeholder left in. */
|
||||
readonly sizeKb: string
|
||||
readonly actions: string
|
||||
/** `Remove {filename}`: the button's accessible name, with the placeholder still in it. */
|
||||
readonly remove: string
|
||||
/** The button's visible text, which its accessible name contains. */
|
||||
readonly removeShort: string
|
||||
/** What removing it does to the file. */
|
||||
readonly removeConfirm: string
|
||||
/** `Download {filename}`: a same-origin, project-scoped file stream. */
|
||||
readonly download: string
|
||||
readonly off: string
|
||||
readonly uploadLabel: string
|
||||
readonly uploadHint: string
|
||||
readonly uploadSubmit: string
|
||||
readonly uploadBusy: string
|
||||
}
|
||||
|
||||
export function Attachments({
|
||||
slugs,
|
||||
noteId,
|
||||
seed,
|
||||
enabled,
|
||||
accept,
|
||||
labels,
|
||||
locale,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
noteId: string
|
||||
/** The attachments the server rendered: shown at once, then kept current. */
|
||||
seed: readonly AttachmentView[]
|
||||
enabled: boolean
|
||||
accept: readonly string[]
|
||||
labels: AttachmentLabels
|
||||
/** The locale the page was rendered in, for the count's plural and the sizes. */
|
||||
locale: string
|
||||
}): ReactElement {
|
||||
const upload = useUpload()
|
||||
const add = useWrite<ProjectSlugs & { noteId: string; fileId: string }, unknown>("notes.attachment.add", {
|
||||
org: slugs.org,
|
||||
})
|
||||
const removal = useWrite<ProjectSlugs & { noteId: string; attachmentId: string }, unknown>(
|
||||
"notes.attachment.remove",
|
||||
{ org: slugs.org },
|
||||
)
|
||||
const { data } = useRead<{ attachments: readonly AttachmentView[] }>(
|
||||
"notes.note.get",
|
||||
{ ...slugs, noteId },
|
||||
{
|
||||
live: {
|
||||
org: slugs.org,
|
||||
topic: { ws: slugs.workspace, project: slugs.project },
|
||||
entities: [NOTE_COLLECTION.entity],
|
||||
},
|
||||
initial: { attachments: seed },
|
||||
},
|
||||
)
|
||||
const rows: AttachmentRow[] = (data?.attachments ?? seed).map((attachment) => ({
|
||||
attachmentId: attachment.attachmentId,
|
||||
fileId: attachment.fileId,
|
||||
filename: attachment.filename,
|
||||
contentType: attachment.contentType,
|
||||
size: formatMessage(labels.sizeKb, { size: kilobytes(attachment.size) }, locale),
|
||||
}))
|
||||
|
||||
const attach = async (_: unknown, form: FormData): Promise<void> => {
|
||||
const file = form.get("file")
|
||||
if (!(file instanceof File) || file.size === 0) return
|
||||
const stored = await upload(slugs, file)
|
||||
await add.mutate({ ...slugs, noteId, fileId: stored.fileId })
|
||||
}
|
||||
|
||||
const columns: ResourceColumn<AttachmentRow>[] = [
|
||||
{ id: "filename", header: labels.file, wrap: true },
|
||||
{ id: "contentType", header: labels.type },
|
||||
{ id: "size", header: labels.sizeHeading, align: "end" },
|
||||
{
|
||||
id: "actions",
|
||||
header: labels.actions,
|
||||
cell: (row) => {
|
||||
const name = labels.remove.replace("{filename}", row.filename)
|
||||
return (
|
||||
<>
|
||||
<a href={downloadHref("files.file.download", { ...slugs, fileId: row.fileId })}>
|
||||
{labels.download.replace("{filename}", row.filename)}
|
||||
</a>{" "}
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" aria-label={name}>
|
||||
{labels.removeShort}
|
||||
</Button>
|
||||
}
|
||||
title={name}
|
||||
description={labels.removeConfirm}
|
||||
confirmLabel={labels.removeShort}
|
||||
onConfirm={async () => {
|
||||
await removal.mutate({ ...slugs, noteId, attachmentId: row.attachmentId })
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PageSection title={labels.title}>
|
||||
<ResourceList
|
||||
label={labels.title}
|
||||
columns={columns}
|
||||
data={rows}
|
||||
rowId={(row) => row.attachmentId}
|
||||
count={formatMessage(labels.count, { count: rows.length }, locale)}
|
||||
rowLabel={(row) => row.filename}
|
||||
empty={{ title: labels.empty }}
|
||||
/>
|
||||
{enabled ? (
|
||||
<FormLayout submitLabel={labels.uploadSubmit} busyLabel={labels.uploadBusy} onSubmit={attach}>
|
||||
<TextField
|
||||
name="file"
|
||||
type="file"
|
||||
label={labels.uploadLabel}
|
||||
description={labels.uploadHint}
|
||||
accept={accept.join(",")}
|
||||
required
|
||||
/>
|
||||
</FormLayout>
|
||||
) : (
|
||||
<p role="status">{labels.off}</p>
|
||||
)}
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
// Deleting a note, in two steps. The attached files are not deleted with the note (they are the
|
||||
// project's); the confirmation says so.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useWrite } from "@conusai-kit/ui/data"
|
||||
import { ConfirmDialog, usePageNavigation } from "@conusai-kit/ui/patterns"
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
import type { ReactElement } from "react"
|
||||
|
||||
export interface DeleteNoteLabels {
|
||||
readonly button: string
|
||||
readonly confirm: string
|
||||
readonly yes: string
|
||||
readonly no: string
|
||||
}
|
||||
|
||||
export function DeleteNote({
|
||||
slugs,
|
||||
noteId,
|
||||
listHref,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
noteId: string
|
||||
/** Where to go once the note is gone. */
|
||||
listHref: string
|
||||
labels: DeleteNoteLabels
|
||||
}): ReactElement {
|
||||
const navigation = usePageNavigation()
|
||||
const deletion = useWrite<Record<string, unknown>, unknown>("notes.note.delete", { org: slugs.org })
|
||||
return (
|
||||
<ConfirmDialog
|
||||
trigger={<Button variant="outline">{labels.button}</Button>}
|
||||
title={labels.button}
|
||||
description={labels.confirm}
|
||||
confirmLabel={labels.yes}
|
||||
cancelLabel={labels.no}
|
||||
onConfirm={async () => {
|
||||
await deletion.mutate({ ...slugs, noteId })
|
||||
navigation.navigate(listHref)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// The notes module's pages and components. `notesPage` is what the web shell mounts; the rest is
|
||||
// exported for an app that composes its own page from the parts.
|
||||
|
||||
export { type AttachmentLabels, type AttachmentRow, Attachments, kilobytes } from "./attachments.js"
|
||||
export { DeleteNote, type DeleteNoteLabels } from "./delete-note.js"
|
||||
export {
|
||||
attachmentLabels,
|
||||
deleteLabels,
|
||||
type NotesResourceLabels,
|
||||
resourceLabels,
|
||||
type Translate,
|
||||
} from "./labels.js"
|
||||
export { useLiveNotes } from "./live-notes.js"
|
||||
export { type NotesProps, NotesResource } from "./notes-resource.js"
|
||||
export { type NotesPage, notesPage } from "./pages.js"
|
||||
export { ProjectPicker, type ProjectPickerProps } from "./project-picker.js"
|
||||
export {
|
||||
type NotesWorkspaceData,
|
||||
type NotesWorkspacePage,
|
||||
notesWorkspacePage,
|
||||
WORKSPACE_LABEL_KEYS,
|
||||
} from "./workspace-page.js"
|
||||
@@ -0,0 +1,61 @@
|
||||
// The labels each component takes, built from a message lookup. Every key this module's pages use is
|
||||
// named here and nowhere else, so `__tests__/i18n.test.ts` can run these builders against the
|
||||
// catalogues and fail on a key either language lacks -- the page itself holds no string.
|
||||
//
|
||||
// `t` takes a key relative to the module (`form.create`); the page binds it to
|
||||
// `i18n.message("notes." + key)`.
|
||||
|
||||
import type { AttachmentLabels } from "./attachments.js"
|
||||
import type { DeleteNoteLabels } from "./delete-note.js"
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
export function resourceLabels(t: Translate) {
|
||||
return {
|
||||
title: t("title"),
|
||||
/** A note: the table's name. */
|
||||
one: t("list.one"),
|
||||
/** The create action, a whole message: a language agrees the adjective with the noun. */
|
||||
new: t("list.new"),
|
||||
empty: t("list.empty"),
|
||||
note: t("list.note"),
|
||||
titleField: t("list.title"),
|
||||
body: t("list.body"),
|
||||
attachments: t("list.attachments"),
|
||||
updated: t("list.updated"),
|
||||
}
|
||||
}
|
||||
|
||||
export type NotesResourceLabels = ReturnType<typeof resourceLabels>
|
||||
|
||||
/** `count` and `sizeKb` keep their placeholders: the list is live, so the browser fills them in. */
|
||||
export function attachmentLabels(t: Translate): AttachmentLabels {
|
||||
return {
|
||||
title: t("attachments.title"),
|
||||
count: t("attachments.count"),
|
||||
empty: t("attachments.empty"),
|
||||
file: t("attachments.file"),
|
||||
type: t("attachments.type"),
|
||||
sizeHeading: t("attachments.size"),
|
||||
sizeKb: t("attachments.sizeKb"),
|
||||
actions: t("attachments.actions"),
|
||||
download: t("attachments.download", { filename: "{filename}" }),
|
||||
remove: t("attachments.remove", { filename: "{filename}" }),
|
||||
removeShort: t("attachments.removeShort"),
|
||||
removeConfirm: t("attachments.removeConfirm"),
|
||||
off: t("attachments.off"),
|
||||
uploadLabel: t("upload.label"),
|
||||
uploadHint: t("upload.hint"),
|
||||
uploadSubmit: t("upload.submit"),
|
||||
uploadBusy: t("upload.busy"),
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteLabels(t: Translate): DeleteNoteLabels {
|
||||
return {
|
||||
button: t("delete.button"),
|
||||
confirm: t("delete.confirm"),
|
||||
yes: t("delete.yes"),
|
||||
no: t("delete.no"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
// The project's notes, live (umbrella TS6.4 and TS6.6): the page's view renders the first page and hands it
|
||||
// over as the seed, so the page arrives complete; this keeps it current through the organization's live stream
|
||||
// -- a note another member adds, renames or deletes appears without a reload. The rows come from
|
||||
// `notes.note.getMany`, under the same permission as the list, so the stream itself never carries a note's
|
||||
// contents.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { liveCollection, useDataClient, useLive } from "@conusai-kit/ui/data"
|
||||
// Its live sync stops when no live query reads it any more (TanStack DB's collection GC).
|
||||
import { useState } from "react"
|
||||
import { NOTE_COLLECTION, type NoteView } from "../contract/index.js"
|
||||
|
||||
export function useLiveNotes(slugs: ProjectSlugs, seed: readonly NoteView[]): readonly NoteView[] {
|
||||
const client = useDataClient()
|
||||
// One collection for the life of the page: a page of another project mounts its own, and the seed is the
|
||||
// first render's.
|
||||
const [notes] = useState(() =>
|
||||
liveCollection<NoteView>({
|
||||
client,
|
||||
org: slugs.org,
|
||||
topic: { ws: slugs.workspace, project: slugs.project },
|
||||
entity: NOTE_COLLECTION.entity,
|
||||
key: NOTE_COLLECTION.key,
|
||||
version: "version",
|
||||
seed,
|
||||
list: {
|
||||
procedure: NOTE_COLLECTION.list,
|
||||
input: { ...slugs },
|
||||
rows: (out) => (out as { notes: NoteView[] }).notes,
|
||||
},
|
||||
getMany: {
|
||||
procedure: NOTE_COLLECTION.getMany,
|
||||
input: { ...slugs },
|
||||
rows: (out) => (out as { items: NoteView[] }).items,
|
||||
},
|
||||
}),
|
||||
)
|
||||
// Newest first, as the list procedure orders them, through the query operators (umbrella §7.2).
|
||||
const { data, isReady } = useLive(notes, (q, rows) =>
|
||||
q.from({ note: rows }).orderBy(({ note }: { note: NoteView }) => note.createdAt, "desc"),
|
||||
)
|
||||
// Until the collection is ready the server's rows are the answer, so the first paint matches it.
|
||||
return isReady ? data : seed
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
// The project's notes as one record type: the list, a new note and an edit in the right panel, all from this
|
||||
// declaration. Attachments need a page, so the panel offers it ("Open in full"). The rows are live (live-notes.ts);
|
||||
// a write resolves once the stream has seen it, so the list shows it the moment the panel closes.
|
||||
|
||||
import { emitAppEvent, type ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useDataClient, useWrite } from "@conusai-kit/ui/data"
|
||||
import { defineResource, ResourcePage } from "@conusai-kit/ui/patterns"
|
||||
import { type ReactElement, type ReactNode, useRef } from "react"
|
||||
import { z } from "zod"
|
||||
import type { NoteView } from "../contract/index.js"
|
||||
import type { NotesResourceLabels } from "./labels.js"
|
||||
import { useLiveNotes } from "./live-notes.js"
|
||||
|
||||
const schema = z.object({ title: z.string().min(1).max(200), body: z.string().max(20_000) })
|
||||
|
||||
export interface NotesProps {
|
||||
readonly slugs: ProjectSlugs
|
||||
/** The first page, rendered on the server. */
|
||||
readonly seed: readonly NoteView[]
|
||||
readonly labels: NotesResourceLabels
|
||||
readonly intro: ReactNode
|
||||
/** The locale the server formatted dates in, so the rows read as they were rendered. */
|
||||
readonly locale: string
|
||||
}
|
||||
|
||||
export function NotesResource({ slugs, seed, labels, intro, locale }: NotesProps): ReactElement {
|
||||
const client = useDataClient()
|
||||
const rows = useLiveNotes(slugs, seed)
|
||||
const create = useWrite<Record<string, unknown>, NoteView>("notes.note.create", { org: slugs.org })
|
||||
const update = useWrite<Record<string, unknown>, NoteView>("notes.note.update", { org: slugs.org })
|
||||
const remove = useWrite<Record<string, unknown>, unknown>("notes.note.delete", { org: slugs.org })
|
||||
// A save carries the version the panel read, so a second editor's is a 409 and never a silent overwrite.
|
||||
const read = useRef(new Map<string, number>())
|
||||
for (const { noteId, version } of rows) if (!read.current.has(noteId)) read.current.set(noteId, version)
|
||||
const day = new Intl.DateTimeFormat(locale).format
|
||||
const resource = defineResource<NoteView, z.output<typeof schema>>({
|
||||
id: "notes",
|
||||
label: labels.one,
|
||||
newLabel: labels.new,
|
||||
columns: [
|
||||
{ id: "title", header: labels.note, wrap: true, minWidth: "10rem", allowsSorting: true },
|
||||
{ id: "attachmentCount", header: labels.attachments, align: "end" },
|
||||
{ id: "updatedAt", header: labels.updated, allowsSorting: true, cell: (n) => day(new Date(n.updatedAt)) },
|
||||
],
|
||||
rowId: (note) => note.noteId,
|
||||
source: { rows },
|
||||
async get(noteId) {
|
||||
const { value } = await client.call<{ note: NoteView }>("notes.note.get", { ...slugs, noteId })
|
||||
read.current.set(noteId, value.note.version)
|
||||
return value.note
|
||||
},
|
||||
schema,
|
||||
fields: [
|
||||
{ name: "title", label: labels.titleField, kind: "text" },
|
||||
{ name: "body", label: labels.body, kind: "textarea" },
|
||||
],
|
||||
async create(input) {
|
||||
const note = await create.mutate({ ...slugs, ...input })
|
||||
await emitAppEvent("note_created") // what an in-app guide's checklist task listens for
|
||||
return note
|
||||
},
|
||||
update: (id, input) => update.mutate({ ...slugs, noteId: id, ...input, expectedVersion: read.current.get(id) }),
|
||||
remove: (noteId) => remove.mutate({ ...slugs, noteId }).then(() => undefined),
|
||||
href: (n) => `/${slugs.org}/${slugs.workspace}/${slugs.project}/notes/${n.noteId}`,
|
||||
permissions: { write: "notes.note.write", remove: "notes.note.delete" },
|
||||
})
|
||||
return <ResourcePage resource={resource} title={labels.title} description={intro} empty={{ title: labels.empty }} />
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// The notes module's page, on the shell's F10 contract (docs/reference/app-kit.md): a loader the web
|
||||
// shell runs on the server under `/<org>/<workspace>/<project>/notes`, and the view of what it returned.
|
||||
//
|
||||
// /notes the list, on `ResourcePage`: a note opens, and a new one starts, in the right panel
|
||||
// /notes/<noteId> one note in full: its text, its attachments, deleting it
|
||||
//
|
||||
// Every read is a procedure through the same caller the API uses (spec §6.2: one API path), and every
|
||||
// label is translated here, on the server: the view receives strings and rows, never a caller or a
|
||||
// catalogue. Every write is a client component that calls a procedure through the shell's transport.
|
||||
// This is the worked example the other modules' pages follow (apps/web/ROUTES.md).
|
||||
|
||||
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n } from "@conusai-kit/i18n"
|
||||
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
|
||||
import { Page, PageHeader, PageSection } from "@conusai-kit/ui/patterns"
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
import type { ReactElement } from "react"
|
||||
import { ATTACHMENT_CONTENT_TYPES, type AttachmentView, type NoteView, noteGet, noteList } from "../contract/index.js"
|
||||
import { FeedbackButton } from "../guides/feedback-button.js" // kit:guides
|
||||
import { type FeedbackButtonLabels, feedbackLabels } from "../guides/feedback-labels.js" // kit:guides
|
||||
import { type AttachmentLabels, Attachments } from "./attachments.js"
|
||||
import { DeleteNote, type DeleteNoteLabels } from "./delete-note.js"
|
||||
import { attachmentLabels, deleteLabels, type NotesResourceLabels, resourceLabels } from "./labels.js"
|
||||
import { NotesResource } from "./notes-resource.js"
|
||||
|
||||
/** The list: its labels and the first page of notes, the resource's seed. */
|
||||
export interface NotesListData {
|
||||
readonly kind: "list"
|
||||
readonly intro: string
|
||||
readonly feedback: FeedbackButtonLabels // kit:guides
|
||||
readonly labels: NotesResourceLabels
|
||||
readonly notes: readonly NoteView[]
|
||||
}
|
||||
|
||||
/** One note, with its attachments as the live list's seed. */
|
||||
export interface NoteDetailData {
|
||||
readonly kind: "note"
|
||||
readonly back: string
|
||||
readonly changed: string
|
||||
readonly note: { readonly noteId: string; readonly title: string; readonly body: string }
|
||||
/** What the link to the note's panel on the list says. */
|
||||
readonly edit: string
|
||||
readonly attachments: readonly AttachmentView[]
|
||||
readonly attachmentsEnabled: boolean
|
||||
readonly attachmentLabels: AttachmentLabels
|
||||
readonly remove: DeleteNoteLabels
|
||||
}
|
||||
|
||||
export type NotesPageData = NotesListData | NoteDetailData
|
||||
|
||||
export type NotesPage = ProjectPage<TransportCaller, I18n, Container, NotesPageData>
|
||||
|
||||
const listHrefOf = ({ org, workspace, project }: ProjectPageViewProps<unknown>["slugs"]): string =>
|
||||
`/${org}/${workspace}/${project}/notes`
|
||||
|
||||
export const notesPage: NotesPage = projectPage({
|
||||
async load({ slugs, segments, caller, i18n }): Promise<NotesPageData> {
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`notes.${key}`, values)
|
||||
|
||||
if (segments.length === 0) {
|
||||
const { notes } = await caller.call(noteList, slugs)
|
||||
return {
|
||||
kind: "list",
|
||||
intro: t("intro"),
|
||||
feedback: feedbackLabels(t), // kit:guides
|
||||
labels: resourceLabels(t),
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
const [noteId, ...rest] = segments
|
||||
if (noteId === undefined || rest.length > 0) throw notFound("no such page")
|
||||
const { note, attachments, attachmentsEnabled } = await caller.call(noteGet, { ...slugs, noteId })
|
||||
return {
|
||||
kind: "note",
|
||||
back: t("detail.back"),
|
||||
changed: t("detail.changed", { date: i18n.date(new Date(note.updatedAt)) }),
|
||||
note: { noteId: note.noteId, title: note.title, body: note.body },
|
||||
edit: t("detail.edit"),
|
||||
attachments,
|
||||
attachmentsEnabled,
|
||||
attachmentLabels: attachmentLabels(t),
|
||||
remove: deleteLabels(t),
|
||||
}
|
||||
},
|
||||
|
||||
View({ slugs, locale, data }: ProjectPageViewProps<NotesPageData>): ReactElement {
|
||||
const listHref = listHrefOf(slugs)
|
||||
if (data.kind === "list") {
|
||||
return (
|
||||
<NotesResource
|
||||
slugs={slugs}
|
||||
seed={data.notes}
|
||||
labels={data.labels}
|
||||
locale={locale}
|
||||
intro={
|
||||
<>
|
||||
{data.intro}
|
||||
<FeedbackButton labels={data.feedback} /* kit:guides */ />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const { note } = data
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
title={note.title}
|
||||
description={data.changed}
|
||||
breadcrumb={<a href={listHref}>{data.back}</a>}
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="outline">
|
||||
<a href={`${listHref}?record=${note.noteId}`}>{data.edit}</a>
|
||||
</Button>
|
||||
<DeleteNote slugs={slugs} noteId={note.noteId} listHref={listHref} labels={data.remove} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<PageSection>
|
||||
<p>{note.body}</p>
|
||||
</PageSection>
|
||||
<Attachments
|
||||
slugs={slugs}
|
||||
noteId={note.noteId}
|
||||
seed={data.attachments}
|
||||
enabled={data.attachmentsEnabled}
|
||||
accept={ATTACHMENT_CONTENT_TYPES}
|
||||
labels={data.attachmentLabels}
|
||||
locale={locale}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
// The workspace page's projects, with a count of those ticked. The count exists only in the browser, so the
|
||||
// loader cannot translate it: the page hands over the message with its placeholder left in, and this island
|
||||
// fills it with `formatMessage`, which brings the language's plural rules and no catalogue (F10, decision 2).
|
||||
|
||||
import { formatMessage } from "@conusai-kit/i18n/format"
|
||||
import { ResourceList } from "@conusai-kit/ui/patterns"
|
||||
import { useUiLabel } from "@conusai-kit/ui/primitives"
|
||||
import { type ReactElement, useState } from "react"
|
||||
|
||||
export interface ProjectPickerProps {
|
||||
readonly label: string
|
||||
readonly column: string
|
||||
readonly empty: string
|
||||
readonly projects: readonly { readonly slug: string; readonly name: string; readonly href: string }[]
|
||||
/** `{count, plural, ...}`, in the person's language, not yet filled in. */
|
||||
readonly selected: string
|
||||
readonly locale: string
|
||||
}
|
||||
|
||||
export function ProjectPicker({ projects, selected, locale, label, column, empty }: ProjectPickerProps): ReactElement {
|
||||
const [picked, setPicked] = useState<ReadonlySet<string>>(new Set())
|
||||
const selectAll = useUiLabel("selectAll")
|
||||
return (
|
||||
<ResourceList
|
||||
label={label}
|
||||
columns={[{ id: "name", header: column, wrap: true }]}
|
||||
data={projects}
|
||||
rowId={(project) => project.slug}
|
||||
rowHref={(project) => project.href}
|
||||
rowLabel={(project) => project.name}
|
||||
selectionMode="multiple"
|
||||
selectionLabel={selectAll}
|
||||
rowSelectionLabel={(project) => project.name}
|
||||
selectedKeys={picked}
|
||||
onSelectionChange={setPicked}
|
||||
count={formatMessage(selected, { count: picked.size }, locale)}
|
||||
empty={{ title: empty }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// A page above any project, on the shell's F10 contract: `/<org>/<workspace>/modules/notes`. It lists the
|
||||
// workspace's projects, each linked to its notes, and `?q=` filters them on the server, so the first byte
|
||||
// already holds the result and a filter is a link anyone can copy.
|
||||
//
|
||||
// The shell answers 403 for `permission` before `load` runs; the rest is a `ProjectPage` with two slugs. This is
|
||||
// the worked example for a catalogue, a list or a setting that belongs to a workspace, not to a project, and for
|
||||
// a page's strings as one record, with a count only the browser knows formatted there (project-picker.tsx).
|
||||
|
||||
import type { Container, TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n } from "@conusai-kit/i18n"
|
||||
import { projectList } from "@conusai-kit/tenancy"
|
||||
import { type WorkspacePage, workspacePage } from "@conusai-kit/ui"
|
||||
import { FilterBar, Page, PageHeader, PageState, TextField } from "@conusai-kit/ui/patterns"
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
import { ProjectPicker } from "./project-picker.js"
|
||||
|
||||
/** Every string of the page, as one record. `selected` keeps its placeholder: only the browser knows the count. */
|
||||
export const WORKSPACE_LABEL_KEYS = [
|
||||
"title",
|
||||
"intro",
|
||||
"filter",
|
||||
"apply",
|
||||
"clear",
|
||||
"empty",
|
||||
"noProjects",
|
||||
"selected",
|
||||
] as const
|
||||
|
||||
export interface NotesWorkspaceData {
|
||||
readonly labels: Readonly<Record<(typeof WORKSPACE_LABEL_KEYS)[number], string>>
|
||||
readonly query: string
|
||||
readonly projects: readonly { readonly slug: string; readonly name: string }[]
|
||||
}
|
||||
|
||||
export type NotesWorkspacePage = WorkspacePage<TransportCaller, I18n, Container, NotesWorkspaceData>
|
||||
|
||||
export const notesWorkspacePage: NotesWorkspacePage = workspacePage({
|
||||
permission: "notes.note.read",
|
||||
async load({ slugs, search, caller, i18n }): Promise<NotesWorkspaceData> {
|
||||
const query = search.q ?? ""
|
||||
const wanted = query.trim().toLowerCase()
|
||||
const { projects } = await caller.call(projectList, slugs)
|
||||
return {
|
||||
labels: Object.fromEntries(
|
||||
WORKSPACE_LABEL_KEYS.map((key) => [key, i18n.message(`notes.workspace.${key}`)]),
|
||||
) as NotesWorkspaceData["labels"],
|
||||
query,
|
||||
projects: projects
|
||||
.filter((project) => project.name.toLowerCase().includes(wanted))
|
||||
.map(({ slug, name }) => ({ slug, name })),
|
||||
}
|
||||
},
|
||||
View({ slugs, locale, data }) {
|
||||
const { labels } = data
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title={labels.title} description={labels.intro} />
|
||||
<FilterBar
|
||||
method="get"
|
||||
actions={
|
||||
<>
|
||||
<Button type="submit" variant="secondary">
|
||||
{labels.apply}
|
||||
</Button>
|
||||
{data.query.trim() && data.projects.length > 0 && (
|
||||
<Button asChild variant="link">
|
||||
<a href={`/${slugs.org}/${slugs.workspace}/modules/notes`}>{labels.clear}</a>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
name="q"
|
||||
label={labels.filter}
|
||||
placeholder={labels.filter}
|
||||
type="search"
|
||||
defaultValue={data.query}
|
||||
/>
|
||||
</FilterBar>
|
||||
{data.projects.length === 0 ? (
|
||||
<PageState
|
||||
title={data.query.trim() ? labels.empty : labels.noProjects}
|
||||
action={
|
||||
data.query.trim() ? (
|
||||
<Button asChild variant="outline">
|
||||
<a href={`/${slugs.org}/${slugs.workspace}/modules/notes`}>{labels.clear}</a>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ProjectPicker
|
||||
label={labels.title}
|
||||
column={labels.filter}
|
||||
empty={labels.noProjects}
|
||||
projects={data.projects.map((project) => ({
|
||||
...project,
|
||||
href: `/${slugs.org}/${slugs.workspace}/${project.slug}/notes`,
|
||||
}))}
|
||||
selected={labels.selected}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</Page>
|
||||
)
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user