@@ -0,0 +1,193 @@
|
||||
"use client"
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useDataClient, useWrite } from "@conusai-kit/ui/data"
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import {
|
||||
FormLayout,
|
||||
type FormValues,
|
||||
PageSection,
|
||||
ResourceList,
|
||||
TextField,
|
||||
usePageNavigation,
|
||||
useResourceList,
|
||||
} from "@conusai-kit/ui/patterns"
|
||||
import { useState } from "react"
|
||||
import type { CommentReceipt, CommentView, TaskReceipt, TaskView } from "../contract/index.js"
|
||||
import { describeFailure, type Labels } from "./labels.js"
|
||||
|
||||
type Page<T> = { rows: T[]; after?: string | undefined }
|
||||
export function Tasks({
|
||||
slugs,
|
||||
seed,
|
||||
href,
|
||||
labels,
|
||||
timeline = false,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
seed: Page<TaskView>
|
||||
href: string
|
||||
labels: Labels
|
||||
timeline?: boolean
|
||||
}) {
|
||||
const client = useDataClient()
|
||||
const { list } = useResourceList<TaskView>({
|
||||
seed,
|
||||
rowId: (task) => task.taskId,
|
||||
read: async ({ after }) =>
|
||||
(
|
||||
await client.call<Page<TaskView>>(timeline ? "activities.timeline.list" : "activities.task.list", {
|
||||
...slugs,
|
||||
...(after ? { after } : {}),
|
||||
})
|
||||
).value,
|
||||
})
|
||||
return (
|
||||
<ResourceList
|
||||
{...list}
|
||||
label={timeline ? labels.timeline : labels.open}
|
||||
columns={[
|
||||
{ id: "subject", header: labels.subject, wrap: true },
|
||||
{
|
||||
id: "status",
|
||||
header: labels.status,
|
||||
cell: (task) =>
|
||||
task.status === "open"
|
||||
? labels.stateOpen
|
||||
: task.status === "done"
|
||||
? labels.stateDone
|
||||
: labels.stateCancelled,
|
||||
},
|
||||
]}
|
||||
rowHref={(task) => `${href}/${task.taskId}`}
|
||||
rowLabel={(task) => task.subject}
|
||||
empty={{ title: labels.empty }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export function TaskForm({
|
||||
slugs,
|
||||
task,
|
||||
href,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
task?: TaskView
|
||||
href: string
|
||||
labels: Labels
|
||||
}) {
|
||||
const navigation = usePageNavigation()
|
||||
const [requestId] = useState(() => crypto.randomUUID())
|
||||
const mutation = useWrite<Record<string, unknown>, TaskReceipt>(
|
||||
task ? "activities.task.update" : "activities.task.create",
|
||||
)
|
||||
const submit = async ({ subject, body = "" }: FormValues) => {
|
||||
const result = await mutation.mutate({
|
||||
...slugs,
|
||||
subject,
|
||||
body,
|
||||
requestId,
|
||||
...(task ? { taskId: task.taskId, expectedVersion: task.revision } : {}),
|
||||
})
|
||||
navigation.navigate(`${href}/${result.taskId}`)
|
||||
}
|
||||
return (
|
||||
<PageSection title={task ? labels.update : labels.create} variant="card">
|
||||
<FormLayout
|
||||
key={requestId}
|
||||
describeError={(error) => describeFailure(error, labels)}
|
||||
submitLabel={task ? labels.update : labels.create}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<TextField name="subject" label={labels.subject} required maxLength={200} defaultValue={task?.subject} />
|
||||
<TextField name="body" label={labels.body} maxLength={10000} defaultValue={task?.body} />
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
export function TaskState({ slugs, task, labels }: { slugs: ProjectSlugs; task: TaskView; labels: Labels }) {
|
||||
const navigation = usePageNavigation()
|
||||
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
|
||||
const change = useWrite<Record<string, unknown>, TaskReceipt>(
|
||||
task.status === "done" ? "activities.task.reopen" : "activities.task.complete",
|
||||
)
|
||||
if (task.status === "cancelled") return null
|
||||
const submit = async () => {
|
||||
await change.mutate({ ...slugs, taskId: task.taskId, expectedVersion: task.revision, requestId })
|
||||
await navigation.refresh()
|
||||
setRequestId(crypto.randomUUID())
|
||||
}
|
||||
return (
|
||||
<FormLayout
|
||||
key={requestId}
|
||||
describeError={(error) => describeFailure(error, labels)}
|
||||
submitLabel={task.status === "done" ? labels.reopen : labels.complete}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export function Comments({
|
||||
slugs,
|
||||
task,
|
||||
seed,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
task: TaskView
|
||||
seed: Page<CommentView>
|
||||
labels: Labels
|
||||
}) {
|
||||
const client = useDataClient()
|
||||
const { list } = useResourceList<CommentView>({
|
||||
seed,
|
||||
rowId: (comment) => comment.commentId,
|
||||
read: async ({ after }) =>
|
||||
(
|
||||
await client.call<Page<CommentView>>("activities.comment.list", {
|
||||
...slugs,
|
||||
taskId: task.taskId,
|
||||
...(after ? { after } : {}),
|
||||
})
|
||||
).value,
|
||||
})
|
||||
return (
|
||||
<ResourceList
|
||||
{...list}
|
||||
label={labels.comments}
|
||||
columns={[
|
||||
{
|
||||
id: "body",
|
||||
header: labels.comment,
|
||||
wrap: true,
|
||||
cell: (comment) => (comment.erased ? labels.erased : comment.body),
|
||||
},
|
||||
]}
|
||||
rowLabel={(comment) => (comment.erased ? labels.erased : comment.body)}
|
||||
empty={{ title: labels.emptyComments }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export function CommentForm({ slugs, task, labels }: { slugs: ProjectSlugs; task: TaskView; labels: Labels }) {
|
||||
const navigation = usePageNavigation()
|
||||
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
|
||||
const add = useWrite<Record<string, unknown>, CommentReceipt>("activities.comment.add")
|
||||
const submit = async ({ body = "" }: FormValues) => {
|
||||
await add.mutate({ ...slugs, taskId: task.taskId, expectedVersion: task.revision, requestId, body })
|
||||
await navigation.refresh()
|
||||
setRequestId(crypto.randomUUID())
|
||||
}
|
||||
return (
|
||||
<PageSection title={labels.addComment}>
|
||||
<FormLayout
|
||||
key={requestId}
|
||||
describeError={(error) => describeFailure(error, labels)}
|
||||
submitLabel={labels.addComment}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<TextField name="body" label={labels.comment} required maxLength={10000} />
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { serviceNotice } from "@conusai-kit/ui/patterns"
|
||||
import type { en } from "../i18n/en.js"
|
||||
export type Messages = { [K in keyof typeof en]: string }
|
||||
/**
|
||||
* What the loader hands the views: the module's messages and the kit's refusal sentences by problem code
|
||||
* (`businessRefusalNotices` of `@conusai-kit/business/refusals`, with this module's wording for a conflict and
|
||||
* an invalid entry).
|
||||
*/
|
||||
export type Labels = Messages & { readonly refusals: Readonly<Record<string, string>> }
|
||||
/** A remote detail never becomes untranslated or unbounded form text. */
|
||||
export function describeFailure(error: unknown, labels: Labels): string {
|
||||
return serviceNotice(error, labels.refusals)?.text ?? labels.unavailable
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { businessRefusalNotices, businessServiceNotices } from "@conusai-kit/business/refusals"
|
||||
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n } from "@conusai-kit/i18n"
|
||||
import { type ProjectPage, projectPage } from "@conusai-kit/ui"
|
||||
import { Can } from "@conusai-kit/ui/data"
|
||||
import {
|
||||
DetailLayout,
|
||||
Page,
|
||||
PageHeader,
|
||||
PageSection,
|
||||
type ServiceNotice,
|
||||
ServiceStatePage,
|
||||
serviceNotice,
|
||||
} from "@conusai-kit/ui/patterns"
|
||||
import * as contracts from "../contract/index.js"
|
||||
import { en } from "../i18n/en.js"
|
||||
import { CommentForm, Comments, TaskForm, TaskState, Tasks } from "./controls.js"
|
||||
import type { Labels, Messages } from "./labels.js"
|
||||
|
||||
type PageOf<T> = { rows: T[]; after?: string | undefined }
|
||||
type Data = { labels: Labels } & (
|
||||
| { kind: "list"; tasks: PageOf<contracts.TaskView>; timeline: PageOf<contracts.TaskView> }
|
||||
| { kind: "task"; task: contracts.TaskView; comments: PageOf<contracts.CommentView> }
|
||||
| { kind: "state"; notice: ServiceNotice }
|
||||
)
|
||||
export const activitiesPage: ProjectPage<TransportCaller, I18n, Container, Data> = projectPage({
|
||||
async load({ slugs, segments, caller, i18n }) {
|
||||
const messages = Object.fromEntries(
|
||||
Object.keys(en).map((key) => [key, i18n.message(`activities.${key}`)]),
|
||||
) as Messages
|
||||
const labels: Labels = {
|
||||
...messages,
|
||||
refusals: businessRefusalNotices(i18n.locale, {
|
||||
conflict: messages.conflict,
|
||||
revision_conflict: messages.conflict,
|
||||
invalid: messages.invalid,
|
||||
}),
|
||||
}
|
||||
const [taskId, ...rest] = segments
|
||||
if (rest.length || (taskId !== undefined && !contracts.activityId.safeParse(taskId).success)) throw notFound()
|
||||
try {
|
||||
if (taskId === undefined) {
|
||||
const [tasks, timeline] = await Promise.all([
|
||||
caller.call(contracts.taskList, slugs),
|
||||
caller.call(contracts.timeline, slugs),
|
||||
])
|
||||
return { kind: "list", labels, tasks, timeline }
|
||||
}
|
||||
const [task, comments] = await Promise.all([
|
||||
caller.call(contracts.taskGet, { ...slugs, taskId }),
|
||||
caller.call(contracts.commentList, { ...slugs, taskId }),
|
||||
])
|
||||
return { kind: "task", labels, task, comments }
|
||||
} catch (error) {
|
||||
const notice = serviceNotice(error, businessServiceNotices(i18n.locale))
|
||||
if (!notice) throw error
|
||||
return { kind: "state", labels, notice }
|
||||
}
|
||||
},
|
||||
View({ slugs, data }) {
|
||||
const labels = data.labels
|
||||
const href = `/${slugs.org}/${slugs.workspace}/${slugs.project}/activities`
|
||||
if (data.kind === "state") return <ServiceStatePage title={labels.title} notice={data.notice} />
|
||||
if (data.kind === "list")
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title={labels.title} description={labels.intro} />
|
||||
<DetailLayout
|
||||
aside={
|
||||
<Can permission="activities.task.write">
|
||||
<TaskForm slugs={slugs} href={href} labels={labels} />
|
||||
</Can>
|
||||
}
|
||||
>
|
||||
<PageSection title={labels.open}>
|
||||
<Tasks slugs={slugs} seed={data.tasks} href={href} labels={labels} />
|
||||
</PageSection>
|
||||
<PageSection title={labels.timeline}>
|
||||
<Tasks slugs={slugs} seed={data.timeline} href={href} labels={labels} timeline />
|
||||
</PageSection>
|
||||
</DetailLayout>
|
||||
</Page>
|
||||
)
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title={data.task.subject} description={data.task.body} />
|
||||
<a href={href}>{labels.back}</a>
|
||||
<Can permission="activities.task.write">
|
||||
<TaskState slugs={slugs} task={data.task} labels={labels} />
|
||||
<TaskForm slugs={slugs} task={data.task} href={href} labels={labels} />
|
||||
</Can>
|
||||
<PageSection title={labels.comments}>
|
||||
<Comments slugs={slugs} task={data.task} seed={data.comments} labels={labels} />
|
||||
</PageSection>
|
||||
<Can permission="activities.comment.write">
|
||||
<CommentForm slugs={slugs} task={data.task} labels={labels} />
|
||||
</Can>
|
||||
</Page>
|
||||
)
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user