From f371fdeaa4ab5d3746220e883ab749dd92a87670 Mon Sep 17 00:00:00 2001 From: Stefan Kairinos Date: Wed, 7 Aug 2024 10:02:58 +0300 Subject: [PATCH] feat: Portal frontend#19 (#51) * add user types * fix type * return result and arg types * Fields Param * add icon * fix list filters * fix: border * teachers in school filter * fix autocomplete field and styling * fix: support multiple search params * exclude id * use full list hook * use pagination hook * export use pagination options * fix import * autocomplete field * name filter * set page to 0 if limit changes * export pagination types * add TablePagination component * support search and error reporting * add country and uk county fields * make query optional * add labels and placeholders * fix: update arg type * update with body * only teachers * getParam helper * merge from main * list result --- src/api/endpoints/klass.ts | 4 +- src/api/endpoints/user.ts | 9 +- src/components/TablePagination.tsx | 124 +++++++++++++ src/components/form/ApiAutocompleteField.tsx | 181 +++++++++++++++++++ src/components/form/AutocompleteField.tsx | 26 ++- src/components/form/CountryField.tsx | 68 +++++++ src/components/form/UkCountyField.tsx | 67 +++++++ src/components/form/index.tsx | 11 ++ src/components/index.ts | 21 ++- src/hooks/api.ts | 37 ++++ src/hooks/index.ts | 10 + src/theme/components/MuiAutocomplete.tsx | 11 ++ src/theme/components/MuiTextField.ts | 6 + src/theme/components/index.ts | 2 + src/utils/api.test.ts | 19 ++ src/utils/api.ts | 28 +-- src/utils/form.ts | 10 +- src/utils/router.ts | 4 + 18 files changed, 601 insertions(+), 37 deletions(-) create mode 100644 src/components/TablePagination.tsx create mode 100644 src/components/form/ApiAutocompleteField.tsx create mode 100644 src/components/form/CountryField.tsx create mode 100644 src/components/form/UkCountyField.tsx create mode 100644 src/hooks/api.ts create mode 100644 src/theme/components/MuiAutocomplete.tsx create mode 100644 src/utils/api.test.ts diff --git a/src/api/endpoints/klass.ts b/src/api/endpoints/klass.ts index 92627306..0f37c942 100644 --- a/src/api/endpoints/klass.ts +++ b/src/api/endpoints/klass.ts @@ -8,7 +8,7 @@ import { type RetrieveArg, type RetrieveResult, } from "../../utils/api" -import type { Class } from "../models" +import type { Class, Teacher } from "../models" import { type TagTypes } from "../tagTypes" import urls from "../urls" @@ -32,7 +32,7 @@ export type ListClassesResult = ListResult< | "school" | "teacher" > -export type ListClassesArg = ListArg +export type ListClassesArg = ListArg<{ teacher: Teacher["id"] }> export default function getReadClassEndpoints( build: EndpointBuilder, diff --git a/src/api/endpoints/user.ts b/src/api/endpoints/user.ts index fd70554a..703b4ccd 100644 --- a/src/api/endpoints/user.ts +++ b/src/api/endpoints/user.ts @@ -8,7 +8,7 @@ import { type RetrieveArg, type RetrieveResult, } from "../../utils/api" -import type { User } from "../models" +import type { Class, User } from "../models" import { type TagTypes } from "../tagTypes" import urls from "../urls" @@ -38,7 +38,12 @@ export type ListUsersResult = ListResult< | "student" | "teacher" > -export type ListUsersArg = ListArg<{ students_in_class: string }> +export type ListUsersArg = ListArg<{ + students_in_class: Class["id"] + only_teachers: boolean + _id: User["id"] | User["id"][] + name: string +}> export default function getReadUserEndpoints( build: EndpointBuilder, diff --git a/src/components/TablePagination.tsx b/src/components/TablePagination.tsx new file mode 100644 index 00000000..fd327f25 --- /dev/null +++ b/src/components/TablePagination.tsx @@ -0,0 +1,124 @@ +import { SyncProblem as SyncProblemIcon } from "@mui/icons-material" +import { + CircularProgress, + TablePagination as MuiTablePagination, + type TablePaginationProps as MuiTablePaginationProps, + Stack, + type StackProps, + type TablePaginationBaseProps, + Typography, +} from "@mui/material" +import type { TypedUseLazyQuery } from "@reduxjs/toolkit/query/react" +import { + type ElementType, + type JSXElementConstructor, + type ReactNode, + useEffect, +} from "react" + +import { type Pagination, usePagination } from "../hooks/api" +import type { ListArg, ListResult } from "../utils/api" + +export type TablePaginationProps< + QueryArg extends ListArg, + ResultType extends ListResult, + RootComponent extends + ElementType = JSXElementConstructor, + AdditionalProps = {}, +> = Omit< + MuiTablePaginationProps, + | "component" + | "count" + | "rowsPerPage" + | "onRowsPerPageChange" + | "page" + | "onPageChange" + | "rowsPerPageOptions" +> & { + children: ( + data: ResultType["data"], + pagination: Pagination & { count?: number; maxLimit?: number }, + ) => ReactNode + useLazyListQuery: TypedUseLazyQuery + filters?: Omit + rowsPerPageOptions?: number[] + stackProps?: StackProps + page?: number + rowsPerPage?: number +} + +const TablePagination = < + QueryArg extends ListArg, + ResultType extends ListResult, + RootComponent extends + ElementType = JSXElementConstructor, + AdditionalProps = {}, +>({ + children, + useLazyListQuery, + filters, + page: initialPage = 0, + rowsPerPage: initialLimit = 50, + rowsPerPageOptions = [50, 100, 150], + stackProps, + ...tablePaginationProps +}: TablePaginationProps< + QueryArg, + ResultType, + RootComponent, + AdditionalProps +>): JSX.Element => { + const [trigger, { data: result, isLoading, error }] = useLazyListQuery() + const [{ limit, page, offset }, setPagination] = usePagination({ + page: initialPage, + limit: initialLimit, + }) + + useEffect(() => { + trigger({ limit, offset, ...filters } as QueryArg) + }, [trigger, limit, offset, filters]) + + useEffect(() => { + console.error(error) + }, [error]) + + const { data, count, max_limit } = result || {} + + if (max_limit) { + rowsPerPageOptions = rowsPerPageOptions.filter( + option => option <= max_limit, + ) + } + + return ( + + {isLoading ? ( + + ) : error || !data ? ( + <> + + Failed to load data + + ) : ( + children(data, { limit, page, offset, count, maxLimit: max_limit }) + )} + { + setPagination({ limit: parseInt(event.target.value), page: 0 }) + }} + page={page} + onPageChange={(_, page) => { + setPagination(({ limit }) => ({ limit, page })) + }} + // ascending order + rowsPerPageOptions={rowsPerPageOptions.sort((a, b) => a - b)} + {...tablePaginationProps} + /> + + ) +} + +export default TablePagination diff --git a/src/components/form/ApiAutocompleteField.tsx b/src/components/form/ApiAutocompleteField.tsx new file mode 100644 index 00000000..479fb381 --- /dev/null +++ b/src/components/form/ApiAutocompleteField.tsx @@ -0,0 +1,181 @@ +import { SyncProblem as SyncProblemIcon } from "@mui/icons-material" +import { + Button, + CircularProgress, + Stack, + Typography, + type ChipTypeMap, +} from "@mui/material" +import type { TypedUseLazyQuery } from "@reduxjs/toolkit/query/react" +import { + Children, + forwardRef, + useEffect, + useState, + type ElementType, +} from "react" + +import { + AutocompleteField, + type AutocompleteFieldProps, +} from "../../components/form" +import { usePagination } from "../../hooks/api" +import type { ListArg, ListResult, TagId } from "../../utils/api" + +export interface ApiAutocompleteFieldProps< + SearchKey extends keyof Omit, + // api type args + QueryArg extends ListArg, + ResultType extends ListResult, + // autocomplete type args + Multiple extends boolean | undefined = false, + DisableClearable extends boolean | undefined = false, + FreeSolo extends boolean | undefined = false, + ChipComponent extends ElementType = ChipTypeMap["defaultComponent"], +> extends Omit< + AutocompleteFieldProps< + TagId, + Multiple, + DisableClearable, + FreeSolo, + ChipComponent + >, + | "options" + | "ListboxComponent" + | "filterOptions" + | "getOptionLabel" + | "getOptionKey" + | "onInputChange" + > { + useLazyListQuery: TypedUseLazyQuery + filterOptions?: Omit + getOptionLabel: (result: ResultType["data"][number]) => string + getOptionKey?: (result: ResultType["data"][number]) => TagId + searchKey: SearchKey +} + +const ApiAutocompleteField = < + SearchKey extends keyof Omit, + // api type args + QueryArg extends ListArg, + ResultType extends ListResult, + // autocomplete type args + Multiple extends boolean | undefined = false, + DisableClearable extends boolean | undefined = false, + FreeSolo extends boolean | undefined = false, + ChipComponent extends ElementType = ChipTypeMap["defaultComponent"], +>({ + useLazyListQuery, + filterOptions, + getOptionLabel, + getOptionKey = result => result.id, + searchKey, + ...otherAutocompleteFieldProps +}: ApiAutocompleteFieldProps< + SearchKey, + // api type args + QueryArg, + ResultType, + // autocomplete type args + Multiple, + DisableClearable, + FreeSolo, + ChipComponent +>): JSX.Element => { + const [search, setSearch] = useState("") + const [trigger, { isLoading, isError }] = useLazyListQuery() + const [{ limit, offset }, setPagination] = usePagination() + const [{ options, hasMore }, setState] = useState<{ + options: Record + hasMore: boolean + }>({ options: {}, hasMore: true }) + + // Call api + useEffect(() => { + const arg = { limit, offset, ...filterOptions } as QueryArg + // @ts-expect-error + if (search) arg[searchKey] = search + + trigger(arg) + .unwrap() + .then(({ data, offset, limit, count }) => { + setState(({ options: previousOptions }) => { + const options = { ...previousOptions } + data.forEach(result => { + options[getOptionKey(result)] = result + }) + return { options, hasMore: offset + limit < count } + }) + }) + .catch(error => { + if (error) console.error(error) + // TODO: gracefully handle error + }) + }, [trigger, limit, offset, filterOptions, getOptionKey, searchKey, search]) + + // Get options keys + let optionKeys: TagId[] = Object.keys(options) + if (!optionKeys.length) return <> + if (typeof getOptionKey(Object.values(options)[0]) === "number") { + optionKeys = optionKeys.map(Number) + } + + function loadNextPage() { + setPagination(({ page, limit }) => ({ page: page + 1, limit })) + } + + return ( + getOptionLabel(options[id])} + onInputChange={(_, value, reason) => { + setSearch(reason === "input" ? value : "") + }} + ListboxComponent={forwardRef(({ children, ...props }, ref) => { + const listItems = Children.toArray(children) + if (isLoading) listItems.push() + else { + if (isError) { + listItems.push( + + + Failed to load data + , + ) + } + if (hasMore) { + listItems.push( + , + ) + } + } + + return ( +
    { + // If not already loading and scrolled to bottom + if ( + !isLoading && + event.currentTarget.clientHeight + + event.currentTarget.scrollTop >= + event.currentTarget.scrollHeight + ) { + loadNextPage() + } + }} + > + {listItems} +
+ ) + })} + {...otherAutocompleteFieldProps} + /> + ) +} + +export default ApiAutocompleteField diff --git a/src/components/form/AutocompleteField.tsx b/src/components/form/AutocompleteField.tsx index 073d12ad..918efb9e 100644 --- a/src/components/form/AutocompleteField.tsx +++ b/src/components/form/AutocompleteField.tsx @@ -6,19 +6,25 @@ import { type TextFieldProps, } from "@mui/material" import { Field, type FieldConfig, type FieldProps } from "formik" -import { string as YupString, type ValidateOptions } from "yup" +import { type ElementType } from "react" +import { + number as YupNumber, + string as YupString, + type ValidateOptions, +} from "yup" import { schemaToFieldValidator } from "../../utils/form" import { getNestedProperty } from "../../utils/general" export interface AutocompleteFieldProps< + Value extends string | number, Multiple extends boolean | undefined = false, DisableClearable extends boolean | undefined = false, FreeSolo extends boolean | undefined = false, - ChipComponent extends React.ElementType = ChipTypeMap["defaultComponent"], + ChipComponent extends ElementType = ChipTypeMap["defaultComponent"], > extends Omit< AutocompleteProps< - string, // NOTE: force type to be string, not generic + Value, Multiple, DisableClearable, FreeSolo, @@ -44,16 +50,18 @@ export interface AutocompleteFieldProps< } const AutocompleteField = < + Value extends string | number, Multiple extends boolean | undefined = false, DisableClearable extends boolean | undefined = false, FreeSolo extends boolean | undefined = false, - ChipComponent extends React.ElementType = ChipTypeMap["defaultComponent"], + ChipComponent extends ElementType = ChipTypeMap["defaultComponent"], >({ textFieldProps, options, validateOptions, ...otherAutocompleteProps }: AutocompleteFieldProps< + Value, Multiple, DisableClearable, FreeSolo, @@ -63,12 +71,16 @@ const AutocompleteField = < const dotPath = name.split(".") - let schema = YupString().oneOf(options, "not a valid option") + const message = "not a valid option" + let schema = + typeof options[0] === "string" + ? YupString().oneOf(options as readonly string[], message) + : YupNumber().oneOf(options as readonly number[], message) if (required) schema = schema.required() const fieldConfig: FieldConfig = { name, - type: "text", + type: typeof options[0] === "string" ? "text" : "number", validate: schemaToFieldValidator(schema, validateOptions), } @@ -90,7 +102,7 @@ const AutocompleteField = < id={name} name={name} required={required} - type="text" + type="text" // Force to be string to avoid number incrementor/decrementor value={value} error={touched && Boolean(error)} helperText={(touched && error) as false | string} diff --git a/src/components/form/CountryField.tsx b/src/components/form/CountryField.tsx new file mode 100644 index 00000000..2980ba3f --- /dev/null +++ b/src/components/form/CountryField.tsx @@ -0,0 +1,68 @@ +import { type ChipTypeMap } from "@mui/material" +import { type ElementType } from "react" +import { COUNTRY_ISO_CODES } from "../../utils/general" +import AutocompleteField, { + type AutocompleteFieldProps, +} from "./AutocompleteField" + +export interface CountryFieldProps< + Multiple extends boolean | undefined = false, + DisableClearable extends boolean | undefined = false, + FreeSolo extends boolean | undefined = false, + ChipComponent extends ElementType = ChipTypeMap["defaultComponent"], +> extends Omit< + AutocompleteFieldProps< + string, + Multiple, + DisableClearable, + FreeSolo, + ChipComponent + >, + "options" | "textFieldProps" | "getOptionLabel" + > { + textFieldProps?: Omit< + AutocompleteFieldProps< + string, + Multiple, + DisableClearable, + FreeSolo, + ChipComponent + >["textFieldProps"], + "name" + > & { + name?: string + } +} + +const CountryField = < + Multiple extends boolean | undefined = false, + DisableClearable extends boolean | undefined = false, + FreeSolo extends boolean | undefined = false, + ChipComponent extends ElementType = ChipTypeMap["defaultComponent"], +>({ + textFieldProps, + ...otherAutocompleteFieldProps +}: CountryFieldProps< + Multiple, + DisableClearable, + FreeSolo, + ChipComponent +>): JSX.Element => { + const { + name = "country", + label = "Country", + placeholder = "Select your country", + ...otherTextFieldProps + } = textFieldProps || {} + + return ( + isoCode} // TODO: return country name + textFieldProps={{ name, label, placeholder, ...otherTextFieldProps }} + {...otherAutocompleteFieldProps} + /> + ) +} + +export default CountryField diff --git a/src/components/form/UkCountyField.tsx b/src/components/form/UkCountyField.tsx new file mode 100644 index 00000000..95403835 --- /dev/null +++ b/src/components/form/UkCountyField.tsx @@ -0,0 +1,67 @@ +import { type ChipTypeMap } from "@mui/material" +import { type ElementType } from "react" +import { UK_COUNTIES } from "../../utils/general" +import AutocompleteField, { + type AutocompleteFieldProps, +} from "./AutocompleteField" + +export interface UkCountyFieldProps< + Multiple extends boolean | undefined = false, + DisableClearable extends boolean | undefined = false, + FreeSolo extends boolean | undefined = false, + ChipComponent extends ElementType = ChipTypeMap["defaultComponent"], +> extends Omit< + AutocompleteFieldProps< + string, + Multiple, + DisableClearable, + FreeSolo, + ChipComponent + >, + "options" | "textFieldProps" + > { + textFieldProps?: Omit< + AutocompleteFieldProps< + string, + Multiple, + DisableClearable, + FreeSolo, + ChipComponent + >["textFieldProps"], + "name" + > & { + name?: string + } +} + +const UkCountyField = < + Multiple extends boolean | undefined = false, + DisableClearable extends boolean | undefined = false, + FreeSolo extends boolean | undefined = false, + ChipComponent extends ElementType = ChipTypeMap["defaultComponent"], +>({ + textFieldProps, + ...otherAutocompleteFieldProps +}: UkCountyFieldProps< + Multiple, + DisableClearable, + FreeSolo, + ChipComponent +>): JSX.Element => { + const { + name = "uk_county", + label = "UK county", + placeholder = "Select your UK county", + ...otherTextFieldProps + } = textFieldProps || {} + + return ( + + ) +} + +export default UkCountyField diff --git a/src/components/form/index.tsx b/src/components/form/index.tsx index 9edb57e0..a8bc35b5 100644 --- a/src/components/form/index.tsx +++ b/src/components/form/index.tsx @@ -1,7 +1,11 @@ +import ApiAutocompleteField, { + type ApiAutocompleteFieldProps, +} from "./ApiAutocompleteField" import AutocompleteField, { type AutocompleteFieldProps, } from "./AutocompleteField" import CheckboxField, { type CheckboxFieldProps } from "./CheckboxField" +import CountryField, { type CountryFieldProps } from "./CountryField" import DatePickerField, { type DatePickerFieldProps } from "./DatePickerField" import EmailField, { type EmailFieldProps } from "./EmailField" import FirstNameField, { type FirstNameFieldProps } from "./FirstNameField" @@ -11,10 +15,13 @@ import PasswordField, { type PasswordFieldProps } from "./PasswordField" import RepeatField, { type RepeatFieldProps } from "./RepeatField" import SubmitButton, { type SubmitButtonProps } from "./SubmitButton" import TextField, { type TextFieldProps } from "./TextField" +import UkCountyField, { type UkCountyFieldProps } from "./UkCountyField" export { + ApiAutocompleteField, AutocompleteField, CheckboxField, + CountryField, DatePickerField, EmailField, FirstNameField, @@ -24,8 +31,11 @@ export { RepeatField, SubmitButton, TextField, + UkCountyField, + type ApiAutocompleteFieldProps, type AutocompleteFieldProps, type CheckboxFieldProps, + type CountryFieldProps, type DatePickerFieldProps, type EmailFieldProps, type FirstNameFieldProps, @@ -36,4 +46,5 @@ export { type RepeatFieldProps, type SubmitButtonProps, type TextFieldProps, + type UkCountyFieldProps, } diff --git a/src/components/index.ts b/src/components/index.ts index 3645a2e2..104ba380 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -9,27 +9,30 @@ import Image, { type ImageProps } from "./Image" import ItemizedList, { type ItemizedListProps } from "./ItemizedList" import OrderedGrid, { type OrderedGridProps } from "./OrderedGrid" import ScrollRoutes, { type ScrollRoutesProps } from "./ScrollRoutes" +import TablePagination, { type TablePaginationProps } from "./TablePagination" import YouTubeVideo, { type YouTubeVideoProps } from "./YouTubeVideo" export { App, - type AppProps, ClickableTooltip, - type ClickableTooltipProps, CopyIconButton, - type CopyIconButtonProps, Countdown, - type CountdownProps, ElevatedAppBar, - type ElevatedAppBarProps, Image, - type ImageProps, ItemizedList, - type ItemizedListProps, OrderedGrid, - type OrderedGridProps, ScrollRoutes, - type ScrollRoutesProps, + TablePagination, YouTubeVideo, + type AppProps, + type ClickableTooltipProps, + type CopyIconButtonProps, + type CountdownProps, + type ElevatedAppBarProps, + type ImageProps, + type ItemizedListProps, + type OrderedGridProps, + type ScrollRoutesProps, + type TablePaginationProps, type YouTubeVideoProps, } diff --git a/src/hooks/api.ts b/src/hooks/api.ts new file mode 100644 index 00000000..3a53daf3 --- /dev/null +++ b/src/hooks/api.ts @@ -0,0 +1,37 @@ +import { type Dispatch, type SetStateAction, useState } from "react" + +export type Pagination = { page: number; limit: number; offset: number } +export type SetPagination = Dispatch< + SetStateAction<{ page: number; limit: number }> +> +export type UsePaginationOptions = Partial<{ + page: number + limit: number +}> + +export function usePagination( + options?: UsePaginationOptions, +): [Pagination, SetPagination] { + const { page = 0, limit = 150 } = options || {} + + const [pagination, _setPagination] = useState({ + page, + limit, + offset: page * limit, + }) + + const setPagination: SetPagination = value => { + _setPagination(({ page: previousPage, limit: previousLimit }) => { + let { page, limit } = + typeof value === "function" + ? value({ page: previousPage, limit: previousLimit }) + : value + + if (limit !== previousLimit) page = 0 + + return { page, limit, offset: page * limit } + }) + } + + return [pagination, setPagination] +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 11ca21a5..7c4d1859 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,9 @@ +import { + usePagination, + type Pagination, + type SetPagination, + type UsePaginationOptions, +} from "./api" import { useSession, useSessionMetadata, @@ -15,11 +21,15 @@ export { useExternalScript, useLocation, useNavigate, + usePagination, useParams, useSearchParams, useSession, useSessionMetadata, + type Pagination, type SessionMetadata, + type SetPagination, + type UsePaginationOptions, type UseSessionChildren, type UseSessionChildrenFunction, type UseSessionOptions, diff --git a/src/theme/components/MuiAutocomplete.tsx b/src/theme/components/MuiAutocomplete.tsx new file mode 100644 index 00000000..ddf26077 --- /dev/null +++ b/src/theme/components/MuiAutocomplete.tsx @@ -0,0 +1,11 @@ +import type Components from "./_components" + +const MuiAutocomplete: Components["MuiAutocomplete"] = { + styleOverrides: { + root: { + width: "100%", + }, + }, +} + +export default MuiAutocomplete diff --git a/src/theme/components/MuiTextField.ts b/src/theme/components/MuiTextField.ts index 17059bb2..2e35ee86 100644 --- a/src/theme/components/MuiTextField.ts +++ b/src/theme/components/MuiTextField.ts @@ -9,6 +9,7 @@ import { } from "@mui/material" import { includesClassNames } from "../../utils/theme" +import palette from "../palette" import typography from "../typography" import type Components from "./_components" @@ -22,9 +23,14 @@ const MuiTextField: Components["MuiTextField"] = { width: "100%", backgroundColor: "transparent", [`& > .${inputBaseClasses.root}`]: { + border: "1px solid black !important", borderRadius: "0px !important", backgroundColor: "white !important", }, + [`& > .${inputBaseClasses.root}.${inputBaseClasses.error}`]: { + // @ts-expect-error + border: `1px solid ${palette.error!.main} !important`, + }, [`& .${outlinedInputClasses.root}.${inputClasses.focused} > fieldset`]: { borderColor: "black !important", }, diff --git a/src/theme/components/index.ts b/src/theme/components/index.ts index 94eb79cf..724eb5e2 100644 --- a/src/theme/components/index.ts +++ b/src/theme/components/index.ts @@ -1,6 +1,7 @@ import { type ThemeOptions } from "@mui/material" import MuiAccordion from "./MuiAccordion" +import MuiAutocomplete from "./MuiAutocomplete" import MuiButton from "./MuiButton" import MuiCardActions from "./MuiCardActions" import MuiCheckbox from "./MuiCheckbox" @@ -27,6 +28,7 @@ import MuiTypography from "./MuiTypography" const components: ThemeOptions["components"] = { MuiAccordion, + MuiAutocomplete, MuiButton, MuiCardActions, MuiCheckbox, diff --git a/src/utils/api.test.ts b/src/utils/api.test.ts new file mode 100644 index 00000000..5cdce175 --- /dev/null +++ b/src/utils/api.test.ts @@ -0,0 +1,19 @@ +import { buildUrl } from "./api" + +test("url params", () => { + const url = buildUrl("/", { url: { id: 1 } }) + + expect(url).toBe("1/") +}) + +test("single search value", () => { + const url = buildUrl("/", { search: { age: 18 } }) + + expect(url).toBe("/?age=18") +}) + +test("multiple search values", () => { + const url = buildUrl("/", { search: { age: [18, 21] } }) + + expect(url).toBe("/?age=18&age=21") +}) diff --git a/src/utils/api.ts b/src/utils/api.ts index 17a9fcd2..38c58048 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -83,7 +83,7 @@ export interface ListResult< count: number offset: number limit: number - maxLimit: number + max_limit: number data: Array & ExtraFields> } @@ -103,8 +103,7 @@ type UpdateWithBody< M extends Model, RequiredFields extends keyof Omit, OptionalFields extends keyof Omit, - ExtraFields extends Fields, -> = [M["id"], Arg & ExtraFields] +> = Pick & Arg // NOTE: Sometimes update does not require a body. For example, if calling the // "refresh" action on an invitation object updates the expiry date to be 24 @@ -113,14 +112,11 @@ export type UpdateArg< M extends Model, RequiredFields extends keyof Omit = never, OptionalFields extends keyof Omit = never, - ExtraFields extends Fields = never, > = [RequiredFields] extends [never] ? [OptionalFields] extends [never] - ? [ExtraFields] extends [never] - ? M["id"] - : UpdateWithBody - : UpdateWithBody - : UpdateWithBody + ? M["id"] + : UpdateWithBody + : UpdateWithBody export type BulkUpdateResult< M extends Model, @@ -163,9 +159,17 @@ export function buildUrl( } if (params.search) { - const searchParams = Object.entries(params.search) - .filter(([_, value]) => value !== undefined) - .map(([key, value]) => [key, String(value)]) + const searchParams: string[][] = [] + for (const key in params.search) { + const values = params.search[key] + if (values === undefined) continue + + if (Array.isArray(values)) { + for (const value of values) searchParams.push([key, String(value)]) + } else { + searchParams.push([key, String(values)]) + } + } if (searchParams.length !== 0) { url += `?${new URLSearchParams(searchParams).toString()}` diff --git a/src/utils/form.ts b/src/utils/form.ts index 8c4fbcdf..5aa16b91 100644 --- a/src/utils/form.ts +++ b/src/utils/form.ts @@ -37,8 +37,8 @@ export function submitForm( trigger: | TypedMutationTrigger | TypedLazyQueryTrigger, - query: { - then: (result: ResultType) => void + query?: { + then?: (result: ResultType) => void catch?: (error: Error) => void finally?: () => void }, @@ -49,13 +49,13 @@ export function submitForm( return (values, helpers) => { trigger(values) .unwrap() - .then(query.then) + .then(query?.then) .catch(error => { - if (query.catch !== undefined) query.catch(error) + if (query?.catch !== undefined) query.catch(error) setFormErrors(error, helpers.setErrors) }) .finally(() => { - if (query.finally !== undefined) query.finally() + if (query?.finally !== undefined) query.finally() }) } } diff --git a/src/utils/router.ts b/src/utils/router.ts index e0ab7b30..cadc049c 100644 --- a/src/utils/router.ts +++ b/src/utils/router.ts @@ -53,3 +53,7 @@ export function path>( } return path } + +export function getParam(path: Path, key: string) { + return (path.__ as Parameters)[key] +}