diff --git a/src/components/CollegeDepartmentAutocomplete/CollegeDepartmentAutocomplete.tsx b/src/components/CollegeDepartmentAutocomplete/CollegeDepartmentAutocomplete.tsx new file mode 100644 index 0000000..23c8b8a --- /dev/null +++ b/src/components/CollegeDepartmentAutocomplete/CollegeDepartmentAutocomplete.tsx @@ -0,0 +1,34 @@ +import { GenericAutocomplete } from "@/src/components/GenericAutocomplete/GenericAutocomplete"; +import type { CollegeAndDepartment } from "@/types"; + +interface CollegeDepartmentAutocompleteProps { + value?: Partial; + onSelect: (collegeAndDepartment: CollegeAndDepartment | null) => void; + defaultFilter?: Record; + required?: boolean; + disabled?: boolean; +} + +const CollegeDepartmentAutocomplete = ({ + value, + onSelect, + defaultFilter, + required, + disabled, +}: CollegeDepartmentAutocompleteProps) => { + return ( + + 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; diff --git a/src/components/FieldOfScienceAutocomplete/FieldOfScienceAutocomplete.tsx b/src/components/FieldOfScienceAutocomplete/FieldOfScienceAutocomplete.tsx new file mode 100644 index 0000000..5069622 --- /dev/null +++ b/src/components/FieldOfScienceAutocomplete/FieldOfScienceAutocomplete.tsx @@ -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; + onSelect: (fieldOfScience: FieldsOfScience | null) => void; + defaultFilter?: Record; + required?: boolean; + disabled?: boolean; +} + +const FieldOfScienceAutocomplete = ({ + value, + onSelect, + defaultFilter, + required, + disabled, +}: FieldOfScienceAutocompleteProps) => { + return ( + + 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{" "} + + SED-CIP field of science codes + + . + + } + required={required} + disabled={disabled} + /> + ); +}; + +export default FieldOfScienceAutocomplete; diff --git a/src/components/Forms/ProjectForm/ProjectForm.tsx b/src/components/Forms/ProjectForm/ProjectForm.tsx index 12a1b3b..c00cd10 100644 --- a/src/components/Forms/ProjectForm/ProjectForm.tsx +++ b/src/components/Forms/ProjectForm/ProjectForm.tsx @@ -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; @@ -41,10 +47,14 @@ export interface ProjectFormProps { function normalizeInitialValues(initial?: Partial): 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 ?? "", @@ -57,10 +67,14 @@ function normalizeInitialValues(initial?: Partial): ProjectFormValues { // Field name mappings for error display const FIELD_NAME_MAP: Record = { 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", @@ -83,10 +97,14 @@ export const ProjectForm: React.FC = ({ 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, @@ -112,6 +130,26 @@ export const ProjectForm: React.FC = ({ disabled={isSubmitting} /> + handleChange("display_name", e.target.value)} + fullWidth + disabled={isSubmitting} + /> + + handleChange("description", e.target.value)} + fullWidth + multiline + minRows={4} + maxRows={10} + disabled={isSubmitting} + helperText="Accepts plain text or Markdown" + /> + = ({ }} /> + handleChange("college_and_department", collegeAndDepartment)} + disabled={isSubmitting} + /> + + handleChange("field_of_science", fieldOfScience)} + disabled={isSubmitting} + /> + { endpoint: string; label: string; defaultFilter?: Record; searchFields: string[]; + getOptionId?: (option: T) => string | number; + helperText?: ReactNode; required?: boolean; disabled?: boolean; } -export type GenericAutocompleteProps = - | (BaseProps & { +export type GenericAutocompleteProps = + | (BaseProps & { multiple?: false; value?: Partial | null; onSelect: (item: T | null) => void; getOptionLabel: (option: T) => string; }) - | (BaseProps & { + | (BaseProps & { multiple: true; value?: Partial[] | null; onSelect: (item: T[]) => void; getOptionLabel: (option: T) => string; }); -export function GenericAutocomplete(props: GenericAutocompleteProps) { +export function GenericAutocomplete(props: GenericAutocompleteProps) { + const getOptionId = props.getOptionId ?? ((option: T) => (option as unknown as { id: number | string }).id); const [searchInput, setSearchInput] = useState(""); const debouncedInput = useDebounce(searchInput, 300); @@ -40,14 +43,14 @@ export function GenericAutocomplete(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`, and MUI demands `T`. - const match = (v: Partial): T => (items.find((i) => i.id === v?.id) || v) as T; + const match = (v: Partial): 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) { @@ -66,13 +69,11 @@ export function GenericAutocomplete(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")) { @@ -89,10 +90,16 @@ export function GenericAutocomplete(props: GenericAuto } }} renderInput={(params) => ( - + )} /> ); } -export default GenericAutocomplete; \ No newline at end of file +export default GenericAutocomplete; diff --git a/types.ts b/types.ts index 3f6a5a3..9bc2cce 100644 --- a/types.ts +++ b/types.ts @@ -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; @@ -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; } @@ -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; @@ -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 {