mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
feat(ui): add team member budget windows (#49091)
This commit is contained in:
parent
efc2304b7e
commit
26ca426393
17 changed files with 523 additions and 12 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, testQueryClient } from "../../tests/test-utils";
|
||||
|
|
@ -47,6 +47,35 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("Teams creation integration", () => {
|
||||
it("should preserve default member budget windows when additional settings are collapsed", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderWithProviders(<Teams accessToken="sk-session-test" userID="test-admin" userRole="Admin" premiumUser />);
|
||||
await user.click(screen.getAllByRole("button", { name: /create team/i })[0]);
|
||||
await screen.findByLabelText(/team name/i);
|
||||
await user.click(screen.getByText("Additional Settings"));
|
||||
await user.click(screen.getByRole("button", { name: /Add Budget Window/i }));
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "Window 1 budget (USD)" }), { target: { value: "20" } });
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: /Add Budget Window/i }));
|
||||
await user.click(screen.getByRole("combobox", { name: "Window 2 duration" }));
|
||||
await user.click(await screen.findByRole("option", { name: "Monthly" }));
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "Window 2 budget (USD)" }), { target: { value: "200" } });
|
||||
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Budget Team" } });
|
||||
});
|
||||
await user.click(screen.getByText("Additional Settings"));
|
||||
await user.click(screen.getAllByRole("button", { name: /create team/i }).at(-1)!);
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(fetch).mock.calls.some(([input]) => String(input).includes("/team/new"))).toBe(true),
|
||||
);
|
||||
const request = vi.mocked(fetch).mock.calls.find(([input]) => String(input).includes("/team/new"));
|
||||
expect(JSON.parse(String(request?.[1]?.body)).team_member_budget_limits).toEqual([
|
||||
{ budget_duration: "24h", max_budget: 20 },
|
||||
{ budget_duration: "30d", max_budget: 200 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends a configured default member per-model budget", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderWithProviders(<Teams accessToken="sk-session-test" userID="test-admin" userRole="Admin" premiumUser />);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { MemberBudgetWindows } from "./team/MemberBudgetWindows";
|
||||
import { memberBudgetWindowsSchema } from "./team/memberBudgetWindowSchema";
|
||||
import { Page, PageTabs, PageTabsList, PageTabsTrigger } from "@/components/shared/Page";
|
||||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
|
|
@ -86,6 +88,7 @@ const teamCreateFieldsSchema = z.object({
|
|||
metadata: metadataPairsSchema.optional(),
|
||||
team_id: z.string().optional(),
|
||||
team_member_budget: z.number().optional(),
|
||||
team_member_budget_limits: memberBudgetWindowsSchema.optional(),
|
||||
team_member_key_duration: z.string().optional(),
|
||||
team_member_rpm_limit: numericInputSchema,
|
||||
team_member_tpm_limit: numericInputSchema,
|
||||
|
|
@ -124,6 +127,7 @@ const EMPTY_TEAM_CREATE_VALUES: TeamCreateFormValues = {
|
|||
metadata: [],
|
||||
team_id: undefined,
|
||||
team_member_budget: undefined,
|
||||
team_member_budget_limits: [],
|
||||
team_member_key_duration: undefined,
|
||||
team_member_rpm_limit: undefined,
|
||||
team_member_tpm_limit: undefined,
|
||||
|
|
@ -553,10 +557,15 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
|
|||
}
|
||||
}
|
||||
|
||||
const { require_trace_id: requireTraceId, ...teamCreateValues } = formValues;
|
||||
const {
|
||||
require_trace_id: requireTraceId,
|
||||
team_member_budget_limits: memberBudgetWindows,
|
||||
...teamCreateValues
|
||||
} = formValues;
|
||||
const teamMemberModelBudgets = modelMaxBudgetUpdate(teamMemberModelMaxBudget, {});
|
||||
await teamCreateCall(accessToken, {
|
||||
...teamCreateValues,
|
||||
...(memberBudgetWindows?.length ? { team_member_budget_limits: memberBudgetWindows } : {}),
|
||||
...(requireTraceId === undefined ? {} : { require_trace_id: requireTraceId }),
|
||||
...(teamMemberModelBudgets !== undefined ? { team_member_model_max_budget: teamMemberModelBudgets } : {}),
|
||||
models: normalizeTeamModelSelection(formValues.models),
|
||||
|
|
@ -895,6 +904,13 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
|
|||
>
|
||||
{({ ref, value, ...field }) => <UIInput {...field} ref={ref} value={value ?? ""} />}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="team_member_budget_limits"
|
||||
label="Default member budget windows"
|
||||
>
|
||||
{({ value, onChange }) => <MemberBudgetWindows value={value ?? []} onChange={onChange} />}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="team_member_budget"
|
||||
|
|
|
|||
|
|
@ -85,10 +85,18 @@ describe("UserSearchModal submit payload", () => {
|
|||
vi.mocked(userFilterUICall).mockResolvedValue([{ user_id: "u-1", user_email: "picked@example.com" }] as never);
|
||||
});
|
||||
|
||||
const setup = () => {
|
||||
const setup = (defaultBudgetWindows?: Array<{ budget_duration: string; max_budget: number }>) => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(<UserSearchModal isVisible onCancel={vi.fn()} onSubmit={onSubmit} accessToken="sk-test" />);
|
||||
render(
|
||||
<UserSearchModal
|
||||
isVisible
|
||||
onCancel={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
accessToken="sk-test"
|
||||
defaultBudgetWindows={defaultBudgetWindows}
|
||||
/>,
|
||||
);
|
||||
return { user, onSubmit };
|
||||
};
|
||||
|
||||
|
|
@ -130,6 +138,25 @@ describe("UserSearchModal submit payload", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should submit custom windows and reset to team defaults for the next member", async () => {
|
||||
const windows = [
|
||||
{ budget_duration: "24h", max_budget: 20 },
|
||||
{ budget_duration: "30d", max_budget: 200 },
|
||||
];
|
||||
const { user, onSubmit } = setup(windows);
|
||||
await user.click(screen.getByRole("radio", { name: "Custom budget windows" }));
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "Window 1 budget (USD)" }), { target: { value: "5" } });
|
||||
});
|
||||
await searchByEmail(user, "pick");
|
||||
await user.click(save());
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
expect(onSubmit.mock.calls[0][0].budget_limits).toEqual([{ ...windows[0], max_budget: 5 }, windows[1]]);
|
||||
expect(screen.getByRole("radio", { name: "Use team defaults" })).toBeChecked();
|
||||
expect(screen.getByText("$20 daily, $200 monthly")).toBeInTheDocument();
|
||||
expect(save()).toBeDisabled();
|
||||
});
|
||||
|
||||
it("carries a role changed off its default into the payload", async () => {
|
||||
const { onSubmit } = setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import type { BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
|
||||
import { MemberBudgetWindows } from "../team/MemberBudgetWindows";
|
||||
import { memberBudgetWindowsSchema } from "../team/memberBudgetWindowSchema";
|
||||
|
||||
interface User {
|
||||
user_id: string;
|
||||
|
|
@ -31,12 +34,14 @@ interface Role {
|
|||
}
|
||||
|
||||
interface FormValues {
|
||||
budget_limits?: BudgetWindowEntry[];
|
||||
user_email: string | null | undefined;
|
||||
user_id: string | null | undefined;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface UserSearchModalProps {
|
||||
defaultBudgetWindows?: BudgetWindowEntry[];
|
||||
isVisible: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: FormValues) => void | Promise<void>;
|
||||
|
|
@ -63,8 +68,14 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
|
|||
],
|
||||
defaultRole = "user",
|
||||
teamId,
|
||||
defaultBudgetWindows,
|
||||
}) => {
|
||||
const emptyValues: FormValues = { user_email: undefined, user_id: undefined, role: defaultRole };
|
||||
const emptyValues: FormValues = {
|
||||
user_email: undefined,
|
||||
user_id: undefined,
|
||||
role: defaultRole,
|
||||
...(defaultBudgetWindows !== undefined ? { budget_limits: [] } : {}),
|
||||
};
|
||||
const form = useForm<FormValues>({ defaultValues: emptyValues });
|
||||
const selectedUserId = form.watch("user_id");
|
||||
const selectedUserEmail = form.watch("user_email");
|
||||
|
|
@ -124,9 +135,17 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
|
|||
};
|
||||
|
||||
const handleSubmit = async (values: FormValues): Promise<void> => {
|
||||
if (values.budget_limits !== undefined) {
|
||||
const validation = memberBudgetWindowsSchema.safeParse(values.budget_limits);
|
||||
if (!validation.success) {
|
||||
form.setError("budget_limits", { type: "validate", message: validation.error.issues[0]?.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(values);
|
||||
form.reset(emptyValues);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
|
@ -235,6 +254,17 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
|
|||
</Select>
|
||||
)}
|
||||
</FormField>
|
||||
{defaultBudgetWindows !== undefined && (
|
||||
<FormField control={form.control} name="budget_limits" label="Budget windows">
|
||||
{({ value, onChange }) => (
|
||||
<MemberBudgetWindows
|
||||
value={value ?? []}
|
||||
onChange={onChange}
|
||||
defaultWindows={defaultBudgetWindows}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-4 text-right">
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp
|
|||
value={window.budget_duration}
|
||||
onValueChange={(v: string | null) => v && updateWindow(idx, "budget_duration", v)}
|
||||
>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectTrigger className="w-[130px]" aria-label={`Window ${idx + 1} duration`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -62,6 +62,7 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp
|
|||
<InputGroupText>$</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
aria-label={`Window ${idx + 1} budget (USD)`}
|
||||
type="number"
|
||||
step={0.01}
|
||||
min={0}
|
||||
|
|
@ -80,6 +81,8 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp
|
|||
/>
|
||||
</InputGroup>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label={`Remove budget window ${idx + 1}`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="px-1 text-destructive hover:text-destructive/80"
|
||||
|
|
@ -93,6 +96,7 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp
|
|||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,49 @@ import { clearTokenCookies } from "@/utils/cookieUtils";
|
|||
import * as Networking from "./networking";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
|
||||
describe("member budget window requests", () => {
|
||||
const windows = [
|
||||
{ budget_duration: "24h", max_budget: 20 },
|
||||
{ budget_duration: "30d", max_budget: 200 },
|
||||
];
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("should send member windows beside the member when adding a member", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(Response.json({}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await Networking.teamMemberAddCall("test-session", "team", {
|
||||
user_id: "member",
|
||||
role: "user",
|
||||
budget_limits: windows,
|
||||
});
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
|
||||
team_id: "team",
|
||||
member: { user_id: "member", role: "user" },
|
||||
budget_limits: windows,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([windows, []])("should send configured or cleared member windows on update", async (budget_limits) => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(Response.json({}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await Networking.teamMemberUpdateCall("test-session", "team", { user_id: "member", role: "user", budget_limits });
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
|
||||
team_id: "team",
|
||||
user_id: "member",
|
||||
role: "user",
|
||||
budget_limits,
|
||||
});
|
||||
});
|
||||
|
||||
it("should omit windows when an older caller does not supply them", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(Response.json({}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await Networking.teamMemberUpdateCall("test-session", "team", { user_id: "member", role: "user" });
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ team_id: "team", user_id: "member", role: "user" });
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/utils/cookieUtils", () => ({
|
||||
clearTokenCookies: vi.fn(),
|
||||
getCookie: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { BudgetWindowEntry } from "./key_team_helpers/BudgetWindowsEditor";
|
||||
// Shared date formatter for daily activity endpoints
|
||||
export const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
|
|
@ -2920,6 +2921,7 @@ export const modelPatchUpdateCall = async (
|
|||
};
|
||||
|
||||
export interface Member {
|
||||
budget_limits?: BudgetWindowEntry[];
|
||||
role: string;
|
||||
user_id: string | null;
|
||||
user_email?: string | null;
|
||||
|
|
@ -2936,6 +2938,7 @@ export interface Member {
|
|||
|
||||
export const teamMemberAddCall = async (accessToken: string, teamId: string, formValues: Member) => {
|
||||
try {
|
||||
const { budget_limits, ...member } = formValues;
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/team/member_add` : `/team/member_add`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
|
|
@ -2946,7 +2949,8 @@ export const teamMemberAddCall = async (accessToken: string, teamId: string, for
|
|||
},
|
||||
body: JSON.stringify({
|
||||
team_id: teamId,
|
||||
member: formValues,
|
||||
member,
|
||||
...(budget_limits !== undefined ? { budget_limits } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -3046,6 +3050,9 @@ export const teamMemberUpdateCall = async (
|
|||
role: formValues.role,
|
||||
user_id: formValues.user_id,
|
||||
};
|
||||
if (formValues.budget_limits !== undefined) {
|
||||
requestBody.budget_limits = formValues.budget_limits;
|
||||
}
|
||||
|
||||
const orNull = (value: unknown) => (value === undefined || value === null || value === "" ? null : value);
|
||||
if (formValues.user_email !== undefined) {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import {
|
|||
type MemberFormValues,
|
||||
} from "./memberFormValues";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { MemberBudgetWindows } from "./MemberBudgetWindows";
|
||||
import { isMemberBudgetWindows, memberBudgetWindowsSchema } from "./memberBudgetWindowSchema";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
|
|
@ -65,6 +67,7 @@ const buildMemberSchema = (config: ModalConfig): z.ZodType<MemberFormValues, Mem
|
|||
user_id: z.string().nullish(),
|
||||
role: z.string({ error: ROLE_REQUIRED_MESSAGE }).min(1, ROLE_REQUIRED_MESSAGE),
|
||||
...Object.fromEntries((config.additionalFields ?? []).map((field) => [field.name, memberFieldSchema])),
|
||||
budget_limits: config.showBudgetWindows ? memberBudgetWindowsSchema.optional() : z.undefined().optional(),
|
||||
};
|
||||
|
||||
return z.object(shape).superRefine((values, ctx) => {
|
||||
|
|
@ -164,7 +167,7 @@ const MemberModal = <T extends BaseMember>({
|
|||
return (
|
||||
<MultiSelect
|
||||
options={field.options ?? []}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
value={Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []}
|
||||
onValueChange={onChange}
|
||||
placeholder={field.placeholder || "Select options"}
|
||||
/>
|
||||
|
|
@ -277,6 +280,17 @@ const MemberModal = <T extends BaseMember>({
|
|||
</FormField>
|
||||
|
||||
{config.additionalFields?.map((field) => renderField(field, field.name))}
|
||||
{config.showBudgetWindows && (
|
||||
<FormField control={form.control} name="budget_limits" label="Budget windows">
|
||||
{({ value, onChange }) => (
|
||||
<MemberBudgetWindows
|
||||
value={isMemberBudgetWindows(value) ? value : []}
|
||||
onChange={onChange}
|
||||
defaultWindows={config.defaultBudgetWindows ?? []}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-6 text-right">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import MemberModal from "./EditMembership";
|
||||
|
||||
const windows = [
|
||||
{ budget_duration: "24h", max_budget: 20 },
|
||||
{ budget_duration: "30d", max_budget: 200 },
|
||||
];
|
||||
|
||||
function renderMember(budget_limits = windows, defaultBudgetWindows = windows) {
|
||||
const onSubmit = vi.fn();
|
||||
renderWithProviders(
|
||||
<MemberModal
|
||||
visible
|
||||
mode="edit"
|
||||
initialData={{ user_id: "member", role: "user", budget_limits, max_budget_in_team: 50 }}
|
||||
onCancel={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
config={{
|
||||
title: "Edit Member",
|
||||
roleOptions: [{ label: "User", value: "user" }],
|
||||
showUserId: true,
|
||||
showBudgetWindows: true,
|
||||
defaultBudgetWindows,
|
||||
additionalFields: [{ name: "max_budget_in_team", label: "Single budget", type: "numerical" }],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
return onSubmit;
|
||||
}
|
||||
|
||||
describe("member budget windows form", () => {
|
||||
it("should render both configured windows", () => {
|
||||
renderMember();
|
||||
expect(screen.getByRole("spinbutton", { name: "Window 1 budget (USD)" })).toHaveValue(20);
|
||||
expect(screen.getByRole("spinbutton", { name: "Window 2 budget (USD)" })).toHaveValue(200);
|
||||
expect(screen.getByRole("radio", { name: "Custom budget windows" })).toBeChecked();
|
||||
});
|
||||
|
||||
it("should inherit team defaults, then save a custom zero-dollar cap without changing the single budget", async () => {
|
||||
const onSubmit = renderMember([]);
|
||||
expect(screen.getByText("$20 daily, $200 monthly")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("spinbutton", { name: "Window 1 budget (USD)" })).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Custom budget windows" }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "Window 1 budget (USD)" }), { target: { value: "0" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
});
|
||||
const expected = {
|
||||
user_id: "member",
|
||||
role: "user",
|
||||
max_budget_in_team: 50,
|
||||
budget_limits: [{ ...windows[0], max_budget: 0 }, windows[1]],
|
||||
};
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expected));
|
||||
});
|
||||
|
||||
it("should send an empty override to restore inheritance", async () => {
|
||||
const onSubmit = renderMember();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Use team defaults" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
});
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ budget_limits: [] })));
|
||||
});
|
||||
|
||||
it("should explain an incomplete custom window and prevent saving it", async () => {
|
||||
const onSubmit = renderMember([], []);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Custom budget windows" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
});
|
||||
expect(await screen.findByText("Enter a budget")).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should explain duplicate durations and prevent saving them", async () => {
|
||||
const onSubmit = renderMember([windows[0], windows[0]]);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
});
|
||||
expect(await screen.findByText("Use each budget duration only once")).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { useId } from "react";
|
||||
import { BudgetWindowsEditor, type BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
|
||||
import { getBudgetDurationLabel } from "../common_components/budget_duration_dropdown";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
||||
interface MemberBudgetWindowsProps {
|
||||
value: BudgetWindowEntry[];
|
||||
onChange: (windows: BudgetWindowEntry[]) => void;
|
||||
defaultWindows?: BudgetWindowEntry[];
|
||||
}
|
||||
|
||||
export function MemberBudgetWindows({ value, onChange, defaultWindows }: MemberBudgetWindowsProps) {
|
||||
const id = useId();
|
||||
const inherits = defaultWindows !== undefined && value.length === 0;
|
||||
const selectMode = (selection: unknown) => {
|
||||
if (selection === "default") {
|
||||
onChange([]);
|
||||
return;
|
||||
}
|
||||
if (defaultWindows?.length) {
|
||||
onChange(defaultWindows.map((window) => ({ ...window })));
|
||||
return;
|
||||
}
|
||||
onChange([{ budget_duration: "24h", max_budget: null }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{defaultWindows !== undefined && (
|
||||
<RadioGroup
|
||||
aria-label="Member budget windows"
|
||||
value={inherits ? "default" : "custom"}
|
||||
onValueChange={selectMode}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id={`${id}-default`} value="default" />
|
||||
<Label htmlFor={`${id}-default`}>Use team defaults</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id={`${id}-custom`} value="custom" />
|
||||
<Label htmlFor={`${id}-custom`}>Custom budget windows</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)}
|
||||
{inherits ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{defaultWindows.length
|
||||
? defaultWindows
|
||||
.map((window) => `$${window.max_budget} ${getBudgetDurationLabel(window.budget_duration)}`)
|
||||
.join(", ")
|
||||
: "No default budget windows set"}
|
||||
</p>
|
||||
) : (
|
||||
<BudgetWindowsEditor value={value} onChange={onChange} />
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{defaultWindows === undefined
|
||||
? "Each window limits one member's spend in this team. All windows apply together."
|
||||
: "Custom windows replace the team defaults for this member. Existing single-budget limits also apply."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -415,6 +415,28 @@ describe("TeamInfoView - member budget reset prompt", () => {
|
|||
return await screen.findByLabelText("Default Budget (USD)");
|
||||
};
|
||||
|
||||
it("should submit cleared default member windows after their section is collapsed", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const data = createMockTeamData({
|
||||
team_member_budget_limits: [
|
||||
{ budget_duration: "24h", max_budget: 20 },
|
||||
{ budget_duration: "30d", max_budget: 200 },
|
||||
],
|
||||
});
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(data);
|
||||
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: savedTeam, team_id: "123" });
|
||||
renderWithProviders(<TeamInfoView {...props} />);
|
||||
await user.click(await screen.findByRole("tab", { name: "Settings" }));
|
||||
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
|
||||
await user.click(screen.getByText("Team Member Settings"));
|
||||
await user.click(screen.getByRole("button", { name: "Remove budget window 2" }));
|
||||
await user.click(screen.getByRole("button", { name: "Remove budget window 1" }));
|
||||
await user.click(screen.getByText("Team Member Settings"));
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
|
||||
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1].team_member_budget_limits).toEqual([]);
|
||||
});
|
||||
|
||||
const submitNewDefault = async (user: ReturnType<typeof userEvent.setup>, input: HTMLElement, value: string) => {
|
||||
fireEvent.change(input, { target: { value } });
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import type { BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
|
||||
import { MemberBudgetWindows } from "./MemberBudgetWindows";
|
||||
import { memberBudgetWindowsSchema, type MemberBudgetWindow } from "./memberBudgetWindowSchema";
|
||||
import { getBudgetDurationLabel } from "../common_components/budget_duration_dropdown";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
|
|
@ -277,6 +281,7 @@ export const mcpUnresolvableSaveError = (reason: string): string =>
|
|||
export type TeamMemberBudgetSource = components["schemas"]["TeamMemberResetBudgetResponse"]["budget_source"];
|
||||
|
||||
export interface TeamMembership {
|
||||
budget_limits?: BudgetWindowEntry[] | null;
|
||||
user_id: string;
|
||||
team_id: string;
|
||||
budget_id: string | null;
|
||||
|
|
@ -302,6 +307,7 @@ export interface TeamMembership {
|
|||
export interface TeamData {
|
||||
team_id: string;
|
||||
team_info: {
|
||||
team_member_budget_limits?: MemberBudgetWindow[] | null;
|
||||
team_alias: string;
|
||||
team_id: string;
|
||||
organization_id: string | null;
|
||||
|
|
@ -376,6 +382,7 @@ const teamUpdateFieldsSchema = z.object({
|
|||
soft_budget_alerting_emails: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
default_team_member_models: z.array(z.string()).optional(),
|
||||
team_member_budget: numericInputSchema,
|
||||
team_member_budget_limits: memberBudgetWindowsSchema.optional(),
|
||||
team_member_budget_duration: z.string().nullish(),
|
||||
team_member_key_duration: z.string().optional(),
|
||||
team_member_tpm_limit: numericInputSchema,
|
||||
|
|
@ -472,6 +479,7 @@ const EMPTY_TEAM_UPDATE_VALUES: TeamUpdateFormValues = {
|
|||
soft_budget_alerting_emails: "",
|
||||
default_team_member_models: [],
|
||||
team_member_budget: undefined,
|
||||
team_member_budget_limits: [],
|
||||
team_member_budget_duration: undefined,
|
||||
team_member_key_duration: undefined,
|
||||
team_member_tpm_limit: undefined,
|
||||
|
|
@ -524,6 +532,7 @@ const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]):
|
|||
: "",
|
||||
default_team_member_models: info.default_team_member_models || [],
|
||||
team_member_budget: info.team_member_budget_table?.max_budget,
|
||||
team_member_budget_limits: info.team_member_budget_limits ?? [],
|
||||
team_member_budget_duration: info.team_member_budget_table?.budget_duration,
|
||||
team_member_key_duration: info.metadata?.team_member_key_duration,
|
||||
team_member_tpm_limit: info.team_member_budget_table?.tpm_limit,
|
||||
|
|
@ -828,6 +837,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
user_email: values.user_email,
|
||||
user_id: values.user_id,
|
||||
role: values.role,
|
||||
budget_limits: values.budget_limits ?? [],
|
||||
};
|
||||
|
||||
await teamMemberAddCall(accessToken, teamId, member);
|
||||
|
|
@ -867,6 +877,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
user_email: values.user_email,
|
||||
user_id: values.user_id,
|
||||
role: values.role,
|
||||
budget_limits: values.budget_limits ?? [],
|
||||
max_budget_in_team: values.max_budget_in_team,
|
||||
tpm_limit: values.tpm_limit,
|
||||
rpm_limit: values.rpm_limit,
|
||||
|
|
@ -1101,6 +1112,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
};
|
||||
|
||||
updateData.team_member_budget_duration = values.team_member_budget_duration;
|
||||
if (
|
||||
values.team_member_budget_limits !== undefined &&
|
||||
(form.getFieldState("team_member_budget_limits").isDirty || info.team_member_budget_limits != null)
|
||||
) {
|
||||
updateData.team_member_budget_limits = values.team_member_budget_limits;
|
||||
}
|
||||
|
||||
const newTeamMemberBudget =
|
||||
values.team_member_budget !== undefined ? numberOrNull(values.team_member_budget) : undefined;
|
||||
|
|
@ -1689,6 +1706,13 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="team_member_budget_limits"
|
||||
label="Default member budget windows"
|
||||
>
|
||||
{({ value, onChange }) => <MemberBudgetWindows value={value ?? []} onChange={onChange} />}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="team_member_budget"
|
||||
|
|
@ -2413,6 +2437,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</SimpleTooltip>
|
||||
</p>
|
||||
<div>
|
||||
Default member budget windows:{" "}
|
||||
{(info.team_member_budget_limits ?? [])
|
||||
.map((window) => `$${window.max_budget} ${getBudgetDurationLabel(window.budget_duration)}`)
|
||||
.join(", ") || "Not set"}
|
||||
</div>
|
||||
<div>Max Budget: {info.team_member_budget_table?.max_budget ?? "No Limit"}</div>
|
||||
<div>Budget Duration: {info.team_member_budget_table?.budget_duration || "No Limit"}</div>
|
||||
{modelMaxBudgetToEntries(
|
||||
|
|
@ -2571,6 +2601,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
mode="edit"
|
||||
config={{
|
||||
title: "Edit Member",
|
||||
showBudgetWindows: true,
|
||||
defaultBudgetWindows: info.team_member_budget_limits ?? [],
|
||||
showEmail: true,
|
||||
showUserId: true,
|
||||
roleOptions: [
|
||||
|
|
@ -2700,6 +2732,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
onSubmit={handleMemberCreate}
|
||||
accessToken={accessToken}
|
||||
teamId={teamId}
|
||||
defaultBudgetWindows={info.team_member_budget_limits ?? []}
|
||||
/>
|
||||
|
||||
{/* Delete Member Confirmation Modal */}
|
||||
|
|
|
|||
|
|
@ -158,6 +158,37 @@ describe("TeamMembersComponent", () => {
|
|||
expect(screen.getByRole("columnheader", { name: /actions/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show member overrides separately from inherited team windows", () => {
|
||||
const original = createMockTeamData();
|
||||
const teamData: TeamData = {
|
||||
...original,
|
||||
team_info: {
|
||||
...original.team_info,
|
||||
team_member_budget_limits: [{ budget_duration: "30d", max_budget: 200 }],
|
||||
},
|
||||
team_memberships: original.team_memberships.map((membership) => ({
|
||||
...membership,
|
||||
budget_limits: [{ budget_duration: "24h", max_budget: 5 }],
|
||||
})),
|
||||
};
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={teamData}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
onMemberSpendReset={mockOnMemberSpendReset}
|
||||
onMemberBudgetReset={mockOnMemberBudgetReset}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("$5.00 daily")).toBeInTheDocument();
|
||||
expect(screen.getByText("Member override")).toBeInTheDocument();
|
||||
expect(screen.getByText("$200.00 monthly")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team default")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render team members data", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
|
|||
import { CircleHelp } from "lucide-react";
|
||||
import { useMemo, useState, type ComponentProps } from "react";
|
||||
import { TeamData, TeamMemberBudgetSource, TeamMembership } from "./TeamInfo";
|
||||
import { effectiveMemberBudgetWindows } from "./memberBudgetWindowSchema";
|
||||
import { getBudgetDurationLabel } from "../common_components/budget_duration_dropdown";
|
||||
|
||||
const BUDGET_SOURCE_LABELS: Record<Exclude<TeamMemberBudgetSource, "none">, string> = {
|
||||
team_default: "Team default",
|
||||
|
|
@ -168,6 +170,25 @@ export default function TeamMemberTab({
|
|||
};
|
||||
|
||||
const extraColumns: NonNullable<ComponentProps<typeof MemberTable>["extraColumns"]> = [
|
||||
{
|
||||
title: "Budget windows",
|
||||
key: "budget_windows",
|
||||
render: (record: Member) => {
|
||||
const override = findMembership(record.user_id)?.budget_limits;
|
||||
const windows = effectiveMemberBudgetWindows(teamData.team_info.team_member_budget_limits, override);
|
||||
if (!windows.length) return <span className="text-muted-foreground">Not set</span>;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{windows.map((window) => (
|
||||
<div key={window.budget_duration}>
|
||||
${formatNumberWithCommas(window.max_budget ?? 0, 2)} {getBudgetDurationLabel(window.budget_duration)}
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs text-muted-foreground">{override?.length ? "Member override" : "Team default"}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<span className="flex items-center gap-1">
|
||||
|
|
@ -334,7 +355,7 @@ export default function TeamMemberTab({
|
|||
const membership = findMembership(record.user_id);
|
||||
setSelectedEditMember(
|
||||
seedMemberBudgetFields(
|
||||
record,
|
||||
{ ...record, budget_limits: membership?.budget_limits ?? [] },
|
||||
membership?.litellm_budget_table,
|
||||
teamData.team_info.team_member_budget_table?.model_max_budget,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { effectiveMemberBudgetWindows, memberBudgetWindowsSchema } from "./memberBudgetWindowSchema";
|
||||
|
||||
const windows = [
|
||||
{ budget_duration: "24h", max_budget: 20 },
|
||||
{ budget_duration: "30d", max_budget: 200 },
|
||||
];
|
||||
|
||||
describe("member budget windows", () => {
|
||||
it("should accept simultaneous daily and monthly limits and preserve zero-dollar caps", () => {
|
||||
expect(memberBudgetWindowsSchema.parse(windows)).toEqual(windows);
|
||||
expect(memberBudgetWindowsSchema.parse([{ ...windows[0], max_budget: 0 }])).toEqual([
|
||||
{ ...windows[0], max_budget: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([null, -1, Infinity, NaN])("should reject an invalid budget of %s", (max_budget) => {
|
||||
expect(memberBudgetWindowsSchema.safeParse([{ ...windows[0], max_budget }]).success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject duplicate and unsupported durations", () => {
|
||||
expect(memberBudgetWindowsSchema.safeParse([windows[0], windows[0]]).success).toBe(false);
|
||||
expect(memberBudgetWindowsSchema.safeParse([{ budget_duration: "tomorrow", max_budget: 20 }]).success).toBe(false);
|
||||
});
|
||||
|
||||
it("should inherit defaults when no override is configured or an override is cleared", () => {
|
||||
expect(effectiveMemberBudgetWindows(windows, undefined)).toEqual(windows);
|
||||
expect(effectiveMemberBudgetWindows(windows, [])).toEqual(windows);
|
||||
expect(effectiveMemberBudgetWindows(undefined, undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should replace all default windows with the member override", () => {
|
||||
const override = [{ budget_duration: "24h", max_budget: 5 }];
|
||||
expect(effectiveMemberBudgetWindows(windows, override)).toEqual(override);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { z } from "zod";
|
||||
import type { BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
|
||||
|
||||
const budgetWindowEntriesSchema = z.array(
|
||||
z.object({
|
||||
budget_duration: z.enum(["1h", "24h", "7d", "30d"]),
|
||||
max_budget: z.number().nullable(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const memberBudgetWindowsSchema = budgetWindowEntriesSchema.superRefine((windows, context) => {
|
||||
windows.forEach((window, index) => {
|
||||
if (window.max_budget === null) {
|
||||
context.addIssue({ code: "custom", message: "Enter a budget" });
|
||||
} else if (window.max_budget < 0) {
|
||||
context.addIssue({ code: "custom", message: "Enter a non-negative budget" });
|
||||
}
|
||||
if (windows.slice(0, index).some((other) => other.budget_duration === window.budget_duration)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Use each budget duration only once",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const isMemberBudgetWindows = (value: unknown): value is BudgetWindowEntry[] =>
|
||||
budgetWindowEntriesSchema.safeParse(value).success;
|
||||
|
||||
export type MemberBudgetWindow = z.infer<typeof memberBudgetWindowsSchema>[number];
|
||||
|
||||
export const effectiveMemberBudgetWindows = (
|
||||
defaults: BudgetWindowEntry[] | null | undefined,
|
||||
override: BudgetWindowEntry[] | null | undefined,
|
||||
): BudgetWindowEntry[] => (override?.length ? override : defaults ?? []);
|
||||
|
|
@ -3,8 +3,10 @@ import {
|
|||
modelMaxBudgetToEntries,
|
||||
type ModelMaxBudget,
|
||||
} from "../key_team_helpers/ModelMaxBudgetEditor";
|
||||
import type { BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
|
||||
import { isMemberBudgetWindows } from "./memberBudgetWindowSchema";
|
||||
|
||||
export type MemberFieldValue = string | number | null | undefined | string[] | ModelMaxBudget;
|
||||
export type MemberFieldValue = string | number | null | undefined | string[] | ModelMaxBudget | BudgetWindowEntry[];
|
||||
|
||||
export type MemberFormValues = Record<string, MemberFieldValue>;
|
||||
|
||||
|
|
@ -30,6 +32,8 @@ export interface MemberAdditionalField {
|
|||
}
|
||||
|
||||
export interface MemberFieldsConfig {
|
||||
showBudgetWindows?: boolean;
|
||||
defaultBudgetWindows?: BudgetWindowEntry[];
|
||||
roleOptions: Array<{ label: string; value: string }>;
|
||||
defaultRole?: string;
|
||||
showEmail?: boolean;
|
||||
|
|
@ -62,6 +66,7 @@ export const memberFieldNames = (config: MemberFieldsConfig): string[] => [
|
|||
...(config.showEmail ? ["user_email"] : []),
|
||||
...(config.showUserId ? ["user_id"] : []),
|
||||
"role",
|
||||
...(config.showBudgetWindows ? ["budget_limits"] : []),
|
||||
...(config.additionalFields ?? []).map((field) => field.name),
|
||||
];
|
||||
|
||||
|
|
@ -76,6 +81,7 @@ export const buildMemberFormValues = (
|
|||
if (mode === "edit" && initialData) {
|
||||
const seeded: MemberFormValues = {
|
||||
...initialData,
|
||||
budget_limits: isMemberBudgetWindows(initialData.budget_limits) ? initialData.budget_limits : [],
|
||||
role: (initialData.role as string) || config.defaultRole,
|
||||
max_budget_in_team: initialData.max_budget_in_team ?? null,
|
||||
tpm_limit: initialData.tpm_limit ?? null,
|
||||
|
|
@ -92,7 +98,7 @@ export const buildMemberFormValues = (
|
|||
return pickFieldNames(config, seeded);
|
||||
}
|
||||
|
||||
return pickFieldNames(config, { role: config.defaultRole || config.roleOptions[0]?.value });
|
||||
return pickFieldNames(config, { role: config.defaultRole || config.roleOptions[0]?.value, budget_limits: [] });
|
||||
};
|
||||
|
||||
const emptyValueForType = (type: MemberFieldType | undefined): MemberFieldValue => {
|
||||
|
|
@ -112,7 +118,12 @@ const emptyValueForType = (type: MemberFieldType | undefined): MemberFieldValue
|
|||
|
||||
export const emptyMemberFormValues = (config: MemberFieldsConfig): MemberFormValues => {
|
||||
const typeByName = new Map((config.additionalFields ?? []).map((field) => [field.name, field.type]));
|
||||
return Object.fromEntries(memberFieldNames(config).map((name) => [name, emptyValueForType(typeByName.get(name))]));
|
||||
return Object.fromEntries(
|
||||
memberFieldNames(config).map((name) => [
|
||||
name,
|
||||
name === "budget_limits" ? [] : emptyValueForType(typeByName.get(name)),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
export const buildMemberFormData = (values: MemberFormValues): MemberFormValues =>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue