Skip to content
Merged
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
10 changes: 2 additions & 8 deletions app/components/assets/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import BarcodesInput, { type BarcodesInputRef } from "../forms/barcodes-input";
import FormRow from "../forms/form-row";
import Input from "../forms/input";
import ImageWithPreview from "../image-with-preview/image-with-preview";
import { AbsolutePositionedHeaderActions } from "../layout/header/absolute-positioned-header-actions";
import { Button } from "../shared/button";
import { ButtonGroup } from "../shared/button-group";
import { Card } from "../shared/card";
Expand Down Expand Up @@ -180,9 +179,6 @@ export const AssetForm = ({
}
}}
>
<AbsolutePositionedHeaderActions className="hidden md:flex">
<Actions disabled={disabled} />
</AbsolutePositionedHeaderActions>
{qrId ? (
<input type="hidden" name={zo.fields.qrId()} value={qrId} />
) : null}
Expand Down Expand Up @@ -531,10 +527,8 @@ export const AssetForm = ({
<AssetCustomFields zo={zo} schema={FormSchema} currency={currency} />

<FormRow className="border-y-0 pb-0 pt-5" rowLabel="">
<div className="ml-auto">
<Button type="submit" disabled={disabled}>
Save
</Button>
<div className="hidden flex-1 justify-end gap-2 md:flex">
<Actions disabled={disabled} />
</div>
</FormRow>
</Form>
Expand Down
8 changes: 0 additions & 8 deletions app/components/kits/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import BarcodesInput, { type BarcodesInputRef } from "../forms/barcodes-input";
import FormRow from "../forms/form-row";
import Input from "../forms/input";
import ImageWithPreview from "../image-with-preview/image-with-preview";
import { AbsolutePositionedHeaderActions } from "../layout/header/absolute-positioned-header-actions";
import { Button } from "../shared/button";
import { Card } from "../shared/card";
import When from "../when/when";
Expand All @@ -41,7 +40,6 @@ type KitFormProps = Partial<
Pick<Kit, "name" | "description" | "categoryId" | "locationId">
> & {
className?: string;
saveButtonLabel?: string;
qrId?: string | null;
barcodes?: Pick<Barcode, "id" | "value" | "type">[];
};
Expand All @@ -50,7 +48,6 @@ export default function KitsForm({
className,
name,
description,
saveButtonLabel = "Add",
qrId,
categoryId,
barcodes,
Expand Down Expand Up @@ -96,11 +93,6 @@ export default function KitsForm({
}
}}
>
<AbsolutePositionedHeaderActions className="hidden md:mr-4 md:flex">
<Button type="submit" disabled={disabled || nameErrorMessage}>
{saveButtonLabel}
</Button>
</AbsolutePositionedHeaderActions>
{qrId ? (
<input type="hidden" name={zo.fields.qrId()} value={qrId} />
) : null}
Expand Down
86 changes: 86 additions & 0 deletions app/components/layout/command-palette/command-palette-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { useMatches } from "@remix-run/react";
import { SearchIcon } from "lucide-react";

import { Button } from "~/components/shared/button";
import type { RouteHandleWithName } from "~/modules/types";
import { tw } from "~/utils/tw";
import { useCommandPaletteSafe } from "./command-palette-context";

type CommandPaletteButtonVariant = "default" | "icon";

function useShortcutLabel() {
const [label, setLabel] = useState("⌘K");

useEffect(() => {
if (typeof navigator === "undefined") {
return;
}

const isAppleDevice = /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
setLabel(isAppleDevice ? "⌘K" : "Ctrl K");
}, []);

return label;
}

export function CommandPaletteButton({
className,
variant = "default",
}: {
className?: string;
variant?: CommandPaletteButtonVariant;
}) {
const matches = useMatches();
const currentRoute: RouteHandleWithName = matches[matches.length - 1];
const shouldRenderButton = !["bookings.$bookingId.overview"].includes(
// on the user bookings page we dont want to show the custodian filter becuase they are alreayd filtered for that user
Comment thread
DonKoko marked this conversation as resolved.
currentRoute?.handle?.name
);
const context = useCommandPaletteSafe();
const shortcut = useShortcutLabel();

// Don't render if the command palette provider is not available
if (!context || !shouldRenderButton) {
return null;
}

const { setOpen } = context;

if (variant === "icon") {
return (
<Button
type="button"
onClick={() => setOpen(true)}
aria-label="Open command palette"
variant={"secondary"}
className={tw(
"flex items-center justify-center rounded border-0 bg-white px-2 py-[2px] text-gray-600 transition hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 md:border-gray-200",
className
)}
>
<SearchIcon className="size-5" />
</Button>
);
}

return (
<Button
type="button"
onClick={() => setOpen(true)}
variant={"secondary"}
className={tw(
"flex w-full items-center gap-2 rounded bg-white py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 md:w-64 md:border md:border-gray-200",
className
)}
>
<div className="flex w-full items-center gap-2">
<span className="hidden sm:inline">Quick find</span>
<span className="sm:hidden">Quick find...</span>
<span className="ml-auto hidden items-center gap-1 rounded bg-gray-50 px-1 text-[10px] font-medium text-gray-500 md:inline-flex md:border md:border-gray-200">
{shortcut}
</span>
</div>
</Button>
);
}
51 changes: 51 additions & 0 deletions app/components/layout/command-palette/command-palette-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { createContext, useContext, useMemo, useState } from "react";

interface CommandPaletteContextValue {
open: boolean;
setOpen: (value: boolean) => void;
toggle: () => void;
}

const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(
null
);

export function useCommandPalette() {
const context = useContext(CommandPaletteContext);

if (!context) {
throw new Error(
"useCommandPalette must be used within a CommandPaletteProvider"
);
}

return context;
}

export function useCommandPaletteSafe() {
const context = useContext(CommandPaletteContext);
return context;
}

export function CommandPaletteProvider({
children,
}: {
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);

const value = useMemo(
() => ({
open,
setOpen,
toggle: () => setOpen((previous) => !previous),
}),
[open]
);

return (
<CommandPaletteContext.Provider value={value}>
{children}
</CommandPaletteContext.Provider>
);
}
180 changes: 180 additions & 0 deletions app/components/layout/command-palette/command-palette.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";

import {
getAssetCommandValue,
getBookingCommandValue,
getKitCommandValue,
getLocationCommandValue,
getTeamMemberCommandValue,
type AssetSearchResult,
type BookingSearchResult,
type KitSearchResult,
type LocationSearchResult,
type TeamMemberSearchResult,
} from "./command-palette";

describe("getAssetCommandValue", () => {
const baseAsset: AssetSearchResult = {
id: "asset-123",
title: "4K Camera",
sequentialId: "AS-100",
mainImage: null,
mainImageExpiration: null,
locationName: "Studio",
description: null,
qrCodes: [],
categoryName: null,
tagNames: [],
custodianName: null,
custodianUserName: null,
barcodes: [],
customFieldValues: [],
};

it("includes the primary searchable fields", () => {
const value = getAssetCommandValue(baseAsset);

expect(value).toContain("asset-123");
expect(value).toContain("4K Camera");
expect(value).toContain("AS-100");
expect(value).toContain("Studio");
});

it("falls back gracefully when optional fields are missing", () => {
const value = getAssetCommandValue({
...baseAsset,
sequentialId: null,
locationName: null,
});

expect(value).toContain("asset-123");
expect(value).toContain("4K Camera");
expect(value).not.toContain("null");
});
});

describe("getKitCommandValue", () => {
const baseKit: KitSearchResult = {
id: "kit-456",
name: "Camera Kit",
description: "Professional camera equipment",
status: "AVAILABLE",
assetCount: 5,
};

it("includes the primary searchable fields", () => {
const value = getKitCommandValue(baseKit);

expect(value).toContain("kit-456");
expect(value).toContain("Camera Kit");
expect(value).toContain("Professional camera equipment");
});

it("falls back gracefully when optional fields are missing", () => {
const value = getKitCommandValue({
...baseKit,
description: null,
});

expect(value).toContain("kit-456");
expect(value).toContain("Camera Kit");
expect(value).not.toContain("null");
});
});

describe("getBookingCommandValue", () => {
const baseBooking: BookingSearchResult = {
id: "booking-789",
name: "Photo Shoot",
description: "Wedding photography session",
status: "RESERVED",
custodianName: "John Doe",
from: "2024-01-15T10:00:00Z",
to: "2024-01-15T18:00:00Z",
};

it("includes the primary searchable fields", () => {
const value = getBookingCommandValue(baseBooking);

expect(value).toContain("booking-789");
expect(value).toContain("Photo Shoot");
expect(value).toContain("Wedding photography session");
expect(value).toContain("John Doe");
});

it("falls back gracefully when optional fields are missing", () => {
const value = getBookingCommandValue({
...baseBooking,
description: null,
custodianName: null,
});

expect(value).toContain("booking-789");
expect(value).toContain("Photo Shoot");
expect(value).not.toContain("null");
});
});

describe("getLocationCommandValue", () => {
const baseLocation: LocationSearchResult = {
id: "location-101",
name: "Main Studio",
description: "Primary photography studio",
address: "123 Main St, City",
assetCount: 12,
};

it("includes the primary searchable fields", () => {
const value = getLocationCommandValue(baseLocation);

expect(value).toContain("location-101");
expect(value).toContain("Main Studio");
expect(value).toContain("Primary photography studio");
expect(value).toContain("123 Main St, City");
});

it("falls back gracefully when optional fields are missing", () => {
const value = getLocationCommandValue({
...baseLocation,
description: null,
address: null,
});

expect(value).toContain("location-101");
expect(value).toContain("Main Studio");
expect(value).not.toContain("null");
});
});

describe("getTeamMemberCommandValue", () => {
const baseMember: TeamMemberSearchResult = {
id: "member-202",
name: "Jane Smith",
email: "jane@example.com",
firstName: "Jane",
lastName: "Smith",
};

it("includes the primary searchable fields", () => {
const value = getTeamMemberCommandValue(baseMember);

expect(value).toContain("member-202");
expect(value).toContain("Jane Smith");
expect(value).toContain("jane@example.com");
expect(value).toContain("Jane");
expect(value).toContain("Smith");
});

it("falls back gracefully when optional fields are missing", () => {
const value = getTeamMemberCommandValue({
...baseMember,
email: null,
firstName: null,
lastName: null,
});

expect(value).toContain("member-202");
expect(value).toContain("Jane Smith");
expect(value).not.toContain("null");
});
});
Loading