dhti-cli 0.7.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -110
- package/dist/commands/compose.d.ts +4 -0
- package/dist/commands/compose.js +119 -0
- package/dist/commands/conch.d.ts +1 -4
- package/dist/commands/conch.js +155 -156
- package/dist/commands/elixir.d.ts +4 -0
- package/dist/commands/elixir.js +246 -5
- package/dist/resources/docker-compose-master.yml +3 -1
- package/dist/resources/spa/README.md +3 -0
- package/dist/resources/spa/config-schema.ts +53 -0
- package/dist/resources/spa/glycemic.component.tsx +86 -0
- package/dist/resources/spa/glycemic.scss +44 -0
- package/dist/resources/spa/hooks/useDhti.ts +69 -0
- package/dist/resources/spa/hooks/usePatient.ts +61 -0
- package/dist/resources/spa/index.ts +17 -0
- package/dist/resources/spa/models/card.ts +80 -0
- package/dist/resources/spa/models/request.ts +42 -0
- package/dist/resources/spa/routes.json +17 -0
- package/oclif.manifest.json +82 -42
- package/package.json +3 -1
- package/dist/resources/spa/Dockerfile +0 -16
- package/dist/resources/spa/def/importmap.json +0 -39
- package/dist/resources/spa/def/routes.registry.json +0 -1599
- package/dist/resources/spa/def/spa-assemble-config.json +0 -40
package/dist/commands/elixir.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { Args, Command, Flags } from '@oclif/core';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
+
// eslint-disable-next-line import/default
|
|
4
|
+
import yaml from 'js-yaml';
|
|
3
5
|
import { exec } from 'node:child_process';
|
|
4
6
|
import fs from 'node:fs';
|
|
5
7
|
import os from 'node:os';
|
|
6
8
|
import path from 'node:path';
|
|
7
9
|
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
const execAsync = promisify(exec);
|
|
8
12
|
export default class Elixir extends Command {
|
|
9
13
|
static args = {
|
|
10
|
-
op: Args.string({ description: 'Operation to perform (install, uninstall or
|
|
14
|
+
op: Args.string({ description: 'Operation to perform (init, install, uninstall, dev or start)' }),
|
|
11
15
|
};
|
|
12
16
|
static description = 'Install or uninstall elixirs to create a Docker image';
|
|
13
17
|
static examples = ['<%= config.bin %> <%= command.id %>'];
|
|
@@ -23,7 +27,17 @@ export default class Elixir extends Command {
|
|
|
23
27
|
default: false,
|
|
24
28
|
description: 'Show what changes would be made without actually making them',
|
|
25
29
|
}),
|
|
30
|
+
elixir: Flags.string({
|
|
31
|
+
char: 'e',
|
|
32
|
+
description: 'Elixir endpoint URL',
|
|
33
|
+
}),
|
|
34
|
+
fhir: Flags.string({
|
|
35
|
+
char: 'f',
|
|
36
|
+
default: 'http://hapi.fhir.org/baseR4',
|
|
37
|
+
description: 'FHIR endpoint URL',
|
|
38
|
+
}),
|
|
26
39
|
git: Flags.string({ char: 'g', default: 'none', description: 'Github repository to install' }),
|
|
40
|
+
local: Flags.string({ char: 'l', default: 'none', description: 'Local directory to install from' }),
|
|
27
41
|
name: Flags.string({ char: 'n', description: 'Name of the elixir' }),
|
|
28
42
|
pypi: Flags.string({
|
|
29
43
|
char: 'p',
|
|
@@ -31,6 +45,11 @@ export default class Elixir extends Command {
|
|
|
31
45
|
description: 'PyPi package to install. Ex: dhti-elixir-base = ">=0.1.0"',
|
|
32
46
|
}),
|
|
33
47
|
repoVersion: Flags.string({ char: 'v', default: '0.1.0', description: 'Version of the elixir' }),
|
|
48
|
+
subdirectory: Flags.string({
|
|
49
|
+
char: 's',
|
|
50
|
+
default: 'none',
|
|
51
|
+
description: 'Subdirectory in the repository to install from (for monorepos)',
|
|
52
|
+
}),
|
|
34
53
|
whl: Flags.string({ char: 'e', default: 'none', description: 'Whl file to install' }),
|
|
35
54
|
workdir: Flags.string({
|
|
36
55
|
char: 'w',
|
|
@@ -44,6 +63,202 @@ export default class Elixir extends Command {
|
|
|
44
63
|
const __filename = fileURLToPath(import.meta.url);
|
|
45
64
|
const __dirname = path.dirname(__filename);
|
|
46
65
|
const RESOURCES_DIR = path.resolve(__dirname, '../resources');
|
|
66
|
+
// Handle init operation
|
|
67
|
+
if (args.op === 'init') {
|
|
68
|
+
// Validate required flags
|
|
69
|
+
if (!flags.workdir) {
|
|
70
|
+
console.error(chalk.red('Error: workdir flag is required for init operation'));
|
|
71
|
+
this.exit(1);
|
|
72
|
+
}
|
|
73
|
+
if (!flags.name) {
|
|
74
|
+
console.error(chalk.red('Error: name flag is required for init operation'));
|
|
75
|
+
this.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const targetDir = path.join(flags.workdir, 'dhti-elixir');
|
|
78
|
+
if (flags['dry-run']) {
|
|
79
|
+
console.log(chalk.yellow('[DRY RUN] Would execute init operation:'));
|
|
80
|
+
console.log(chalk.cyan(` npx degit dermatologist/dhti-elixir ${targetDir}`));
|
|
81
|
+
console.log(chalk.cyan(` Copy ${targetDir}/packages/starter to ${targetDir}/packages/${flags.name}`));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
// Run npx degit to clone the dhti-elixir template
|
|
86
|
+
console.log(chalk.blue(`Initializing DHTI elixir template in ${targetDir}...`));
|
|
87
|
+
const degitCommand = `npx degit dermatologist/dhti-elixir ${targetDir}`;
|
|
88
|
+
await execAsync(degitCommand);
|
|
89
|
+
console.log(chalk.green('✓ DHTI elixir template cloned successfully'));
|
|
90
|
+
// Copy packages/starter subdirectory to packages/<name>
|
|
91
|
+
const simpleChatSource = path.join(targetDir, 'packages', 'starter');
|
|
92
|
+
const targetPackageDir = path.join(targetDir, 'packages', flags.name);
|
|
93
|
+
if (fs.existsSync(simpleChatSource)) {
|
|
94
|
+
console.log(chalk.blue(`Copying starter to packages/${flags.name}...`));
|
|
95
|
+
fs.cpSync(simpleChatSource, targetPackageDir, { recursive: true });
|
|
96
|
+
console.log(chalk.green(`✓ starter copied to packages/${flags.name}`));
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
console.log(chalk.yellow(`Warning: starter not found at ${simpleChatSource}`));
|
|
100
|
+
}
|
|
101
|
+
console.log(chalk.green(`\n✓ Initialization complete! Your elixir workspace is ready at ${targetDir}`));
|
|
102
|
+
console.log(chalk.blue(`\nNext steps:`));
|
|
103
|
+
console.log(chalk.cyan(` 1. cd ${targetDir}`));
|
|
104
|
+
console.log(chalk.cyan(` 2. Follow the README.md for development instructions`));
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
console.error(chalk.red('Error during initialization:'), error);
|
|
108
|
+
this.exit(1);
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// Handle start operation
|
|
113
|
+
if (args.op === 'start') {
|
|
114
|
+
// Determine the elixir endpoint URL
|
|
115
|
+
let elixirUrl = flags.elixir;
|
|
116
|
+
if (!elixirUrl) {
|
|
117
|
+
// If --elixir is not provided, construct it from --name
|
|
118
|
+
if (!flags.name) {
|
|
119
|
+
console.error(chalk.red('Error: Either --elixir or --name flag must be provided for start operation'));
|
|
120
|
+
this.exit(1);
|
|
121
|
+
}
|
|
122
|
+
const nameWithUnderscores = flags.name.replaceAll('-', '_');
|
|
123
|
+
elixirUrl = `http://localhost:8001/langserve/${nameWithUnderscores}/cds-services`;
|
|
124
|
+
}
|
|
125
|
+
const sandboxDir = path.join(flags.workdir, 'cds-hooks-sandbox');
|
|
126
|
+
if (flags['dry-run']) {
|
|
127
|
+
const directoryExists = fs.existsSync(sandboxDir);
|
|
128
|
+
console.log(chalk.yellow('[DRY RUN] Would execute start operation:'));
|
|
129
|
+
if (directoryExists) {
|
|
130
|
+
console.log(chalk.cyan(` [SKIP] Directory already exists: ${sandboxDir}`));
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
console.log(chalk.cyan(` npx degit dermatologist/cds-hooks-sandbox ${sandboxDir}`));
|
|
134
|
+
console.log(chalk.cyan(` cd ${sandboxDir}`));
|
|
135
|
+
console.log(chalk.cyan(` yarn install`));
|
|
136
|
+
}
|
|
137
|
+
console.log(chalk.cyan(` yarn dhti ${elixirUrl} ${flags.fhir}`));
|
|
138
|
+
console.log(chalk.cyan(` Update docker-compose.yml FHIR_BASE_URL=${flags.fhir}`));
|
|
139
|
+
console.log(chalk.cyan(` docker restart ${flags.container}`));
|
|
140
|
+
console.log(chalk.cyan(` yarn dev`));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
// Check if the directory already exists
|
|
145
|
+
const directoryExists = fs.existsSync(sandboxDir);
|
|
146
|
+
if (directoryExists) {
|
|
147
|
+
console.log(chalk.blue(`Using existing CDS Hooks Sandbox at ${sandboxDir}...`));
|
|
148
|
+
console.log(chalk.green('✓ Skipped clone and install (directory already exists)'));
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// Clone the cds-hooks-sandbox repository
|
|
152
|
+
console.log(chalk.blue(`Cloning CDS Hooks Sandbox to ${sandboxDir}...`));
|
|
153
|
+
const degitCommand = `npx degit dermatologist/cds-hooks-sandbox ${sandboxDir}`;
|
|
154
|
+
await execAsync(degitCommand);
|
|
155
|
+
console.log(chalk.green('✓ CDS Hooks Sandbox cloned successfully'));
|
|
156
|
+
// Install dependencies
|
|
157
|
+
console.log(chalk.blue('Installing dependencies...'));
|
|
158
|
+
const installCommand = `cd ${sandboxDir} && yarn install`;
|
|
159
|
+
const { stderr: installError } = await execAsync(installCommand);
|
|
160
|
+
if (installError && !installError.includes('warning')) {
|
|
161
|
+
console.error(chalk.yellow(`Installation warnings: ${installError}`));
|
|
162
|
+
}
|
|
163
|
+
console.log(chalk.green('✓ Dependencies installed successfully'));
|
|
164
|
+
}
|
|
165
|
+
// Configure dhti endpoints
|
|
166
|
+
console.log(chalk.blue('Configuring DHTI endpoints...'));
|
|
167
|
+
const dhtiCommand = `cd ${sandboxDir} && yarn dhti ${elixirUrl} ${flags.fhir}`;
|
|
168
|
+
const { stderr: dhtiError } = await execAsync(dhtiCommand);
|
|
169
|
+
if (dhtiError && !dhtiError.includes('warning')) {
|
|
170
|
+
console.error(chalk.yellow(`Configuration warnings: ${dhtiError}`));
|
|
171
|
+
}
|
|
172
|
+
console.log(chalk.green('✓ DHTI endpoints configured successfully'));
|
|
173
|
+
// Configure Docker container with FHIR_BASE_URL environment variable
|
|
174
|
+
console.log(chalk.blue('Setting up Docker container environment...'));
|
|
175
|
+
try {
|
|
176
|
+
// Update the docker-compose.yml file with the new FHIR_BASE_URL
|
|
177
|
+
const dockerComposeFile = path.join(flags.workdir, 'docker-compose.yml');
|
|
178
|
+
if (fs.existsSync(dockerComposeFile)) {
|
|
179
|
+
const composeContent = fs.readFileSync(dockerComposeFile, 'utf8');
|
|
180
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
181
|
+
const compose = yaml.load(composeContent);
|
|
182
|
+
if (compose.services && compose.services.langserve) {
|
|
183
|
+
if (!compose.services.langserve.environment) {
|
|
184
|
+
compose.services.langserve.environment = [];
|
|
185
|
+
}
|
|
186
|
+
// Update or add the FHIR_BASE_URL environment variable
|
|
187
|
+
const envArray = compose.services.langserve.environment;
|
|
188
|
+
const fhirIndex = envArray.findIndex((env) => {
|
|
189
|
+
if (typeof env === 'string') {
|
|
190
|
+
return env.startsWith('FHIR_BASE_URL=');
|
|
191
|
+
}
|
|
192
|
+
return typeof env === 'object' && env !== null && 'FHIR_BASE_URL' in env;
|
|
193
|
+
});
|
|
194
|
+
if (fhirIndex >= 0) {
|
|
195
|
+
// Update existing environment variable
|
|
196
|
+
const existingEnv = envArray[fhirIndex];
|
|
197
|
+
if (typeof existingEnv === 'string') {
|
|
198
|
+
envArray[fhirIndex] = `FHIR_BASE_URL=${flags.fhir}`;
|
|
199
|
+
}
|
|
200
|
+
else if (typeof existingEnv === 'object' && existingEnv !== null) {
|
|
201
|
+
existingEnv.FHIR_BASE_URL = flags.fhir;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
// Add new environment variable
|
|
206
|
+
envArray.push(`FHIR_BASE_URL=${flags.fhir}`);
|
|
207
|
+
}
|
|
208
|
+
const updatedCompose = yaml.dump(compose);
|
|
209
|
+
fs.writeFileSync(dockerComposeFile, updatedCompose);
|
|
210
|
+
console.log(chalk.green(`✓ docker-compose.yml updated with FHIR_BASE_URL=${flags.fhir}`));
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
console.warn(chalk.yellow('⚠ Warning: langserve service not found in docker-compose.yml'));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
console.warn(chalk.yellow(`⚠ Warning: docker-compose.yml not found at ${dockerComposeFile}`));
|
|
218
|
+
}
|
|
219
|
+
// Restart the container to apply the new environment variables
|
|
220
|
+
const upCommand = `docker compose up -d`; // Docker Compose is smart enough to re-create only the services where the configuration, including environment variables, has changed
|
|
221
|
+
await execAsync(upCommand, { cwd: flags.workdir });
|
|
222
|
+
console.log(chalk.green(`✓ Docker container ${flags.container} restarted (compose up -d) successfully`));
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
const err = error;
|
|
226
|
+
if (err.message?.includes('No such container')) {
|
|
227
|
+
console.warn(chalk.yellow(`⚠ Warning: Docker container ${flags.container} not found. Skipping container restart.`));
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
console.error(chalk.red(`Error setting up Docker container: ${err.message}`));
|
|
231
|
+
this.exit(1);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Start the development server
|
|
235
|
+
console.log(chalk.blue('Starting development server...'));
|
|
236
|
+
const { spawn } = await import('node:child_process');
|
|
237
|
+
const devCommand = `yarn dhti ${elixirUrl} ${flags.fhir} && yarn dev`;
|
|
238
|
+
try {
|
|
239
|
+
const child = spawn(devCommand, { cwd: sandboxDir, shell: true, stdio: 'inherit' });
|
|
240
|
+
await new Promise((resolve, reject) => {
|
|
241
|
+
child.on('exit', (code) => {
|
|
242
|
+
if (code === 0)
|
|
243
|
+
resolve(undefined);
|
|
244
|
+
else
|
|
245
|
+
reject(new Error(`Dev server exited with code ${code}`));
|
|
246
|
+
});
|
|
247
|
+
child.on('error', reject);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
const err = error;
|
|
252
|
+
console.error(chalk.red('Error starting development server:'), err.message);
|
|
253
|
+
this.exit(1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
console.error(chalk.red('Error during start operation:'), error);
|
|
258
|
+
this.exit(1);
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
47
262
|
if (!flags.name) {
|
|
48
263
|
console.log('Please provide a name for the elixir');
|
|
49
264
|
this.exit(1);
|
|
@@ -126,11 +341,31 @@ export default class Elixir extends Command {
|
|
|
126
341
|
lineToAdd = `${flags.name} = { file = "whl/${path.basename(flags.whl)}" }`;
|
|
127
342
|
}
|
|
128
343
|
if (flags.git !== 'none') {
|
|
129
|
-
lineToAdd =
|
|
344
|
+
lineToAdd =
|
|
345
|
+
flags.subdirectory === 'none'
|
|
346
|
+
? `${flags.name} = { git = "${flags.git}", branch = "${flags.branch}" }`
|
|
347
|
+
: `${flags.name} = { git = "${flags.git}", branch = "${flags.branch}", subdirectory = "${flags.subdirectory}" }`;
|
|
130
348
|
}
|
|
131
349
|
if (flags.pypi !== 'none') {
|
|
132
350
|
lineToAdd = flags.pypi;
|
|
133
351
|
}
|
|
352
|
+
if (flags.local !== 'none') {
|
|
353
|
+
// Use path for local directory installation
|
|
354
|
+
const absolutePath = path.isAbsolute(flags.local) ? flags.local : path.resolve(process.cwd(), flags.local);
|
|
355
|
+
// Validate that the path exists and is a directory (skip validation in dry-run mode)
|
|
356
|
+
if (!flags['dry-run']) {
|
|
357
|
+
if (!fs.existsSync(absolutePath)) {
|
|
358
|
+
console.error(chalk.red(`Error: Local directory does not exist: ${absolutePath}`));
|
|
359
|
+
this.exit(1);
|
|
360
|
+
}
|
|
361
|
+
const stats = fs.statSync(absolutePath);
|
|
362
|
+
if (!stats.isDirectory()) {
|
|
363
|
+
console.error(chalk.red(`Error: Path is not a directory: ${absolutePath}`));
|
|
364
|
+
this.exit(1);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
lineToAdd = `${flags.name} = { path = "${absolutePath}" }`;
|
|
368
|
+
}
|
|
134
369
|
if (!flags['dry-run']) {
|
|
135
370
|
pyproject = pyproject.replace('dependencies = [', `dependencies = [\n"${flags.name}",`);
|
|
136
371
|
pyproject = pyproject.replace('[tool.uv.sources]', `[tool.uv.sources]\n${lineToAdd}\n`);
|
|
@@ -152,11 +387,17 @@ mcp_server.add_tool(${expoName}_mcp_tool) # type: ignore
|
|
|
152
387
|
.replace('# DHTI_CLI_IMPORT', `#DHTI_CLI_IMPORT\n${CliImport}`);
|
|
153
388
|
}
|
|
154
389
|
const langfuseRoute = `add_routes(app, ${expoName}_chain.with_config(config), path="/langserve/${expoName}")`;
|
|
155
|
-
const newLangfuseRoute = flags['dry-run']
|
|
390
|
+
const newLangfuseRoute = flags['dry-run']
|
|
391
|
+
? ''
|
|
392
|
+
: newCliImport.replace('# DHTI_LANGFUSE_ROUTE', `#DHTI_LANGFUSE_ROUTE\n ${langfuseRoute}`);
|
|
156
393
|
const normalRoute = `add_routes(app, ${expoName}_chain, path="/langserve/${expoName}")`;
|
|
157
|
-
const newNormalRoute = flags['dry-run']
|
|
394
|
+
const newNormalRoute = flags['dry-run']
|
|
395
|
+
? ''
|
|
396
|
+
: newLangfuseRoute.replace('# DHTI_NORMAL_ROUTE', `#DHTI_NORMAL_ROUTE\n ${normalRoute}`);
|
|
158
397
|
const commonRoutes = `\nadd_invokes(app, path="/langserve/${expoName}")\nadd_services(app, path="/langserve/${expoName}")`;
|
|
159
|
-
const finalRoute = flags['dry-run']
|
|
398
|
+
const finalRoute = flags['dry-run']
|
|
399
|
+
? ''
|
|
400
|
+
: newNormalRoute.replace('# DHTI_COMMON_ROUTE', `#DHTI_COMMON_ROUTES${commonRoutes}`);
|
|
160
401
|
// if args.op === install, add the line to the pyproject.toml file
|
|
161
402
|
if (args.op === 'install') {
|
|
162
403
|
if (flags['dry-run']) {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
version: "3.7"
|
|
2
1
|
|
|
3
2
|
services:
|
|
4
3
|
gateway:
|
|
@@ -72,6 +71,7 @@ services:
|
|
|
72
71
|
|
|
73
72
|
langserve:
|
|
74
73
|
image: beapen/genai:latest
|
|
74
|
+
network_mode: host
|
|
75
75
|
pull_policy: always
|
|
76
76
|
ports:
|
|
77
77
|
- "8001:8001"
|
|
@@ -86,6 +86,8 @@ services:
|
|
|
86
86
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-openai-api-key}
|
|
87
87
|
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://openrouter.ai/api/v1}
|
|
88
88
|
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-openrouter-api-key}
|
|
89
|
+
- FHIR_BASE_URL=${FHIR_BASE_URL:-http://localhost:8080/openmrs/ws/fhir2/R4}
|
|
90
|
+
|
|
89
91
|
|
|
90
92
|
ollama:
|
|
91
93
|
image: ollama/ollama:latest
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {Type, validator} from '@openmrs/esm-framework'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This is the config schema. It expects a configuration object which
|
|
5
|
+
* looks like this:
|
|
6
|
+
*
|
|
7
|
+
* ```json
|
|
8
|
+
* { "casualGreeting": true, "whoToGreet": ["Mom"] }
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* In OpenMRS Microfrontends, all config parameters are optional. Thus,
|
|
12
|
+
* all elements must have a reasonable default. A good default is one
|
|
13
|
+
* that works well with the reference application.
|
|
14
|
+
*
|
|
15
|
+
* To understand the schema below, please read the configuration system
|
|
16
|
+
* documentation:
|
|
17
|
+
* https://openmrs.github.io/openmrs-esm-core/#/main/config
|
|
18
|
+
* Note especially the section "How do I make my module configurable?"
|
|
19
|
+
* https://openmrs.github.io/openmrs-esm-core/#/main/config?id=im-developing-an-esm-module-how-do-i-make-it-configurable
|
|
20
|
+
* and the Schema Reference
|
|
21
|
+
* https://openmrs.github.io/openmrs-esm-core/#/main/config?id=schema-reference
|
|
22
|
+
*/
|
|
23
|
+
export const configSchema = {
|
|
24
|
+
casualGreeting: {
|
|
25
|
+
_type: Type.Boolean,
|
|
26
|
+
_default: false,
|
|
27
|
+
_description: 'Whether to use a casual greeting (or a formal one).',
|
|
28
|
+
},
|
|
29
|
+
dhtiTitle: {
|
|
30
|
+
_type: Type.String,
|
|
31
|
+
_default: 'DHTI Template Widget',
|
|
32
|
+
_description: 'Title to display for the DHTI Template Widget',
|
|
33
|
+
},
|
|
34
|
+
dhtiRoute: {
|
|
35
|
+
_type: Type.String,
|
|
36
|
+
_default: 'http://localhost:8001/langserve/dhti_elixir_template/cds-services/dhti-service',
|
|
37
|
+
_description: 'Route for the DHTI Template Service',
|
|
38
|
+
},
|
|
39
|
+
whoToGreet: {
|
|
40
|
+
_type: Type.Array,
|
|
41
|
+
_default: ['World'],
|
|
42
|
+
_description: 'Who should be greeted. Names will be separated by a comma and space.',
|
|
43
|
+
_elements: {
|
|
44
|
+
_type: Type.String,
|
|
45
|
+
},
|
|
46
|
+
_validators: [validator((v) => v.length > 0, 'At least one person must be greeted.')],
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type Config = {
|
|
51
|
+
casualGreeting: boolean
|
|
52
|
+
whoToGreet: Array<string>
|
|
53
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { useConfig, openmrsFetch, fhirBaseUrl } from '@openmrs/esm-framework';
|
|
3
|
+
import useSWR from 'swr';
|
|
4
|
+
import styles from './glycemic.scss';
|
|
5
|
+
import { useDhti } from './hooks/useDhti';
|
|
6
|
+
|
|
7
|
+
interface GlycemicWidgetProps {
|
|
8
|
+
patientUuid: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const GlycemicWidget: React.FC<GlycemicWidgetProps> = ({ patientUuid }) => {
|
|
12
|
+
const config = useConfig();
|
|
13
|
+
const serviceName = config?.dhtiServiceName || 'dhti_elixir_glycemic';
|
|
14
|
+
const { submitMessage, loading: aiLoading, error: aiError } = useDhti();
|
|
15
|
+
const [aiResponse, setAiResponse] = useState<any>(null);
|
|
16
|
+
|
|
17
|
+
// Check for Diabetes (SNOMED 44054006)
|
|
18
|
+
const { data: conditionsData, isLoading: conditionsLoading } = useSWR<any>(
|
|
19
|
+
patientUuid ? `${fhirBaseUrl}/Condition?patient=${patientUuid}&code=44054006` : null,
|
|
20
|
+
openmrsFetch
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const isDiabetic = conditionsData?.data?.entry?.length > 0;
|
|
24
|
+
|
|
25
|
+
// Fetch HbA1c (4548-4) and Glucose (2339-0) observations
|
|
26
|
+
const { data: obsData, isLoading: obsLoading } = useSWR<any>(
|
|
27
|
+
isDiabetic ? `${fhirBaseUrl}/Observation?patient=${patientUuid}&code=4548-4,2339-0&_sort=-date&_count=5` : null,
|
|
28
|
+
openmrsFetch
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!isDiabetic && !aiResponse && !aiLoading && !aiError) {
|
|
33
|
+
submitMessage('Analyze glycemic control', patientUuid).then(setAiResponse);
|
|
34
|
+
}
|
|
35
|
+
}, [isDiabetic, patientUuid, submitMessage, aiResponse, aiLoading, aiError]);
|
|
36
|
+
|
|
37
|
+
// if (conditionsLoading) return null;
|
|
38
|
+
// if (!isDiabetic) return null;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className={styles.container}>
|
|
42
|
+
<div className={styles.header}>
|
|
43
|
+
<h3>{config?.dhtiTitle || 'DHTI Glycemic Widget'}</h3>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div className={styles.content}>
|
|
47
|
+
<div className={styles.observations}>
|
|
48
|
+
<h4>Latest Readings</h4>
|
|
49
|
+
{obsLoading ? (
|
|
50
|
+
<p>Loading observations...</p>
|
|
51
|
+
) : obsData?.data?.entry?.length > 0 ? (
|
|
52
|
+
<ul>
|
|
53
|
+
{obsData.data.entry.map((entry: any) => {
|
|
54
|
+
const obs = entry.resource;
|
|
55
|
+
const value = obs.valueQuantity ? `${obs.valueQuantity.value} ${obs.valueQuantity.unit}` : obs.valueString;
|
|
56
|
+
const date = new Date(obs.effectiveDateTime).toLocaleDateString();
|
|
57
|
+
const name = obs.code?.text || obs.code?.coding?.[0]?.display || 'Observation';
|
|
58
|
+
return (
|
|
59
|
+
<li key={obs.id}>
|
|
60
|
+
<strong>{name}:</strong> {value} <small>({date})</small>
|
|
61
|
+
</li>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</ul>
|
|
65
|
+
) : (
|
|
66
|
+
<p>No recent HbA1c or Glucose readings found.</p>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className={styles.aiInsights}>
|
|
71
|
+
<h4>GenAI Interpretation</h4>
|
|
72
|
+
{aiLoading && <p>Generating insights...</p>}
|
|
73
|
+
{aiError && <p className={styles.error}>Error: {aiError}</p>}
|
|
74
|
+
{aiResponse && (
|
|
75
|
+
<div className={styles.aiContent}>
|
|
76
|
+
<p>{aiResponse.summary}</p>
|
|
77
|
+
{aiResponse.detail && <p><small>{aiResponse.detail}</small></p>}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export default GlycemicWidget;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
padding: 1rem;
|
|
3
|
+
background-color: var(--ui-01);
|
|
4
|
+
border: 1px solid var(--ui-03);
|
|
5
|
+
margin-bottom: 1rem;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.header {
|
|
9
|
+
margin-bottom: 1rem;
|
|
10
|
+
h3 {
|
|
11
|
+
margin: 0;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.content {
|
|
16
|
+
display: flex;
|
|
17
|
+
gap: 2rem;
|
|
18
|
+
flex-wrap: wrap;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.observations, .aiInsights {
|
|
22
|
+
flex: 1;
|
|
23
|
+
min-width: 300px;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.observations ul {
|
|
27
|
+
list-style: none;
|
|
28
|
+
padding: 0;
|
|
29
|
+
li {
|
|
30
|
+
margin-bottom: 0.5rem;
|
|
31
|
+
border-bottom: 1px solid var(--ui-03);
|
|
32
|
+
padding-bottom: 0.25rem;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.aiContent {
|
|
37
|
+
background-color: var(--ui-02);
|
|
38
|
+
padding: 1rem;
|
|
39
|
+
border-radius: 4px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.error {
|
|
43
|
+
color: var(--support-01);
|
|
44
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {useState} from 'react'
|
|
2
|
+
import axios from 'axios'
|
|
3
|
+
import {CDSHookRequest} from '../models/request'
|
|
4
|
+
import {CDSHookCard} from '../models/card'
|
|
5
|
+
import {useConfig, openmrsFetch, fhirBaseUrl} from '@openmrs/esm-framework'
|
|
6
|
+
|
|
7
|
+
interface UseDhtiReturn {
|
|
8
|
+
submitMessage: (newMessage: string, patientId?: string) => Promise<CDSHookCard | null>
|
|
9
|
+
loading: boolean
|
|
10
|
+
error: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Custom hook to handle DHTI service submissions
|
|
15
|
+
* @returns Object with submitMessage function, loading state, and error state
|
|
16
|
+
*/
|
|
17
|
+
export const useDhti = (): UseDhtiReturn => {
|
|
18
|
+
const [loading, setLoading] = useState<boolean>(false)
|
|
19
|
+
const [error, setError] = useState<string | null>(null)
|
|
20
|
+
const config = useConfig()
|
|
21
|
+
|
|
22
|
+
const submitMessage = async (newMessage: string, patientId?: string): Promise<CDSHookCard | null> => {
|
|
23
|
+
setLoading(true)
|
|
24
|
+
setError(null)
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const request = new CDSHookRequest({
|
|
28
|
+
context: {input: newMessage, patientId: patientId || undefined},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// TODO: Investigate why nested input is required
|
|
32
|
+
const _request = {
|
|
33
|
+
input: request,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const response = await axios.post(
|
|
37
|
+
config?.dhtiRoute || 'http://localhost:8001/langserve/dhti_elixir_template/cds-services/dhti-service',
|
|
38
|
+
{
|
|
39
|
+
input: _request,
|
|
40
|
+
config: {},
|
|
41
|
+
kwargs: {},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
headers: {
|
|
45
|
+
'Access-Control-Allow-Origin': '*',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// Assuming the response contains a card or cards
|
|
51
|
+
if (response.data && response.data.cards && response.data.cards.length > 0) {
|
|
52
|
+
return CDSHookCard.from(response.data.cards[0])
|
|
53
|
+
} else if (response.data && response.data.summary) {
|
|
54
|
+
// Handle case where response is directly a card (must have summary property)
|
|
55
|
+
return CDSHookCard.from(response.data)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null
|
|
59
|
+
} catch (err: any) {
|
|
60
|
+
const errorMessage = err.response?.data?.message || err.message || 'Failed to submit message'
|
|
61
|
+
setError(errorMessage)
|
|
62
|
+
return null
|
|
63
|
+
} finally {
|
|
64
|
+
setLoading(false)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {submitMessage, loading, error}
|
|
69
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import useSWR from 'swr'
|
|
2
|
+
import {fhirBaseUrl, openmrsFetch} from '@openmrs/esm-framework'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* This hook searches for a patient using the provided search term from the
|
|
6
|
+
* OpenMRS FHIR API. It leverages the useSWR hook from the SWR library
|
|
7
|
+
* https://swr.vercel.app/docs/data-fetching to fetch data. SWR provides a
|
|
8
|
+
* number of benefits over the standard React useEffect hook, including:
|
|
9
|
+
*
|
|
10
|
+
* - Fast, lightweight and reusable data fetching
|
|
11
|
+
* - Built-in cache and request deduplication
|
|
12
|
+
* - Real-time updates
|
|
13
|
+
* - Simplified error and loading state handling, and more.
|
|
14
|
+
*
|
|
15
|
+
* We recommend using SWR for data fetching in your OpenMRS frontend modules.
|
|
16
|
+
*
|
|
17
|
+
* See the docs for the underlying fhir.js Client object: https://github.com/FHIR/fhir.js#api
|
|
18
|
+
* See the OpenMRS FHIR Module docs: https://wiki.openmrs.org/display/projects/OpenMRS+FHIR+Module
|
|
19
|
+
* See the OpenMRS REST API docs: https://rest.openmrs.org/#openmrs-rest-api
|
|
20
|
+
*
|
|
21
|
+
* @param query A patient name or ID
|
|
22
|
+
* @returns The first matching patient
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export function usePatient(query: string) {
|
|
26
|
+
// If query has a number anywhere in it, treat it as an identifier search
|
|
27
|
+
const isId = /\d/.test(query)
|
|
28
|
+
let url = null
|
|
29
|
+
if (query && query.trim()) {
|
|
30
|
+
if (isId) {
|
|
31
|
+
url = `${fhirBaseUrl}/Patient?identifier=${encodeURIComponent(query.trim())}&_summary=data`
|
|
32
|
+
} else {
|
|
33
|
+
url = `${fhirBaseUrl}/Patient?name=${encodeURIComponent(query.trim())}&_summary=data`
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const {data, error, isLoading} = useSWR<any, Error>(url, openmrsFetch)
|
|
37
|
+
|
|
38
|
+
let patient = null
|
|
39
|
+
// * Use the code below to handle direct ID searches, if needed in future
|
|
40
|
+
// --- IGNORE ---
|
|
41
|
+
// if (isId) {
|
|
42
|
+
// // FHIR /Patient/{id} returns the patient directly
|
|
43
|
+
// if (data && data.resourceType === 'Patient') {
|
|
44
|
+
// patient = data;
|
|
45
|
+
// }
|
|
46
|
+
// } else {
|
|
47
|
+
// if (data && data.data && Array.isArray(data.data.entry) && data.data.entry.length > 0) {
|
|
48
|
+
// patient = data.data.entry[0].resource;
|
|
49
|
+
// }
|
|
50
|
+
// }
|
|
51
|
+
|
|
52
|
+
if (data && data.data && Array.isArray(data.data.entry) && data.data.entry.length > 0) {
|
|
53
|
+
patient = data.data.entry[0].resource
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
patient,
|
|
58
|
+
error: error,
|
|
59
|
+
isLoading,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {getAsyncLifecycle, defineConfigSchema} from '@openmrs/esm-framework'
|
|
2
|
+
import {configSchema} from './config-schema'
|
|
3
|
+
|
|
4
|
+
const moduleName = '@openmrs/esm-dhti-glycemic'
|
|
5
|
+
|
|
6
|
+
const options = {
|
|
7
|
+
featureName: 'dhti-glycemic',
|
|
8
|
+
moduleName,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const importTranslation = require.context('../translations', false, /.json$/, 'lazy')
|
|
12
|
+
|
|
13
|
+
export function startupApp() {
|
|
14
|
+
defineConfigSchema(moduleName, configSchema)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const glycemic = getAsyncLifecycle(() => import('./glycemic.component'), options)
|