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
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { GenericAutocomplete } from "@/src/components/GenericAutocomplete/GenericAutocomplete";
import type { CollegeAndDepartment } from "@/types";

interface CollegeDepartmentAutocompleteProps {
value?: Partial<CollegeAndDepartment>;
onSelect: (collegeAndDepartment: CollegeAndDepartment | null) => void;
defaultFilter?: Record<string, string>;
required?: boolean;
disabled?: boolean;
}

const CollegeDepartmentAutocomplete = ({
value,
onSelect,
defaultFilter,
required,
disabled,
}: CollegeDepartmentAutocompleteProps) => {
return (
<GenericAutocomplete<CollegeAndDepartment>
endpoint="/college_and_departments"
label="College / Department"
value={value}
onSelect={onSelect}
defaultFilter={defaultFilter}
getOptionLabel={(option) => [option.college, option.department].filter(Boolean).join(", ")}
searchFields={["college", "department"]}
required={required}
disabled={disabled}
/>
);
};

export default CollegeDepartmentAutocomplete;
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { GenericAutocomplete } from "@/src/components/GenericAutocomplete/GenericAutocomplete";
import type { FieldsOfScience } from "@/types";
import { Link } from "@mui/material";

interface FieldOfScienceAutocompleteProps {
value?: Partial<FieldsOfScience>;
onSelect: (fieldOfScience: FieldsOfScience | null) => void;
defaultFilter?: Record<string, string>;
required?: boolean;
disabled?: boolean;
}

const FieldOfScienceAutocomplete = ({
value,
onSelect,
defaultFilter,
required,
disabled,
}: FieldOfScienceAutocompleteProps) => {
return (
<GenericAutocomplete<FieldsOfScience>
endpoint="/fields_of_science"
label="Field of Science"
value={value}
onSelect={onSelect}
defaultFilter={defaultFilter}
// fields_of_science is keyed by the string fos_id
getOptionId={(option) => option.fos_id}
getOptionLabel={(option) => (option.sed_cip_title ? `${option.fos_id}: ${option.sed_cip_title}` : option.fos_id)}
searchFields={["fos_id", "sed_cip_title"]}
helperText={
<>
Uses the NSF{" "}
<Link
href="https://ncses.nsf.gov/pubs/nsf24300/assets/technical-notes/nsf24300-technical-notes.pdf"
target="_blank"
rel="noopener noreferrer"
>
SED-CIP field of science codes
</Link>
.
</>
}
required={required}
disabled={disabled}
/>
);
};

export default FieldOfScienceAutocomplete;
52 changes: 51 additions & 1 deletion src/components/Forms/ProjectForm/ProjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@

import FormErrorAlert from "@/src/components/FormErrorAlert/FormErrorAlert";
import UserAutocomplete from "@/src/components/UserAutocomplete/UserAutocomplete";
import CollegeDepartmentAutocomplete from "@/src/components/CollegeDepartmentAutocomplete/CollegeDepartmentAutocomplete";
import FieldOfScienceAutocomplete from "@/src/components/FieldOfScienceAutocomplete/FieldOfScienceAutocomplete";
import { ApiError } from "@/src/utils/formErrors";
import { useFormState } from "@/src/utils/useFormState";
import { FormMode, Project, ProjectCreateUpdate, User } from "@/types";
import { CollegeAndDepartment, FieldsOfScience, FormMode, Project, ProjectCreateUpdate, User } from "@/types";
import { Box, Button, Stack, TextField } from "@mui/material";
import React from "react";

export interface ProjectFormValues {
name: string;
display_name: string;
description: string;
accounting_group: string;
pi: string; // stringified user ID
staff1: User | null;
staff2: User | null;
college_and_department: CollegeAndDepartment | null;
field_of_science: FieldsOfScience | null;
status: string;
access: string;
url: string;
Expand Down Expand Up @@ -41,10 +47,14 @@ export interface ProjectFormProps {
function normalizeInitialValues(initial?: Partial<Project>): ProjectFormValues {
return {
name: initial?.name ?? "",
display_name: initial?.display_name ?? "",
description: initial?.description ?? "",
accounting_group: initial?.accounting_group ?? "",
pi: initial?.pi !== undefined && initial?.pi !== null ? String(initial.pi) : "",
staff1: initial?.staff1 ?? null,
staff2: initial?.staff2 ?? null,
college_and_department: initial?.college_and_department ?? null,
field_of_science: initial?.field_of_science ?? null,
status: initial?.status ?? "",
access: initial?.access ?? "",
url: initial?.url ?? "",
Expand All @@ -57,10 +67,14 @@ function normalizeInitialValues(initial?: Partial<Project>): ProjectFormValues {
// Field name mappings for error display
const FIELD_NAME_MAP: Record<string, string> = {
name: "Name",
display_name: "Display Name",
description: "Description",
accounting_group: "Accounting Group",
pi: "PI",
staff1: "Staff 1",
staff2: "Staff 2",
college_and_department_id: "College / Department",
fos_id: "Field of Science",
status: "Status",
access: "Access",
url: "URL",
Expand All @@ -83,10 +97,14 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({

const payload: ProjectCreateUpdate = {
name: values.name.trim(),
display_name: values.display_name.trim() || null,
description: values.description || null,
accounting_group: values.accounting_group.trim(),
pi: values.pi ? Number(values.pi) : null,
staff1: values.staff1?.id ?? null,
staff2: values.staff2?.id ?? null,
college_and_department_id: values.college_and_department?.id ?? null,
fos_id: values.field_of_science?.fos_id ?? null,
status: values.status || null,
access: values.access || null,
url: values.url || null,
Expand All @@ -112,6 +130,26 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
disabled={isSubmitting}
/>

<TextField
label="Display Name"
value={values.display_name}
onChange={(e) => handleChange("display_name", e.target.value)}
fullWidth
disabled={isSubmitting}
/>

<TextField
label="Description"
value={values.description}
onChange={(e) => handleChange("description", e.target.value)}
fullWidth
multiline
minRows={4}
maxRows={10}
disabled={isSubmitting}
helperText="Accepts plain text or Markdown"
/>

<TextField
label="Accounting Group"
value={values.accounting_group}
Expand Down Expand Up @@ -139,6 +177,18 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
}}
/>

<CollegeDepartmentAutocomplete
value={values.college_and_department ?? undefined}
onSelect={(collegeAndDepartment) => handleChange("college_and_department", collegeAndDepartment)}
disabled={isSubmitting}
/>

<FieldOfScienceAutocomplete
value={values.field_of_science ?? undefined}
onSelect={(fieldOfScience) => handleChange("field_of_science", fieldOfScience)}
disabled={isSubmitting}
/>

<TextField
label="Status"
value={values.status}
Expand Down
37 changes: 22 additions & 15 deletions src/components/GenericAutocomplete/GenericAutocomplete.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,36 @@
import { apiFetch } from "@/src/components/AuthProvider";
import useDebounce from "@/src/utils/useDebounce";
import { Autocomplete, TextField } from "@mui/material";
import { useState, useMemo } from "react";
import { type ReactNode, useState, useMemo } from "react";
import useSWR from "swr";

interface BaseProps {
interface BaseProps<T> {
endpoint: string;
label: string;
defaultFilter?: Record<string, string>;
searchFields: string[];
getOptionId?: (option: T) => string | number;
helperText?: ReactNode;
required?: boolean;
disabled?: boolean;
}

export type GenericAutocompleteProps<T extends { id: number }> =
| (BaseProps & {
export type GenericAutocompleteProps<T> =
| (BaseProps<T> & {
multiple?: false;
value?: Partial<T> | null;
onSelect: (item: T | null) => void;
getOptionLabel: (option: T) => string;
})
| (BaseProps & {
| (BaseProps<T> & {
multiple: true;
value?: Partial<T>[] | null;
onSelect: (item: T[]) => void;
getOptionLabel: (option: T) => string;
});

export function GenericAutocomplete<T extends { id: number }>(props: GenericAutocompleteProps<T>) {
export function GenericAutocomplete<T>(props: GenericAutocompleteProps<T>) {
const getOptionId = props.getOptionId ?? ((option: T) => (option as unknown as { id: number | string }).id);
const [searchInput, setSearchInput] = useState("");
const debouncedInput = useDebounce(searchInput, 300);

Expand All @@ -40,14 +43,14 @@ export function GenericAutocomplete<T extends { id: number }>(props: GenericAuto
}
return (await apiFetch(`${props.endpoint}?${params}`)).json();
},
{ keepPreviousData: true }
{ keepPreviousData: true },
);

const items = useMemo(() => data ?? [], [data]);

const activeValue = useMemo(() => {
// We still keep `as T` here because the parent is passing `Partial<T>`, and MUI demands `T`.
const match = (v: Partial<T>): T => (items.find((i) => i.id === v?.id) || v) as T;
const match = (v: Partial<T>): T => (items.find((i) => getOptionId(i) === getOptionId(v as T)) || v) as T;

// TypeScript now natively knows props.value is an Array if props.multiple is true! No casts!
if (props.multiple) {
Expand All @@ -66,13 +69,11 @@ export function GenericAutocomplete<T extends { id: number }>(props: GenericAuto
loading={isValidating}
inputValue={props.multiple ? searchInput : undefined}
filterOptions={(opts, state) =>
opts.filter((opt) =>
props.getOptionLabel(opt).toLowerCase().includes(state.inputValue.toLowerCase())
)
opts.filter((opt) => props.getOptionLabel(opt).toLowerCase().includes(state.inputValue.toLowerCase()))
}
getOptionKey={(opt) => opt.id}
getOptionKey={(opt) => getOptionId(opt)}
getOptionLabel={props.getOptionLabel}
isOptionEqualToValue={(opt, val) => opt.id === val.id}
isOptionEqualToValue={(opt, val) => getOptionId(opt) === getOptionId(val)}
onInputChange={(_, val, reason) => {
if (reason === "input") setSearchInput(val);
else if (["clear", "blur"].includes(reason) || (!props.multiple && reason === "reset")) {
Expand All @@ -89,10 +90,16 @@ export function GenericAutocomplete<T extends { id: number }>(props: GenericAuto
}
}}
renderInput={(params) => (
<TextField {...params} label={props.label} variant="outlined" required={props.required} />
<TextField
{...params}
label={props.label}
variant="outlined"
required={props.required}
helperText={props.helperText}
/>
)}
/>
);
}

export default GenericAutocomplete;
export default GenericAutocomplete;
24 changes: 24 additions & 0 deletions types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,25 @@ export interface GroupCreateUpdate {
has_groupdir?: boolean | null;
}

export interface FieldsOfScience {
fos_id: string;
sed_cip_title: string | null;
broad_field: string | null;
major_field: string | null;
detailed_field: string | null;
}

export interface CollegeAndDepartment {
id: number;
college: string | null;
department: string | null;
}

export interface Project {
id: number;
name: string;
display_name: string | null;
description: string | null;
pi: number | null;
staff1: User | null;
staff2: User | null;
Expand All @@ -90,6 +106,10 @@ export interface Project {
date: string | null;
ticket: number | null;
last_contact: string | null;
college_and_department_id: number | null;
fos_id: string | null;
college_and_department: CollegeAndDepartment | null;
field_of_science: FieldsOfScience | null;
managed_by: EntityManagerEnum | null;
}

Expand All @@ -105,6 +125,8 @@ export interface PiProjectView {

export interface ProjectCreateUpdate {
name: string;
display_name?: string | null;
description?: string | null;
pi?: number | null;
staff1?: number | null;
staff2?: number | null;
Expand All @@ -115,6 +137,8 @@ export interface ProjectCreateUpdate {
date?: string | null;
ticket?: number | null;
last_contact?: string | null;
college_and_department_id?: number | null;
fos_id?: string | null;
}

export interface User {
Expand Down