diff --git a/client/app/components/legacyUi/HtSpinner.tsx b/client/app/components/legacyUi/HtSpinner.tsx
deleted file mode 100644
index 52aa56ce..00000000
--- a/client/app/components/legacyUi/HtSpinner.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import React, { ReactElement } from 'react';
-import { Container } from 'react-bootstrap';
-import { IconBaseProps } from 'react-icons';
-import { FaGear } from 'react-icons/fa6';
-
-interface HtSpinnerProps extends IconBaseProps {
- center?: boolean;
-}
-
-export function HtSpinner({ className, size, center, ...props }: HtSpinnerProps): ReactElement {
- const spinner = (
-
- );
-
- const centerWrapper = (children: ReactElement) => (
-
- {children}
-
- );
-
- return center ? centerWrapper(spinner) : spinner;
-}
diff --git a/client/app/components/legacyUi/index.ts b/client/app/components/legacyUi/index.ts
index 60054b5f..7d43a275 100644
--- a/client/app/components/legacyUi/index.ts
+++ b/client/app/components/legacyUi/index.ts
@@ -5,7 +5,6 @@ export { HtPageControls } from '~/components/legacyUi/HtPaginate/HtPageControls'
export { HtPaginate } from '~/components/legacyUi/HtPaginate/HtPaginate';
export { HtCard } from './HtCard/HtCard';
export { HtModal } from './HtModal/HtModal';
-export { HtSpinner } from './HtSpinner';
export { HtTooltip, InfoIconTooltip } from './HtTooltip';
export { FeatureDescription } from './FeatureDescription';
export { FloatingActionBtn } from './FloatingActionBtn';
diff --git a/client/app/components/ui/Spinner/Spinner.spec.tsx b/client/app/components/ui/Spinner/Spinner.spec.tsx
new file mode 100644
index 00000000..6494ab01
--- /dev/null
+++ b/client/app/components/ui/Spinner/Spinner.spec.tsx
@@ -0,0 +1,41 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Spinner } from './Spinner';
+
+describe('Spinner', () => {
+ it('renders with default size and show true', () => {
+ const { container } = render(
);
+ expect(container.querySelector('span')).toHaveClass('tw-flex');
+ expect(container.querySelector('svg')).toHaveClass('tw-animate-spin');
+ });
+
+ it('renders hidden with show false', () => {
+ const { container } = render(
);
+ expect(container.querySelector('span')).toHaveClass('tw-hidden');
+ });
+
+ it('renders with custom className', () => {
+ const { container } = render(
);
+ expect(container.querySelector('svg')).toHaveClass('custom-class');
+ });
+
+ it('renders children correctly', () => {
+ const { getByText } = render(
Loading...);
+ expect(getByText('Loading...')).toBeInTheDocument();
+ });
+
+ it('renders asChild when true', () => {
+ const { container } = render(
+
+ Child Element
+
+ );
+ expect(container.querySelector('div')).toHaveClass('tw-animate-spin');
+ expect(container.querySelector('div')).toHaveTextContent('Child Element');
+ });
+
+ it('renders with additional props', () => {
+ const { container } = render(
);
+ expect(container.querySelector('span')).toHaveAttribute('data-testid', 'spinner-test');
+ });
+});
diff --git a/client/app/components/ui/Spinner/Spinner.tsx b/client/app/components/ui/Spinner/Spinner.tsx
new file mode 100644
index 00000000..03d54cb4
--- /dev/null
+++ b/client/app/components/ui/Spinner/Spinner.tsx
@@ -0,0 +1,63 @@
+import { cva, VariantProps } from 'class-variance-authority';
+import * as React from 'react';
+import { FaGear } from 'react-icons/fa6';
+import { cn } from '~/lib/utils';
+
+const spinnerVariants = cva('tw-flex-col tw-items-center tw-justify-center', {
+ variants: {
+ show: {
+ true: 'tw-flex',
+ false: 'tw-hidden',
+ },
+ },
+ defaultVariants: {
+ show: true,
+ },
+});
+
+const loaderVariants = cva('tw-text-reset tw-animate-spin', {
+ variants: {
+ size: {
+ sm: 'tw-size-6',
+ md: 'tw-size-8',
+ lg: 'tw-size-24',
+ xl: 'tw-size-32',
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ },
+});
+
+interface SpinnerContentProps
+ extends VariantProps
,
+ VariantProps {
+ className?: string;
+ children?: React.ReactNode;
+ asChild?: boolean;
+}
+
+type SpinnerElement = React.ElementRef<'span'>;
+
+const Spinner = React.forwardRef(
+ ({ size, show, children, className, asChild, ...props }, ref) => {
+ return (
+
+ {asChild && children ? (
+ React.cloneElement(children as React.ReactElement, {
+ className: cn(loaderVariants({ size }), children),
+ })
+ ) : (
+ <>
+
+ {children}
+ >
+ )}
+
+ );
+ }
+);
+
+Spinner.displayName = 'Spinner';
+
+export { Spinner };
diff --git a/client/app/components/ui/index.ts b/client/app/components/ui/index.ts
index 7f9cfd41..48efccf0 100644
--- a/client/app/components/ui/index.ts
+++ b/client/app/components/ui/index.ts
@@ -23,3 +23,5 @@ export {
} from '~/components/ui/Sheet/Sheet';
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card/Card';
+
+export { Spinner } from './Spinner/Spinner';
diff --git a/client/app/components/Layout/Root.tsx b/client/app/features/layout/Root.tsx
similarity index 64%
rename from client/app/components/Layout/Root.tsx
rename to client/app/features/layout/Root.tsx
index 4960fb7e..b4435ee7 100644
--- a/client/app/components/Layout/Root.tsx
+++ b/client/app/features/layout/Root.tsx
@@ -1,8 +1,10 @@
-import { HtSpinner } from '~/components/legacyUi';
import React, { createContext, Dispatch, SetStateAction, Suspense, useState } from 'react';
import { Container } from 'react-bootstrap';
-import { Outlet } from 'react-router-dom';
+import { LoaderFunction, Outlet } from 'react-router-dom';
import { ErrorBoundary } from '~/components/Error';
+import { Spinner } from '~/components/ui';
+import { rootStore as store } from '~/store';
+import { haztrakApi } from '~/store/htApi.slice';
import { Sidebar } from './Sidebar/Sidebar';
import { TopNav } from './TopNav/TopNav';
@@ -16,6 +18,15 @@ export const NavContext = createContext({
setShowSidebar: () => console.warn('no showSidebar context'),
});
+export const rootLoader: LoaderFunction = async () => {
+ const query = store.dispatch(haztrakApi.endpoints.getOrgs.initiate());
+
+ return query
+ .unwrap()
+ .catch((_err) => console.error('Error fetching orgs'))
+ .finally(() => query.unsubscribe());
+};
+
export function Root() {
const [showSidebar, setShowSidebar] = useState(false);
return (
@@ -25,7 +36,7 @@ export function Root() {
- }>
+ }>
diff --git a/client/app/features/layout/Sidebar/Nav/NavItem.spec.tsx b/client/app/features/layout/Sidebar/Nav/NavItem.spec.tsx
new file mode 100644
index 00000000..292aa61b
--- /dev/null
+++ b/client/app/features/layout/Sidebar/Nav/NavItem.spec.tsx
@@ -0,0 +1,47 @@
+import { fireEvent, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { NavContext } from '~/features/layout/Root';
+import { NavItem } from '~/features/layout/Sidebar/Nav/NavItem';
+import { Route } from '~/features/layout/Sidebar/SidebarRoutes';
+import { renderWithProviders } from '~/mocks';
+
+const mockRoute: Route = {
+ id: 'test',
+ url: '/test',
+ text: 'Test Route',
+ icon: vi.fn(),
+ external: false,
+};
+
+const mockSetShowSidebar = vi.fn();
+
+const renderNavItem = (route = mockRoute, targetBlank = false) => {
+ return renderWithProviders(
+
+
+
+ );
+};
+
+describe('NavItem', () => {
+ it('renders the NavItem with the correct text', () => {
+ renderNavItem();
+ expect(screen.getByText('Test Route')).toBeInTheDocument();
+ });
+
+ it('calls setShowSidebar when the link is clicked', () => {
+ renderNavItem();
+ fireEvent.click(screen.getByRole('link'));
+ expect(mockSetShowSidebar).toHaveBeenCalledWith(false);
+ });
+
+ it('opens link in a new tab when targetBlank is true', () => {
+ renderNavItem(mockRoute, true);
+ expect(screen.getByRole('link')).toHaveAttribute('target', '_blank');
+ });
+
+ it('does not open link in a new tab when targetBlank is false', () => {
+ renderNavItem(mockRoute, false);
+ expect(screen.getByRole('link')).not.toHaveAttribute('target', '_blank');
+ });
+});
diff --git a/client/app/components/Layout/Nav/NavItem.tsx b/client/app/features/layout/Sidebar/Nav/NavItem.tsx
similarity index 86%
rename from client/app/components/Layout/Nav/NavItem.tsx
rename to client/app/features/layout/Sidebar/Nav/NavItem.tsx
index 2d62a25a..166139de 100644
--- a/client/app/components/Layout/Nav/NavItem.tsx
+++ b/client/app/features/layout/Sidebar/Nav/NavItem.tsx
@@ -1,10 +1,10 @@
-import colors from 'tailwindcss/colors';
-import { NavContext, NavContextProps } from '~/components/Layout/Root';
-import { Route } from '~/components/Layout/Sidebar/SidebarRoutes';
import React, { useContext } from 'react';
-import { Link } from 'react-router-dom';
import { LuExternalLink } from 'react-icons/lu';
+import { Link } from 'react-router-dom';
+import colors from 'tailwindcss/colors';
import { Button } from '~/components/ui';
+import { NavContext, NavContextProps } from '~/features/layout/Root';
+import { Route } from '~/features/layout/Sidebar/SidebarRoutes';
interface NavItemProps {
route: Route;
diff --git a/client/app/features/layout/Sidebar/Nav/NavSection.spec.tsx b/client/app/features/layout/Sidebar/Nav/NavSection.spec.tsx
new file mode 100644
index 00000000..7f8dbd9b
--- /dev/null
+++ b/client/app/features/layout/Sidebar/Nav/NavSection.spec.tsx
@@ -0,0 +1,38 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { RoutesSection } from '~/features/layout/Sidebar/SidebarRoutes';
+import { renderWithProviders } from '~/mocks';
+import { NavSection } from './NavSection';
+
+const mockSection: RoutesSection = {
+ icon: () => icon
,
+ id: '',
+ name: 'Test Section',
+ routes: [
+ {
+ id: '1',
+ text: 'Route 1',
+ icon: () => icon
,
+ url: '',
+ },
+ ],
+};
+
+describe('NavSection', () => {
+ it('renders section name', () => {
+ const { getByText } = renderWithProviders();
+ expect(getByText(/Test Section/i)).toBeInTheDocument();
+ });
+
+ it('renders NavItem components for each route', () => {
+ const { getByText } = renderWithProviders();
+ expect(getByText(/Route 1/i)).toBeInTheDocument();
+ });
+
+ it('handles empty routes array', () => {
+ // @ts-expect-error - expected error
+ const section: RoutesSection = { name: 'Test Section', routes: [] };
+ const { container } = render();
+ expect(container.querySelectorAll('div').length).toBe(2); // One for the main div and one for the separator
+ });
+});
diff --git a/client/app/components/Layout/Nav/NavSection.tsx b/client/app/features/layout/Sidebar/Nav/NavSection.tsx
similarity index 78%
rename from client/app/components/Layout/Nav/NavSection.tsx
rename to client/app/features/layout/Sidebar/Nav/NavSection.tsx
index d37c77c0..eaa7bea8 100644
--- a/client/app/components/Layout/Nav/NavSection.tsx
+++ b/client/app/features/layout/Sidebar/Nav/NavSection.tsx
@@ -1,7 +1,6 @@
-import { NavItem } from '~/components/Layout/Nav/NavItem';
-import { RoutesSection } from '~/components/Layout/Sidebar/SidebarRoutes';
-
import { Separator } from '~/components/ui/Separator/Separator';
+import { RoutesSection } from '~/features/layout/Sidebar/SidebarRoutes';
+import { NavItem } from './NavItem';
interface SidebarSectionProps {
section: RoutesSection;
diff --git a/client/app/components/Layout/Sidebar/Sidebar.spec.tsx b/client/app/features/layout/Sidebar/Sidebar.spec.tsx
similarity index 86%
rename from client/app/components/Layout/Sidebar/Sidebar.spec.tsx
rename to client/app/features/layout/Sidebar/Sidebar.spec.tsx
index 3d9551a1..33a9c542 100644
--- a/client/app/components/Layout/Sidebar/Sidebar.spec.tsx
+++ b/client/app/features/layout/Sidebar/Sidebar.spec.tsx
@@ -1,7 +1,7 @@
-import { Sidebar } from '~/components/Layout/Sidebar/Sidebar';
+import { afterEach, describe, expect, test } from 'vitest';
import { cleanup, renderWithProviders, screen } from '~/mocks';
-import { afterEach, describe, expect, test } from 'vitest';
+import { Sidebar } from './Sidebar';
afterEach(() => {
cleanup();
diff --git a/client/app/components/Layout/Sidebar/Sidebar.tsx b/client/app/features/layout/Sidebar/Sidebar.tsx
similarity index 76%
rename from client/app/components/Layout/Sidebar/Sidebar.tsx
rename to client/app/features/layout/Sidebar/Sidebar.tsx
index 7cb9e6fb..e8f76cc4 100644
--- a/client/app/components/Layout/Sidebar/Sidebar.tsx
+++ b/client/app/features/layout/Sidebar/Sidebar.tsx
@@ -1,10 +1,11 @@
import logo from '/assets/img/haztrak-logos/haztrak-logo-zip-file/png/logo-black-crop.png';
import React, { ReactElement, useContext } from 'react';
import { Link } from 'react-router-dom';
-import { NavItem } from '~/components/Layout/Nav/NavItem';
-import { NavSection } from '~/components/Layout/Nav/NavSection';
-import { NavContext, NavContextProps } from '~/components/Layout/Root';
+import { OrgSelect } from '~/components/Org/OrgSelect';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '~/components/ui';
+import { NavContext, NavContextProps } from '~/features/layout/Root';
+import { NavItem } from './Nav/NavItem';
+import { NavSection } from './Nav/NavSection';
import { routes } from './SidebarRoutes';
/** Vertical sidebar for navigation that disappears when the viewport is small*/
@@ -13,7 +14,7 @@ export function Sidebar(): ReactElement | null {
return (
-
+
@@ -27,6 +28,9 @@ export function Sidebar(): ReactElement | null {
+
+
+