From d914001810877733f73d24edd903efab91740671 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Oct 2025 19:48:09 +0000 Subject: [PATCH 1/4] Initial plan From caae430d05c7dc50ade5b18d525982e27da56b71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Oct 2025 20:16:21 +0000 Subject: [PATCH 2/4] Add ProgramIndicatorsViewer component with listing and navigation Co-authored-by: litlfred <662242+litlfred@users.noreply.github.com> --- public/routes-config.json | 4 + src/components/DAKDashboard.js | 12 + src/components/ProgramIndicatorsViewer.css | 241 ++++++++++++++++++ src/components/ProgramIndicatorsViewer.js | 279 +++++++++++++++++++++ src/services/componentRouteService.js | 3 + 5 files changed, 539 insertions(+) create mode 100644 src/components/ProgramIndicatorsViewer.css create mode 100644 src/components/ProgramIndicatorsViewer.js diff --git a/public/routes-config.json b/public/routes-config.json index c196e8e844..e47064690d 100644 --- a/public/routes-config.json +++ b/public/routes-config.json @@ -58,6 +58,10 @@ "persona-viewer": { "component": "PersonaViewer", "path": "./components/PersonaViewer" + }, + "program-indicators": { + "component": "ProgramIndicatorsViewer", + "path": "./components/ProgramIndicatorsViewer" } }, "standardComponents": { diff --git a/src/components/DAKDashboard.js b/src/components/DAKDashboard.js index 7d1f44f6c6..389c189aa7 100644 --- a/src/components/DAKDashboard.js +++ b/src/components/DAKDashboard.js @@ -506,6 +506,18 @@ const DAKDashboardContent = () => { return; } + // For program-indicators, navigate to indicators viewer + if (component.id === 'program-indicators') { + const owner = repository.owner?.login || repository.full_name.split('/')[0]; + const repoName = repository.name; + const path = selectedBranch + ? `/program-indicators/${owner}/${repoName}/${selectedBranch}` + : `/program-indicators/${owner}/${repoName}`; + + handleNavigationClick(event, path, navigate, navigationState); + return; + } + // For other components, check permissions before proceeding if (!hasWriteAccess) { // If command-click, still show permission dialog instead of opening new tab diff --git a/src/components/ProgramIndicatorsViewer.css b/src/components/ProgramIndicatorsViewer.css new file mode 100644 index 0000000000..3ff567ec61 --- /dev/null +++ b/src/components/ProgramIndicatorsViewer.css @@ -0,0 +1,241 @@ +/* Program Indicators Viewer Styles */ + +.program-indicators-viewer { + background: linear-gradient(135deg, #0078d4 0%, #005a9e 100%); + min-height: 100vh; + padding: 2rem; +} + +.viewer-header { + background: rgb(4, 11, 118); + color: white; + padding: 2rem; + border-radius: 8px; + margin-bottom: 2rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.viewer-header h1 { + margin: 0 0 0.5rem 0; + font-size: 2rem; + color: white; +} + +.viewer-header p { + margin: 0; + font-size: 1rem; + opacity: 0.9; +} + +.branch-display { + background: rgba(255, 255, 255, 0.2); + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-family: monospace; + font-weight: 500; +} + +.viewer-controls { + background: white; + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 2rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.search-box { + flex: 1; +} + +.search-input { + width: 100%; + padding: 0.75rem; + border: 2px solid #e0e0e0; + border-radius: 6px; + font-size: 1rem; + transition: border-color 0.2s; +} + +.search-input:focus { + outline: none; + border-color: #0078d4; +} + +.controls-info { + display: flex; + align-items: center; + gap: 1rem; +} + +.measure-count { + font-weight: 600; + color: #0078d4; +} + +.loading-state, +.error-message, +.empty-state { + background: white; + padding: 3rem; + text-align: center; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.error-message { + background: #fff3cd; + border: 2px solid #ffc107; + color: #856404; +} + +.error-icon { + font-size: 2rem; + margin-right: 0.5rem; +} + +.empty-state { + background: white; +} + +.empty-icon { + font-size: 4rem; + margin-bottom: 1rem; + opacity: 0.5; +} + +.empty-state h3 { + color: #666; + margin-bottom: 0.5rem; +} + +.empty-state p { + color: #999; +} + +.measures-list { + display: grid; + gap: 1rem; +} + +.measure-card { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 1.5rem; + background: white; + border: 2px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: all 0.3s; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.measure-card:hover { + border-color: #0078d4; + box-shadow: 0 4px 12px rgba(0, 120, 212, 0.2); + transform: translateY(-2px); +} + +.measure-icon { + font-size: 2.5rem; + flex-shrink: 0; +} + +.measure-info { + flex: 1; + min-width: 0; +} + +.measure-title { + margin: 0 0 0.5rem 0; + color: #333; + font-size: 1.25rem; + font-weight: 600; +} + +.measure-meta { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.875rem; +} + +.measure-filename { + font-weight: 600; + font-family: 'Courier New', monospace; + color: #0078d4; +} + +.measure-path { + color: #999; + font-family: 'Courier New', monospace; + font-size: 0.75rem; +} + +.measure-actions { + flex-shrink: 0; +} + +.action-button { + padding: 0.6rem 1.2rem; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + transition: all 0.2s; + font-size: 0.95rem; +} + +.view-button { + background: #0078d4; + color: white; +} + +.view-button:hover { + background: #005a9e; + transform: scale(1.05); +} + +.loading-spinner { + border: 4px solid #f3f3f3; + border-top: 4px solid #0078d4; + border-radius: 50%; + width: 50px; + height: 50px; + animation: spin 1s linear infinite; + margin: 0 auto 1rem; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Responsive Design */ +@media (max-width: 768px) { + .program-indicators-viewer { + padding: 1rem; + } + + .viewer-header { + padding: 1.5rem; + } + + .viewer-header h1 { + font-size: 1.5rem; + } + + .measure-card { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .measure-actions { + width: 100%; + } + + .action-button { + width: 100%; + } +} diff --git a/src/components/ProgramIndicatorsViewer.js b/src/components/ProgramIndicatorsViewer.js new file mode 100644 index 0000000000..e08fd73fd2 --- /dev/null +++ b/src/components/ProgramIndicatorsViewer.js @@ -0,0 +1,279 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import githubService from '../services/githubService'; +import useDAKUrlParams from '../hooks/useDAKUrlParams'; +import { PageLayout } from './framework'; +import './ProgramIndicatorsViewer.css'; + +const ProgramIndicatorsViewer = () => { + const navigate = useNavigate(); + + // Use the DAK URL params hook to get profile, repository, and branch + const { + profile, + repository, + selectedBranch, + loading: dakLoading, + error: dakError + } = useDAKUrlParams(); + + const [measureFiles, setMeasureFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [hasWriteAccess, setHasWriteAccess] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + + // Check write permissions + useEffect(() => { + const checkPermissions = async () => { + if (repository && profile) { + try { + const writeAccess = profile.token && repository.permissions?.push; + setHasWriteAccess(writeAccess || false); + } catch (error) { + console.warn('Could not check write permissions:', error); + setHasWriteAccess(false); + } + } + }; + + checkPermissions(); + }, [repository, profile]); + + // Load measure files from repository + useEffect(() => { + const loadMeasureFiles = async () => { + if (!repository) { + navigate('/'); + return; + } + + try { + setLoading(true); + setError(null); + + const owner = repository.owner?.login || repository.full_name.split('/')[0]; + const repoName = repository.name; + const ref = selectedBranch || 'main'; + + console.log(`🔍 ProgramIndicatorsViewer: Fetching measure files from ${owner}/${repoName} (branch: ${ref})`); + + // Get files from input/fsh/measures directory + const files = await githubService.getContents(owner, repoName, 'input/fsh/measures', ref); + + // Filter for .fsh files + const fshFiles = files.filter(f => f.name.endsWith('.fsh')); + + // Load content for each file to extract Title + const measuresWithTitles = await Promise.all( + fshFiles.map(async (file) => { + try { + const content = await githubService.getFileContent(owner, repoName, file.path, ref); + const title = extractTitleFromFSH(content); + return { + ...file, + title: title || file.name.replace('.fsh', ''), + content + }; + } catch (err) { + console.warn(`Could not load content for ${file.name}:`, err); + return { + ...file, + title: file.name.replace('.fsh', ''), + content: null + }; + } + }) + ); + + console.log('📊 ProgramIndicatorsViewer: Loaded measure files:', { + count: measuresWithTitles.length, + files: measuresWithTitles.map(f => ({ name: f.name, title: f.title })) + }); + + setMeasureFiles(measuresWithTitles); + setLoading(false); + } catch (err) { + console.error('Error loading measure files:', err); + setError(err.message); + setLoading(false); + } + }; + + if (!dakLoading && repository) { + loadMeasureFiles(); + } + }, [repository, selectedBranch, dakLoading, navigate]); + + // Extract Title from FSH content + const extractTitleFromFSH = (content) => { + if (!content) return null; + + // Look for Title line in FSH + const titleMatch = content.match(/^Title:\s*"(.+)"$/m); + if (titleMatch) { + return titleMatch[1]; + } + + // Fallback: look for * title = "..." + const altTitleMatch = content.match(/^\*\s*title\s*=\s*"(.+)"$/m); + if (altTitleMatch) { + return altTitleMatch[1]; + } + + return null; + }; + + // Filter measures based on search + const filteredMeasures = measureFiles.filter(measure => { + if (!searchTerm) return true; + const searchLower = searchTerm.toLowerCase(); + return ( + measure.title.toLowerCase().includes(searchLower) || + measure.name.toLowerCase().includes(searchLower) + ); + }); + + // Navigate to editor for a measure + const handleMeasureClick = (measure) => { + const owner = repository.owner?.login || repository.full_name.split('/')[0]; + const repoName = repository.name; + const path = selectedBranch + ? `/program-indicator-editor/${owner}/${repoName}/${selectedBranch}/${encodeURIComponent(measure.path)}` + : `/program-indicator-editor/${owner}/${repoName}/${encodeURIComponent(measure.path)}`; + + const navigationState = { + profile, + repository, + selectedBranch, + measure + }; + + navigate(path, { state: navigationState }); + }; + + if (dakLoading) { + return ( + +
+
+

Loading...

+
+
+ ); + } + + if (dakError) { + return ( + +
+

Error

+

{dakError}

+
+
+ ); + } + + return ( + +
+
+

Program Indicators & Measures

+

+ Performance indicators and measurement definitions for monitoring and evaluation in{' '} + {repository?.name} + {selectedBranch && ( + on branch {selectedBranch} + )} +

+
+ + {/* Search and Filter */} +
+
+ setSearchTerm(e.target.value)} + className="search-input" + /> +
+
+ + {filteredMeasures.length} measure{filteredMeasures.length !== 1 ? 's' : ''} found + +
+
+ + {loading && ( +
+
+

Loading measure files...

+
+ )} + + {error && ( +
+ ⚠️ + {error} +
+ )} + + {!loading && !error && filteredMeasures.length === 0 && ( +
+
📊
+

No measures found

+

+ {searchTerm + ? `No measures match "${searchTerm}"` + : 'No measure files found in input/fsh/measures directory' + } +

+
+ )} + + {!loading && !error && filteredMeasures.length > 0 && ( +
+ {filteredMeasures.map((measure) => ( +
handleMeasureClick(measure)} + onKeyPress={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + handleMeasureClick(measure); + } + }} + role="button" + tabIndex={0} + > +
📊
+
+

{measure.title}

+
+ {measure.name} + {measure.path} +
+
+
+ +
+
+ ))} +
+ )} +
+
+ ); +}; + +export default ProgramIndicatorsViewer; diff --git a/src/services/componentRouteService.js b/src/services/componentRouteService.js index 00d2345316..1ecb365342 100644 --- a/src/services/componentRouteService.js +++ b/src/services/componentRouteService.js @@ -125,6 +125,9 @@ function createLazyComponent(componentName) { case 'PersonaViewer': LazyComponent = React.lazy(() => import('../components/PersonaViewer')); break; + case 'ProgramIndicatorsViewer': + LazyComponent = React.lazy(() => import('../components/ProgramIndicatorsViewer')); + break; default: console.warn(`Unknown component ${componentName}, using fallback`); From 9a553e1c871b5616348cd8bffcae7ec5bbe31f00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Oct 2025 20:24:24 +0000 Subject: [PATCH 3/4] Add ProgramIndicatorEditor with LM field mapping and FSH preview Co-authored-by: litlfred <662242+litlfred@users.noreply.github.com> --- public/routes-config.json | 4 + src/components/ProgramIndicatorEditor.css | 276 +++++++++++++ src/components/ProgramIndicatorEditor.js | 460 ++++++++++++++++++++++ src/services/componentRouteService.js | 3 + 4 files changed, 743 insertions(+) create mode 100644 src/components/ProgramIndicatorEditor.css create mode 100644 src/components/ProgramIndicatorEditor.js diff --git a/public/routes-config.json b/public/routes-config.json index e47064690d..78331a03da 100644 --- a/public/routes-config.json +++ b/public/routes-config.json @@ -62,6 +62,10 @@ "program-indicators": { "component": "ProgramIndicatorsViewer", "path": "./components/ProgramIndicatorsViewer" + }, + "program-indicator-editor": { + "component": "ProgramIndicatorEditor", + "path": "./components/ProgramIndicatorEditor" } }, "standardComponents": { diff --git a/src/components/ProgramIndicatorEditor.css b/src/components/ProgramIndicatorEditor.css new file mode 100644 index 0000000000..1ea45a375e --- /dev/null +++ b/src/components/ProgramIndicatorEditor.css @@ -0,0 +1,276 @@ +/* Program Indicator Editor Styles */ + +.program-indicator-editor { + padding: 1rem; + max-width: 1800px; + margin: 0 auto; +} + +.editor-header { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 2px solid #e0e0e0; +} + +.editor-header h2 { + margin: 0 0 0.5rem 0; + color: #333; +} + +.editor-header p { + margin: 0; + color: #666; + font-size: 0.95rem; +} + +.editor-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + min-height: 600px; +} + +.editor-panel { + background: white; + border: 1px solid #ddd; + border-radius: 8px; + padding: 1.5rem; + overflow-y: auto; + max-height: calc(100vh - 250px); +} + +.editor-panel h3 { + margin: 0 0 1rem 0; + color: #0078d4; + font-size: 1.25rem; + padding-bottom: 0.5rem; + border-bottom: 2px solid #0078d4; +} + +/* LM Fields Panel */ +.lm-fields-panel { + background: #f8f9fa; +} + +.field-mapping-note { + background: #e7f3ff; + border-left: 4px solid #0078d4; + padding: 0.75rem; + margin-bottom: 1.5rem; + border-radius: 4px; +} + +.field-mapping-note small { + color: #555; + font-size: 0.875rem; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + color: #333; + font-size: 0.95rem; +} + +.form-group label strong { + color: #0078d4; +} + +.form-group label .required { + color: #d32f2f; + margin-left: 0.25rem; +} + +.form-group label small { + display: block; + color: #666; + font-weight: normal; + font-size: 0.85rem; + margin-top: 0.25rem; +} + +.form-control { + width: 100%; + padding: 0.75rem; + border: 2px solid #ddd; + border-radius: 6px; + font-size: 0.95rem; + font-family: inherit; + transition: border-color 0.2s; +} + +.form-control:focus { + outline: none; + border-color: #0078d4; +} + +.form-control:invalid { + border-color: #d32f2f; +} + +textarea.form-control { + font-family: 'Courier New', monospace; + resize: vertical; +} + +/* Field Mapping Help Table */ +.field-mapping-help { + margin-top: 2rem; + padding-top: 2rem; + border-top: 1px solid #ddd; +} + +.field-mapping-help h4 { + margin: 0 0 1rem 0; + color: #333; + font-size: 1rem; +} + +.mapping-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.mapping-table th, +.mapping-table td { + padding: 0.5rem; + text-align: left; + border: 1px solid #ddd; +} + +.mapping-table th { + background: #0078d4; + color: white; + font-weight: 600; +} + +.mapping-table td { + background: white; +} + +.mapping-table code { + background: #f5f5f5; + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: 'Courier New', monospace; + font-size: 0.85rem; + color: #d32f2f; +} + +/* FSH Preview Panel */ +.fsh-preview-panel { + background: #1e1e1e; + color: #d4d4d4; +} + +.fsh-preview-panel h3 { + color: #4ec9b0; + border-bottom-color: #4ec9b0; +} + +.fsh-preview { + font-family: 'Courier New', Consolas, monospace; + font-size: 0.9rem; + line-height: 1.5; + overflow-x: auto; +} + +.fsh-preview pre { + margin: 0; + padding: 0; + white-space: pre-wrap; + word-wrap: break-word; +} + +.fsh-preview code { + color: #d4d4d4; +} + +/* Loading and Error States */ +.editor-loading, +.editor-error { + padding: 3rem; + text-align: center; +} + +.editor-error { + background: #fff3cd; + border: 2px solid #ffc107; + border-radius: 8px; + color: #856404; +} + +.editor-error h3 { + margin: 0 0 1rem 0; + color: #856404; +} + +.editor-error button { + margin-top: 1rem; + padding: 0.6rem 1.5rem; + background: #0078d4; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 600; +} + +.editor-error button:hover { + background: #005a9e; +} + +.loading-spinner { + border: 4px solid #f3f3f3; + border-top: 4px solid #0078d4; + border-radius: 50%; + width: 50px; + height: 50px; + animation: spin 1s linear infinite; + margin: 0 auto 1rem; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Responsive Design */ +@media (max-width: 1200px) { + .editor-layout { + grid-template-columns: 1fr; + gap: 1.5rem; + } + + .editor-panel { + max-height: 500px; + } + + .fsh-preview-panel { + order: -1; /* Show FSH preview first on mobile */ + } +} + +@media (max-width: 768px) { + .program-indicator-editor { + padding: 0.5rem; + } + + .editor-panel { + padding: 1rem; + } + + .mapping-table { + font-size: 0.75rem; + } + + .mapping-table th, + .mapping-table td { + padding: 0.4rem; + } +} diff --git a/src/components/ProgramIndicatorEditor.js b/src/components/ProgramIndicatorEditor.js new file mode 100644 index 0000000000..b102a6be3f --- /dev/null +++ b/src/components/ProgramIndicatorEditor.js @@ -0,0 +1,460 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { AssetEditorLayout, usePage } from './framework'; +import githubService from '../services/githubService'; +import stagingGroundService from '../services/stagingGroundService'; +import './ProgramIndicatorEditor.css'; + +const ProgramIndicatorEditor = () => { + const navigate = useNavigate(); + const { repository, branch } = usePage(); + const { '*': assetPath } = useParams(); // Get the file path from URL + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [originalContent, setOriginalContent] = useState(''); + const [fshContent, setFshContent] = useState(''); + + // ProgramIndicator LM fields + const [indicatorData, setIndicatorData] = useState({ + id: '', + name: '', + definition: '', + numerator: '', + denominator: '', + disaggregation: '', + descriptionString: '', + descriptionUri: '', + references: [] + }); + + // Load measure file + useEffect(() => { + const loadMeasureFile = async () => { + if (!repository || !assetPath) { + navigate('/'); + return; + } + + try { + setLoading(true); + setError(null); + + const owner = repository.owner?.login || repository.full_name.split('/')[0]; + const repoName = repository.name; + const ref = branch || 'main'; + + // Decode the asset path + const decodedPath = decodeURIComponent(assetPath); + + console.log('Loading measure file:', { owner, repoName, path: decodedPath, ref }); + + // Get file content + const content = await githubService.getFileContent(owner, repoName, decodedPath, ref); + + setOriginalContent(content); + setFshContent(content); + + // Parse FSH to extract ProgramIndicator LM data + const parsed = parseFSHToIndicatorData(content); + setIndicatorData(parsed); + + setLoading(false); + } catch (err) { + console.error('Error loading measure file:', err); + setError(err.message); + setLoading(false); + } + }; + + loadMeasureFile(); + }, [repository, branch, assetPath, navigate]); + + // Parse FSH content to extract ProgramIndicator LM fields + const parseFSHToIndicatorData = (fshContent) => { + const data = { + id: '', + name: '', + definition: '', + numerator: '', + denominator: '', + disaggregation: '', + descriptionString: '', + descriptionUri: '', + references: [] + }; + + if (!fshContent) return data; + + // Extract Instance name (ID) + const instanceMatch = fshContent.match(/^Instance:\s+(\S+)/m); + if (instanceMatch) { + data.id = instanceMatch[1]; + } + + // Extract Title (maps to name) + const titleMatch = fshContent.match(/^Title:\s*"(.+)"$/m); + if (titleMatch) { + data.name = titleMatch[1]; + } + + // Extract description + const descMatch = fshContent.match(/^\*\s*description\s*=\s*"(.+)"$/m); + if (descMatch) { + data.definition = descMatch[1]; + } + + // Extract numerator description + const numeratorMatch = fshContent.match(/population\[numerator\][\s\S]*?description\s*=\s*"(.+?)"/); + if (numeratorMatch) { + data.numerator = numeratorMatch[1]; + } + + // Extract denominator description + const denominatorMatch = fshContent.match(/population\[denominator\][\s\S]*?description\s*=\s*"(.+?)"/); + if (denominatorMatch) { + data.denominator = denominatorMatch[1]; + } + + // Look for comments that might contain disaggregation or references + const commentMatches = fshContent.matchAll(/^\/\/\s*(.+)$/gm); + for (const match of commentMatches) { + const comment = match[1].trim(); + if (comment.toLowerCase().startsWith('disaggregation:')) { + data.disaggregation = comment.substring('disaggregation:'.length).trim(); + } else if (comment.toLowerCase().startsWith('references:')) { + const refs = comment.substring('references:'.length).trim(); + data.references = refs.split(',').map(r => r.trim()).filter(r => r); + } + } + + return data; + }; + + // Generate FSH content from ProgramIndicator LM data + const generateFSHFromIndicatorData = (data) => { + // Keep the original FSH structure but update specific fields + let newFsh = fshContent; + + // Update Instance name + if (data.id && data.id !== indicatorData.id) { + newFsh = newFsh.replace(/^Instance:\s+\S+/m, `Instance: ${data.id}`); + } + + // Update Title + if (data.name) { + newFsh = newFsh.replace(/^Title:\s*".+"$/m, `Title: "${data.name}"`); + newFsh = newFsh.replace(/^\*\s*title\s*=\s*".+"$/m, `* title = "${data.name}"`); + newFsh = newFsh.replace(/^\*\s*name\s*=\s*".+"$/m, `* name = "${data.id}"`); + } + + // Update description (definition) + if (data.definition) { + newFsh = newFsh.replace(/^\*\s*description\s*=\s*".+"$/m, `* description = "${data.definition}"`); + } + + // Update numerator description + if (data.numerator) { + newFsh = newFsh.replace( + /(population\[numerator\][\s\S]*?description\s*=\s*")(.+?)(")/, + `$1${data.numerator}$3` + ); + } + + // Update denominator description + if (data.denominator) { + newFsh = newFsh.replace( + /(population\[denominator\][\s\S]*?description\s*=\s*")(.+?)(")/, + `$1${data.denominator}$3` + ); + } + + // Add comments for disaggregation and references if they don't exist + if (data.disaggregation || data.references.length > 0) { + const lines = newFsh.split('\n'); + const titleLineIndex = lines.findIndex(line => line.startsWith('Title:')); + + if (titleLineIndex >= 0) { + const commentsToAdd = []; + if (data.disaggregation) { + commentsToAdd.push(`// Disaggregation: ${data.disaggregation}`); + } + if (data.references.length > 0) { + commentsToAdd.push(`// References: ${data.references.join(', ')}`); + } + + // Insert comments after Title line + lines.splice(titleLineIndex + 1, 0, ...commentsToAdd); + newFsh = lines.join('\n'); + } + } + + return newFsh; + }; + + // Handle field changes + const handleFieldChange = (field, value) => { + const newData = { ...indicatorData, [field]: value }; + setIndicatorData(newData); + + // Update FSH content + const newFsh = generateFSHFromIndicatorData(newData); + setFshContent(newFsh); + }; + + // Handle save + const handleSave = async (content, saveType) => { + try { + if (saveType === 'local') { + // Save to staging ground + const decodedPath = decodeURIComponent(assetPath); + stagingGroundService.updateFile(decodedPath, content, { + source: 'program-indicator-editor', + timestamp: Date.now() + }); + + // TODO: Update dak.json indicators array + console.log('Saved to staging ground:', decodedPath); + return { result: 'success' }; + } else { + // GitHub save would go here, but we're only saving to staging ground per requirements + console.log('GitHub save not yet implemented'); + return { result: 'error', message: 'GitHub save not implemented' }; + } + } catch (error) { + console.error('Save error:', error); + return { result: 'error', message: error.message }; + } + }; + + const hasChanges = fshContent !== originalContent; + + if (loading) { + return ( + +
+
+

Loading measure...

+
+
+ ); + } + + if (error) { + return ( + +
+

Error Loading Measure

+

{error}

+ +
+
+ ); + } + + const fileName = assetPath ? decodeURIComponent(assetPath).split('/').pop() : 'measure.fsh'; + + return ( + +
+
+

Program Indicator Editor

+

Edit ProgramIndicator Logical Model fields mapped to FHIR Measure FSH

+
+ +
+ {/* Left side: Editable LM fields */} +
+

ProgramIndicator LM Fields

+
+ WHO SMART Base ProgramIndicator Logical Model fields mapped to FHIR Measure FSH +
+ +
+ + handleFieldChange('id', e.target.value)} + pattern="[A-Za-z0-9\-\.]{1,64}" + className="form-control" + /> +
+ +
+ + handleFieldChange('name', e.target.value)} + className="form-control" + /> +
+ +
+ +