mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 02:33:47 +10:00
refactor(web): drop combobox groups, SSR hydration options and a hand-typed form store
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
|
||||
import type { ComboboxOption } from "./combobox";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
@@ -47,26 +46,6 @@ describe("Combobox", () => {
|
||||
expect(screen.getAllByText(/Gamma/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders grouped options with group labels", async () => {
|
||||
const user = userEvent.setup();
|
||||
const groupedOptions: ComboboxOption[] = [
|
||||
{ value: "alpha", label: "Alpha", group: "Primary" },
|
||||
{ value: "beta", label: "Beta", group: { value: "secondary", label: "Secondary" } },
|
||||
{ value: "gamma", label: "Gamma", group: { value: "secondary", label: "Secondary" } },
|
||||
];
|
||||
|
||||
wrap(<Combobox options={groupedOptions} placeholder="Pick something" />);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
|
||||
expect(screen.getByText("Primary")).toBeInTheDocument();
|
||||
expect(screen.getByText("Secondary")).toBeInTheDocument();
|
||||
expect(screen.getByText("Alpha")).toBeInTheDocument();
|
||||
expect(screen.getByText("Beta")).toBeInTheDocument();
|
||||
expect(screen.getByText("Gamma")).toBeInTheDocument();
|
||||
expect(document.querySelectorAll("[data-slot=combobox-group]")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders nothing extra when given an empty options array (no crash)", () => {
|
||||
expect(() => wrap(<Combobox options={[]} placeholder="Empty" />)).not.toThrow();
|
||||
expect(screen.getByText("Empty")).toBeInTheDocument();
|
||||
|
||||
@@ -5,13 +5,10 @@ import React from "react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
ComboboxClear,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxRoot,
|
||||
ComboboxTrigger,
|
||||
@@ -28,21 +25,10 @@ type ComboboxOption<TValue extends string | number = string> = {
|
||||
// Plain-text label used for the collapsed trigger and filtering when `label` is a ReactNode.
|
||||
// Without it, a JSX label falls back to String(value) (e.g. a raw enum or locale code) in the trigger.
|
||||
textValue?: string;
|
||||
group?: string | ComboboxOptionGroup;
|
||||
keywords?: string[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type ComboboxOptionGroup = {
|
||||
value: string;
|
||||
label: React.ReactNode;
|
||||
};
|
||||
|
||||
type GroupedComboboxOption<TValue extends string | number = string> = ComboboxOptionGroup & {
|
||||
key: string;
|
||||
items: ComboboxOption<TValue>[];
|
||||
};
|
||||
|
||||
type SingleComboboxProps<TValue extends string | number = string> = {
|
||||
options: ComboboxOption<TValue>[];
|
||||
value?: TValue | null;
|
||||
@@ -52,7 +38,6 @@ type SingleComboboxProps<TValue extends string | number = string> = {
|
||||
disabled?: boolean;
|
||||
showClear?: boolean;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: React.ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
@@ -69,7 +54,6 @@ type MultiComboboxProps<TValue extends string | number = string> = {
|
||||
disabled?: boolean;
|
||||
showClear?: boolean;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: React.ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
@@ -85,13 +69,6 @@ const listContent = <TValue extends string | number>(item: ComboboxOption<TValue
|
||||
</ComboboxItem>
|
||||
);
|
||||
|
||||
const groupedListContent = <TValue extends string | number>(group: GroupedComboboxOption<TValue>) => (
|
||||
<ComboboxGroup key={group.key} items={group.items}>
|
||||
{group.label !== null && group.label !== undefined ? <ComboboxLabel>{group.label}</ComboboxLabel> : null}
|
||||
<ComboboxCollection>{listContent}</ComboboxCollection>
|
||||
</ComboboxGroup>
|
||||
);
|
||||
|
||||
function Combobox<TValue extends string | number = string>(props: ComboboxProps<TValue>) {
|
||||
const {
|
||||
options,
|
||||
@@ -99,7 +76,6 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
|
||||
disabled = false,
|
||||
showClear = false,
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
className,
|
||||
id,
|
||||
@@ -111,40 +87,6 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
|
||||
|
||||
const optionMap = React.useMemo(() => new Map(options.map((opt) => [String(opt.value), opt])), [options]);
|
||||
|
||||
const optionGroups = React.useMemo(() => {
|
||||
const groups: GroupedComboboxOption<TValue>[] = [];
|
||||
const groupMap = new Map<string, GroupedComboboxOption<TValue>>();
|
||||
let ungroupedGroup: GroupedComboboxOption<TValue> | null = null;
|
||||
let hasGroupedOptions = false;
|
||||
|
||||
for (const option of options) {
|
||||
if (option.group === undefined) {
|
||||
if (!ungroupedGroup) {
|
||||
ungroupedGroup = { key: "ungrouped", value: "", label: null, items: [] };
|
||||
groups.push(ungroupedGroup);
|
||||
}
|
||||
|
||||
ungroupedGroup.items.push(option);
|
||||
continue;
|
||||
}
|
||||
|
||||
hasGroupedOptions = true;
|
||||
|
||||
const group = typeof option.group === "string" ? { value: option.group, label: option.group } : option.group;
|
||||
let optionGroup = groupMap.get(group.value);
|
||||
|
||||
if (!optionGroup) {
|
||||
optionGroup = { ...group, key: `group:${group.value}`, items: [] };
|
||||
groupMap.set(group.value, optionGroup);
|
||||
groups.push(optionGroup);
|
||||
}
|
||||
|
||||
optionGroup.items.push(option);
|
||||
}
|
||||
|
||||
return hasGroupedOptions ? groups : null;
|
||||
}, [options]);
|
||||
|
||||
const findOption = React.useCallback(
|
||||
(v: TValue | TValue[] | null | undefined) => {
|
||||
if (multiple) {
|
||||
@@ -238,7 +180,7 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
|
||||
return (
|
||||
<ComboboxRoot
|
||||
name={name}
|
||||
items={optionGroups ?? options}
|
||||
items={options}
|
||||
filter={filter}
|
||||
disabled={disabled}
|
||||
value={selectedValue as ComboboxOption<TValue>[] & ComboboxOption<TValue>}
|
||||
@@ -265,11 +207,11 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
|
||||
<ComboboxContent>
|
||||
<ComboboxInput
|
||||
showTrigger={false}
|
||||
placeholder={searchPlaceholder ?? placeholder ?? t`Search...`}
|
||||
placeholder={placeholder ?? t`Search...`}
|
||||
render={<Input disabled={disabled} className="border-none focus-visible:border-none focus-visible:ring-0" />}
|
||||
/>
|
||||
<ComboboxEmpty>{emptyMessage ?? t`No results found.`}</ComboboxEmpty>
|
||||
<ComboboxList>{optionGroups ? groupedListContent : listContent}</ComboboxList>
|
||||
<ComboboxList>{listContent}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AnyFormApi } from "@tanstack/react-form";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
@@ -8,29 +9,7 @@ interface UseFormBlockerOptions {
|
||||
shouldBlock?: () => boolean;
|
||||
}
|
||||
|
||||
type BlockableFormStore = {
|
||||
get: () => {
|
||||
isDirty: boolean;
|
||||
isSubmitting: boolean;
|
||||
};
|
||||
subscribe: {
|
||||
(observer: {
|
||||
next?: (value: { isDirty: boolean; isSubmitting: boolean }) => void;
|
||||
error?: (error: unknown) => void;
|
||||
complete?: () => void;
|
||||
}): { unsubscribe: () => void };
|
||||
(
|
||||
next: (value: { isDirty: boolean; isSubmitting: boolean }) => void,
|
||||
error?: (error: unknown) => void,
|
||||
complete?: () => void,
|
||||
): { unsubscribe: () => void };
|
||||
};
|
||||
};
|
||||
|
||||
export function useFormBlocker<TStore extends BlockableFormStore>(
|
||||
form: { store: TStore },
|
||||
options?: UseFormBlockerOptions,
|
||||
) {
|
||||
export function useFormBlocker(form: Pick<AnyFormApi, "store">, options?: UseFormBlockerOptions) {
|
||||
const confirm = useConfirm();
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const setOnBeforeClose = useDialogStore((state) => state.setOnBeforeClose);
|
||||
|
||||
@@ -29,21 +29,4 @@ describe("getQueryClient", () => {
|
||||
// json/meta envelope is included
|
||||
expect(hashA).toContain('"json"');
|
||||
});
|
||||
|
||||
it("round-trips data through dehydrate/hydrate via oRPC serializer", () => {
|
||||
const client = getQueryClient();
|
||||
const serializeData = client.getDefaultOptions().dehydrate?.serializeData;
|
||||
const deserializeData = client.getDefaultOptions().hydrate?.deserializeData;
|
||||
|
||||
expect(serializeData).toBeTypeOf("function");
|
||||
expect(deserializeData).toBeTypeOf("function");
|
||||
|
||||
const original = { id: "x", count: 3, when: new Date("2024-01-01T00:00:00Z") };
|
||||
const serialized = serializeData?.(original);
|
||||
const restored = deserializeData?.(serialized) as typeof original;
|
||||
|
||||
expect(restored.id).toBe(original.id);
|
||||
expect(restored.count).toBe(original.count);
|
||||
expect(restored.when.getTime()).toBe(original.when.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,17 +14,6 @@ export const getQueryClient = () => {
|
||||
return JSON.stringify({ json, meta });
|
||||
},
|
||||
},
|
||||
dehydrate: {
|
||||
serializeData(data) {
|
||||
const [json, meta] = serializer.serialize(data);
|
||||
return { json, meta };
|
||||
},
|
||||
},
|
||||
hydrate: {
|
||||
deserializeData(data) {
|
||||
return serializer.deserialize(data.json, data.meta);
|
||||
},
|
||||
},
|
||||
},
|
||||
mutationCache: new MutationCache({
|
||||
onSettled: (_1, _2, _3, _4, _5, context) => {
|
||||
|
||||
@@ -3,13 +3,10 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxRoot,
|
||||
ComboboxSeparator,
|
||||
@@ -74,29 +71,11 @@ describe("ComboboxItem", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxList / ComboboxGroup / ComboboxLabel", () => {
|
||||
describe("ComboboxList", () => {
|
||||
it("ComboboxList renders with data-slot='combobox-list'", () => {
|
||||
renderCombobox({ open: true });
|
||||
expect(document.querySelector("[data-slot=combobox-list]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ComboboxGroup and ComboboxLabel render with their slots", () => {
|
||||
render(
|
||||
<ComboboxRoot items={items} open>
|
||||
<ComboboxInput />
|
||||
<ComboboxContent>
|
||||
<ComboboxList>
|
||||
<ComboboxGroup items={items}>
|
||||
<ComboboxLabel>Fruits</ComboboxLabel>
|
||||
<ComboboxItem value="Apple">Apple</ComboboxItem>
|
||||
</ComboboxGroup>
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(document.querySelector("[data-slot=combobox-group]")).toBeInTheDocument();
|
||||
expect(document.querySelector("[data-slot=combobox-label]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxSeparator and ComboboxEmpty", () => {
|
||||
@@ -129,29 +108,6 @@ describe("ComboboxSeparator and ComboboxEmpty", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxCollection", () => {
|
||||
it("renders without throwing", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<ComboboxRoot items={items} open>
|
||||
<ComboboxInput />
|
||||
<ComboboxContent>
|
||||
<ComboboxList>
|
||||
<ComboboxCollection>
|
||||
{(item) => (
|
||||
<ComboboxItem key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxCollection>
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxClear", () => {
|
||||
it("renders inside ComboboxInput when showClear=true", () => {
|
||||
const { container } = render(
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { menuLabelClassName, menuSeparatorClassName } from "@reactive-resume/ui/components/menu-styles";
|
||||
import { menuSeparatorClassName } from "@reactive-resume/ui/components/menu-styles";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
const ComboboxRoot = ComboboxPrimitive.Root;
|
||||
@@ -145,20 +145,6 @@ function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return <ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel data-slot="combobox-label" className={cn(menuLabelClassName, className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
@@ -212,13 +198,10 @@ export {
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxClear,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxRoot,
|
||||
ComboboxSeparator,
|
||||
|
||||
Reference in New Issue
Block a user