Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 50 additions & 20 deletions src/components/ui/sidebar/facility/facility-switcher.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CaretSortIcon, DashboardIcon } from "@radix-ui/react-icons";
import { Hospital } from "lucide-react";
import { Hospital, Search } from "lucide-react";
import { Link } from "raviger";
import { useTranslation } from "react-i18next";

Expand All @@ -20,7 +20,9 @@ import {
useSidebar,
} from "@/components/ui/sidebar";

import { Input } from "@/components/ui/input";
import { FacilityBareMinimum } from "@/types/facility/facility";
import { useState } from "react";

export function FacilitySwitcher({
facilities,
Expand All @@ -31,16 +33,21 @@ export function FacilitySwitcher({
}) {
const { isMobile } = useSidebar();
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const filteredFacilities = facilities.filter((facility) =>
facility.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
Comment thread
abhimanyurajeesh marked this conversation as resolved.

return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenu onOpenChange={(open) => !open && setSearchQuery("")}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground hover:bg-white"
tooltip={selectedFacility?.name}
aria-label={t("select_facility")}
Comment thread
abhimanyurajeesh marked this conversation as resolved.
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-sidebar-primary-foreground">
<Hospital className="size-4" />
Expand Down Expand Up @@ -68,24 +75,47 @@ export function FacilitySwitcher({
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("facilities")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{facilities.map((facility, index) => (
<DropdownMenuItem
key={index}
asChild
className={cn(
"gap-2 p-2",
facility.id === selectedFacility?.id &&
"bg-primary-500 text-white focus:bg-primary-600 focus:text-white",
)}
>
<Link href={`/facility/${facility.id}/overview`}>
<div className="flex size-6 items-center justify-center rounded-sm border border-gray-200 shrink-0">
<Hospital className="size-4 shrink-0 text-current" />
</div>
{facility.name}
</Link>
</DropdownMenuItem>
))}
{facilities.length > 1 && (
<div className="relative p-1.5">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 size-3.5 text-gray-400" />
<Input
placeholder={t("search_button")}
aria-label={t("search_button")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 sm:text-sm"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
Comment thread
abhimanyurajeesh marked this conversation as resolved.
/>
</div>
)}
{filteredFacilities.length === 0 ? (
<div className="px-2 py-4 text-center text-sm text-gray-500">
{t("no_facilities_found")}
</div>
) : (
Comment thread
abhimanyurajeesh marked this conversation as resolved.
filteredFacilities.map((facility) => (
<DropdownMenuItem
key={facility.id}
asChild
className={cn(
"gap-2 p-2",
facility.id === selectedFacility?.id &&
"bg-primary-500 text-white focus:bg-primary-600 focus:text-white",
)}
>
<Link
href={`/facility/${facility.id}/overview`}
onClick={() => setSearchQuery("")}
>
<div className="flex size-6 items-center justify-center rounded-sm border border-gray-200 shrink-0">
<Hospital className="size-4 shrink-0 text-current" />
</div>
{facility.name}
</Link>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
Expand Down
46 changes: 43 additions & 3 deletions src/pages/UserDashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { ChevronRight, LogOut, SquarePen, User2Icon } from "lucide-react";
import {
ChevronRight,
LogOut,
Search,
SquarePen,
User2Icon,
} from "lucide-react";
import { Link } from "raviger";
import { useState } from "react";
import { useTranslation } from "react-i18next";
Expand All @@ -22,6 +28,8 @@ import useAuthUser, { useAuthContext } from "@/hooks/useAuthUser";
import useBreakpoints from "@/hooks/useBreakpoints";

import { formatName } from "@/Utils/utils";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { FacilityBareMinimum } from "@/types/facility/facility";
import { Organization, getOrgLabel } from "@/types/organization/organization";

Expand All @@ -37,6 +45,8 @@ type TabContentProps = {
description: string;
renderChild: (item: FacilityBareMinimum | Organization) => React.ReactNode;
isLoading?: boolean;
searchComponent?: React.ReactNode;
emptyStateTitle?: string;
};

export default function UserDashboard() {
Expand Down Expand Up @@ -79,6 +89,11 @@ export default function UserDashboard() {
availableTabs.length > 0 ? availableTabs[0] : null,
);

const [facilitySearch, setFacilitySearch] = useState("");
const filteredFacilities = facilities.filter((facility) =>
facility.name.toLowerCase().includes(facilitySearch.toLowerCase()),
);

Comment thread
abhimanyurajeesh marked this conversation as resolved.
Outdated
const isMobile = useBreakpoints({ default: true, sm: false });

return (
Expand Down Expand Up @@ -184,7 +199,7 @@ export default function UserDashboard() {
{availableTabs.length > 0 && (
<div className="w-full">
<div
className="flex border-b border-gray-200"
className="flex border-b border-gray-200 overflow-auto"
role="tablist"
aria-label="Dashboard Sections"
>
Expand Down Expand Up @@ -212,8 +227,23 @@ export default function UserDashboard() {
{activeTab === DashboardTabs.TAB_FACILITIES && (
<TabContent

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can just inline a new component here for just facilities with search, input, card etc, instead of changing the shape of TabContent.

If we ever add search to other tabs, we can adjust it then.

tabId="facilities-panel"
tabItems={facilities}
tabItems={filteredFacilities}
description={t("dashboard_tab_facilities")}
searchComponent={
facilities.length > 1 ? (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-gray-400" />
<Input
placeholder={t("search_button")}
aria-label={t("search_button")}
value={facilitySearch}
onChange={(e) => setFacilitySearch(e.target.value)}
className="pl-9"
/>
</div>
) : undefined
}
emptyStateTitle={t("no_facilities_found")}
renderChild={(facility) => {
return (
<Link
Expand Down Expand Up @@ -332,7 +362,10 @@ const TabContent = ({
description,
renderChild,
isLoading,
searchComponent,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

lift these up/remove props

emptyStateTitle,
}: TabContentProps) => {
const { t } = useTranslation();
return (
<section
className="space-y-3 md:space-y-4"
Expand All @@ -341,6 +374,7 @@ const TabContent = ({
aria-labelledby={tabId}
Comment on lines 351 to 354
>
Comment thread
abhimanyurajeesh marked this conversation as resolved.
<p className="text-sm text-gray-800 font-normal px-1">{description}</p>
{searchComponent}

{isLoading ? (
<div className="grid gap-3 md:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
Expand All @@ -356,6 +390,12 @@ const TabContent = ({
</Card>
))}
</div>
) : tabItems.length === 0 ? (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Specific to facilities, but technically won't ever show up for other tabs. Hmm, I think we can lift this up.

<EmptyState
icon={<Search className="size-5 text-primary" />}
title={emptyStateTitle || t("no_results_found")}
className="border-solid"
/>
Comment thread
abhimanyurajeesh marked this conversation as resolved.
Outdated
) : (
<div className="grid gap-3 md:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{tabItems.map((item: FacilityBareMinimum | Organization) => {
Expand Down
38 changes: 38 additions & 0 deletions tests/facility/sidebar/dashboardFacilitySearch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, test } from "@playwright/test";

test.use({ storageState: "tests/.auth/user.json" });

test.describe("Dashboard - Facility Search", () => {
test("should filter facilities in the Dashboard", async ({ page }) => {
await page.goto(`/`);
await expect(page.getByRole("tab", { name: "Facilities" })).toBeVisible();
const searchInput = page.getByPlaceholder(/Search Facilities/i);

const isSearchVisible = await searchInput.isVisible().catch(() => false);
if (!isSearchVisible) {
test.skip();
return;
}

await test.step("Search for a facility FACILITY WITH PATIENTS", async () => {
const facilityName = "FACILITY WITH PATIENTS";

await searchInput.fill(facilityName);

// Verify matching facility is still visible
await expect(
page.getByRole("link").filter({ hasText: facilityName }),
).toBeVisible();
});

await test.step("Show empty state for non-matching search", async () => {
await searchInput.fill("zzz_nonexistent_facility_xyz");
await expect(page.getByText(/no facilities found/i)).toBeVisible();

// Ensure more than one facility link is shown after clearing search
await searchInput.clear();
const facilities = await page.getByRole("link").all();
expect(facilities.length).toBeGreaterThan(2);
Comment thread
abhimanyurajeesh marked this conversation as resolved.
Outdated
});
Comment thread
abhimanyurajeesh marked this conversation as resolved.
Outdated
});
});
46 changes: 46 additions & 0 deletions tests/facility/sidebar/sidebarFacilitySearch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { expect, test } from "@playwright/test";
import { getFacilityId } from "tests/support/facilityId";

test.use({ storageState: "tests/.auth/user.json" });

test.describe("Sidebar Facility Switcher - Search", () => {
test("should filter facilities in the switcher dropdown", async ({
page,
}) => {
await page.goto(`/facility/${getFacilityId()}/overview`);

//switcher
await page.getByRole("button", { name: "Select Facility" }).click();
await expect(page.getByText(/view dashboard/i)).toBeVisible();
await expect(page.getByText(/Facilities/i)).toBeVisible();

const searchInput = page.getByPlaceholder(/search/i);

Comment thread
abhimanyurajeesh marked this conversation as resolved.
const isSearchVisible = await searchInput.isVisible().catch(() => false);
if (!isSearchVisible) {
test.skip();
return;
}
Comment thread
abhimanyurajeesh marked this conversation as resolved.
Comment on lines +19 to +23

await test.step("Search for a facility FACILITY WITH PATIENTS", async () => {
const facilityName = "FACILITY WITH PATIENTS";

await searchInput.fill(facilityName);

// Verify matching facility is still visible
await expect(
page.getByRole("menuitem").filter({ hasText: facilityName }),
).toBeVisible();
});

await test.step("Show empty state for non-matching search", async () => {
await searchInput.fill("zzz_nonexistent_facility_xyz");
await expect(page.getByText(/no facilities found/i)).toBeVisible();

// Ensure more than one facility menuitem is shown after clearing search
await searchInput.clear();
const facilities = await page.getByRole("menuitem").all();
expect(facilities.length).toBeGreaterThan(1);
Comment on lines +40 to +43
});
Comment thread
abhimanyurajeesh marked this conversation as resolved.
});
});
Loading