Scaffold Truck Desk

This commit is contained in:
Zino Studio 2026-11-04 13:56:31 +00:00
parent 28ec106014
commit 285b3a3e15
26 changed files with 1849 additions and 3 deletions

12
.env.example Normal file
View File

@ -0,0 +1,12 @@
# Local development only (`npm run dev`). Reference for the values
# `npm run dev` reads from .env; nothing here is used by a deployed build.
#
# In a deployed environment these values come from config.js, which the server
# writes when it places the build. Do not commit environment-specific values —
# one build is promoted across environments unchanged.
VITE_ZINO_API_URL=http://localhost:8085
# The app this frontend serves. With it set, the SDK sends workflow and view
# calls to /app/<id>/... on the API host; sign-in stays at /usr/login.
VITE_ZINO_APP_ID=
VITE_ZINO_ORG_ID=
VITE_ZINO_MOCK=false

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.local

View File

@ -1,3 +0,0 @@
# truck-desk
Truck Desk — generated by frontgen

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--BASE_HREF-->
<title>Truck Desk</title>
<script src="./config.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "truck-desk",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc -b",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.60.5",
"lucide-react": "^0.441.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"typescript": "~5.5.4",
"vite": "^5.4.2"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

11
src/App.tsx Normal file
View File

@ -0,0 +1,11 @@
// Replace this file with the real app: routes, pages and layout live here.
export default function App() {
return (
<main className="min-h-screen flex items-center justify-center p-6 text-center bg-slate-50 text-slate-800">
<div>
<h1 className="text-2xl font-semibold">Truck Desk</h1>
<p className="mt-2 text-slate-500">Nothing has been built here yet.</p>
</div>
</main>
)
}

9
src/config.ts Normal file
View File

@ -0,0 +1,9 @@
// Workflow, step and view ids this frontend uses. Keep every id here and
// import it where it is needed; never write an id inline in a page.
//
// Examples:
// export const WORKFLOW_IDS = { TRUCK_ONBOARDING: 'truck-onboarding' } as const
// export const ACTIVITY_IDS = { START: 'start-registration' } as const
// export const RECORD_VIEW_IDS = { TRUCKS: 'rv-trucks' } as const
// export const DETAIL_VIEW_IDS = { TRUCK: 'dv-truck' } as const
export {}

3
src/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

21
src/main.tsx Normal file
View File

@ -0,0 +1,21 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { ZinoProvider } from './zino-sdk'
import { requireConfigValue, resolveConfigValue, basePath } from './runtimeConfig'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ZinoProvider
baseUrl={requireConfigValue('VITE_ZINO_API_URL')}
appId={resolveConfigValue('VITE_ZINO_APP_ID')}
orgId={resolveConfigValue('VITE_ZINO_ORG_ID')}
>
<BrowserRouter basename={basePath()}>
<App />
</BrowserRouter>
</ZinoProvider>
</StrictMode>,
)

35
src/runtimeConfig.ts Normal file
View File

@ -0,0 +1,35 @@
declare global {
interface Window {
__RUNTIME_CONFIG__?: Record<string, string>
}
}
// SAME_ORIGIN resolves to the serving origin, for a frontend mounted on a custom
// domain where the platform is proxied under the same host. It exists because an
// empty string is falsy and so can never survive the || chain below, which is
// exactly what a "use the current origin" value has to do.
export function resolveConfigValue(key: string, fallback = ''): string {
const runtime = (window.__RUNTIME_CONFIG__ || {})[key]
const devFallback = import.meta.env.DEV ? (import.meta.env[key] as string) : ''
const value = runtime || devFallback || fallback
return value === 'SAME_ORIGIN' ? window.location.origin.replace(/\/$/, '') : value
}
// Throws rather than silently running against the wrong backend.
export function requireConfigValue(key: string): string {
const value = resolveConfigValue(key)
if (!value) {
throw new Error(
'Missing runtime config "' + key + '". The server writes config.js when it ' +
'places the build; for local dev set ' + key + ' in .env.',
)
}
return value
}
// The mount path this app is served under, taken from the <base href> the server
// writes into index.html. Drives the router basename, so one build serves any
// mount without being rebuilt.
export function basePath(): string {
return new URL(document.baseURI).pathname
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,175 @@
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useZino } from "./provider";
import type { FormField } from "./types";
export interface DynamicFormProps {
/** Workflow definition ID. */
workflowId: string;
/** Activity UID — the form schema is fetched for this activity. */
activityId: string;
/** Instance ID — omit for INIT activities (start workflow). */
instanceId?: string;
/** Called after successful submission with the result. */
onSuccess?: (result: unknown) => void;
/** Called on submission error. */
onError?: (error: unknown) => void;
/** Optional CSS class for the form wrapper. */
className?: string;
}
/**
* Renders a dynamic form based on the activity's form schema.
*
* Fetches the form config from the core-service, renders fields by type,
* and submits via performActivity or startWorkflow.
*
* ```tsx
* <DynamicForm
* workflowId={WORKFLOW_ID}
* activityId={ACTIVITY_IDS.ASSIGN_TICKET}
* instanceId={instanceId}
* onSuccess={() => { closeModal(); }}
* />
* ```
*/
export function DynamicForm({
workflowId,
activityId,
instanceId,
onSuccess,
onError,
className,
}: DynamicFormProps) {
const { workflows } = useZino();
const queryClient = useQueryClient();
const [formData, setFormData] = useState<Record<string, string>>({});
// Fetch form schema
const { data: form, isLoading: formLoading, error: formError } = useQuery({
queryKey: ["form", workflowId, activityId, instanceId],
queryFn: () => workflows.getForm(workflowId, activityId, "desktop", instanceId),
});
// Submit mutation
const submit = useMutation({
mutationFn: async (data: Record<string, string>) => {
if (instanceId) {
return workflows.performActivity(workflowId, instanceId, activityId, data);
} else {
return workflows.startWorkflow(workflowId, activityId, data);
}
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ["recordview"] });
queryClient.invalidateQueries({ queryKey: ["instance"] });
queryClient.invalidateQueries({ queryKey: ["detailview"] });
queryClient.invalidateQueries({ queryKey: ["audit"] });
onSuccess?.(result);
},
onError: (err) => {
onError?.(err);
},
});
const handleChange = (fieldId: string, value: string) => {
setFormData((prev) => ({ ...prev, [fieldId]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Map form field IDs to their mapped_activity_field_id
const mapped: Record<string, string> = {};
for (const field of form?.form_json?.fields ?? []) {
const key = field.mapped_activity_field_id || field.id;
mapped[key] = formData[field.id] ?? "";
}
submit.mutate(mapped);
};
if (formLoading) {
return <div className="flex justify-center py-8"><div className="animate-spin h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full" /></div>;
}
if (formError) {
return <div className="text-red-600 text-sm py-4">Failed to load form: {(formError as any)?.message ?? "Unknown error"}</div>;
}
const fields: FormField[] = form?.form_json?.fields ?? [];
return (
<form onSubmit={handleSubmit} className={className}>
{form?.form_json?.title && (
<h3 className="text-lg font-semibold mb-4">{form.form_json.title}</h3>
)}
<div className="space-y-4">
{fields.map((field) => (
<div key={field.id}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{field.label}
</label>
{renderField(field, formData[field.id] ?? "", (v) => handleChange(field.id, v))}
</div>
))}
</div>
{submit.error && (
<div className="mt-3 text-sm text-red-600">
{(submit.error as any)?.message ?? "Submission failed"}
</div>
)}
<button
type="submit"
disabled={submit.isPending}
className="mt-6 w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium py-2.5 px-4 rounded-lg transition-colors"
>
{submit.isPending ? "Submitting..." : "Submit"}
</button>
</form>
);
}
function renderField(
field: FormField,
value: string,
onChange: (value: string) => void,
) {
const baseClass =
"w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent";
switch (field.type) {
case "paragraph":
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={4}
className={baseClass}
placeholder={field.label}
/>
);
case "number":
return (
<input
type="number"
value={value}
onChange={(e) => onChange(e.target.value)}
className={baseClass}
placeholder={field.label}
/>
);
case "text":
default:
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
className={baseClass}
placeholder={field.label}
/>
);
}
}

88
src/zino-sdk/auth.ts Normal file
View File

@ -0,0 +1,88 @@
import type { ZinoClient } from "./client";
import type { LoginResponse, User } from "./types";
import { mockDelay, MOCK_USER } from "./mock";
/**
* Authentication methods — login, logout, and JWT decoding.
* Maps to user-service endpoints. Signs in as an API session
* (`client_type: "api"`): the answer carries a refresh token in the body,
* which the client uses to renew the 15-minute access token.
*/
export class AuthService {
private client: ZinoClient;
private defaultOrgId?: string;
/** defaultOrgId is sent with sign-in when the caller passes no orgId. */
constructor(client: ZinoClient, defaultOrgId?: string) {
this.client = client;
this.defaultOrgId = defaultOrgId || undefined;
}
async login(
email: string,
password: string,
orgId?: string,
): Promise<LoginResponse> {
if (this.client.isMock) {
await mockDelay();
const res: LoginResponse = {
token: "mock-jwt-token-" + Date.now(),
user: { ...MOCK_USER },
};
this.client.setToken(res.token);
return res;
}
const org = orgId || this.defaultOrgId;
const res = await this.client.request<LoginResponse>("POST", "/usr/login", {
email,
password,
client_type: "api",
...(org ? { org_id: org } : {}),
});
this.client.setToken(res.access_token ?? res.token);
this.client.setRefreshToken(res.refresh_token ?? null);
return res;
}
/**
* Ends the session server-side (`POST /usr/logout {refresh_token}`), then
* clears both stored tokens. Best effort: an unreachable server must not
* keep the user signed in here.
*/
async logout(): Promise<void> {
const refreshToken = this.client.getRefreshToken();
if (!this.client.isMock && refreshToken) {
try {
await this.client.request<unknown>("POST", "/usr/logout", {
refresh_token: refreshToken,
});
} catch {
// Already ended server-side, or offline — clear locally either way.
}
}
this.client.setToken(null);
this.client.setRefreshToken(null);
}
getCurrentUser(): User | null {
if (this.client.isMock) return { ...MOCK_USER };
const token = this.client.getToken();
if (!token) return null;
try {
const payload = JSON.parse(atob(token.split(".")[1]));
return {
id: payload.user_id ?? payload.sub,
org_id: payload.org_id ?? "",
name: payload.name ?? "",
email: payload.email ?? "",
roles: payload.roles ?? [],
groups: payload.groups ?? [],
role_assignments: payload.role_assignments ?? [],
};
} catch {
return null;
}
}
}

212
src/zino-sdk/client.ts Normal file
View File

@ -0,0 +1,212 @@
import type { ApiError, ZinoClientConfig } from "./types";
const TOKEN_KEY = "zino_token";
const REFRESH_TOKEN_KEY = "zino_refresh_token";
const REFRESH_PATH = "/usr/session/refresh";
// Sign-in / sign-out routes: a 401 from them is an answer (bad credentials),
// never a cue to refresh the session.
const AUTH_PATHS = ["/usr/login", "/usr/logout", REFRESH_PATH];
/**
* Core HTTP client for the Zino API.
*
* Signs in as an API session (`client_type: "api"`): the 15-minute access
* token and the refresh token are persisted in localStorage so sessions
* survive page refresh. On a 401 the client refreshes once through
* `POST /usr/session/refresh` and replays the request. The refresh token
* rotates on every refresh, so the answer's token always replaces the stored
* one.
*/
export class ZinoClient {
readonly baseUrl: string;
readonly appId: string;
private token: string | null = null;
private refreshToken: string | null = null;
private onAuthError?: () => void;
// Unix ms of the last call made through request() that the server
// ANSWERED — the idle signal a refresh reports. It advances only on a
// non-401 answer, so the call that hits the 401 never counts: the refresh
// it triggers reports the activity BEFORE it, and after a long idle gap the
// server sees the gap and ends the session (spec §6.4.1). 0 until the first
// answer (the server then keeps its own).
private lastActivityAt = 0;
// One refresh at a time: concurrent 401s wait for the same one, so the
// token is never rotated twice in a race.
private refreshing: Promise<boolean> | null = null;
constructor(config: ZinoClientConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
this.appId = (config.appId ?? "").trim();
this.onAuthError = config.onAuthError;
// Restore tokens from localStorage on init
if (typeof window !== "undefined") {
this.token = localStorage.getItem(TOKEN_KEY);
this.refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
}
}
get isMock(): boolean {
return this.baseUrl === "mock";
}
setToken(token: string | null): void {
this.token = token;
if (typeof window !== "undefined") {
if (token) {
localStorage.setItem(TOKEN_KEY, token);
} else {
localStorage.removeItem(TOKEN_KEY);
}
}
}
getToken(): string | null {
return this.token;
}
setRefreshToken(token: string | null): void {
this.refreshToken = token;
if (typeof window !== "undefined") {
if (token) {
localStorage.setItem(REFRESH_TOKEN_KEY, token);
} else {
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
}
}
getRefreshToken(): string | null {
return this.refreshToken;
}
async request<T>(
method: "GET" | "POST" | "PUT" | "DELETE",
path: string,
body?: unknown,
): Promise<T> {
const isAuthPath = AUTH_PATHS.some((p) => path.startsWith(p));
const sentAt = Date.now();
let res = await this.send(method, path, body);
// One body refresh, then one replay. A network failure during the refresh
// rejects like any other network failure — it is never a logout.
if (res.status === 401 && !isAuthPath && this.refreshToken) {
if (await this.refreshOnce()) {
res = await this.send(method, path, body);
}
}
// Stamp only now, once answered with something other than 401 (the
// original call, or its replay after a successful refresh).
if (!isAuthPath && res.status !== 401) {
this.lastActivityAt = Math.max(this.lastActivityAt, sentAt);
}
if (res.status === 401) {
this.token = null;
this.onAuthError?.();
const err: ApiError = { status: 401, message: "Unauthorized" };
throw err;
}
if (!res.ok) {
let message = res.statusText;
try {
const errBody = (await res.json()) as { error?: string };
if (errBody.error) message = errBody.error;
} catch {
// body wasn't JSON
}
const err: ApiError = { status: res.status, message };
throw err;
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
private send(
method: "GET" | "POST" | "PUT" | "DELETE",
path: string,
body?: unknown,
): Promise<Response> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`;
}
// credentials "omit": the SDK authenticates by Bearer / body token only.
// On the platform's own origin the browser would otherwise attach the
// platform's refresh cookie, and /usr/logout would end the platform's
// browser session instead of this API session.
return fetch(`${this.baseUrl}${this.scopedPath(path)}`, {
method,
credentials: "omit",
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
/**
* Routes an app call under `/app/<appId>` when the client is bound to an
* app. Sign-in, sign-out and refresh live on the platform host itself.
*/
scopedPath(path: string): string {
if (!this.appId || path.startsWith("/usr/")) return path;
return `/app/${encodeURIComponent(this.appId)}${path}`;
}
private refreshOnce(): Promise<boolean> {
if (!this.refreshing) {
this.refreshing = this.refreshSession().finally(() => {
this.refreshing = null;
});
}
return this.refreshing;
}
/**
* POST /usr/session/refresh {refresh_token, last_activity_at}. Resolves
* true with both tokens replaced; false when the server ended the session
* (401 — idle, absolute, revoked), after dropping both stored tokens.
* Rejects on a network failure, keeping the tokens for the next attempt.
*/
private async refreshSession(): Promise<boolean> {
const res = await fetch(`${this.baseUrl}${REFRESH_PATH}`, {
method: "POST",
credentials: "omit",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
refresh_token: this.refreshToken,
last_activity_at: this.lastActivityAt,
}),
});
if (res.status === 401) {
this.setToken(null);
this.setRefreshToken(null);
return false;
}
if (!res.ok) {
const err: ApiError = { status: res.status, message: res.statusText };
throw err;
}
const data = (await res.json()) as {
access_token: string;
refresh_token?: string;
};
this.setToken(data.access_token);
if (data.refresh_token) {
this.setRefreshToken(data.refresh_token);
}
return true;
}
}

61
src/zino-sdk/index.ts Normal file
View File

@ -0,0 +1,61 @@
// ---------------------------------------------------------------------------
// Zino Service SDK — Public API
// ---------------------------------------------------------------------------
// Core client
export { ZinoClient } from "./client";
// Service modules
export { AuthService } from "./auth";
export { WorkflowService } from "./workflow";
export { ViewService } from "./views";
// React bindings
export { ZinoProvider, useZino, queryClient } from "./provider";
// Components
export { DynamicForm } from "./DynamicForm";
// Re-export TanStack Query hooks for convenience
export { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
// Types
export type {
User,
RoleAssignment,
LoginRequest,
LoginResponse,
App,
WorkflowDefinition,
State,
Transition,
Activity,
ActivityDataField,
AllowedRole,
WorkflowDataField,
GridColumn,
DataType,
StageGate,
StageGateRuleSet,
SGRuleOutcome,
WorkflowInstance,
ActivityLogEntry,
FormFieldType,
FormField,
FormConfig,
ActivityForm,
ViewField,
ViewFieldType,
ActionConfig,
Column,
SortDir,
TabularViewParams,
TabularViewResponse,
ActivityEntry,
InstanceReport,
DetailViewResponse,
ReportSection,
StateTransition,
ZinoClientConfig,
ApiError,
} from "./types";

243
src/zino-sdk/mock.ts Normal file
View File

@ -0,0 +1,243 @@
import type {
ActivityForm,
ActivityLogEntry,
Column,
InstanceReport,
ReportSection,
StateTransition,
TabularViewParams,
TabularViewResponse,
User,
WorkflowDefinition,
WorkflowInstance,
} from "./types";
export function mockDelay(): Promise<void> {
const ms = 200 + Math.random() * 200;
return new Promise((r) => setTimeout(r, ms));
}
function randomId(): string {
return Math.random().toString(36).slice(2, 10);
}
function isoNow(): string {
return new Date().toISOString();
}
function daysAgo(n: number): string {
const d = new Date();
d.setDate(d.getDate() - n);
return d.toISOString();
}
export const MOCK_USER: User = {
id: "usr-001",
org_id: "org-acme",
name: "Jane Doe",
email: "jane@acme.corp",
roles: ["admin", "reviewer"],
groups: ["engineering"],
role_assignments: [
{ role_id: "admin", positions: ["manager"] },
{ role_id: "reviewer" },
],
};
export const MOCK_WORKFLOW_DEF: WorkflowDefinition = {
workflow_id: "wf-support-ticket",
version: 1,
name: "Support Ticket",
states: [
{ uid: "s-new", name: "New", type: "initial", allowed_activities: ["a-init"] },
{ uid: "s-open", name: "Open", allowed_activities: ["a-assign", "a-close"] },
{ uid: "s-in-progress", name: "In Progress", allowed_activities: ["a-resolve", "a-escalate"] },
{ uid: "s-resolved", name: "Resolved", allowed_activities: ["a-reopen", "a-close"] },
{ uid: "s-closed", name: "Closed", type: "terminal", allowed_activities: [] },
],
activities: [
{ uid: "a-init", name: "Create Ticket", type: "INIT", data_fields: [
{ id: "f-title", name: "title", type: "local", data_type: "text", mandatory: true },
{ id: "f-desc", name: "description", type: "local", data_type: "longtext", mandatory: false },
{ id: "f-priority", name: "priority", type: "local", data_type: "text", mandatory: true },
] },
{ uid: "a-assign", name: "Assign Agent", type: "USER", data_fields: [
{ id: "f-agent", name: "assigned_to", type: "local", data_type: "text", mandatory: true },
] },
{ uid: "a-resolve", name: "Resolve", type: "USER", data_fields: [
{ id: "f-resolution", name: "resolution_notes", type: "local", data_type: "longtext", mandatory: true },
] },
{ uid: "a-escalate", name: "Escalate", type: "USER" },
{ uid: "a-reopen", name: "Reopen", type: "USER" },
{ uid: "a-close", name: "Close", type: "USER" },
],
transitions: [
{ from_state_id: "s-new", by_activity_id: "a-init", transition_type: "direct", to_state_id: "s-open" },
{ from_state_id: "s-open", by_activity_id: "a-assign", transition_type: "direct", to_state_id: "s-in-progress" },
{ from_state_id: "s-in-progress", by_activity_id: "a-resolve", transition_type: "direct", to_state_id: "s-resolved" },
{ from_state_id: "s-in-progress", by_activity_id: "a-escalate", transition_type: "direct", to_state_id: "s-open" },
{ from_state_id: "s-resolved", by_activity_id: "a-reopen", transition_type: "direct", to_state_id: "s-open" },
{ from_state_id: "s-resolved", by_activity_id: "a-close", transition_type: "direct", to_state_id: "s-closed" },
{ from_state_id: "s-open", by_activity_id: "a-close", transition_type: "direct", to_state_id: "s-closed" },
],
workflow_data_fields: [
{ id: "wdf-title", uid: "wdf-title", name: "title", data_type: "text" },
{ id: "wdf-desc", uid: "wdf-desc", name: "description", data_type: "longtext" },
{ id: "wdf-priority", uid: "wdf-priority", name: "priority", data_type: "text" },
],
};
const NAMES = ["Alice Johnson", "Bob Ramirez", "Chandra Patel", "Dina Sokolov", "Emeka Obi"];
const PRIORITIES = ["High", "Medium", "Low"];
const STATUSES = ["Open", "In Progress", "Resolved", "Closed"];
const TITLES = [
"Login page not loading",
"Payment gateway timeout",
"Dashboard chart misaligned",
"Export CSV returns empty file",
"Password reset email delayed",
"Mobile sidebar overlaps content",
"Search returns stale results",
"Role permissions not syncing",
];
export function mockWorkflowInstance(workflowId: string, instanceId?: string): WorkflowInstance {
return {
instance_id: instanceId ?? `inst-${randomId()}`,
workflow_id: workflowId,
workflow_version: 1,
current_state_id: "s-open",
data: {
title: TITLES[Math.floor(Math.random() * TITLES.length)],
priority: PRIORITIES[Math.floor(Math.random() * PRIORITIES.length)],
description: "Detailed description of the reported issue.",
},
created_at: daysAgo(3),
updated_at: isoNow(),
};
}
export function mockActivityForm(workflowId: string, activityId: string): ActivityForm {
return {
id: `form-${randomId()}`,
workflow_id: workflowId,
activity_id: activityId,
form_id: `form-${activityId}`,
device_type: "desktop",
form_json: {
title: activityId === "a-init" ? "Create Ticket" : "Update Ticket",
fields: [
{ id: "f1", label: "Title", type: "text", mapped_activity_field_id: "f-title" },
{ id: "f2", label: "Description", type: "paragraph", mapped_activity_field_id: "f-desc" },
{ id: "f3", label: "Priority", type: "text", mapped_activity_field_id: "f-priority" },
],
},
created_at: daysAgo(30),
};
}
export function mockTabularResponse(params: TabularViewParams): TabularViewResponse {
const pageSize = params.pageSize ?? 10;
const page = params.page ?? 1;
const total = 47;
const columns: Column[] = [
{ key: "instance_id", label: "ID", data_type: "text", sortable: true, filterable: false, searchable: true },
{ key: "title", label: "Title", data_type: "text", sortable: true, filterable: false, searchable: true },
{ key: "priority", label: "Priority", data_type: "text", sortable: true, filterable: true, searchable: false },
{ key: "status", label: "Status", data_type: "text", sortable: true, filterable: true, searchable: false },
{ key: "assigned_to", label: "Assigned To", data_type: "text", sortable: true, filterable: true, searchable: true },
{ key: "created_at", label: "Created", data_type: "text", sortable: true, filterable: false, searchable: false },
];
const rows: Record<string, unknown>[] = [];
const count = Math.min(pageSize, total - (page - 1) * pageSize);
for (let i = 0; i < count; i++) {
const idx = (page - 1) * pageSize + i;
rows.push({
instance_id: `TICKET-${1000 + idx}`,
title: TITLES[idx % TITLES.length],
priority: PRIORITIES[idx % PRIORITIES.length],
status: STATUSES[idx % STATUSES.length],
assigned_to: NAMES[idx % NAMES.length],
created_at: daysAgo(total - idx),
});
}
return { rows, total, page, columns };
}
export function mockInstanceReport(instanceId: string): {
report: InstanceReport;
sections: ReportSection[];
history: StateTransition[];
} {
const report: InstanceReport = {
workflow_id: "wf-support-ticket",
instance_id: instanceId,
workflow_version: 1,
current_state_id: "s-in-progress",
current_state_name: "In Progress",
global_data: {
title: "Payment gateway timeout",
description: "Users report intermittent 504 errors during checkout.",
priority: "High",
assigned_to: "Bob Ramirez",
},
activities: [
{ activity_id: "a-init", data: { title: "Payment gateway timeout" }, activity_performed_at: daysAgo(5), performed_by_id: "usr-001" },
{ activity_id: "a-assign", data: { assigned_to: "Bob Ramirez" }, activity_performed_at: daysAgo(4), performed_by_id: "usr-001" },
],
created_at: daysAgo(5),
updated_at: daysAgo(4),
};
const sections: ReportSection[] = [
{
title: "Instance Details",
fields: [
{ label: "Instance ID", value: instanceId },
{ label: "Workflow", value: "Support Ticket" },
{ label: "Current State", value: "In Progress" },
{ label: "Created", value: report.created_at },
{ label: "Updated", value: report.updated_at },
],
},
{
title: "Data",
fields: Object.entries(report.global_data).map(([k, v]) => ({ label: k, value: v })),
},
];
const history: StateTransition[] = [
{ from_state: "New", to_state: "Open", activity_name: "Create Ticket", performed_by: "Jane Doe", timestamp: daysAgo(5), data: {} },
{ from_state: "Open", to_state: "In Progress", activity_name: "Assign Agent", performed_by: "Jane Doe", timestamp: daysAgo(4), data: { assigned_to: "Bob Ramirez" } },
];
return { report, sections, history };
}
export function mockAuditLog(): ActivityLogEntry[] {
return [
{
id: 1,
user_id: "usr-001",
user_roles: ["admin"],
user_groups: ["engineering"],
activity_id: "a-init",
data: { title: "Payment gateway timeout", priority: "High" },
execution_state: "s-open",
created_at: daysAgo(5),
},
{
id: 2,
user_id: "usr-001",
user_roles: ["admin"],
user_groups: ["engineering"],
activity_id: "a-assign",
data: { assigned_to: "Bob Ramirez" },
execution_state: "s-in-progress",
created_at: daysAgo(4),
},
];
}

148
src/zino-sdk/provider.tsx Normal file
View File

@ -0,0 +1,148 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ZinoClient } from "./client";
import { AuthService } from "./auth";
import { WorkflowService } from "./workflow";
import { ViewService } from "./views";
import type { User } from "./types";
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
export interface ZinoContextValue {
client: ZinoClient;
auth: AuthService;
workflows: WorkflowService;
views: ViewService;
user: User | null;
setUser: (user: User | null) => void;
}
const ZinoContext = createContext<ZinoContextValue | null>(null);
const MOCK_SESSION_KEY = "zino_mock_session";
// Shared QueryClient — sensible defaults for generated apps.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30s before refetch
retry: 1,
refetchOnWindowFocus: false,
},
},
});
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export interface ZinoProviderProps {
baseUrl: string;
/** The app this frontend serves; see ZinoClientConfig.appId. */
appId?: string;
/** The app's organisation, passed to sign-in so the right tenant answers. */
orgId?: string;
children: React.ReactNode;
}
/**
* Wrap your app with `<ZinoProvider>` to get access to Zino services
* and TanStack Query throughout the component tree.
*
* ```tsx
* <ZinoProvider baseUrl="http://localhost:8091">
* <App />
* </ZinoProvider>
* ```
*/
export function ZinoProvider({ baseUrl, appId, orgId, children }: ZinoProviderProps) {
const [user, setUserState] = useState<User | null>(null);
const viteMock = import.meta.env.VITE_ZINO_MOCK === "true";
const resolvedUrl = baseUrl === "mock" || viteMock ? "mock" : baseUrl;
const isMock = resolvedUrl === "mock";
const setUser = useMemo(() => (u: User | null) => {
setUserState(u);
if (isMock) {
if (u) {
sessionStorage.setItem(MOCK_SESSION_KEY, "1");
} else {
sessionStorage.removeItem(MOCK_SESSION_KEY);
}
}
}, [isMock]);
// Restore session on mount — from localStorage token or mock sessionStorage.
useEffect(() => {
if (isMock && sessionStorage.getItem(MOCK_SESSION_KEY)) {
const authSvc = new AuthService(new ZinoClient({ baseUrl: "mock" }));
const mockUser = authSvc.getCurrentUser();
if (mockUser) setUserState(mockUser);
} else {
// Real mode — check if a token exists in localStorage
const client = new ZinoClient({ baseUrl: resolvedUrl, appId });
if (client.getToken()) {
const authSvc = new AuthService(client);
const restored = authSvc.getCurrentUser();
if (restored) setUserState(restored);
}
}
}, []);
const value = useMemo<ZinoContextValue>(() => {
const client = new ZinoClient({
baseUrl: resolvedUrl,
appId,
onAuthError: () => setUser(null),
});
return {
client,
auth: new AuthService(client, orgId),
workflows: new WorkflowService(client),
views: new ViewService(client),
user,
setUser,
};
}, [resolvedUrl, appId, orgId, setUser]);
const ctx = useMemo(
() => ({ ...value, user, setUser }),
[value, user, setUser],
);
return (
<QueryClientProvider client={queryClient}>
<ZinoContext.Provider value={ctx}>{children}</ZinoContext.Provider>
</QueryClientProvider>
);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* Access Zino services. Use with TanStack Query:
*
* ```tsx
* const { views } = useZino();
* const { data } = useQuery({
* queryKey: ['recordview', rvId],
* queryFn: () => views.getTabularView(rvId, { page: 1 }),
* });
* ```
*/
export function useZino(): ZinoContextValue {
const ctx = useContext(ZinoContext);
if (!ctx) {
throw new Error("useZino must be used within a <ZinoProvider>");
}
return ctx;
}
/** Re-export queryClient for direct invalidation in mutations. */
export { queryClient };

302
src/zino-sdk/types.ts Normal file
View File

@ -0,0 +1,302 @@
// ---------------------------------------------------------------------------
// Zino Service SDK — TypeScript Interfaces
// ---------------------------------------------------------------------------
// ---- Auth & User ----
export interface RoleAssignment {
role_id: string;
positions?: string[];
}
export interface User {
id: string;
org_id: string;
name: string;
email: string;
roles: string[];
groups: string[];
role_assignments: RoleAssignment[];
}
export interface LoginRequest {
email: string;
password: string;
org_id?: string;
/** "api" = API session: refresh_token in the body, no cookie. */
client_type?: "api";
}
export interface LoginResponse {
/** Legacy token while the server's legacy mode is on, else = access_token. */
token: string;
/** 15-minute session access token. */
access_token?: string;
/** access_token's expiry, unix seconds. */
expires_at?: number;
sid?: string;
/** API sessions only. Rotates on every refresh. */
refresh_token?: string;
user: User;
}
export interface App {
id: string;
name: string;
description?: string;
org_id: string;
}
// ---- Workflow Definition ----
export interface AllowedRole {
role: string;
positions?: string;
}
export interface State {
uid: string;
name: string;
type?: string;
allowed_activities: string[];
}
export interface Transition {
from_state_id: string;
by_activity_id?: string;
by_outcome?: string;
transition_type: string;
to_state_id?: string;
stage_gate_id?: string;
}
export type DataType = "text" | "longtext" | "number" | "grid";
export interface GridColumn {
id: string;
name: string;
data_type: DataType;
}
export interface WorkflowDataField {
id: string;
uid: string;
name: string;
data_type: DataType;
columns?: GridColumn[];
}
export interface ActivityDataField {
id: string;
name: string;
type: "local" | "mapped";
data_type?: DataType;
mandatory: boolean;
mapped_workflow_field?: string;
columns?: GridColumn[];
}
export interface Activity {
uid: string;
name: string;
type: string;
allowed_roles?: AllowedRole[];
allowed_groups?: string[];
dynamic_rbac_allowed?: boolean;
only_roles_positions?: boolean;
data_fields?: ActivityDataField[];
trigger_uid?: string;
}
export interface StageGate {
id: string;
rules_id: string;
}
export interface SGRuleOutcome {
state_id: string;
sg_rule_config?: {
condition_field?: string;
condition_operator?: string;
condition_value?: unknown;
};
}
export interface StageGateRuleSet {
id: string;
default_state: string;
other_states: SGRuleOutcome[];
}
export interface WorkflowDefinition {
workflow_id: string;
version: number;
name: string;
states: State[];
activities: Activity[];
transitions: Transition[];
workflow_data_fields: WorkflowDataField[];
stage_gates?: StageGate[];
sg_rules?: StageGateRuleSet[];
}
// ---- Workflow Instances ----
export interface WorkflowInstance {
instance_id: string;
workflow_id: string;
workflow_version?: number;
current_state_id: string;
current_state_name?: string;
data: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface ActivityLogEntry {
id: number;
user_id: string;
user_roles: string[];
user_groups: string[];
activity_id: string;
data: Record<string, unknown>;
execution_state: string;
created_at: string;
}
// ---- Forms ----
export type FormFieldType = "text" | "paragraph" | "number";
export interface FormField {
id: string;
label: string;
type: FormFieldType;
mapped_activity_field_id: string;
}
export interface FormConfig {
title: string;
fields: FormField[];
}
export interface ActivityForm {
id: string;
workflow_id: string;
activity_id: string;
form_id: string;
device_type: string;
form_json: FormConfig;
created_at: string;
}
// ---- Views ----
export interface ActionConfig {
label: string;
activity_uid: string;
workflow_id?: string;
}
export type ViewFieldType =
| "global"
| "local"
| "system-global"
| "system-local"
| "action";
export interface ViewField {
type: ViewFieldType;
field_key: string;
output_label: string;
activity_id?: string;
action?: ActionConfig;
data_type: string;
is_filter: boolean;
is_search: boolean;
}
export interface Column {
key: string;
label: string;
data_type: string;
sortable: boolean;
filterable: boolean;
searchable: boolean;
}
export type SortDir = "asc" | "desc";
export interface TabularViewParams {
page?: number;
pageSize?: number;
sortBy?: string;
sortDir?: SortDir;
filters?: Record<string, string>;
search?: string;
}
export interface TabularViewResponse {
rows: Record<string, unknown>[];
total: number;
totalPages?: number;
page: number;
columns: Column[];
}
export interface ActivityEntry {
activity_id: string;
data: Record<string, unknown>;
activity_performed_at: string;
performed_by_id: string;
}
export interface InstanceReport {
workflow_id: string;
instance_id: string;
workflow_version: number;
current_state_id: string;
current_state_name: string;
global_data: Record<string, unknown>;
activities: ActivityEntry[];
created_at: string;
updated_at: string;
}
export interface StateTransition {
from_state: string;
to_state: string;
activity_name: string;
performed_by: string;
timestamp: string;
data: Record<string, unknown>;
}
export interface ReportSection {
title: string;
fields: { label: string; value: unknown }[];
}
export interface DetailViewResponse {
data: Record<string, unknown>;
sections: ReportSection[];
}
// ---- SDK-internal ----
export interface ZinoClientConfig {
baseUrl: string;
/**
* The app this frontend serves. When set, every call except sign-in and
* session paths (`/usr/...`) goes to `/app/<appId>` + path on baseUrl,
* which is how the platform host routes one app's workflow and view API.
* Leave unset when baseUrl already points at one app's runtime.
*/
appId?: string;
onAuthError?: () => void;
}
export interface ApiError {
status: number;
message: string;
}

197
src/zino-sdk/views.ts Normal file
View File

@ -0,0 +1,197 @@
import type { ZinoClient } from "./client";
import type {
ActivityLogEntry,
Column,
DetailViewResponse,
InstanceReport,
ReportSection,
StateTransition,
TabularViewParams,
TabularViewResponse,
} from "./types";
import {
mockDelay,
mockTabularResponse,
mockInstanceReport,
mockAuditLog,
} from "./mock";
/**
* View and reporting methods — tabular record views, detail views,
* and audit trails. Maps to view-service endpoints.
*/
export class ViewService {
private client: ZinoClient;
constructor(client: ZinoClient) {
this.client = client;
}
/**
* Fetch a paginated, sortable, filterable record view.
* Maps to `GET /view/recordview?rv_id=…`.
*/
async getTabularView(
viewId: string,
params: TabularViewParams = {},
): Promise<TabularViewResponse> {
if (this.client.isMock) {
await mockDelay();
return mockTabularResponse(params);
}
const qs = new URLSearchParams();
qs.set("rv_id", viewId);
if (params.page) qs.set("page", String(params.page));
if (params.pageSize) qs.set("limit", String(params.pageSize));
if (params.sortBy) qs.set("sort_by", params.sortBy);
if (params.sortDir) qs.set("sort_dir", params.sortDir);
if (params.search) qs.set("search", params.search);
if (params.filters) {
for (const [k, v] of Object.entries(params.filters)) {
qs.set(`filter.${k}`, v);
}
}
const raw = await this.client.request<{
config: { fields: Array<{ field_key: string; output_label: string; data_type: string; is_filter: boolean; is_search: boolean }> };
data: Record<string, unknown>[];
pagination?: { page: number; limit: number; total_count: number; total_pages: number };
}>("GET", `/view/recordview?${qs.toString()}`);
const columns: Column[] = raw.config.fields
.filter((f) => f.field_key !== "")
.map((f) => ({
key: f.field_key,
label: f.output_label,
data_type: f.data_type,
sortable: true,
filterable: f.is_filter,
searchable: f.is_search,
}));
const rows = Array.isArray(raw.data) ? raw.data : [];
return {
rows,
total: raw.pagination?.total_count ?? rows.length,
totalPages: raw.pagination?.total_pages,
page: raw.pagination?.page ?? params.page ?? 1,
columns,
};
}
/**
* Fetch a detail view for a specific workflow instance.
* Maps to `GET /view/detailview?dv_id=…&instance_id=…`.
*/
async getDetailView(viewId: string, instanceId: string): Promise<DetailViewResponse> {
if (this.client.isMock) {
await mockDelay();
const data: Record<string, unknown> = {
instance_id: instanceId,
view_id: viewId,
status: "Open",
created_at: new Date().toISOString(),
};
return {
data,
sections: [
{
title: "Details",
fields: Object.entries(data).map(([k, v]) => ({ label: k, value: v })),
},
],
};
}
const raw = await this.client.request<{
config: { fields: Array<{ field_key: string; output_label: string; data_type: string }> };
data: Record<string, unknown>;
}>("GET", `/view/detailview?dv_id=${encodeURIComponent(viewId)}&instance_id=${encodeURIComponent(instanceId)}`);
const data = raw.data ?? {};
const fields = (raw.config?.fields ?? []).filter((f) => f.field_key !== "");
const sections: ReportSection[] = [
{
title: "Details",
fields: fields.length > 0
? fields.map((f) => ({ label: f.output_label, value: data[f.field_key] }))
: Object.entries(data).map(([k, v]) => ({ label: k, value: v })),
},
];
return { data, sections };
}
/**
* Fetch a full instance report — global data, activity history,
* and state transitions.
*/
async getInstanceReport(
workflowId: string,
instanceId: string,
): Promise<{ report: InstanceReport; sections: ReportSection[]; history: StateTransition[] }> {
if (this.client.isMock) {
await mockDelay();
return mockInstanceReport(instanceId);
}
const [instance, audit] = await Promise.all([
this.client.request<InstanceReport>("POST", "/view/instance", {
workflow_id: workflowId,
instance_id: instanceId,
}),
this.client.request<ActivityLogEntry[]>(
"GET",
`/view/audit?instance_id=${encodeURIComponent(instanceId)}`,
),
]);
const sections: ReportSection[] = [
{
title: "Instance Details",
fields: [
{ label: "Instance ID", value: instance.instance_id },
{ label: "Workflow", value: instance.workflow_id },
{ label: "Current State", value: instance.current_state_name },
{ label: "Created", value: instance.created_at },
{ label: "Updated", value: instance.updated_at },
],
},
{
title: "Data",
fields: Object.entries((instance as any).data ?? instance.global_data ?? {}).map(([k, v]) => ({
label: k,
value: v,
})),
},
];
const history: StateTransition[] = audit.map((entry) => ({
from_state: "",
to_state: entry.execution_state,
activity_name: entry.activity_id,
performed_by: entry.user_id,
timestamp: entry.created_at,
data: entry.data,
}));
return { report: instance, sections, history };
}
/**
* Fetch the raw audit log for an instance.
*/
async getAuditLog(instanceId: string): Promise<ActivityLogEntry[]> {
if (this.client.isMock) {
await mockDelay();
return mockAuditLog();
}
return this.client.request<ActivityLogEntry[]>(
"GET",
`/audit?instance_id=${encodeURIComponent(instanceId)}`,
);
}
}

223
src/zino-sdk/workflow.ts Normal file
View File

@ -0,0 +1,223 @@
import type { ZinoClient } from "./client";
import type {
Activity,
ActivityForm,
WorkflowDefinition,
WorkflowInstance,
} from "./types";
import {
mockDelay,
MOCK_WORKFLOW_DEF,
mockWorkflowInstance,
mockActivityForm,
} from "./mock";
/**
* Workflow execution methods — start instances, perform activities,
* fetch definitions, forms, and instance state.
* Maps to core-service and view-service endpoints.
*/
export class WorkflowService {
private client: ZinoClient;
constructor(client: ZinoClient) {
this.client = client;
}
/**
* Start a new workflow instance.
* Core-service returns a wrapped response; we extract instance_id.
*/
async startWorkflow(
workflowId: string,
activityId: string,
data?: Record<string, unknown>,
): Promise<WorkflowInstance> {
if (this.client.isMock) {
await mockDelay();
return mockWorkflowInstance(workflowId);
}
const raw = await this.client.request<{
success: boolean;
instance_id?: string;
data?: Record<string, unknown>;
}>("POST", "/start", {
workflow_id: workflowId,
activity_id: activityId,
data,
});
return {
instance_id: raw.instance_id ?? "",
workflow_id: workflowId,
current_state_id: "",
current_state_name: "",
data: raw.data ?? {},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as WorkflowInstance;
}
/**
* Execute an activity on a running workflow instance.
* Core-service returns a wrapped response; we extract instance_id.
*/
async performActivity(
workflowId: string,
instanceId: string,
activityId: string,
data?: Record<string, unknown>,
): Promise<WorkflowInstance> {
if (this.client.isMock) {
await mockDelay();
return mockWorkflowInstance(workflowId, instanceId);
}
const raw = await this.client.request<{
success: boolean;
instance_id?: string;
data?: Record<string, unknown>;
}>("POST", "/activity", {
workflow_id: workflowId,
instance_id: instanceId,
activity_id: activityId,
data,
});
return {
instance_id: raw.instance_id ?? instanceId,
workflow_id: workflowId,
current_state_id: "",
current_state_name: "",
data: raw.data ?? {},
created_at: "",
updated_at: new Date().toISOString(),
} as WorkflowInstance;
}
/**
* Fetch instance state and compute available activities.
* Uses /api-docs to get the activity list (no states endpoint exists),
* so all non-INIT activities are returned — the server enforces real constraints.
*/
async getInstanceState(
workflowId: string,
instanceId: string,
): Promise<{
instance: WorkflowInstance;
availableActivities: Activity[];
}> {
if (this.client.isMock) {
await mockDelay();
return {
instance: mockWorkflowInstance(workflowId, instanceId),
availableActivities: MOCK_WORKFLOW_DEF.activities.filter(
(a) => a.type !== "INIT",
),
};
}
const [apiDocs, instance] = await Promise.all([
this.client.request<Array<{
activity_uid: string;
activity_name: string;
type: string;
}>>("GET", `/view/api-docs?workflow_id=${encodeURIComponent(workflowId)}`),
this.client.request<WorkflowInstance>("POST", "/view/instance", {
workflow_id: workflowId,
instance_id: instanceId,
}),
]);
// api-docs returns activity documentation; filter out INIT activities
const availableActivities: Activity[] = (apiDocs ?? [])
.filter((a) => a.type !== "INIT")
.map((a) => ({
uid: a.activity_uid,
name: a.activity_name,
type: a.type,
}));
return { instance, availableActivities };
}
/**
* Fetch the workflow definition.
* Note: /api-docs returns activity documentation (not full definition with states).
* We build a partial WorkflowDefinition from it — states/transitions will be empty.
*/
async getWorkflowDefinition(
workflowId: string,
): Promise<WorkflowDefinition> {
if (this.client.isMock) {
await mockDelay();
return { ...MOCK_WORKFLOW_DEF, workflow_id: workflowId };
}
const apiDocs = await this.client.request<Array<{
activity_uid: string;
activity_name: string;
type: string;
}>>("GET", `/view/api-docs?workflow_id=${encodeURIComponent(workflowId)}`);
return {
workflow_id: workflowId,
version: 1,
name: workflowId,
states: [],
activities: (apiDocs ?? []).map((a) => ({
uid: a.activity_uid,
name: a.activity_name,
type: a.type,
})),
transitions: [],
workflow_data_fields: [],
};
}
/**
* Fetch the form schema for a specific activity.
*/
async getForm(
workflowId: string,
activityId: string,
deviceType: string = "desktop",
instanceId?: string,
): Promise<ActivityForm> {
if (this.client.isMock) {
await mockDelay();
return mockActivityForm(workflowId, activityId);
}
return this.client.request<ActivityForm>("POST", "/form", {
workflow_uuid: workflowId,
activity_id: activityId,
device_type: deviceType,
...(instanceId ? { instance_id: instanceId } : {}),
});
}
/**
* Submit form data for an activity.
*/
async submitForm(
workflowId: string,
activityId: string,
formData: Record<string, unknown>,
deviceType: string = "desktop",
instanceId?: string,
): Promise<WorkflowInstance> {
if (this.client.isMock) {
await mockDelay();
return mockWorkflowInstance(workflowId, instanceId);
}
return this.client.request<WorkflowInstance>("POST", "/form/submit", {
workflow_uuid: workflowId,
activity_id: activityId,
device_type: deviceType,
form_data: formData,
...(instanceId ? { instance_id: instanceId } : {}),
});
}
}

6
tailwind.config.js Normal file
View File

@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: { extend: {} },
plugins: [],
}

19
tsconfig.app.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

15
tsconfig.node.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}

8
vite.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './',
server: { port: 5173 },
})