Skip to content
Open
Show file tree
Hide file tree
Changes from all 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")}
>
<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
38 changes: 35 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 Down Expand Up @@ -184,7 +192,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 @@ -333,6 +341,11 @@ const TabContent = ({
renderChild,
isLoading,
}: TabContentProps) => {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const filteredItems = tabItems.filter((item) =>
item.name.toLowerCase().includes(search.trim().toLowerCase()),
);
return (
<section
className="space-y-3 md:space-y-4"
Expand All @@ -341,6 +354,19 @@ 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>
{tabItems.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")}
aria-label={t("search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
autoFocus
/>
</div>
)}

{isLoading ? (
<div className="grid gap-3 md:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
Expand All @@ -356,9 +382,15 @@ const TabContent = ({
</Card>
))}
</div>
) : filteredItems.length === 0 ? (
<EmptyState
icon={<Search className="size-5 text-primary" />}
title={t("no_results_found")}
className="border-solid"
/>
) : (
<div className="grid gap-3 md:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{tabItems.map((item: FacilityBareMinimum | Organization) => {
{filteredItems.map((item: FacilityBareMinimum | Organization) => {
return renderChild(item);
})}
</div>
Expand Down
82 changes: 82 additions & 0 deletions tests/facility/dashboard/dashboardSearch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { faker } from "@faker-js/faker";
import { expect, test } from "@playwright/test";

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

test.describe("Dashboard - Search", () => {
test.beforeEach(async ({ page }) => {
await page.goto(`/`);
});

test("should filter facilities in the Dashboard", async ({ page }) => {
await expect(page.getByRole("tab", { name: "Facilities" })).toBeVisible();

const panel = page.getByRole("tabpanel");
const searchInput = panel.getByPlaceholder("Search");

const isSearchVisible = await searchInput.isVisible().catch(() => false);
if (!isSearchVisible) {
test.skip();
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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(
panel.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(panel.getByText(/no results found/i)).toBeVisible();

// Ensure more than one facility link is shown after clearing search
await searchInput.clear();
const facilities = await panel.getByRole("link").all();
expect(facilities.length).toBeGreaterThan(1);
});
});

test("should filter responsibilities in the Dashboard", async ({ page }) => {
const responsibilitiesTab = page.getByRole("tab", {
name: "Responsibilities",
});
await expect(responsibilitiesTab).toBeVisible();
await responsibilitiesTab.click();

const panel = page.getByRole("tabpanel");
const searchInput = panel.getByPlaceholder("Search");

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

await test.step("Search for a random responsibility", async () => {
const responsibility = faker.helpers.arrayElement(["Doctor", "Staff"]);

await searchInput.fill(responsibility);

await expect(
panel.getByRole("link").filter({ hasText: responsibility }),
).toBeVisible();
await expect(panel.getByRole("link")).toHaveCount(1);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

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

// Ensure more than one responsibility link is shown after clearing search
await searchInput.clear();
const responsibilities = await panel.getByRole("link").all();
expect(responsibilities.length).toBeGreaterThan(1);
});
});
});
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 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
});
});
});
Loading