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
28 changes: 16 additions & 12 deletions app/(application-record)/application-record/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { redirect } from "next/navigation";
import { getUser } from "@/lib/session";
import { getUserApplications } from "@/db/queries";
import { computeStats } from "@/lib/applications";
import { buildSuggestions } from "@/lib/suggestions";
import { StatsRow } from "@/components/dashboard/stats-row";
import { ApplicationsView } from "@/components/applications/applications-view";
import { NewApplicationButton } from "@/components/applications/application-dialog";
import { SuggestionsProvider } from "@/components/applications/suggestions-provider";
import { Chatbot } from "@/components/ai-chatbot/chatbot";

export const metadata: Metadata = {
Expand All @@ -21,19 +23,21 @@ export default async function ApplicationRecord() {
const applications = await getUserApplications(user.id);

return (
<div className="flex flex-col gap-8">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Applications</h1>
<p className="text-sm text-muted-foreground">
Everything you have applied to, in one place.
</p>
<SuggestionsProvider suggestions={buildSuggestions(applications)}>
<div className="flex flex-col gap-8">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Applications</h1>
<p className="text-sm text-muted-foreground">
Everything you have applied to, in one place.
</p>
</div>
<NewApplicationButton />
</div>
<NewApplicationButton />
<StatsRow stats={computeStats(applications)} />
<ApplicationsView applications={applications} />
<Chatbot />
</div>
<StatsRow stats={computeStats(applications)} />
<ApplicationsView applications={applications} />
<Chatbot />
</div>
</SuggestionsProvider>
);
}
98 changes: 66 additions & 32 deletions components/applications/application-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { DateField } from "./date-field";
import { SuggestInput } from "./suggest-input";
import { useSuggestions } from "./suggestions-provider";
import { statusClasses } from "./status-badge";
import { cn } from "@/lib/utils";

Expand All @@ -44,6 +46,7 @@ export function ApplicationForm({
onDone: () => void;
}) {
const [pending, startTransition] = useTransition();
const suggestions = useSuggestions();
const form = useForm<ApplicationInput>({
resolver: zodResolver(applicationSchema),
defaultValues: toFormValues(application),
Expand Down Expand Up @@ -72,41 +75,72 @@ export function ApplicationForm({
return (
<form onSubmit={submit} className="flex flex-col gap-6">
<FieldGroup>
<Field data-invalid={errors.role ? true : undefined}>
<FieldLabel htmlFor="role">Role</FieldLabel>
<Input
id="role"
placeholder="Software Engineer"
aria-invalid={errors.role ? true : undefined}
autoComplete="off"
{...register("role")}
/>
<FieldError>{errors.role?.message}</FieldError>
</Field>
<Controller
control={control}
name="role"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid || undefined}>
<FieldLabel htmlFor="role">Role</FieldLabel>
<SuggestInput
id="role"
name={field.name}
ref={field.ref}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
items={suggestions.roles}
placeholder="Software Engineer"
invalid={fieldState.invalid}
/>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>

<Field data-invalid={errors.company_name ? true : undefined}>
<FieldLabel htmlFor="company_name">Company</FieldLabel>
<Input
id="company_name"
placeholder="Acme"
aria-invalid={errors.company_name ? true : undefined}
autoComplete="organization"
{...register("company_name")}
/>
<FieldError>{errors.company_name?.message}</FieldError>
</Field>
<Controller
control={control}
name="company_name"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid || undefined}>
<FieldLabel htmlFor="company_name">Company</FieldLabel>
<SuggestInput
id="company_name"
name={field.name}
ref={field.ref}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
items={suggestions.companies}
placeholder="Acme"
invalid={fieldState.invalid}
/>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>

<div className="grid gap-6 sm:grid-cols-2">
<Field data-invalid={errors.location ? true : undefined}>
<FieldLabel htmlFor="location">Location</FieldLabel>
<Input
id="location"
placeholder="Remote, NYC"
aria-invalid={errors.location ? true : undefined}
{...register("location")}
/>
<FieldError>{errors.location?.message}</FieldError>
</Field>
<Controller
control={control}
name="location"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid || undefined}>
<FieldLabel htmlFor="location">Location</FieldLabel>
<SuggestInput
id="location"
name={field.name}
ref={field.ref}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
items={suggestions.locations}
placeholder="Remote, NYC"
invalid={fieldState.invalid}
/>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>

<Controller
control={control}
Expand Down
67 changes: 67 additions & 0 deletions components/applications/suggest-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"use client";

import type { Ref } from "react";
import {
Autocomplete,
AutocompleteContent,
AutocompleteInput,
AutocompleteItem,
AutocompleteList,
} from "@/components/ui/autocomplete";

const LIMIT = 8;

/**
* Free-text input with a suggestion list. The user can type any value, or
* pick a match with the pointer or arrow keys + Enter.
*/
export function SuggestInput({
id,
name,
value,
onChange,
onBlur,
items,
placeholder,
invalid,
ref,
}: {
id: string;
name: string;
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
items: readonly string[];
placeholder?: string;
invalid?: boolean;
ref?: Ref<HTMLInputElement>;
}) {
return (
<Autocomplete
items={items}
value={value}
onValueChange={onChange}
limit={LIMIT}
openOnInputClick
>
<AutocompleteInput
ref={ref}
id={id}
name={name}
placeholder={placeholder}
aria-invalid={invalid || undefined}
autoComplete="off"
onBlur={onBlur}
/>
<AutocompleteContent>
<AutocompleteList>
{(item: string) => (
<AutocompleteItem key={item} value={item}>
{item}
</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
);
}
24 changes: 24 additions & 0 deletions components/applications/suggestions-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"use client";

import { createContext, useContext } from "react";
import { DEFAULT_SUGGESTIONS, type Suggestions } from "@/lib/suggestions";

const SuggestionsContext = createContext<Suggestions>(DEFAULT_SUGGESTIONS);

/**
* Makes the user's past entries available to the application form no matter
* where the dialog is opened from (page header, row actions, cards).
*/
export function SuggestionsProvider({
suggestions,
children,
}: {
suggestions: Suggestions;
children: React.ReactNode;
}) {
return <SuggestionsContext.Provider value={suggestions}>{children}</SuggestionsContext.Provider>;
}

export function useSuggestions() {
return useContext(SuggestionsContext);
}
119 changes: 119 additions & 0 deletions components/ui/autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"use client";

import * as React from "react";
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete";

import { cn } from "@/lib/utils";

const Autocomplete = AutocompletePrimitive.Root;

function AutocompleteInput({ className, ...props }: AutocompletePrimitive.Input.Props) {
return (
<AutocompletePrimitive.Input
data-slot="autocomplete-input"
className={cn(
"h-8 w-full min-w-0 rounded-2xl border border-transparent bg-input/50 px-2.5 py-1 text-base transition-[color,box-shadow] duration-200 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}

function AutocompleteContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "start",
alignOffset = 0,
...props
}: AutocompletePrimitive.Popup.Props &
Pick<AutocompletePrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<AutocompletePrimitive.Portal>
<AutocompletePrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
className="isolate z-50 data-empty:hidden"
>
<AutocompletePrimitive.Popup
data-slot="autocomplete-content"
className={cn(
"relative isolate z-50 max-h-[min(20rem,var(--available-height))] w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
{children}
</AutocompletePrimitive.Popup>
</AutocompletePrimitive.Positioner>
</AutocompletePrimitive.Portal>
);
}

function AutocompleteList({ className, ...props }: AutocompletePrimitive.List.Props) {
return (
<AutocompletePrimitive.List
data-slot="autocomplete-list"
className={cn("scroll-my-1.5 p-1", className)}
{...props}
/>
);
}

function AutocompleteItem({ className, ...props }: AutocompletePrimitive.Item.Props) {
return (
<AutocompletePrimitive.Item
data-slot="autocomplete-item"
className={cn(
"relative flex min-h-7 w-full cursor-default items-center gap-2 rounded-xl px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}

function AutocompleteEmpty({ className, ...props }: AutocompletePrimitive.Empty.Props) {
return (
<AutocompletePrimitive.Empty
data-slot="autocomplete-empty"
className={cn("px-3 py-2 text-sm text-muted-foreground empty:m-0 empty:p-0", className)}
{...props}
/>
);
}

function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group.Props) {
return (
<AutocompletePrimitive.Group
data-slot="autocomplete-group"
className={cn("scroll-my-1.5", className)}
{...props}
/>
);
}

function AutocompleteGroupLabel({ className, ...props }: AutocompletePrimitive.GroupLabel.Props) {
return (
<AutocompletePrimitive.GroupLabel
data-slot="autocomplete-group-label"
className={cn("px-2 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
);
}

export {
Autocomplete,
AutocompleteContent,
AutocompleteEmpty,
AutocompleteGroup,
AutocompleteGroupLabel,
AutocompleteInput,
AutocompleteItem,
AutocompleteList,
};
Loading