diff --git a/docs/care-apps-plugin-overrides.md b/docs/care-apps-plugin-overrides.md new file mode 100644 index 00000000000..04716d39103 --- /dev/null +++ b/docs/care-apps-plugin-overrides.md @@ -0,0 +1,153 @@ +# Care Apps Plugin Overrides + +This document describes the final approach for making core CARE frontend +components overrideable by Care Apps. + +## Goals + +- Let Care Apps replace selected core React components. +- Keep core component call sites unchanged. +- Preserve context-aware overrides, including route and stack-path conditions. +- Avoid registering every exported component when deployments only need a small + override surface. +- Keep transformed-code debugging usable with source maps. + +## Build-Time Registration + +Core components are registered by the Vite plugin in +`plugins/autoRegisterComponents.ts`. + +The plugin scans `src/**/*.tsx`, excluding `src/lib/override`, and transforms +exported PascalCase React components into registered components. + +Example source: + +```tsx +export function BookAppointmentDetails(props: Props) { + return
; +} +``` + +Generated shape: + +```tsx +function BookAppointmentDetailsBase(props: Props) { + return
; +} + +export const BookAppointmentDetails = register( + "BookAppointmentDetails", + BookAppointmentDetailsBase, +); +``` + +The plugin uses `MagicString` to produce high-resolution source maps for these +edits. This improves browser stack traces and debugger locations for transformed +code. It does not remove the registered wrapper from the React component tree. + +## Registration Allowlist + +Use `REACT_MFE_REGISTERED_COMPONENTS` to limit which exported components are +auto-registered. + +```env +REACT_MFE_REGISTERED_COMPONENTS=BookAppointmentDetails,Login,AuthHero +``` + +Behavior: + +- Empty, unset, or `*` registers every exported component. +- A comma-separated list registers only those exact component names. +- Unknown names fail the Vite build/dev server startup. +- Duplicate exported component names are still rejected so registration keys stay + unambiguous. + +This is a build-time setting. Changing it requires restarting the Vite dev server +or rebuilding the production bundle. + +## Runtime Override Flow + +Care Apps declare overrides in their manifest: + +```ts +overrides: [ + { + component: "BookAppointmentDetails", + replacement: PluginBookAppointmentDetails, + condition: { + page: "appointments", + }, + priority: 10, + }, +]; +``` + +`PluginEngine` loads enabled app manifests, reads their `overrides`, and +registers them with the override registry. The core registered component remains +the stable React component type. At render time it chooses either the base +component or the highest-priority matching override. + +This keeps dynamic plugin loading safe: overrides can arrive after the app has +started, and mounted registered components can re-render against the updated +override registry. + +## Stack-Aware Overrides + +Overrides may use `condition.stackPath` when the same component should be +overridden only under a specific parent path. + +```ts +condition: { + stackPath: ["AppointmentPage", "BookAppointmentDetails"], +} +``` + +Every component name used in a stack path must be registered. If +`REACT_MFE_REGISTERED_COMPONENTS` is set, include both the overridden component +and the stack-path ancestors required for matching. + +## Performance Model + +Only registered rendered component instances pay the wrapper cost. + +Measured synthetic production-mode overhead for an inactive registered wrapper is +roughly sub-microsecond per rendered instance. This is negligible for ordinary +page components, but can compound in dense lists or tables. + +The allowlist is the main performance control: + +- Registering all exported components maximizes override reach. +- Registering only listed components avoids wrapper overhead for every unlisted + export. + +The wrapper does not add a DOM node. It adds one React component boundary for +registered rendered components. + +## Debugging + +The Vite plugin emits high-resolution source maps with original source content. +This improves transformed-code error locations, especially around generated +imports, renamed base components, and appended registration exports. + +React DevTools still shows the runtime wrapper shape: + +```txt +Registered(BookAppointmentDetails) + BookAppointmentDetailsBase +``` + +That is expected. Source maps improve file and line mapping, not the runtime +component hierarchy. + +## Why Vite Plugin Instead of Babel + +The override system needs both a per-file transform and whole-app validation: + +- duplicate registration-name detection +- unknown allowlist-name detection +- `src/lib/override` exclusion +- dev and production Vite lifecycle coverage + +Those concerns fit Vite better than a Babel-only plugin. Babel would mainly help +with AST code generation; `MagicString` covers the current source-map need while +keeping the architecture in Vite. diff --git a/package-lock.json b/package-lock.json index 7fee2c54a0e..93ef1c6fbd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -135,6 +135,7 @@ "husky": "^9.1.7", "jsdom": "^26.0.0", "lint-staged": "^15.2.10", + "magic-string": "^0.27.0", "marked": "^15.0.0", "prettier": "^3.3.3", "prettier-plugin-organize-imports": "^4.2.0", diff --git a/package.json b/package.json index e8cfe9f224d..75fd05f6f26 100644 --- a/package.json +++ b/package.json @@ -180,6 +180,7 @@ "husky": "^9.1.7", "jsdom": "^26.0.0", "lint-staged": "^15.2.10", + "magic-string": "^0.27.0", "marked": "^15.0.0", "prettier": "^3.3.3", "prettier-plugin-organize-imports": "^4.2.0", diff --git a/plugins/autoRegisterComponents.ts b/plugins/autoRegisterComponents.ts new file mode 100644 index 00000000000..2b664103e40 --- /dev/null +++ b/plugins/autoRegisterComponents.ts @@ -0,0 +1,589 @@ +import fs from "fs"; +import MagicString from "magic-string"; +import path from "path"; +import ts from "typescript"; +import type { Plugin } from "vite"; +import { normalizePath } from "vite"; + +const REGISTER_IMPORT = "@/lib/override"; +const REGISTER_ALIAS = "__careRegisterComponent"; + +interface Edit { + start: number; + end: number; + text: string; + storeName?: boolean; +} + +interface TransformTarget { + exportName: string; + baseName: string; + isDefault: boolean; +} + +interface ComponentTarget { + name: string; + file: string; +} + +interface AutoRegisterComponentsOptions { + include?: ReadonlySet | null; +} + +function shouldRegisterComponent( + name: string, + include: ReadonlySet | null | undefined, +) { + return !include || include.has(name); +} + +function isPascalCase(name: string) { + return /^[A-Z][A-Za-z0-9_]*$/.test(name); +} + +function hasModifier( + node: ts.Node, + kind: ts.SyntaxKind.ExportKeyword | ts.SyntaxKind.DefaultKeyword, +) { + return ( + (ts.canHaveModifiers(node) && + ts.getModifiers(node)?.some((modifier) => modifier.kind === kind)) ?? + false + ); +} + +function containsJsx(node: ts.Node): boolean { + let found = false; + + function visit(current: ts.Node) { + if (found) { + return; + } + + switch (current.kind) { + case ts.SyntaxKind.JsxElement: + case ts.SyntaxKind.JsxSelfClosingElement: + case ts.SyntaxKind.JsxFragment: + found = true; + return; + default: + ts.forEachChild(current, visit); + } + } + + visit(node); + return found; +} + +function isComponentFunction(node: ts.FunctionDeclaration) { + return !!node.body && containsJsx(node.body); +} + +function isComponentInitializer(node: ts.Expression | undefined): boolean { + if (!node) { + return false; + } + + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + return containsJsx(node.body); + } + + if (ts.isCallExpression(node)) { + return containsJsx(node); + } + + return false; +} + +function uniqueName(source: string, preferred: string) { + let name = preferred; + let index = 2; + + while (new RegExp(`\\b${name}\\b`).test(source)) { + name = `${preferred}${index}`; + index += 1; + } + + return name; +} + +function removeExportAndDefaultModifiers( + sourceFile: ts.SourceFile, + node: ts.Node, + edits: Edit[], +) { + if (!ts.canHaveModifiers(node)) { + return; + } + + for (const modifier of ts.getModifiers(node) ?? []) { + if ( + modifier.kind !== ts.SyntaxKind.ExportKeyword && + modifier.kind !== ts.SyntaxKind.DefaultKeyword + ) { + continue; + } + + let end = modifier.end; + while (/\s/.test(sourceFile.text[end] ?? "")) { + end += 1; + } + + edits.push({ start: modifier.getStart(sourceFile), end, text: "" }); + } +} + +function appendRegistration(statement: ts.Statement, target: TransformTarget) { + const registration = `${REGISTER_ALIAS}(${JSON.stringify( + target.exportName, + )}, ${target.baseName})`; + + if (target.isDefault) { + return `\nconst ${target.exportName} = ${registration};\nexport default ${target.exportName};`; + } + + return `\nexport const ${target.exportName} = ${registration};`; +} + +function applyEdits(source: string, id: string, edits: Edit[]) { + const code = new MagicString(source, { filename: id }); + + for (const edit of edits.sort((left, right) => right.start - left.start)) { + if (edit.start === edit.end) { + code.appendLeft(edit.start, edit.text); + continue; + } + + if (edit.text === "") { + code.remove(edit.start, edit.end); + continue; + } + + code.update(edit.start, edit.end, edit.text, { + storeName: edit.storeName, + }); + } + + return { + code: code.toString(), + map: code.generateMap({ + hires: true, + includeContent: true, + source: id, + }), + }; +} + +function getImportInsertPosition(sourceFile: ts.SourceFile) { + let position = 0; + + for (const statement of sourceFile.statements) { + if (ts.isImportDeclaration(statement)) { + position = statement.end; + continue; + } + + if ( + ts.isExpressionStatement(statement) && + ts.isStringLiteral(statement.expression) + ) { + position = statement.end; + continue; + } + + break; + } + + return position; +} + +function createRegisterImport(sourceFile: ts.SourceFile) { + const insertPosition = getImportInsertPosition(sourceFile); + const prefix = insertPosition > 0 ? "\n" : ""; + + return { + start: insertPosition, + end: insertPosition, + text: `${prefix}import { register as ${REGISTER_ALIAS} } from "${REGISTER_IMPORT}";\n`, + }; +} + +function findLocalComponentDeclaration( + sourceFile: ts.SourceFile, + name: string, +): ts.FunctionDeclaration | ts.VariableDeclaration | null { + for (const statement of sourceFile.statements) { + if (ts.isFunctionDeclaration(statement) && statement.name?.text === name) { + return isComponentFunction(statement) ? statement : null; + } + + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if ( + !ts.isIdentifier(declaration.name) || + declaration.name.text !== name + ) { + continue; + } + + return isComponentInitializer(declaration.initializer) + ? declaration + : null; + } + } + } + + return null; +} + +function collectComponentTargets( + source: string, + id: string, +): ComponentTarget[] { + const sourceFile = ts.createSourceFile( + id, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + const targets: ComponentTarget[] = []; + const localComponents = new Set(); + + for (const statement of sourceFile.statements) { + if ( + ts.isFunctionDeclaration(statement) && + statement.name && + isPascalCase(statement.name.text) && + isComponentFunction(statement) + ) { + localComponents.add(statement.name.text); + } + + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && + isPascalCase(declaration.name.text) && + isComponentInitializer(declaration.initializer) + ) { + localComponents.add(declaration.name.text); + } + } + } + } + + for (const statement of sourceFile.statements) { + const isExported = hasModifier(statement, ts.SyntaxKind.ExportKeyword); + + if ( + isExported && + ts.isFunctionDeclaration(statement) && + statement.name && + localComponents.has(statement.name.text) + ) { + targets.push({ name: statement.name.text, file: id }); + continue; + } + + if ( + isExported && + ts.isVariableStatement(statement) && + statement.declarationList.declarations.length === 1 + ) { + const declaration = statement.declarationList.declarations[0]; + + if ( + ts.isIdentifier(declaration.name) && + localComponents.has(declaration.name.text) + ) { + targets.push({ name: declaration.name.text, file: id }); + } + + continue; + } + + if ( + ts.isExportAssignment(statement) && + ts.isIdentifier(statement.expression) && + localComponents.has(statement.expression.text) + ) { + targets.push({ name: statement.expression.text, file: id }); + } + } + + return targets; +} + +function findTsxFiles(root: string): string[] { + if (!fs.existsSync(root)) { + return []; + } + + return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const filePath = path.join(root, entry.name); + + if (entry.isDirectory()) { + return findTsxFiles(filePath); + } + + return entry.isFile() && filePath.endsWith(".tsx") ? [filePath] : []; + }); +} + +function collectRegisteredComponentTargets( + srcRoot: string, + overrideRoot: string, +) { + return findTsxFiles(srcRoot).flatMap((filePath) => { + const normalizedPath = normalizePath(filePath); + + if (normalizedPath.startsWith(overrideRoot)) { + return []; + } + + return collectComponentTargets( + fs.readFileSync(filePath, "utf8"), + normalizedPath, + ); + }); +} + +function assertUniqueComponentNames(targets: ComponentTarget[]) { + const componentFiles = new Map>(); + + for (const target of targets) { + const files = componentFiles.get(target.name) ?? new Set(); + files.add(target.file); + componentFiles.set(target.name, files); + } + + const duplicates = Array.from(componentFiles.entries()) + .filter(([, files]) => files.size > 1) + .sort(([left], [right]) => left.localeCompare(right)); + + if (duplicates.length === 0) { + return; + } + + const message = duplicates + .map(([name, files]) => + [ + `- ${name}`, + ...Array.from(files) + .sort() + .map((file) => ` ${file}`), + ].join("\n"), + ) + .join("\n"); + + throw new Error( + `Duplicate exported component names are not allowed for auto-registration:\n${message}`, + ); +} + +function assertKnownComponentNames( + targets: ComponentTarget[], + include: ReadonlySet | null | undefined, +) { + if (!include) { + return; + } + + const knownNames = new Set(targets.map((target) => target.name)); + const unknownNames = Array.from(include) + .filter((name) => !knownNames.has(name)) + .sort((left, right) => left.localeCompare(right)); + + if (unknownNames.length === 0) { + return; + } + + throw new Error( + [ + "Unknown component names in REACT_MFE_REGISTERED_COMPONENTS:", + ...unknownNames.map((name) => `- ${name}`), + ].join("\n"), + ); +} + +function transformSource( + source: string, + id: string, + include: ReadonlySet | null | undefined, +) { + if (!source.includes("export")) { + return null; + } + + const sourceFile = ts.createSourceFile( + id, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + + const edits: Edit[] = []; + const transformedNames = new Set(); + + for (const statement of sourceFile.statements) { + const isExported = hasModifier(statement, ts.SyntaxKind.ExportKeyword); + const isDefault = hasModifier(statement, ts.SyntaxKind.DefaultKeyword); + + if ( + isExported && + ts.isFunctionDeclaration(statement) && + statement.name && + isPascalCase(statement.name.text) && + isComponentFunction(statement) + ) { + const exportName = statement.name.text; + if (!shouldRegisterComponent(exportName, include)) { + continue; + } + + const baseName = uniqueName(source, `${exportName}Base`); + + removeExportAndDefaultModifiers(sourceFile, statement, edits); + edits.push({ + start: statement.name.getStart(sourceFile), + end: statement.name.end, + text: baseName, + storeName: true, + }); + edits.push({ + start: statement.end, + end: statement.end, + text: appendRegistration(statement, { + exportName, + baseName, + isDefault, + }), + }); + transformedNames.add(exportName); + continue; + } + + if ( + isExported && + ts.isVariableStatement(statement) && + statement.declarationList.declarations.length === 1 + ) { + const declaration = statement.declarationList.declarations[0]; + + if ( + !ts.isIdentifier(declaration.name) || + !isPascalCase(declaration.name.text) || + !isComponentInitializer(declaration.initializer) + ) { + continue; + } + + const exportName = declaration.name.text; + if (!shouldRegisterComponent(exportName, include)) { + continue; + } + + const baseName = uniqueName(source, `${exportName}Base`); + + removeExportAndDefaultModifiers(sourceFile, statement, edits); + edits.push({ + start: declaration.name.getStart(sourceFile), + end: declaration.name.end, + text: baseName, + storeName: true, + }); + edits.push({ + start: statement.end, + end: statement.end, + text: appendRegistration(statement, { + exportName, + baseName, + isDefault: false, + }), + }); + transformedNames.add(exportName); + continue; + } + + if ( + ts.isExportAssignment(statement) && + ts.isIdentifier(statement.expression) && + isPascalCase(statement.expression.text) && + !transformedNames.has(statement.expression.text) + ) { + const exportName = statement.expression.text; + if (!shouldRegisterComponent(exportName, include)) { + continue; + } + + const declaration = findLocalComponentDeclaration(sourceFile, exportName); + + if (!declaration) { + continue; + } + + const registeredName = uniqueName(source, `${exportName}Registered`); + edits.push({ + start: statement.getStart(sourceFile), + end: statement.end, + text: `const ${registeredName} = ${REGISTER_ALIAS}(${JSON.stringify( + exportName, + )}, ${exportName});\nexport default ${registeredName};`, + }); + transformedNames.add(exportName); + } + } + + if (edits.length === 0) { + return null; + } + + edits.push(createRegisterImport(sourceFile)); + return applyEdits(source, id, edits); +} + +export function autoRegisterComponents({ + include = null, +}: AutoRegisterComponentsOptions = {}): Plugin { + let srcRoot = ""; + let overrideRoot = ""; + + return { + name: "auto-register-components", + enforce: "pre", + configResolved(config) { + srcRoot = `${normalizePath(path.resolve(config.root, "src"))}/`; + overrideRoot = `${normalizePath( + path.resolve(config.root, "src/lib/override"), + )}/`; + }, + buildStart() { + const targets = collectRegisteredComponentTargets(srcRoot, overrideRoot); + assertUniqueComponentNames(targets); + assertKnownComponentNames(targets, include); + }, + transform(source, id) { + const normalizedId = normalizePath(id.split("?")[0]); + + if ( + !normalizedId.endsWith(".tsx") || + !normalizedId.startsWith(srcRoot) || + normalizedId.startsWith(overrideRoot) + ) { + return null; + } + + const transformed = transformSource(source, normalizedId, include); + if (!transformed) { + return null; + } + + return { + code: transformed.code, + map: transformed.map, + }; + }, + }; +} diff --git a/src/Routers/PatientRouter.tsx b/src/Routers/PatientRouter.tsx index f7fe0b89413..0015aaa2711 100644 --- a/src/Routers/PatientRouter.tsx +++ b/src/Routers/PatientRouter.tsx @@ -15,7 +15,7 @@ import useSidebarState from "@/hooks/useSidebarState"; import PatientUserProvider from "@/Providers/PatientUserProvider"; import { FacilitiesPage } from "@/pages/Facility/FacilitiesPage"; import PatientIndex from "@/pages/Patient/index"; -import { PatientRegistration } from "@/pages/PublicAppointments/PatientRegistration"; +import { PublicPatientRegistration } from "@/pages/PublicAppointments/PatientRegistration"; import PatientSelect from "@/pages/PublicAppointments/PatientSelect"; import { ScheduleAppointment } from "@/pages/PublicAppointments/Schedule"; import { AppointmentSuccess } from "@/pages/PublicAppointments/Success"; @@ -78,7 +78,7 @@ const AppointmentRoutes = { }: { facilityId: string; staffId: string; - }) => , + }) => , }; export default function PatientRouter() { diff --git a/src/components/Auth/AuthHero.tsx b/src/components/Auth/AuthHero.tsx index 081e3d2fe2d..ae8601b7635 100644 --- a/src/components/Auth/AuthHero.tsx +++ b/src/components/Auth/AuthHero.tsx @@ -2,9 +2,7 @@ import careConfig from "@careConfig"; import { Link } from "raviger"; import { useTranslation } from "react-i18next"; -import { register } from "@/lib/override"; - -const AuthHeroBase = () => { +export const AuthHero = () => { const { urls, stateLogo, customLogo, customLogoAlt } = careConfig; const customDescriptionHtml = __CUSTOM_DESCRIPTION_HTML__; const { t } = useTranslation(); @@ -119,5 +117,3 @@ const AuthHeroBase = () => { ); }; - -export const AuthHero = register("AuthHero", AuthHeroBase); diff --git a/src/components/Auth/Login.tsx b/src/components/Auth/Login.tsx index 3ef17d1dab6..d4b8ea8b881 100644 --- a/src/components/Auth/Login.tsx +++ b/src/components/Auth/Login.tsx @@ -49,7 +49,6 @@ import otpApi from "@/types/otp/otpApi"; import { clearQueryPersistenceCache } from "@/Utils/request/queryClient"; import { invalidateAllPaymentReconcilationLocationCaches } from "@/atoms/paymentReconcilationLocationAtom"; import { clearQueuePractitionerCache } from "@/atoms/queuePractitionerAtom"; -import { register } from "@/lib/override"; import { AuthHero } from "./AuthHero"; interface OtpLoginData { @@ -807,4 +806,4 @@ const Login = (props: LoginProps) => { ); }; -export default register("Login", Login); +export default Login; diff --git a/src/components/CareTeam/CareTeamSheet.tsx b/src/components/CareTeam/CareTeamSheet.tsx index 9bbee3c3b74..ea68daad9b4 100644 --- a/src/components/CareTeam/CareTeamSheet.tsx +++ b/src/components/CareTeam/CareTeamSheet.tsx @@ -40,7 +40,7 @@ type CareTeamSheetProps = { setOpen?: (open: boolean) => void; }; -export function EmptyState() { +export function CareTeamEmptyState() { const { t } = useTranslation(); return (
@@ -252,7 +252,7 @@ export function CareTeamSheet({
{encounter.care_team.length === 0 ? ( - + ) : ( encounter.care_team.map((member, index) => (
{ const prevTitle = document.title; if (title) { diff --git a/src/components/Facility/ConsultationDetails/PrintAllQuestionnaireResponses.tsx b/src/components/Facility/ConsultationDetails/PrintAllQuestionnaireResponses.tsx index 48caa8df2ef..57c121ff4f2 100644 --- a/src/components/Facility/ConsultationDetails/PrintAllQuestionnaireResponses.tsx +++ b/src/components/Facility/ConsultationDetails/PrintAllQuestionnaireResponses.tsx @@ -96,7 +96,7 @@ export function PrintAllQuestionnaireResponses({
- @@ -113,7 +113,7 @@ export function PrintAllQuestionnaireResponses({ {questionnaireResponses?.results?.map( (item: QuestionnaireResponse) => (
- +
), )} @@ -150,7 +150,7 @@ interface EncounterDetailsProps { patient?: PatientRead; } -export function EncounterDetails({ +export function PrintableEncounterDetails({ encounter, patient, }: EncounterDetailsProps) { @@ -331,7 +331,7 @@ interface ResponseCardProps { item?: QuestionnaireResponse; } -export function ResponseCard({ item }: ResponseCardProps) { +export function PrintableResponseCard({ item }: ResponseCardProps) { const { t } = useTranslation(); if (!item) return null; diff --git a/src/components/Facility/ConsultationDetails/PrintQuestionnaireResponse.tsx b/src/components/Facility/ConsultationDetails/PrintQuestionnaireResponse.tsx index 9b540edada8..7bb538a3aec 100644 --- a/src/components/Facility/ConsultationDetails/PrintQuestionnaireResponse.tsx +++ b/src/components/Facility/ConsultationDetails/PrintQuestionnaireResponse.tsx @@ -4,8 +4,8 @@ import { useTranslation } from "react-i18next"; import PrintPreview from "@/CAREUI/misc/PrintPreview"; import { - EncounterDetails, - ResponseCard, + PrintableEncounterDetails as EncounterDetails, + PrintableResponseCard as ResponseCard, } from "@/components/Facility/ConsultationDetails/PrintAllQuestionnaireResponses"; import query from "@/Utils/request/query"; diff --git a/src/components/Medicine/MedicationAdministration/AdministrationTab.tsx b/src/components/Medicine/MedicationAdministration/AdministrationTab.tsx index 6f1cd8bfb97..befe7b3b80a 100644 --- a/src/components/Medicine/MedicationAdministration/AdministrationTab.tsx +++ b/src/components/Medicine/MedicationAdministration/AdministrationTab.tsx @@ -16,7 +16,7 @@ import { Label } from "@/components/ui/label"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { TableSkeleton } from "@/components/Common/SkeletonLoading"; -import { EmptyState } from "@/components/Medicine/MedicationRequestTable"; +import { MedicationRequestEmptyState as EmptyState } from "@/components/Medicine/MedicationRequestTable"; import mutate from "@/Utils/request/mutate"; import query from "@/Utils/request/query"; diff --git a/src/components/Medicine/MedicationRequestTable/index.tsx b/src/components/Medicine/MedicationRequestTable/index.tsx index 2e0f33be21b..ca5d58f4e22 100644 --- a/src/components/Medicine/MedicationRequestTable/index.tsx +++ b/src/components/Medicine/MedicationRequestTable/index.tsx @@ -14,19 +14,19 @@ import { MedicationStatementList } from "@/components/Patient/MedicationStatemen import { useEncounter } from "@/pages/Encounters/utils/EncounterProvider"; -interface EmptyStateProps { +interface MedicationRequestEmptyStateProps { searching?: boolean; searchQuery?: string; message?: string; description?: string; } -export const EmptyState = ({ +export const MedicationRequestEmptyState = ({ searching, searchQuery, message, description, -}: EmptyStateProps) => { +}: MedicationRequestEmptyStateProps) => { const { t } = useTranslation(); return ( diff --git a/src/components/Patient/Common/EmptyState.tsx b/src/components/Patient/Common/EmptyState.tsx index c3578bf7335..5a1fab0c52e 100644 --- a/src/components/Patient/Common/EmptyState.tsx +++ b/src/components/Patient/Common/EmptyState.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { Skeleton } from "@/components/ui/skeleton"; -export default function EmptyState({ +export default function PatientClinicalEmptyState({ title, description, }: { diff --git a/src/components/Patient/MedicationStatementList.tsx b/src/components/Patient/MedicationStatementList.tsx index fdea805816c..9450dcab0a6 100644 --- a/src/components/Patient/MedicationStatementList.tsx +++ b/src/components/Patient/MedicationStatementList.tsx @@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { TableSkeleton } from "@/components/Common/SkeletonLoading"; -import { EmptyState } from "@/components/Medicine/MedicationRequestTable"; +import { MedicationRequestEmptyState as EmptyState } from "@/components/Medicine/MedicationRequestTable"; import query from "@/Utils/request/query"; import { PaginatedResponse } from "@/Utils/request/types"; diff --git a/src/pages/Admin/organizations/components/AdminOrganizationSelector.tsx b/src/pages/Admin/organizations/components/AdminOrganizationSelector.tsx index 302ea0862df..778e211a64b 100644 --- a/src/pages/Admin/organizations/components/AdminOrganizationSelector.tsx +++ b/src/pages/Admin/organizations/components/AdminOrganizationSelector.tsx @@ -39,7 +39,7 @@ interface FacilityOrganizationSelectorProps { singleSelection?: boolean; } -export default function FacilityOrganizationSelector( +export default function AdminFacilityOrganizationSelector( props: FacilityOrganizationSelectorProps, ) { const { t } = useTranslation(); diff --git a/src/pages/Appointments/BookAppointment/BookAppointmentDetails.tsx b/src/pages/Appointments/BookAppointment/BookAppointmentDetails.tsx index e6f003e62d4..32316226d9e 100644 --- a/src/pages/Appointments/BookAppointment/BookAppointmentDetails.tsx +++ b/src/pages/Appointments/BookAppointment/BookAppointmentDetails.tsx @@ -10,7 +10,6 @@ import { scheduleServiceTypeAtom } from "@/atoms/scheduleServiceTypeAtom"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; -import { register } from "@/lib/override"; import { AppointmentSlotPicker } from "@/pages/Appointments/BookAppointment/AppointmentSlotPicker"; import useCurrentFacility from "@/pages/Facility/utils/useCurrentFacility"; import { TagConfig } from "@/types/emr/tagConfig/tagConfig"; @@ -27,7 +26,7 @@ export interface BookAppointmentDetailsProps { onSuccess?: () => void; } -const BookAppointmentDetailsBase = ({ +export const BookAppointmentDetails = ({ patientId, onSuccess, }: BookAppointmentDetailsProps) => { @@ -242,8 +241,3 @@ const BookAppointmentDetailsBase = ({
); }; - -export const BookAppointmentDetails = register( - "BookAppointmentDetails", - BookAppointmentDetailsBase, -); diff --git a/src/pages/Encounters/tabs/overview/quick-actions.tsx b/src/pages/Encounters/tabs/overview/quick-actions.tsx index 608e29c4a24..5928363df58 100644 --- a/src/pages/Encounters/tabs/overview/quick-actions.tsx +++ b/src/pages/Encounters/tabs/overview/quick-actions.tsx @@ -14,7 +14,6 @@ import { import { ShortcutBadge } from "@/Utils/keyboardShortcutComponents"; -import { register } from "@/lib/override"; import { FormDialog } from "./FormsDialog"; export const QuickActions = (props: React.ComponentProps<"div">) => { @@ -72,9 +71,7 @@ export const QuickActions = (props: React.ComponentProps<"div">) => { ); }; -export const QuickAction = register("QuickAction", QuickActionBase); - -function QuickActionBase({ +export function QuickAction({ icon, title, actionId, diff --git a/src/pages/Encounters/tabs/overview/summary-panel-details-tab/account.tsx b/src/pages/Encounters/tabs/overview/summary-panel-details-tab/account.tsx index ccf0dbb6eeb..9fc1da09570 100644 --- a/src/pages/Encounters/tabs/overview/summary-panel-details-tab/account.tsx +++ b/src/pages/Encounters/tabs/overview/summary-panel-details-tab/account.tsx @@ -17,7 +17,7 @@ import accountApi from "@/types/billing/account/accountApi"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; -import { EmptyState } from "./empty-state"; +import { SummaryPanelEmptyState as EmptyState } from "./empty-state"; export const Account = () => { const { t } = useTranslation(); diff --git a/src/pages/Encounters/tabs/overview/summary-panel-details-tab/department-and-team.tsx b/src/pages/Encounters/tabs/overview/summary-panel-details-tab/department-and-team.tsx index f2e53d0afe3..177cdf8219b 100644 --- a/src/pages/Encounters/tabs/overview/summary-panel-details-tab/department-and-team.tsx +++ b/src/pages/Encounters/tabs/overview/summary-panel-details-tab/department-and-team.tsx @@ -8,7 +8,7 @@ import { CardListSkeleton } from "@/components/Common/SkeletonLoading"; import { useEncounter } from "@/pages/Encounters/utils/EncounterProvider"; -import { EmptyState } from "./empty-state"; +import { SummaryPanelEmptyState as EmptyState } from "./empty-state"; export const DepartmentsAndTeams = () => { const { t } = useTranslation(); diff --git a/src/pages/Encounters/tabs/overview/summary-panel-details-tab/discharge-summary.tsx b/src/pages/Encounters/tabs/overview/summary-panel-details-tab/discharge-summary.tsx index e6f8343d65a..aef8fe9594a 100644 --- a/src/pages/Encounters/tabs/overview/summary-panel-details-tab/discharge-summary.tsx +++ b/src/pages/Encounters/tabs/overview/summary-panel-details-tab/discharge-summary.tsx @@ -17,7 +17,7 @@ import { CardListSkeleton } from "@/components/Common/SkeletonLoading"; import { useEncounter } from "@/pages/Encounters/utils/EncounterProvider"; -import { EmptyState } from "./empty-state"; +import { SummaryPanelEmptyState as EmptyState } from "./empty-state"; export const DischargeDetails = () => { const { t } = useTranslation(); diff --git a/src/pages/Encounters/tabs/overview/summary-panel-details-tab/empty-state.tsx b/src/pages/Encounters/tabs/overview/summary-panel-details-tab/empty-state.tsx index 3058735b1f1..a657caf9d9e 100644 --- a/src/pages/Encounters/tabs/overview/summary-panel-details-tab/empty-state.tsx +++ b/src/pages/Encounters/tabs/overview/summary-panel-details-tab/empty-state.tsx @@ -1,6 +1,6 @@ import CareIcon from "@/CAREUI/icons/CareIcon"; -export const EmptyState = ({ message }: { message: string }) => ( +export const SummaryPanelEmptyState = ({ message }: { message: string }) => (
{message} diff --git a/src/pages/Encounters/tabs/overview/summary-panel-details-tab/encounter-tags.tsx b/src/pages/Encounters/tabs/overview/summary-panel-details-tab/encounter-tags.tsx index db2422ba837..1f3dd83eeb3 100644 --- a/src/pages/Encounters/tabs/overview/summary-panel-details-tab/encounter-tags.tsx +++ b/src/pages/Encounters/tabs/overview/summary-panel-details-tab/encounter-tags.tsx @@ -6,7 +6,7 @@ import { useEncounter } from "@/pages/Encounters/utils/EncounterProvider"; import { useQueryClient } from "@tanstack/react-query"; import { SquarePen } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { EmptyState } from "./empty-state"; +import { SummaryPanelEmptyState as EmptyState } from "./empty-state"; export const EncounterTags = () => { const { canWriteSelectedEncounter: canEdit, selectedEncounter: encounter } = diff --git a/src/pages/Encounters/tabs/overview/summary-panel-details-tab/locations.tsx b/src/pages/Encounters/tabs/overview/summary-panel-details-tab/locations.tsx index 34590a9283e..16ddfc2a3d5 100644 --- a/src/pages/Encounters/tabs/overview/summary-panel-details-tab/locations.tsx +++ b/src/pages/Encounters/tabs/overview/summary-panel-details-tab/locations.tsx @@ -8,7 +8,7 @@ import { LocationTree } from "@/components/Location/LocationTree"; import { useEncounter } from "@/pages/Encounters/utils/EncounterProvider"; -import { EmptyState } from "./empty-state"; +import { SummaryPanelEmptyState as EmptyState } from "./empty-state"; export const Locations = () => { const { t } = useTranslation(); diff --git a/src/pages/Encounters/tabs/overview/summary-panel-details-tab/manage-care-team.tsx b/src/pages/Encounters/tabs/overview/summary-panel-details-tab/manage-care-team.tsx index e080c18768f..e31025611fe 100644 --- a/src/pages/Encounters/tabs/overview/summary-panel-details-tab/manage-care-team.tsx +++ b/src/pages/Encounters/tabs/overview/summary-panel-details-tab/manage-care-team.tsx @@ -12,7 +12,7 @@ import { useEncounter } from "@/pages/Encounters/utils/EncounterProvider"; import { formatName } from "@/Utils/utils"; -import { EmptyState } from "./empty-state"; +import { SummaryPanelEmptyState as EmptyState } from "./empty-state"; export const ManageCareTeam = () => { const { t } = useTranslation(); diff --git a/src/pages/Facility/FacilityDetailsPage.tsx b/src/pages/Facility/FacilityDetailsPage.tsx index 08decff311b..a5ea5401760 100644 --- a/src/pages/Facility/FacilityDetailsPage.tsx +++ b/src/pages/Facility/FacilityDetailsPage.tsx @@ -21,7 +21,7 @@ import publicFacilityApi from "@/types/facility/publicFacilityApi"; import { goBack } from "@/Utils/utils"; import { Button } from "@/components/ui/button"; import { FeatureBadge } from "./Utils"; -import { UserCard } from "./components/UserCard"; +import { FacilityUserCard as UserCard } from "./components/UserCard"; interface Props { id: string; diff --git a/src/pages/Facility/components/UserCard.tsx b/src/pages/Facility/components/UserCard.tsx index 1ae440955d1..11907b11100 100644 --- a/src/pages/Facility/components/UserCard.tsx +++ b/src/pages/Facility/components/UserCard.tsx @@ -22,7 +22,7 @@ interface Props { facilityId: string; } -export function UserCard({ user, className, facilityId }: Props) { +export function FacilityUserCard({ user, className, facilityId }: Props) { const { t } = useTranslation(); const { patientToken: tokenData } = useAuthContext(); diff --git a/src/pages/Facility/settings/activityDefinition/ActivityDefinitionList.tsx b/src/pages/Facility/settings/activityDefinition/ActivityDefinitionList.tsx index abaea50c050..2090b578340 100644 --- a/src/pages/Facility/settings/activityDefinition/ActivityDefinitionList.tsx +++ b/src/pages/Facility/settings/activityDefinition/ActivityDefinitionList.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import Page from "@/components/Common/Page"; import { ResourceCategoryList } from "@/components/Common/ResourceCategoryList"; -import { ActivityDefinitionList as ActivityDefinitionListComponent } from "@/pages/Facility/settings/activityDefinition/ActivityDefinitionListComponent"; +import { ActivityDefinitionListContent } from "@/pages/Facility/settings/activityDefinition/ActivityDefinitionListComponent"; import { ResourceCategoryResourceType } from "@/types/base/resourceCategory/resourceCategory"; import { Status } from "@/types/emr/activityDefinition/activityDefinition"; import activityDefinitionApi from "@/types/emr/activityDefinition/activityDefinitionApi"; @@ -58,7 +58,7 @@ export default function ActivityDefinitionList({ }} > {categorySlug && ( - void; } -export function ActivityDefinitionList({ +export function ActivityDefinitionListContent({ facilityId, categorySlug, setAllowCategoryCreate, diff --git a/src/pages/Facility/settings/healthcareService/HealthcareServiceShow.tsx b/src/pages/Facility/settings/healthcareService/HealthcareServiceShow.tsx index 0301a99433d..470e08f9e6a 100644 --- a/src/pages/Facility/settings/healthcareService/HealthcareServiceShow.tsx +++ b/src/pages/Facility/settings/healthcareService/HealthcareServiceShow.tsx @@ -31,7 +31,7 @@ import queryClient from "@/Utils/request/queryClient"; type DuoToneIconName = keyof typeof duoToneIcons; -export default function HealthcareServiceShow({ +export default function SettingsHealthcareServiceShow({ facilityId, healthcareServiceId, }: { diff --git a/src/pages/Facility/settings/locations/LocationSettings.tsx b/src/pages/Facility/settings/locations/LocationSettings.tsx index f84d5370636..1b0a5f94ba7 100644 --- a/src/pages/Facility/settings/locations/LocationSettings.tsx +++ b/src/pages/Facility/settings/locations/LocationSettings.tsx @@ -33,7 +33,7 @@ import locationApi from "@/types/location/locationApi"; import LocationMap from "./LocationMap"; import LocationSheet from "./LocationSheet"; import LocationView from "./LocationView"; -import { LocationCard } from "./components/LocationCard"; +import { SettingsLocationCard as LocationCard } from "./components/LocationCard"; import { LocationTable } from "./components/LocationTable"; interface LocationSettingsProps { diff --git a/src/pages/Facility/settings/locations/LocationSheet.tsx b/src/pages/Facility/settings/locations/LocationSheet.tsx index 1a881c20e05..9fb6a21dea0 100644 --- a/src/pages/Facility/settings/locations/LocationSheet.tsx +++ b/src/pages/Facility/settings/locations/LocationSheet.tsx @@ -20,7 +20,7 @@ interface Props { parentId?: string; } -export default function LocationSheet({ +export default function SettingsLocationSheet({ open, onOpenChange, facilityId, diff --git a/src/pages/Facility/settings/locations/LocationView.tsx b/src/pages/Facility/settings/locations/LocationView.tsx index 6cf967ed547..9a150365125 100644 --- a/src/pages/Facility/settings/locations/LocationView.tsx +++ b/src/pages/Facility/settings/locations/LocationView.tsx @@ -34,7 +34,7 @@ import { LocationRead } from "@/types/location/location"; import locationApi from "@/types/location/locationApi"; import LocationSheet from "./LocationSheet"; -import { LocationCard } from "./components/LocationCard"; +import { SettingsLocationCard as LocationCard } from "./components/LocationCard"; import { LocationTable } from "./components/LocationTable"; interface Props { diff --git a/src/pages/Facility/settings/locations/components/LocationCard.tsx b/src/pages/Facility/settings/locations/components/LocationCard.tsx index 5780d2dd13b..8d0fe7ab0c8 100644 --- a/src/pages/Facility/settings/locations/components/LocationCard.tsx +++ b/src/pages/Facility/settings/locations/components/LocationCard.tsx @@ -47,7 +47,7 @@ interface Props { setPage?: (page: number) => void; } -export function LocationCard({ +export function SettingsLocationCard({ location, onEdit, onView, diff --git a/src/pages/Facility/settings/organizations/components/EditFacilityUserRoleSheet.tsx b/src/pages/Facility/settings/organizations/components/EditFacilityUserRoleSheet.tsx index 2ac4be57d5d..ebdf9388e35 100644 --- a/src/pages/Facility/settings/organizations/components/EditFacilityUserRoleSheet.tsx +++ b/src/pages/Facility/settings/organizations/components/EditFacilityUserRoleSheet.tsx @@ -32,7 +32,7 @@ interface Props { trigger?: React.ReactNode; } -export default function EditUserRoleSheet({ +export default function EditFacilityUserRoleSheet({ facilityId, organizationId, userRole, diff --git a/src/pages/Facility/settings/productKnowledge/ProductKnowledgeList.tsx b/src/pages/Facility/settings/productKnowledge/ProductKnowledgeList.tsx index 0f8d8b0fe0d..283fc674daa 100644 --- a/src/pages/Facility/settings/productKnowledge/ProductKnowledgeList.tsx +++ b/src/pages/Facility/settings/productKnowledge/ProductKnowledgeList.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import Page from "@/components/Common/Page"; import { ResourceCategoryList } from "@/components/Common/ResourceCategoryList"; -import { ProductKnowledgeList as ProductKnowledgeListComponent } from "@/pages/Facility/settings/productKnowledge/ProductKnowledgeListComponent"; +import { ProductKnowledgeListContent } from "@/pages/Facility/settings/productKnowledge/ProductKnowledgeListComponent"; import { ResourceCategoryResourceType } from "@/types/base/resourceCategory/resourceCategory"; import { ProductKnowledgeStatus } from "@/types/inventory/productKnowledge/productKnowledge"; import productKnowledgeApi from "@/types/inventory/productKnowledge/productKnowledgeApi"; @@ -60,7 +60,7 @@ export default function ProductKnowledgeList({ }} > {categorySlug && ( - void; } -export function ProductKnowledgeList({ +export function ProductKnowledgeListContent({ facilityId, categorySlug, setAllowCategoryCreate, diff --git a/src/pages/Patient/History/MedicationHistory.tsx b/src/pages/Patient/History/MedicationHistory.tsx index 280c38fcda3..414581bd8e5 100644 --- a/src/pages/Patient/History/MedicationHistory.tsx +++ b/src/pages/Patient/History/MedicationHistory.tsx @@ -9,7 +9,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TableSkeleton } from "@/components/Common/SkeletonLoading"; import { AdministrationTab } from "@/components/Medicine/MedicationAdministration/AdministrationTab"; -import { EmptyState } from "@/components/Medicine/MedicationRequestTable"; +import { MedicationRequestEmptyState as EmptyState } from "@/components/Medicine/MedicationRequestTable"; import { MedicationsTable } from "@/components/Medicine/MedicationsTable"; import { MedicationStatementList } from "@/components/Patient/MedicationStatementList"; diff --git a/src/pages/Patient/index.tsx b/src/pages/Patient/index.tsx index f79dedd5d64..86838d559af 100644 --- a/src/pages/Patient/index.tsx +++ b/src/pages/Patient/index.tsx @@ -24,7 +24,7 @@ import { import AppointmentDialog from "./components/AppointmentDialog"; -function PatientIndex() { +function PatientPortalIndex() { const { t } = useTranslation(); const [selectedAppointment, setSelectedAppointment] = useState< @@ -205,4 +205,4 @@ function PatientIndex() { ); } -export default PatientIndex; +export default PatientPortalIndex; diff --git a/src/pages/PublicAppointments/PatientRegistration.tsx b/src/pages/PublicAppointments/PatientRegistration.tsx index 900c249b9c5..373fb236b66 100644 --- a/src/pages/PublicAppointments/PatientRegistration.tsx +++ b/src/pages/PublicAppointments/PatientRegistration.tsx @@ -40,7 +40,7 @@ type PatientRegistrationProps = { staffId: string; }; -export function PatientRegistration(props: PatientRegistrationProps) { +export function PublicPatientRegistration(props: PatientRegistrationProps) { const { staffId } = props; const { t } = useTranslation(); const [{ slotId, reason }] = useQueryParams(); diff --git a/vite.config.mts b/vite.config.mts index ab8fb178e73..91a8cd35068 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -19,6 +19,7 @@ import path from "path"; import checker from "vite-plugin-checker"; import { VitePWA } from "vite-plugin-pwa"; import { viteStaticCopy } from "vite-plugin-static-copy"; +import { autoRegisterComponents } from "./plugins/autoRegisterComponents"; import { careConsoleArt } from "./plugins/careConsoleArt"; import { fixSonnerPackageJson } from "./plugins/fixSonnerPackageJson"; import { treeShakeCareIcons } from "./plugins/treeShakeCareIcons"; @@ -103,6 +104,19 @@ function getMimeType(filePath: string) { } } +function parseRegisteredComponentNames(value: string | undefined) { + if (!value || value.trim() === "" || value.trim() === "*") { + return null; + } + + return new Set( + value + .split(",") + .map((name) => name.trim()) + .filter(Boolean), + ); +} + function isPluginManifestPath(rootDir: string, filePath: string) { const normalizedFilePath = normalizePath(filePath); const appsPrefix = `${normalizePath(path.join(rootDir, "apps"))}/`; @@ -415,6 +429,11 @@ export default defineConfig(async ({ mode }): Promise => { careConsoleArt(), fixSonnerPackageJson(), localPluginDevSupport(), + autoRegisterComponents({ + include: parseRegisteredComponentNames( + env.REACT_MFE_REGISTERED_COMPONENTS, + ), + }), tailwindcss(), federation({ name: "core",