Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# @aidbox-forms/cli

Terminal demo app for `@aidbox-forms/renderer` using `@aidbox-forms/opentui-theme`.

## Run

```bash
pnpm -C cli dev -- --questionnaire site/stories/questionnaire/samples/text-controls.json --output response.json
```

On submit, the app exits and writes a `QuestionnaireResponse` JSON to `--output`.
If `--output` is omitted, it prints the response JSON to stdout and exits.

### Flags

- `--questionnaire <path>` (required)
- `--initial-response <path>`
- `--terminology-server-url <url>`
- `--output <path>`

## Notes

- Attachment upload is not supported in TUI (TBD).
31 changes: 31 additions & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@aidbox-forms/cli",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun src/index.tsx",
"lint": "eslint .",
"test": "echo \"@aidbox-forms/cli: no tests defined\""
},
"dependencies": {
"@aidbox-forms/opentui-theme": "workspace:*",
"@aidbox-forms/renderer": "workspace:*",
"@lhncbc/ucum-lhc": "7.1.3",
"@opentui/core": "^0.1.72",
"@opentui/react": "^0.1.72",
"classnames": "^2.5.1",
"fhirpath": "^4.6.0",
"mobx": "^6.15.0",
"mobx-react-lite": "^4.1.1",
"mobx-utils": "^6.1.1",
"react": "^19.2.0"
},
"devDependencies": {
"@types/bun": "^1.2.22",
"@types/fhir": "^0.0.41",
"@types/node": "^24.10.3",
"@types/react": "^19.2.2",
"typescript": "~5.9.3"
}
}
1 change: 1 addition & 0 deletions cli/src/css.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module "*.css";
259 changes: 259 additions & 0 deletions cli/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import Renderer from "@aidbox-forms/renderer";
import {
Provider as FocusProvider,
theme as opentuiTheme,
} from "@aidbox-forms/opentui-theme";
import { createCliRenderer } from "@opentui/core";
import { createRoot, useKeyboard, useRenderer } from "@opentui/react";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import type { Questionnaire, QuestionnaireResponse } from "fhir/r5";
import { useCallback, useEffect, useMemo } from "react";

type CliOptions = {
questionnairePath: string;
initialResponsePath: string | undefined;
terminologyServerUrl: string | undefined;
outputPath: string | undefined;
};

type CompletionState =
| { status: "submit"; response: QuestionnaireResponse }
| { status: "exit" };

type CompletionHandler = (state: CompletionState) => void;

type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
};

function createDeferred<T>(): Deferred<T> {
let resolve: ((value: T) => void) | undefined;
let reject: ((reason?: unknown) => void) | undefined;

const promise = new Promise<T>((resolveFunction, rejectFunction) => {
resolve = resolveFunction;
reject = rejectFunction;
});

if (!resolve || !reject) {
throw new Error("Failed to create deferred promise.");
}

return { promise, resolve, reject };
}

function usage(): string {
return `Usage:
pnpm -C cli dev -- --questionnaire <path> [--initial-response <path>] [--terminology-server-url <url>] [--output <path>]

Flags:
--questionnaire Path to Questionnaire JSON (required)
--initial-response Path to QuestionnaireResponse JSON
--terminology-server-url FHIR terminology server base URL
--output Write response JSON to this file on submit
`;
}

function findAttachmentItems(items: unknown): boolean {
if (!Array.isArray(items)) return false;

for (const entry of items) {
if (!entry || typeof entry !== "object") continue;

const item = entry as { type?: unknown; item?: unknown };

if (item.type === "attachment") {
return true;
}

if (findAttachmentItems(item.item)) {
return true;
}
}

return false;
}

async function readJsonFile<T>(filePath: string): Promise<T> {
const text = await readFile(filePath, "utf8");
return JSON.parse(text) as T;
}

function stringifyPretty(value: unknown): string {
return JSON.stringify(value, undefined, 2);
}

function App({
questionnaire,
initialResponse,
terminologyServerUrl,
hasAttachments,
onComplete,
}: {
questionnaire: Questionnaire;
initialResponse: QuestionnaireResponse | undefined;
terminologyServerUrl: string | undefined;
hasAttachments: boolean;
onComplete: CompletionHandler;
}) {
const renderer = useRenderer();

useEffect(() => {
if (!hasAttachments) return;

renderer.console.show();
console.warn(
"This Questionnaire contains attachment items. Upload is not supported in the TUI (TBD).",
);
}, [hasAttachments, renderer]);

useKeyboard((key) => {
if (key.eventType !== "press") return;

if (key.name === "escape") {
key.preventDefault();
key.stopPropagation();
onComplete({ status: "exit" });
}
});

const onSubmit = useCallback(
(response: QuestionnaireResponse) => {
onComplete({ status: "submit", response });
},
[onComplete],
);

const header = useMemo(() => {
return (
<box flexDirection="column" style={{ gap: 0, marginBottom: 1 }}>
<text>
Aidbox Forms TUI <span fg="#666666">(OpenTUI)</span>
</text>
<text fg="#666666">Tab navigate • Ctrl+S submit • Esc quit</text>
</box>
);
}, []);

return (
<FocusProvider>
<box flexDirection="column">
{header}
<Renderer
questionnaire={questionnaire}
initialResponse={initialResponse}
terminologyServerUrl={terminologyServerUrl}
theme={opentuiTheme}
onSubmit={onSubmit}
/>
</box>
</FocusProvider>
);
}

const moduleFilePath = fileURLToPath(import.meta.url);
const moduleDirectoryPath = path.dirname(moduleFilePath);
const workspaceRootPath = path.resolve(moduleDirectoryPath, "..", "..");
if (process.cwd() !== workspaceRootPath) {
process.chdir(workspaceRootPath);
}

const { values } = parseArgs({
options: {
questionnaire: { type: "string" },
"initial-response": { type: "string" },
"terminology-server-url": { type: "string" },
output: { type: "string" },
help: { type: "boolean" },
},
allowPositionals: false,
});

if (values.help) {
console.log(usage());
} else if (values.questionnaire) {
const options: CliOptions = {
questionnairePath: path.resolve(process.cwd(), values.questionnaire),
initialResponsePath: values["initial-response"]
? path.resolve(process.cwd(), values["initial-response"])
: undefined,
terminologyServerUrl: values["terminology-server-url"],
outputPath: values.output
? path.resolve(process.cwd(), values.output)
: undefined,
};

const questionnaire = await readJsonFile<Questionnaire>(
options.questionnairePath,
);
const initialResponse = options.initialResponsePath
? await readJsonFile<QuestionnaireResponse>(options.initialResponsePath)
: undefined;

const hasAttachments = findAttachmentItems(questionnaire.item);

if (hasAttachments) {
console.warn(
"Warning: attachment items detected; upload is not supported in the TUI (TBD).",
);
}

const renderer = await createCliRenderer();

const completion = createDeferred<CompletionState>();
let isCompleted = false;

const completeOnce: CompletionHandler = (state) => {
if (isCompleted) return;
isCompleted = true;
completion.resolve(state);
};

renderer.once("destroy", () => {
completeOnce({ status: "exit" });
});

const root = createRoot(renderer);
root.render(
<App
questionnaire={questionnaire}
initialResponse={initialResponse}
terminologyServerUrl={options.terminologyServerUrl}
hasAttachments={hasAttachments}
onComplete={completeOnce}
/>,
);

const completionState = await completion.promise;

renderer.destroy();

if (completionState.status === "submit") {
const serialized = stringifyPretty(completionState.response);

try {
if (options.outputPath) {
await writeFile(options.outputPath, serialized, "utf8");
} else {
process.stdout.write(`${serialized}\n`);
}

process.exitCode = 0;
} catch (error) {
console.error(error);
process.exitCode = 1;
}
} else {
process.exitCode = 0;
}
} else {
console.error("Missing required flag: --questionnaire");
console.log(usage());
process.exitCode = 1;
}
18 changes: 18 additions & 0 deletions cli/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"jsxImportSource": "@opentui/react",
"baseUrl": "..",
"paths": {
"@aidbox-forms/theme": ["packages/theme/lib"],
"@aidbox-forms/antd-theme": ["themes/antd-theme/lib"],
"@aidbox-forms/mantine-theme": ["themes/mantine-theme/lib"],
"@aidbox-forms/hs-theme": ["themes/hs-theme/lib"],
"@aidbox-forms/nshuk-theme": ["themes/nshuk-theme/lib"],
"@aidbox-forms/opentui-theme": ["themes/opentui-theme/lib"],
"@aidbox-forms/renderer": ["packages/renderer/lib"],
"@aidbox-forms/renderer/*": ["packages/renderer/lib/*"]
}
},
"include": ["src/**/*"]
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@opentui/react": "^0.1.72",
"@types/node": "^25.0.7",
"@types/react": "^19.2.2",
"eslint": "^9.39.2",
"react": "^19.2.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-storybook": "^10.1.11",
Expand Down
18 changes: 14 additions & 4 deletions packages/renderer/lib/ui/theme.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
/* eslint-disable react-refresh/only-export-components */
import { theme } from "@aidbox-forms/hs-theme";
import type { Theme } from "@aidbox-forms/theme";
import { createContext, type PropsWithChildren, useContext } from "react";

const ThemeContext = createContext<Theme>(theme);
const fallbackTheme = {} as Theme;

let defaultTheme: Theme = fallbackTheme;

try {
const module = await import("@aidbox-forms/hs-theme");
defaultTheme = module.theme;
} catch {
defaultTheme = fallbackTheme;
}

const ThemeContext = createContext<Theme>(defaultTheme);

export function ThemeProvider({
theme: providedTheme = theme,
theme,
children,
}: PropsWithChildren<{ theme?: Theme | undefined }>) {
return (
<ThemeContext.Provider value={providedTheme}>
<ThemeContext.Provider value={theme ?? defaultTheme}>
{children}
</ThemeContext.Provider>
);
Expand Down
13 changes: 13 additions & 0 deletions packages/renderer/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
{
"compilerOptions": {
"baseUrl": "../..",
"paths": {
"@aidbox-forms/theme": ["packages/theme/lib"],
"@aidbox-forms/antd-theme": ["themes/antd-theme/lib"],
"@aidbox-forms/mantine-theme": ["themes/mantine-theme/lib"],
"@aidbox-forms/hs-theme": ["themes/hs-theme/lib"],
"@aidbox-forms/nshuk-theme": ["themes/nshuk-theme/lib"],
"@aidbox-forms/opentui-theme": ["themes/opentui-theme/lib"],
"@aidbox-forms/renderer": ["packages/renderer/lib"],
"@aidbox-forms/renderer/*": ["packages/renderer/lib/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.lib.json" },
Expand Down
Loading