Skip to content

(fix) O3-5831: Fix patient banner building blocks at narrow widths - #1820

Open
solomonfortune wants to merge 1 commit into
openmrs:mainfrom
solomonfortune:O3-5831-patient-banner-building-blocks-break-down-at-narro
Open

(fix) O3-5831: Fix patient banner building blocks at narrow widths#1820
solomonfortune wants to merge 1 commit into
openmrs:mainfrom
solomonfortune:O3-5831-patient-banner-building-blocks-break-down-at-narro

Conversation

@solomonfortune

Copy link
Copy Markdown
Contributor

Requirements

  • This PR has a title that briefly describes the work done including the ticket number. If there is a ticket, make sure your PR title includes a conventional commit label. See existing PR titles for inspiration.
  • My work is based on designs, which are linked or shown either in the Jira ticket or the description below. (See also: Styleguide)
  • My work includes tests or is validated by existing tests.
  • I have updated the esm-framework and storybook mocks (mock.tsx, mock-jest.tsx, and storybook mocks) to reflect any API changes.

Summary

Fixes five patient banner bugs that surface at narrow widths:

  1. Contact details columns now stack via container query (≤40rem); relationships row wraps.
  2. Long single-token names now wrap instead of overflowing.
  3. Avatar initials capped at 2 (first + last name part), was overflowing at 3.
  4. Separator dots now wrap with the item they introduce instead of getting stranded.
  5. Added showAllIdentifiers prop (default true, non-breaking) to show only the primary identifier when collapsed. Wiring to patient-chart's actual collapse/expand state is a follow-up ticket.

Open question: designs show primary identifier as plain text (collapsed) vs. tag (expanded); this PR keeps it a tag in both states pending design input, to avoid flicker on toggle.

CI note: @openmrs/esm-utils#test fails intermittently under the local Turbo pre-push hook but passes 161/161 in isolation — appears to be pre-existing flakiness unrelated to this diff.

Screenshots

1. Contact details stacking
contact-details-column-stacking

2. Long name wrapping
long name overflow

3. Avatar initials capped
Screenshot (514)

4. Separator dot wrapping
separator-dot -wrapping

5. showAllIdentifiers
showAllIdentifiers

Related Issue

https://issues.openmrs.org/browse/O3-5831

Other

N/A

@changeset-bot

changeset-bot Bot commented Jul 24, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 729747a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR improves narrow patient-banner layouts and adds a primary-only identifier display mode.

  • Stacks contact columns and wraps relationships, names, demographics, and separators.
  • Limits generated avatar initials to the first and last name parts.
  • Adds showAllIdentifiers to control secondary identifier visibility.

Confidence Score: 3/5

The primary-only identifier path should be fixed before merging because it can temporarily or permanently hide every patient identifier.

The new filter compares identifiers directly against an asynchronously resolved optional code, so collapsed banners render no identifier until that code exists and never recover when the metadata mapping is absent.

packages/framework/esm-styleguide/src/patient-banner/patient-info/patient-banner-patient-identifiers.component.tsx

T-Rex T-Rex Logs

What T-Rex did

  • Validated that the focused verbose Vitest run reproduced the two identifier scenarios and logged the supplied values with empty rendered identifier arrays.
  • Observed the UI layout changes after the change: the banner scroll width and client width became 360px, the name width tightened to 260px, the avatar shows 'AJ', every demographic/identifier item carries its separator, and the contact columns stack at 360px, with automated layout comparisons exiting with code 0.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
packages/framework/esm-styleguide/src/patient-banner/patient-info/patient-banner-patient-identifiers.component.tsx Adds primary-only filtering and grouped separators, but collapsed mode drops all identifiers while primary metadata is unavailable.
packages/framework/esm-styleguide/src/patient-banner/patient-info/patient-banner-patient-info.component.tsx Exposes the identifier visibility option and groups separators with their following demographic items.
packages/framework/esm-styleguide/src/patient-banner/patient-info/patient-banner-patient-info.module.scss Adds wrapping and pseudo-element separator styles for narrow layouts.
packages/framework/esm-styleguide/src/patient-banner/contact-details/patient-banner-contact-details.module.scss Adds container-query column stacking and relationship wrapping.
packages/framework/esm-styleguide/src/patient-photo/patient-photo.component.tsx Limits generated initials to two characters using the first and last name parts.
packages/framework/esm-styleguide/src/patient-photo/patient-photo.test.tsx Verifies first-and-last initials for multipart names.

Reviews (1): Last reviewed commit: "(fix) O3-5831: Fix patient banner buildi..." | Re-trigger Greptile

return code && !excludePatientIdentifierCodeTypes?.uuids.includes(code);
}) ?? [];
}) ?? []
).filter((identifier) => (showAllIdentifiers ? true : identifier.type?.coding?.[0]?.code === primaryIdentifierCode));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Primary metadata hides identifiers

When showAllIdentifiers is false and the primary identifier code is still loading or has no configured mapping, this filter rejects every identifier, causing the banner to display no patient identifier temporarily or permanently.

Artifacts

Repro: focused component test covering loading and unavailable primary-code metadata

  • Evidence file captured while the check ran.

Repro: narrow Vitest configuration used to execute the component test

  • Evidence file captured while the check ran.

Repro: verbose Vitest output showing supplied identifiers and empty rendered DOM in both cases

  • The full command output behind this check.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still present at the head commit, and the blast radius is a bit wider than the loading window. I ran the component with showAllIdentifiers={false} against five states, and every one renders an empty DOM with no identifier at all: primaryIdentifierCode undefined while SWR is in flight, undefined because emr.primaryIdentifierType has no metadata mapping, undefined after the lookup errors, a patient whose identifiers simply don't include the primary type, and a primary type that excludePatientIdentifierCodeTypes already filtered out.

The true default is the only reason this isn't live today, which also means no unit test or E2E run can reach it, and there's no test covering either direction of the prop.

The code as it stands degrades gracefully when primaryIdentifierCode is missing: everything falls through to SecondaryIdentifier, so nothing vanishes. I'd keep that guarantee by falling back to the first available identifier instead of to none. Dropping the trailing .filter(...) and deriving the visible set separately does it (the JSX below then maps visibleIdentifiers):

const primaryIdentifiers = filteredIdentifiers.filter(
  (identifier) => identifier.type?.coding?.[0]?.code === primaryIdentifierCode,
);
const visibleIdentifiers = showAllIdentifiers
  ? filteredIdentifiers
  : primaryIdentifiers.length > 0
    ? primaryIdentifiers
    : filteredIdentifiers.slice(0, 1);

I patched that in locally and all five cases render an identifier again, with the existing 15 tests in the banner and photo suites still green. Gating on isLoading on its own won't cover it, since the missing-mapping and no-primary-identifier cases leave the code undefined with isLoading false.

This does need sorting before merge: on a server without that metadata mapping, a collapsed banner shows a patient with no identifier at all, nothing throws, and the first person to notice is whoever is reading the chart.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The analysis is spot-on, and the proposed fix is correct. The single-line .filter(...) chain leaves no fallback when primaryIdentifierCode is undefined or unmatched — all five failure modes you listed produce an empty array.

The visibleIdentifiers approach handles all of them cleanly:

Suggested change
).filter((identifier) => (showAllIdentifiers ? true : identifier.type?.coding?.[0]?.code === primaryIdentifierCode));
const filteredIdentifiers = (
identifiers?.filter((identifier) => {
const code = identifier.type?.coding?.[0]?.code;
return code && !excludePatientIdentifierCodeTypes?.uuids.includes(code);
}) ?? []
);
const primaryIdentifiers = filteredIdentifiers.filter(
(identifier) => identifier.type?.coding?.[0]?.code === primaryIdentifierCode,
);
const visibleIdentifiers = showAllIdentifiers
? filteredIdentifiers
: primaryIdentifiers.length > 0
? primaryIdentifiers
: filteredIdentifiers.slice(0, 1);

Then replace filteredIdentifiers with visibleIdentifiers in the JSX map. The .slice(0, 1) fallback ensures a collapsed banner always shows at least something — matching the current showAllIdentifiers={true} guarantee — regardless of whether primaryIdentifierCode is still loading, unconfigured, or excluded.

One small addition worth having: a test that passes showAllIdentifiers={false} with primaryIdentifierCode undefined (or returning no match) and asserts that the first identifier still renders. That would have caught this before review and covers the regression path going forward.

@solomonfortune
solomonfortune force-pushed the O3-5831-patient-banner-building-blocks-break-down-at-narro branch from 4ad45f6 to a5edd49 Compare July 24, 2026 10:31
@solomonfortune

Copy link
Copy Markdown
Contributor Author

@denniskigen could you please review this PR in regards to the patient banner building blocks break down at narrow widths

@solomonfortune

Copy link
Copy Markdown
Contributor Author

@dkayiwa could you please review this.

Comment on lines +16 to 20
function getInitials(name: string, maxInitials = 2): string {
const parts = name.split(/\s+/).filter(Boolean);
const selected = parts.length > maxInitials ? [parts[0], parts.at(-1)] : parts;
return selected.map((part) => part![0]).join('');
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maxInitials no longer means "at most this many initials": getInitials(name, 4) on a five-part name returns two, not four. It reads as a threshold now, and the only call site (line 104) passes no second argument, so the parameter is mostly something for the next reader to puzzle over. Dropping it also removes the need for the non-null assertion.

Suggested change
function getInitials(name: string, maxInitials = 2): string {
const parts = name.split(/\s+/).filter(Boolean);
const selected = parts.length > maxInitials ? [parts[0], parts.at(-1)] : parts;
return selected.map((part) => part![0]).join('');
}
function getInitials(name: string): string {
const parts = name.split(/\s+/).filter(Boolean);
const selected = parts.length > 2 ? [parts[0], parts[parts.length - 1]] : parts;
return selected.map((part) => part[0]).join('');
}

Identical output for every input, so take it or leave it.

Comment on lines +16 to +21
.withSeparator {
&::before {
content: '\00B7';
margin: 0 layout.$spacing-02;
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.separator further down (line 137) has no callers left, now that both of its usages became this pseudo-element, so the file is left with two separator styles and one of them dead. Worth deleting it in the same pass. Not something I'd hold the PR for on its own.

@solomonfortune
solomonfortune force-pushed the O3-5831-patient-banner-building-blocks-break-down-at-narro branch 2 times, most recently from 67feefc to 46f2d9a Compare August 6, 2026 08:51

@solomonfortune solomonfortune left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dkayiwa i have applied the fix exactly as suggested i.e. visibleIdentifiers now falls back to the first identifier when primaryIdentifierCode is unresolved, rather than filtering to empty. Also added the regression test you flagged: showAllIdentifiers={false} with primaryIdentifierCode undefined now asserts the first identifier still renders. All 11 tests in the patient-banner suite pass.

@denniskigen denniskigen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this, @solomonfortune. I've left a few suggestions below.

Comment on lines +16 to +21
.withSeparator {
&::before {
content: '\00B7';
margin: 0 layout.$spacing-02;
}
}

@denniskigen denniskigen Aug 7, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.withSeparator {
&::before {
content: '\00B7';
margin: 0 layout.$spacing-02;
}
}
.withSeparator:not(:first-child)::before {
content: '\00B7';
margin-inline-end: layout.$spacing-02;
}

Letting the selector decide when the dot shows means we don't need showLeadingSeparator at all. It also fixes the spacing. margin: 0 $spacing-02 stacks on top of the .demographics gap, so the dot currently gets 8px on its left and 4px on its right. Main is 6px both sides.

Comment on lines 15 to 20
interface PatientBannerPatientIdentifiersProps {
identifiers: fhir.Identifier[] | undefined;
showIdentifierLabel: boolean;
showLeadingSeparator?: boolean;
showAllIdentifiers?: boolean;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
interface PatientBannerPatientIdentifiersProps {
identifiers: fhir.Identifier[] | undefined;
showIdentifierLabel: boolean;
showLeadingSeparator?: boolean;
showAllIdentifiers?: boolean;
}
interface PatientBannerPatientIdentifiersProps {
identifiers: fhir.Identifier[] | undefined;
showIdentifierLabel: boolean;
showAllIdentifiers?: boolean;
}

Both components go out through @openmrs/esm-framework, so this becomes public API that only its sibling component would ever set. The destructured default below and the conditional class can go with it:

<span key={value} className={classNames(styles.identifier, styles.withSeparator)}>

<PatientBannerPatientIdentifiers
identifiers={patient.identifier}
showIdentifierLabel
showLeadingSeparator={Boolean(patient.birthDate)}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
showLeadingSeparator={Boolean(patient.birthDate)}

Same as above, showLeadingSeparator={Boolean(patient.birthDate)} can go once the separator is selector-driven.

Comment on lines +93 to +101
it('shows only the primary identifier when showAllIdentifiers is false and the primary identifier code resolves', () => {
render(
<PatientBannerPatientIdentifiers identifiers={mockIdentifiers} showIdentifierLabel showAllIdentifiers={false} />,
);

expect(screen.getByText(/openmrs id/i)).toBeInTheDocument();
expect(screen.getByText(/100gej/i)).toBeInTheDocument();
expect(screen.queryByText(/national id/i)).not.toBeInTheDocument();
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it('shows only the primary identifier when showAllIdentifiers is false and the primary identifier code resolves', () => {
render(
<PatientBannerPatientIdentifiers identifiers={mockIdentifiers} showIdentifierLabel showAllIdentifiers={false} />,
);
expect(screen.getByText(/openmrs id/i)).toBeInTheDocument();
expect(screen.getByText(/100gej/i)).toBeInTheDocument();
expect(screen.queryByText(/national id/i)).not.toBeInTheDocument();
});
it('shows only the primary identifier when showAllIdentifiers is false and the primary identifier code resolves', () => {
mockUsePrimaryIdentifierCode.mockReturnValue({
primaryIdentifierCode: '4281ec43-388b-4c25-8bb2-deaff0867b2c',
isLoading: false,
error: undefined,
});
render(
<PatientBannerPatientIdentifiers identifiers={mockIdentifiers} showIdentifierLabel showAllIdentifiers={false} />,
);
expect(screen.getByText(/national id/i)).toBeInTheDocument();
expect(screen.getByText(/123456789/i)).toBeInTheDocument();
expect(screen.queryByText(/openmrs id/i)).not.toBeInTheDocument();
});

This one can't fail as written. The primary identifier is also mockIdentifiers[0], so it passes whether we pick the primary or just take the first one. I swapped the branch for a bare filteredIdentifiers.slice(0, 1) and all six tests still passed. Making the primary the second identifier makes it fail on that mutation.

@solomonfortune
solomonfortune force-pushed the O3-5831-patient-banner-building-blocks-break-down-at-narro branch from 46f2d9a to 729747a Compare August 10, 2026 05:09
@sonarqubecloud

Copy link
Copy Markdown

@solomonfortune solomonfortune left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@denniskigen Separator dots are now selector-driven (:not(:first-child)::before); showLeadingSeparator removed from the interface and call site. Fixed the dot spacing. Rewrote the flagged test so it fails if primary vs. first gets swapped and all 11 tests pass locally.

@solomonfortune

Copy link
Copy Markdown
Contributor Author

@dkayiwa please review this PR at your time of convenience.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants