Add schema-driven project task creation

This commit is contained in:
Timothy J. Aveni
2026-07-18 12:10:00 -07:00
parent 676a882ac1
commit ee693da2ca
6 changed files with 158 additions and 1 deletions
@@ -105,6 +105,40 @@ const requireRuntime = () => {
return runtimeConfig;
};
export const callObjectMethod = async <Result = unknown>(
object: ObjectRef<string>,
methodName: string,
input: Record<string, unknown> = {},
): Promise<Result> => {
const runtime = requireRuntime();
const [objectResponse, classesResponse] = await Promise.all([
runtime.camino.getObject({ objectId: object }),
runtime.camino.listClasses({}),
]);
const classId = objectResponse.object?.classId;
if (!classId) {
throw new Error(`Unknown Camino object ${object}`);
}
const schema = classesResponse.classes.find(
(entry) => entry.schema?.id && symbolKey(entry.schema.id) === classId,
)?.schema;
const method = schema?.methods.find((entry) => entry.name === methodName);
if (!method?.function) {
throw new Error(`${classId} has no method ${methodName}`);
}
const response = await runtime.orch.invokeFunction({
function: functionToRequest(method.function),
objectId: object,
input: Object.fromEntries(
Object.entries(input).map(([name, value]) => [name, jsToProtoValue(value)]),
),
});
if (!response.ok) {
throw new Error(response.error || `${classId}.${methodName} failed`);
}
return valueToJs(response.result) as Result;
};
const bytesToBase64 = (value: Uint8Array) => {
let binary = "";
for (const byte of value) {
@@ -612,6 +612,12 @@ const reactRuntimeDeclaration = `${generatedWarning}declare module "@quixos/cami
forObject: ForObject;
} & RenderProps & ReactComponentHostProps<Action>) => any;
export const callObjectMethod: <Result = unknown>(
object: ObjectRef<string>,
methodName: string,
input?: Record<string, unknown>,
) => Promise<Result>;
export const h: (...args: any[]) => any;
export const useLiveField: <T>(
field: LiveFieldProp<T>,
@@ -1,5 +1,6 @@
import { useState } from "react";
import {
callObjectMethod,
h,
type ObjectRef,
type ReactComponentImplementationProps,
@@ -30,11 +31,31 @@ export default function ProjectComponent({
render,
}: ProjectComponentProps) {
const [selectedTask, setSelectedTask] = useState<TaskRef | null>(null);
const [newTaskTitle, setNewTaskTitle] = useState("");
const [addingTask, setAddingTask] = useState(false);
const [addTaskError, setAddTaskError] = useState("");
const handleTaskAction = (task: TaskRef, action: TodoReactComponentAction) => {
if (action.type === "SELECT") {
setSelectedTask(task);
}
};
const addTask = async () => {
if (addingTask) {
return;
}
setAddingTask(true);
setAddTaskError("");
try {
await callObjectMethod(camino.project_id, "addTask", {
title: newTaskTitle,
});
setNewTaskTitle("");
} catch (error) {
setAddTaskError(error instanceof Error ? error.message : String(error));
} finally {
setAddingTask(false);
}
};
return (
<section
@@ -51,6 +72,51 @@ export default function ProjectComponent({
{camino.tasks.length} tasks
</span>
</header>
<form
onSubmit={(event) => {
event.preventDefault();
void addTask();
}}
style={{ display: "grid", gridTemplateColumns: "1fr auto", gap: "8px" }}
>
<input
aria-label="New task title"
value={newTaskTitle}
onInput={(event) => setNewTaskTitle(event.currentTarget.value)}
placeholder="New task"
disabled={addingTask}
style={{
minWidth: 0,
border: "1px solid #d4d4d8",
borderRadius: "6px",
fontSize: "14px",
padding: "8px 10px",
}}
/>
<button
type="submit"
disabled={addingTask || newTaskTitle.trim().length === 0}
style={{
border: "1px solid #18181b",
borderRadius: "6px",
background: "#18181b",
color: "#ffffff",
fontSize: "13px",
fontWeight: 600,
padding: "8px 12px",
}}
>
{addingTask ? "Adding" : "Add task"}
</button>
{addTaskError ? (
<div
role="alert"
style={{ gridColumn: "1 / -1", color: "#b91c1c", fontSize: "12px" }}
>
{addTaskError}
</div>
) : null}
</form>
<div style={{ display: "grid", gap: "10px" }}>
{camino.tasks.map((task) => (
<TodoComponent
@@ -7,6 +7,19 @@ source_repo: "path:."
server_installable: "#server"
runtime_protocol_version: "camino-orch-v0"
function_exports: {
function: {
package_namespace: "quixos.todo"
package_name: "project-runtime"
symbol: "addTask"
}
input_type: "quixos.todo.ProjectAddTaskInput"
output_type: "quixos.todo.Task"
interface_version: 1
interface_compatible_back_to: 1
capability_kind: METHOD
}
function_exports: {
function: {
package_namespace: "quixos.todo"
@@ -1,4 +1,8 @@
import { derivedWithDeps, protoValueToJs } from "@quixos/camino-package-runtime";
import {
derivedWithDeps,
jsToProtoValue,
protoValueToJs,
} from "@quixos/camino-package-runtime";
import type { ProjectContext } from "./gen/quixos.todo.Project.runtime.js";
type TaskSummaryItem = {
@@ -7,6 +11,31 @@ type TaskSummaryItem = {
priority: string;
};
export const addTask = async (context: ProjectContext) => {
const requestedTitle = protoValueToJs(context.request.input.title);
const title =
typeof requestedTitle === "string" && requestedTitle.trim().length > 0
? requestedTitle.trim()
: "Untitled task";
const created = await context.camino.createObject({
classId: "quixos.todo:Task:1:",
fields: {
title: jsToProtoValue(title),
status: jsToProtoValue("OPEN"),
priority: jsToProtoValue("NORMAL"),
},
});
if (!created.object) {
throw new Error("Camino did not return the created Task");
}
await context.camino.addEdge({
fromObjectId: context.object.id,
sourceField: "tasks",
toObjectId: created.object.id,
});
return { objectId: created.object.id };
};
const stringField = (
fields: Record<string, unknown>,
fieldName: string,
@@ -20,6 +20,15 @@ message Project {
}
};
option (camino.method) = {
name: "addTask"
function: {
package_namespace: "quixos.todo"
package_name: "project-runtime"
symbol: "addTask"
}
};
string name = 1 [
(camino.display_label) = true,
(camino.conflict) = PRESERVE_CONFLICTS