diff --git a/src/components/PersonaViewer.css b/src/components/PersonaViewer.css
new file mode 100644
index 000000000..0b25254ce
--- /dev/null
+++ b/src/components/PersonaViewer.css
@@ -0,0 +1,288 @@
+.persona-viewer {
+ background: linear-gradient(135deg, #0078d4 0%, #005a9e 100%);
+ min-height: 100vh;
+ color: white;
+ padding: 0;
+ margin: 0;
+}
+
+.page-header {
+ background: rgb(4, 11, 118);
+ padding: 2rem;
+ border-bottom: 2px solid rgba(255, 255, 255, 0.1);
+}
+
+.page-header h1 {
+ margin: 0 0 0.5rem 0;
+ font-size: 2.5rem;
+ font-weight: bold;
+ color: white;
+}
+
+.page-description {
+ margin: 0.5rem 0 1rem 0;
+ font-size: 1.2rem;
+ opacity: 0.9;
+ color: white;
+}
+
+.repository-info {
+ background: rgba(255, 255, 255, 0.1);
+ padding: 1rem;
+ border-radius: 8px;
+ margin-top: 1rem;
+ color: white;
+}
+
+.branch-info {
+ margin-left: 1rem;
+ opacity: 0.8;
+ font-style: italic;
+}
+
+.loading-state, .error-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 4rem 2rem;
+ text-align: center;
+}
+
+.loading-spinner {
+ width: 50px;
+ height: 50px;
+ border: 4px solid rgba(255, 255, 255, 0.3);
+ border-left: 4px solid white;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin-bottom: 1rem;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+.scan-status {
+ font-style: italic;
+ opacity: 0.8;
+ margin-top: 0.5rem;
+ color: white;
+}
+
+.scan-controls {
+ padding: 2rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.rescan-button, .retry-button {
+ background: rgba(255, 255, 255, 0.2);
+ color: white;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ padding: 0.75rem 1.5rem;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 1rem;
+ transition: all 0.3s ease;
+}
+
+.rescan-button:hover, .retry-button:hover {
+ background: rgba(255, 255, 255, 0.3);
+ border-color: rgba(255, 255, 255, 0.5);
+}
+
+.rescan-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.actors-summary {
+ padding: 2rem;
+}
+
+.actors-summary h2 {
+ margin: 0 0 1.5rem 0;
+ font-size: 2rem;
+ color: white;
+}
+
+.no-actors {
+ background: rgba(255, 255, 255, 0.1);
+ padding: 2rem;
+ border-radius: 8px;
+ text-align: center;
+}
+
+.search-info {
+ margin-top: 1.5rem;
+ text-align: left;
+}
+
+.search-info h3 {
+ margin: 0 0 1rem 0;
+ color: white;
+}
+
+.search-info ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.search-info li {
+ background: rgba(255, 255, 255, 0.05);
+ margin: 0.5rem 0;
+ padding: 0.75rem;
+ border-radius: 4px;
+ border-left: 3px solid rgba(255, 255, 255, 0.3);
+}
+
+.search-info code {
+ background: rgba(0, 0, 0, 0.2);
+ padding: 0.2rem 0.4rem;
+ border-radius: 3px;
+ font-family: 'Courier New', monospace;
+ color: #ffeb3b;
+}
+
+.actors-list {
+ display: grid;
+ gap: 1.5rem;
+ grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
+}
+
+.actor-card {
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 8px;
+ padding: 1.5rem;
+ transition: all 0.3s ease;
+}
+
+.actor-card:hover {
+ background: rgba(255, 255, 255, 0.15);
+ border-color: rgba(255, 255, 255, 0.3);
+ transform: translateY(-2px);
+}
+
+.actor-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 1rem;
+}
+
+.actor-name {
+ margin: 0;
+ font-size: 1.3rem;
+ color: white;
+ flex: 1;
+ word-break: break-word;
+}
+
+.actor-type {
+ background: rgba(255, 255, 255, 0.2);
+ color: white;
+ padding: 0.3rem 0.6rem;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ font-weight: bold;
+ margin-left: 1rem;
+ white-space: nowrap;
+}
+
+.actor-type.fsh {
+ background: rgba(76, 175, 80, 0.8);
+}
+
+.actor-type.json {
+ background: rgba(255, 152, 0, 0.8);
+}
+
+.actor-details {
+ margin-bottom: 1rem;
+}
+
+.actor-id {
+ margin: 0 0 0.5rem 0;
+ font-family: 'Courier New', monospace;
+ font-size: 0.9rem;
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.actor-description {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.8);
+ line-height: 1.4;
+}
+
+.actor-source {
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ padding-top: 1rem;
+}
+
+.source-path {
+ margin: 0 0 0.5rem 0;
+ font-size: 0.9rem;
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.source-link {
+ color: #81d4fa;
+ text-decoration: none;
+ font-family: 'Courier New', monospace;
+ word-break: break-all;
+}
+
+.source-link:hover {
+ color: #4fc3f7;
+ text-decoration: underline;
+}
+
+.resource-type {
+ margin: 0;
+ font-size: 0.9rem;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+/* Responsive design */
+@media (max-width: 768px) {
+ .actors-list {
+ grid-template-columns: 1fr;
+ }
+
+ .page-header {
+ padding: 1.5rem;
+ }
+
+ .page-header h1 {
+ font-size: 2rem;
+ }
+
+ .actor-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ .actor-type {
+ margin-left: 0;
+ }
+
+ .scan-controls {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ }
+}
+
+/* Dark mode adjustments */
+@media (prefers-color-scheme: dark) {
+ .persona-viewer {
+ /* Already using dark theme */
+ }
+}
\ No newline at end of file
diff --git a/src/components/PersonaViewer.js b/src/components/PersonaViewer.js
new file mode 100644
index 000000000..c52327956
--- /dev/null
+++ b/src/components/PersonaViewer.js
@@ -0,0 +1,393 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import githubService from '../services/githubService';
+import { PageLayout, usePage } from './framework';
+import './PersonaViewer.css';
+
+const PersonaViewer = () => {
+ return (
+
+
+
+ );
+};
+
+const PersonaViewerContent = () => {
+ const pageContext = usePage();
+
+ // All hooks must be called before any early returns
+ const [actors, setActors] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [scanStatus, setScanStatus] = useState('');
+
+ // Handle case where PageProvider context might be null - AFTER all hooks
+ if (!pageContext) {
+ return (
+
+
+
Generic Personas & Actor Definitions
+
+ Initializing page context...
+
+
+
+
+
Loading
+
Page framework is initializing. Please wait...
+
+
+
+ );
+ }
+
+ const { profile, repository, branch } = pageContext;
+
+ // Get data from page framework
+ const user = profile?.login;
+ const repo = repository?.name;
+ const selectedBranch = branch || repository?.default_branch || 'main';
+
+ // Helper function to parse FSH file content for actor definitions
+ const parseFshFileForActors = useCallback((filePath, content) => {
+ const actors = [];
+ const lines = content.split('\n');
+
+ let currentActor = null;
+ let inActorDefinition = false;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+
+ // Look for Profile definitions that could be actors
+ if (line.startsWith('Profile:') || line.startsWith('Instance:')) {
+ const id = line.split(':')[1]?.trim();
+ if (id) {
+ // Generous assumption: any instance ID could be an actor
+ currentActor = {
+ id,
+ name: id,
+ description: '',
+ type: 'FSH Profile/Instance',
+ source: {
+ type: 'fsh',
+ path: filePath,
+ lineNumber: i + 1
+ }
+ };
+ inActorDefinition = true;
+ }
+ }
+
+ // Look for explicit actor-related keywords
+ if (line.includes('ActorDefinition') || line.includes('Actor') ||
+ line.toLowerCase().includes('persona') || line.toLowerCase().includes('role')) {
+ if (!currentActor && line.includes(':')) {
+ const parts = line.split(':');
+ const id = parts[parts.length - 1]?.trim();
+ if (id) {
+ currentActor = {
+ id,
+ name: id,
+ description: 'Actor definition found',
+ type: 'FSH Actor',
+ source: {
+ type: 'fsh',
+ path: filePath,
+ lineNumber: i + 1
+ }
+ };
+ inActorDefinition = true;
+ }
+ }
+ }
+
+ // Extract title and description
+ if (currentActor && inActorDefinition) {
+ if (line.startsWith('Title:')) {
+ currentActor.name = line.split(':')[1]?.trim().replace(/"/g, '') || currentActor.id;
+ } else if (line.startsWith('Description:')) {
+ currentActor.description = line.split(':')[1]?.trim().replace(/"/g, '') || '';
+ } else if (line.startsWith('Id:')) {
+ currentActor.id = line.split(':')[1]?.trim() || currentActor.id;
+ }
+
+ // End of definition (empty line or new definition)
+ if (line === '' || line.startsWith('Profile:') || line.startsWith('Instance:')) {
+ if (currentActor.id && i > 0) {
+ actors.push(currentActor);
+ currentActor = null;
+ inActorDefinition = false;
+ }
+ }
+ }
+ }
+
+ // Add the last actor if we ended the file while in a definition
+ if (currentActor && currentActor.id) {
+ actors.push(currentActor);
+ }
+
+ return actors;
+ }, []);
+
+ // Helper function to parse JSON file content for actor definitions
+ const parseJsonFileForActors = useCallback((filePath, content) => {
+ const actors = [];
+
+ try {
+ const jsonData = JSON.parse(content);
+
+ // Function to recursively search for actor-like objects
+ const searchForActors = (obj, path = '') => {
+ if (typeof obj !== 'object' || obj === null) return;
+
+ if (Array.isArray(obj)) {
+ obj.forEach((item, index) => searchForActors(item, `${path}[${index}]`));
+ return;
+ }
+
+ // Check if this object looks like an actor definition
+ const resourceType = obj.resourceType;
+ const id = obj.id;
+
+ if (resourceType && id) {
+ // Be generous with actor-like resource types
+ if (resourceType === 'ActorDefinition' ||
+ resourceType === 'SGActorDefinition' ||
+ resourceType === 'Persona' ||
+ resourceType === 'SGPersona' ||
+ resourceType.toLowerCase().includes('actor') ||
+ resourceType.toLowerCase().includes('persona')) {
+
+ actors.push({
+ id: id,
+ name: obj.name || obj.title || id,
+ description: obj.description || `${resourceType} resource`,
+ type: `JSON ${resourceType}`,
+ source: {
+ type: 'json',
+ path: filePath,
+ resourceType: resourceType,
+ fullPath: path
+ }
+ });
+ }
+ }
+
+ // Recursively search nested objects
+ Object.keys(obj).forEach(key => {
+ searchForActors(obj[key], path ? `${path}.${key}` : key);
+ });
+ };
+
+ searchForActors(jsonData);
+
+ } catch (parseError) {
+ console.warn(`Failed to parse JSON file ${filePath}:`, parseError);
+ }
+
+ return actors;
+ }, []);
+
+ // Scan the repository for actor definitions
+ const scanForActors = useCallback(async () => {
+ if (!githubService.isAuth() || !user || !repo) {
+ setError('GitHub authentication required and repository information needed');
+ setLoading(false);
+ return;
+ }
+
+ setScanStatus('Starting scan...');
+ setActors([]);
+
+ try {
+ const allActors = [];
+
+ // 1. Scan FSH files under input/fsh/actors
+ setScanStatus('Scanning FSH files in input/fsh/actors...');
+ try {
+ const actorsDir = await githubService.getDirectoryContents(user, repo, 'input/fsh/actors', selectedBranch);
+
+ for (const file of actorsDir) {
+ if (file.type === 'file' && file.name.endsWith('.fsh')) {
+ setScanStatus(`Scanning FSH file: ${file.name}`);
+ try {
+ const content = await githubService.getFileContent(user, repo, file.path, selectedBranch);
+ const fshActors = parseFshFileForActors(file.path, content);
+ allActors.push(...fshActors);
+ } catch (fileError) {
+ console.warn(`Failed to read FSH file ${file.path}:`, fileError);
+ }
+ }
+ }
+ } catch (dirError) {
+ console.warn('No input/fsh/actors directory found or access denied:', dirError);
+ }
+
+ // 2. Scan JSON files under inputs/resources
+ setScanStatus('Scanning JSON files in inputs/resources...');
+ try {
+ const resourcesDir = await githubService.getDirectoryContents(user, repo, 'inputs/resources', selectedBranch);
+
+ for (const file of resourcesDir) {
+ if (file.type === 'file' && file.name.endsWith('.json')) {
+ setScanStatus(`Scanning JSON file: ${file.name}`);
+ try {
+ const content = await githubService.getFileContent(user, repo, file.path, selectedBranch);
+ const jsonActors = parseJsonFileForActors(file.path, content);
+ allActors.push(...jsonActors);
+ } catch (fileError) {
+ console.warn(`Failed to read JSON file ${file.path}:`, fileError);
+ }
+ }
+ }
+ } catch (dirError) {
+ console.warn('No inputs/resources directory found or access denied:', dirError);
+ }
+
+ setScanStatus(`Scan complete. Found ${allActors.length} actors.`);
+ setActors(allActors);
+ setError(null);
+
+ } catch (error) {
+ console.error('Error scanning for actors:', error);
+ setError(`Failed to scan repository: ${error.message}`);
+ setScanStatus('Scan failed');
+ } finally {
+ setLoading(false);
+ }
+ }, [user, repo, selectedBranch, parseFshFileForActors, parseJsonFileForActors]);
+
+ // Initial scan when component mounts
+ useEffect(() => {
+ if (user && repo) {
+ scanForActors();
+ } else {
+ setLoading(false);
+ setError('Repository information not available');
+ }
+ }, [user, repo, selectedBranch, scanForActors]);
+
+ // Helper function to generate source file link
+ const getSourceFileLink = useCallback((actor) => {
+ if (!user || !repo || !actor.source) return '#';
+
+ const baseUrl = `https://github.com/${user}/${repo}/blob/${selectedBranch}/${actor.source.path}`;
+
+ if (actor.source.lineNumber) {
+ return `${baseUrl}#L${actor.source.lineNumber}`;
+ }
+
+ return baseUrl;
+ }, [user, repo, selectedBranch]);
+
+ if (loading) {
+ return (
+
+
+
+
Loading user scenarios and personas...
+ {scanStatus &&
{scanStatus}
}
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
Error
+
{error}
+ {user && repo && (
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+
Generic Personas & Actor Definitions
+
+ Actor definitions and personas found in this DAK repository for healthcare workflows.
+
+ {user && repo && (
+
+ Repository: {user}/{repo}
+ (branch: {selectedBranch})
+
+ )}
+
+
+
+
+ {scanStatus &&
{scanStatus}
}
+
+
+
+
Found Personas & Actors ({actors.length})
+ {actors.length === 0 ? (
+
+
No actor definitions or personas found in this repository.
+
+
Searched in:
+
+ input/fsh/actors/*.fsh - FSH actor definitions (generous matching)
+ inputs/resources/*.json - JSON ActorDefinition resources (strict matching)
+
+
+
+ ) : (
+
+ {actors.map((actor, index) => (
+
+
+
{actor.name}
+
+ {actor.type}
+
+
+
+
+
ID: {actor.id}
+ {actor.description && (
+
{actor.description}
+ )}
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+export default PersonaViewer;
\ No newline at end of file
diff --git a/src/components/QuestionnaireEditor.js b/src/components/QuestionnaireEditor.js
index 75dcf6d69..a6659b091 100644
--- a/src/components/QuestionnaireEditor.js
+++ b/src/components/QuestionnaireEditor.js
@@ -287,21 +287,50 @@ const LFormsVisualEditor = ({ questionnaire, onChange }) => {
};
const QuestionnaireEditorContent = () => {
- const { repository, branch, isLoading: pageLoading } = useDAKParams();
+ const pageParams = useDAKParams();
- // Component state
+ // Component state - ALL HOOKS MUST BE AT THE TOP
const [questionnaires, setQuestionnaires] = useState([]);
const [selectedQuestionnaire, setSelectedQuestionnaire] = useState(null);
const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
- const [editing, setEditing] = useState(false);
- const [questionnaireContent, setQuestionnaireContent] = useState(null);
- const [originalContent, setOriginalContent] = useState(null);
+ const [searchTerm, setSearchTerm] = useState('');
+ const [showPreview, setShowPreview] = useState(false);
+
+ // Handle PageProvider initialization issues - AFTER all hooks
+ if (pageParams.error) {
+ return (
+
+
+
Page Context Error
+
{pageParams.error}
+
This component requires a DAK repository context to function properly.
+
+
+ );
+ }
- // LForms integration state
+ if (pageParams.loading) {
+ return (
+
+
+
Loading...
+
Initializing page context...
+
+
+ );
+ }
+
+ const { repository, branch, isLoading: pageLoading } = pageParams;
+
+ // LForms integration state (additional state)
const [lformsLoaded, setLformsLoaded] = useState(false);
const [editMode, setEditMode] = useState('visual'); // 'visual' or 'json'
const [lformsError, setLformsError] = useState(null);
+ const [editing, setEditing] = useState(false);
+ const [questionnaireContent, setQuestionnaireContent] = useState(null);
+ const [originalContent, setOriginalContent] = useState(null);
// Check if we have the necessary context data
const hasRequiredData = repository && branch && !pageLoading;
diff --git a/src/components/framework/usePageParams.js b/src/components/framework/usePageParams.js
index 1e0d8f341..0c88706fc 100644
--- a/src/components/framework/usePageParams.js
+++ b/src/components/framework/usePageParams.js
@@ -60,12 +60,41 @@ export const useDAKParams = () => {
try {
const pageParams = usePageParams();
+ // Handle case where PageProvider context is null or not properly initialized
+ if (!pageParams || !pageParams.type) {
+ console.warn('useDAKParams: PageProvider context not available, returning empty data');
+ return {
+ user: null,
+ profile: null,
+ repository: null,
+ branch: null,
+ asset: null,
+ updateBranch: () => {},
+ navigate: () => {},
+ loading: true,
+ error: null
+ };
+ }
+
// Only throw error if page is fully loaded and type is not DAK/ASSET
- // This prevents errors during initial loading or page type determination
+ // But allow loading state to pass through
if (!pageParams.loading &&
pageParams.type !== PAGE_TYPES.DAK &&
pageParams.type !== PAGE_TYPES.ASSET) {
- throw new Error(`useDAKParams can only be used on DAK or Asset pages. Current page type: ${pageParams.type}`);
+
+ // Instead of throwing, return null data with error flag for graceful degradation
+ console.warn(`useDAKParams: Component loaded on ${pageParams.type} page instead of DAK/Asset page. Returning empty data for graceful degradation.`);
+ return {
+ user: null,
+ profile: null,
+ repository: null,
+ branch: null,
+ asset: null,
+ updateBranch: () => {},
+ navigate: pageParams.navigate || (() => {}),
+ loading: false,
+ error: `This component requires a DAK or Asset page context but was loaded on a ${pageParams.type} page.`
+ };
}
return {
@@ -75,23 +104,24 @@ export const useDAKParams = () => {
branch: pageParams.branch,
asset: pageParams.asset,
updateBranch: pageParams.updateBranch,
- navigate: pageParams.navigate
+ navigate: pageParams.navigate,
+ loading: pageParams.loading || false,
+ error: null
};
} catch (error) {
- // If PageProvider is not ready yet, return empty object
- if (error.message.includes('usePage must be used within a PageProvider')) {
- console.log('useDAKParams: PageProvider not ready yet, returning empty data');
- return {
- user: null,
- profile: null,
- repository: null,
- branch: null,
- asset: null,
- updateBranch: () => {},
- navigate: () => {}
- };
- }
- throw error;
+ // If PageProvider is not ready yet, return empty object with loading state
+ console.warn('useDAKParams: PageProvider error, returning empty data:', error.message);
+ return {
+ user: null,
+ profile: null,
+ repository: null,
+ branch: null,
+ asset: null,
+ updateBranch: () => {},
+ navigate: () => {},
+ loading: true,
+ error: null
+ };
}
};
diff --git a/src/services/componentRouteService.js b/src/services/componentRouteService.js
index 0877c44e6..622d3b646 100644
--- a/src/services/componentRouteService.js
+++ b/src/services/componentRouteService.js
@@ -119,6 +119,9 @@ function createLazyComponent(componentName) {
case 'QuestionnaireEditor':
LazyComponent = React.lazy(() => import('../components/QuestionnaireEditor'));
break;
+ case 'PersonaViewer':
+ LazyComponent = React.lazy(() => import('../components/PersonaViewer'));
+ break;
default:
console.warn(`Unknown component ${componentName}, using fallback`);