Skip to content

Commit 90cf9d0

Browse files
committed
feat(webapp): admin editor for org maximum project count
1 parent a3c82e4 commit 90cf9d0

3 files changed

Lines changed: 167 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Admin back office: edit an organization's `maximumProjectCount` from the org page, beneath the API rate limit editor.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { z } from "zod";
2+
import { prisma } from "~/db.server";
3+
import { logger } from "~/services/logger.server";
4+
import { MAX_PROJECTS_INTENT } from "./MaxProjectsSection";
5+
6+
const SetMaxProjectsSchema = z.object({
7+
intent: z.literal(MAX_PROJECTS_INTENT),
8+
maximumProjectCount: z.coerce.number().int().min(1),
9+
});
10+
11+
export type MaxProjectsActionResult =
12+
| { ok: true }
13+
| { ok: false; errors: Record<string, string[] | undefined> };
14+
15+
export async function handleMaxProjectsAction(
16+
formData: FormData,
17+
orgId: string,
18+
adminUserId: string
19+
): Promise<MaxProjectsActionResult> {
20+
const submission = SetMaxProjectsSchema.safeParse(Object.fromEntries(formData));
21+
if (!submission.success) {
22+
return { ok: false, errors: submission.error.flatten().fieldErrors };
23+
}
24+
25+
const existing = await prisma.organization.findFirst({
26+
where: { id: orgId },
27+
select: { maximumProjectCount: true },
28+
});
29+
if (!existing) {
30+
throw new Response(null, { status: 404 });
31+
}
32+
33+
await prisma.organization.update({
34+
where: { id: orgId },
35+
data: { maximumProjectCount: submission.data.maximumProjectCount },
36+
});
37+
38+
logger.info("admin.backOffice.maxProjects", {
39+
adminUserId,
40+
orgId,
41+
previous: existing.maximumProjectCount,
42+
next: submission.data.maximumProjectCount,
43+
});
44+
45+
return { ok: true };
46+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { Form } from "@remix-run/react";
2+
import { useEffect, useState } from "react";
3+
import { Button } from "~/components/primitives/Buttons";
4+
import { FormError } from "~/components/primitives/FormError";
5+
import { Header2 } from "~/components/primitives/Headers";
6+
import { Input } from "~/components/primitives/Input";
7+
import { Label } from "~/components/primitives/Label";
8+
import { Paragraph } from "~/components/primitives/Paragraph";
9+
import * as Property from "~/components/primitives/PropertyTable";
10+
11+
export const MAX_PROJECTS_INTENT = "set-max-projects";
12+
export const MAX_PROJECTS_SAVED_VALUE = "max-projects";
13+
14+
type FieldErrors = Record<string, string[] | undefined> | null;
15+
16+
type Props = {
17+
maximumProjectCount: number;
18+
errors: FieldErrors;
19+
savedJustNow: boolean;
20+
isSubmitting: boolean;
21+
};
22+
23+
export function MaxProjectsSection({
24+
maximumProjectCount,
25+
errors,
26+
savedJustNow,
27+
isSubmitting,
28+
}: Props) {
29+
const hasFieldErrors = !!errors && Object.keys(errors).length > 0;
30+
const fieldError = (field: string) =>
31+
errors && field in errors ? errors[field]?.[0] : undefined;
32+
33+
const [isEditing, setIsEditing] = useState(false);
34+
const [value, setValue] = useState(String(maximumProjectCount));
35+
36+
useEffect(() => {
37+
if (hasFieldErrors) setIsEditing(true);
38+
}, [hasFieldErrors]);
39+
40+
useEffect(() => {
41+
if (savedJustNow) setIsEditing(false);
42+
}, [savedJustNow]);
43+
44+
return (
45+
<section className="flex flex-col gap-3 rounded-md border border-charcoal-700 bg-charcoal-800 p-4">
46+
<div className="flex items-center justify-between">
47+
<Header2>Maximum projects</Header2>
48+
{!isEditing && (
49+
<Button
50+
variant="tertiary/small"
51+
onClick={() => setIsEditing(true)}
52+
disabled={isSubmitting}
53+
>
54+
Edit
55+
</Button>
56+
)}
57+
</div>
58+
59+
{savedJustNow && (
60+
<div className="rounded-md border border-green-600/40 bg-green-600/10 px-3 py-2">
61+
<Paragraph variant="small" className="text-green-500">
62+
Saved.
63+
</Paragraph>
64+
</div>
65+
)}
66+
67+
{!isEditing ? (
68+
<Property.Table>
69+
<Property.Item>
70+
<Property.Label>Limit</Property.Label>
71+
<Property.Value>
72+
{maximumProjectCount.toLocaleString()}
73+
</Property.Value>
74+
</Property.Item>
75+
</Property.Table>
76+
) : (
77+
<Form method="post" className="flex flex-col gap-3 pt-2">
78+
<input type="hidden" name="intent" value={MAX_PROJECTS_INTENT} />
79+
<div className="flex flex-col gap-1">
80+
<Label>Maximum projects</Label>
81+
<Input
82+
name="maximumProjectCount"
83+
type="number"
84+
min={1}
85+
value={value}
86+
onChange={(e) => setValue(e.target.value)}
87+
required
88+
/>
89+
<FormError>{fieldError("maximumProjectCount")}</FormError>
90+
</div>
91+
<div className="flex items-center gap-2">
92+
<Button
93+
type="submit"
94+
variant="primary/medium"
95+
disabled={isSubmitting || !value.trim()}
96+
>
97+
Save
98+
</Button>
99+
<Button
100+
type="button"
101+
variant="tertiary/medium"
102+
onClick={() => {
103+
setValue(String(maximumProjectCount));
104+
setIsEditing(false);
105+
}}
106+
disabled={isSubmitting}
107+
>
108+
Cancel
109+
</Button>
110+
</div>
111+
</Form>
112+
)}
113+
</section>
114+
);
115+
}

0 commit comments

Comments
 (0)