Skip to content

Commit a4c9b28

Browse files
committed
[TOOL-4374] Dashboard: Update Team name and slug validation (#6935)
<!-- ## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes" If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000): ## Notes for the reviewer Anything important to call out? Be sure to also clarify these in your comments. ## How to test Unit tests, playground, etc. --> <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces validation schemas for team slugs and names using `zod`, refactors the `TeamInfoForm` and `TeamGeneralSettingsPageUI` components to utilize these schemas, and enhances form handling and error messaging. ### Detailed summary - Added `teamSlugSchema` and `teamNameSchema` for validation. - Refactored `TeamInfoForm` to use `teamNameSchema` and `teamSlugSchema`. - Updated form submission handling in `TeamInfoForm` and `TeamSlugFormControl`. - Improved error handling and messaging for team name and slug inputs. - Removed redundant validations and constants from `TeamInfoForm`. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent 9615501 commit a4c9b28

File tree

3 files changed

+142
-112
lines changed

3 files changed

+142
-112
lines changed

apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/TeamInfoForm.tsx

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,35 +20,21 @@ import { useForm } from "react-hook-form";
2020
import { toast } from "sonner";
2121
import { useDebounce } from "use-debounce";
2222
import { z } from "zod";
23-
import { teamSlugRegex } from "../../../team/[team_slug]/(team)/~/settings/general/common";
23+
import {
24+
maxTeamNameLength,
25+
maxTeamSlugLength,
26+
teamNameSchema,
27+
teamSlugSchema,
28+
} from "../../../team/[team_slug]/(team)/~/settings/general/common";
2429

2530
type TeamData = {
2631
name?: string;
2732
slug?: string;
2833
image?: File;
2934
};
3035

31-
const teamSlugSchema = z
32-
.string()
33-
.min(3, {
34-
message: "URL must be at least 3 characters",
35-
})
36-
.max(48, {
37-
message: "URL must be at most 48 characters",
38-
})
39-
.refine((slug) => !teamSlugRegex.test(slug), {
40-
message: "URL can only contain lowercase letters, numbers and hyphens",
41-
});
42-
4336
const formSchema = z.object({
44-
name: z
45-
.string()
46-
.min(3, {
47-
message: "Name must be at least 3 characters",
48-
})
49-
.max(32, {
50-
message: "Name must be at most 32 characters",
51-
}),
37+
name: teamNameSchema,
5238
slug: teamSlugSchema,
5339
image: z.instanceof(File).optional(),
5440
});
@@ -155,8 +141,6 @@ export function TeamInfoFormUI(props: {
155141
});
156142
}
157143

158-
const maxTeamNameLength = 32;
159-
160144
return (
161145
<div className="rounded-lg border bg-card ">
162146
<Form {...form}>
@@ -234,6 +218,7 @@ export function TeamInfoFormUI(props: {
234218
}}
235219
className="truncate border-0 font-mono"
236220
placeholder="my-team"
221+
maxLength={maxTeamSlugLength}
237222
/>
238223
{(isCheckingSlug || isCalculatingSlug) && (
239224
<div className="-translate-y-1/2 fade-in-0 absolute top-1/2 right-3 duration-300">

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx

Lines changed: 94 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,21 @@ import { CopyTextButton } from "@/components/ui/CopyTextButton";
88
import { Input } from "@/components/ui/input";
99
import { useDashboardRouter } from "@/lib/DashboardRouter";
1010
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
11+
import { zodResolver } from "@hookform/resolvers/zod";
1112
import { useMutation } from "@tanstack/react-query";
1213
import { FileInput } from "components/shared/FileInput";
1314
import { useState } from "react";
15+
import { useForm } from "react-hook-form";
1416
import { toast } from "sonner";
1517
import type { ThirdwebClient } from "thirdweb";
18+
import { z } from "zod";
1619
import { TeamDomainVerificationCard } from "../_components/settings-cards/domain-verification";
17-
import { teamSlugRegex } from "./common";
20+
import {
21+
maxTeamNameLength,
22+
maxTeamSlugLength,
23+
teamNameSchema,
24+
teamSlugSchema,
25+
} from "./common";
1826

1927
type UpdateTeamField = (team: Partial<Team>) => Promise<void>;
2028

@@ -59,111 +67,109 @@ export function TeamGeneralSettingsPageUI(props: {
5967
);
6068
}
6169

70+
const teamNameFormSchema = z.object({
71+
name: teamNameSchema,
72+
});
73+
6274
function TeamNameFormControl(props: {
6375
team: Team;
6476
updateTeamField: UpdateTeamField;
6577
}) {
66-
const [teamName, setTeamName] = useState(props.team.name);
67-
const maxTeamNameLength = 32;
68-
78+
const form = useForm<{ name: string }>({
79+
values: { name: props.team.name },
80+
resolver: zodResolver(teamNameFormSchema),
81+
});
6982
const updateTeamMutation = useMutation({
7083
mutationFn: (name: string) => props.updateTeamField({ name }),
7184
});
7285

73-
function handleSave() {
74-
const promises = updateTeamMutation.mutateAsync(teamName);
75-
toast.promise(promises, {
76-
success: "Team name updated successfully",
77-
error: "Failed to update team name",
78-
});
79-
}
80-
8186
return (
82-
<SettingsCard
83-
header={{
84-
title: "Team Name",
85-
description: "This is your team's name on thirdweb",
86-
}}
87-
bottomText={`Please use ${maxTeamNameLength} characters at maximum.`}
88-
saveButton={{
89-
onClick: handleSave,
90-
disabled: teamName.length === 0,
91-
isPending: updateTeamMutation.isPending,
92-
}}
93-
errorText={undefined}
94-
noPermissionText={undefined} // TODO
87+
<form
88+
onSubmit={form.handleSubmit((values) => {
89+
const promise = updateTeamMutation.mutateAsync(values.name);
90+
toast.promise(promise, {
91+
success: "Team name updated successfully",
92+
error: "Failed to update team name",
93+
});
94+
})}
9595
>
96-
<Input
97-
value={teamName}
98-
maxLength={maxTeamNameLength}
99-
onChange={(e) => {
100-
setTeamName(e.target.value);
96+
<SettingsCard
97+
header={{
98+
title: "Team Name",
99+
description: "This is your team's name on thirdweb",
101100
}}
102-
className="md:w-[450px]"
103-
/>
104-
</SettingsCard>
101+
bottomText={`Please use ${maxTeamNameLength} characters at maximum.`}
102+
saveButton={{
103+
type: "submit",
104+
disabled: !form.formState.isDirty,
105+
isPending: updateTeamMutation.isPending,
106+
}}
107+
errorText={form.formState.errors.name?.message}
108+
noPermissionText={undefined}
109+
>
110+
<Input
111+
{...form.register("name")}
112+
maxLength={maxTeamNameLength}
113+
className="md:w-[450px]"
114+
/>
115+
</SettingsCard>
116+
</form>
105117
);
106118
}
107119

120+
const teamSlugFormSchema = z.object({
121+
slug: teamSlugSchema,
122+
});
123+
108124
function TeamSlugFormControl(props: {
109125
team: Team;
110126
updateTeamField: (team: Partial<Team>) => Promise<void>;
111127
}) {
112-
const [teamSlug, setTeamSlug] = useState(props.team.slug);
113-
const maxTeamURLLength = 48;
114-
const [errorMessage, setErrorMessage] = useState<string | undefined>();
115-
128+
const form = useForm<{ slug: string }>({
129+
defaultValues: { slug: props.team.slug },
130+
resolver: zodResolver(teamSlugFormSchema),
131+
});
116132
const updateTeamMutation = useMutation({
117-
mutationFn: (slug: string) => props.updateTeamField({ slug: slug }),
133+
mutationFn: (slug: string) => props.updateTeamField({ slug }),
118134
});
119135

120-
function handleSave() {
121-
const promises = updateTeamMutation.mutateAsync(teamSlug);
122-
toast.promise(promises, {
123-
success: "Team URL updated successfully",
124-
error: "Failed to update team URL",
125-
});
126-
}
127-
128136
return (
129-
<SettingsCard
130-
header={{
131-
title: "Team URL",
132-
description:
133-
"This is your team's URL namespace on thirdweb. All your team's projects and settings can be accessed using this URL",
134-
}}
135-
bottomText={`Please use ${maxTeamURLLength} characters at maximum.`}
136-
errorText={errorMessage}
137-
saveButton={{
138-
onClick: handleSave,
139-
disabled: errorMessage !== undefined,
140-
isPending: updateTeamMutation.isPending,
141-
}}
142-
noPermissionText={undefined} // TODO
137+
<form
138+
onSubmit={form.handleSubmit((values) => {
139+
const promise = updateTeamMutation.mutateAsync(values.slug);
140+
toast.promise(promise, {
141+
success: "Team URL updated successfully",
142+
error: "Failed to update team URL",
143+
});
144+
})}
143145
>
144-
<div className="relative flex rounded-lg border border-border md:w-[450px]">
145-
<div className="flex items-center self-stretch rounded-l-lg border-border border-r bg-card px-3 font-mono text-muted-foreground/80 text-sm">
146-
thirdweb.com/team/
146+
<SettingsCard
147+
header={{
148+
title: "Team URL",
149+
description:
150+
"This is your team's URL namespace on thirdweb. All your team's projects and settings can be accessed using this URL",
151+
}}
152+
bottomText={`Please use ${maxTeamSlugLength} characters at maximum.`}
153+
errorText={form.formState.errors.slug?.message}
154+
saveButton={{
155+
type: "submit",
156+
disabled: !form.formState.isDirty,
157+
isPending: updateTeamMutation.isPending,
158+
}}
159+
noPermissionText={undefined}
160+
>
161+
<div className="relative flex rounded-lg border border-border md:w-[450px]">
162+
<div className="flex items-center self-stretch rounded-l-lg border-border border-r bg-card px-3 font-mono text-muted-foreground/80 text-sm">
163+
thirdweb.com/team/
164+
</div>
165+
<Input
166+
{...form.register("slug")}
167+
maxLength={maxTeamSlugLength}
168+
className="truncate border-0 font-mono"
169+
/>
147170
</div>
148-
<Input
149-
value={teamSlug}
150-
onChange={(e) => {
151-
const value = e.target.value.slice(0, maxTeamURLLength);
152-
setTeamSlug(value);
153-
if (value.trim().length === 0) {
154-
setErrorMessage("Team URL can not be empty");
155-
} else if (teamSlugRegex.test(value)) {
156-
setErrorMessage(
157-
"Invalid Team URL. Only letters, numbers and hyphens are allowed",
158-
);
159-
} else {
160-
setErrorMessage(undefined);
161-
}
162-
}}
163-
className="truncate border-0 font-mono"
164-
/>
165-
</div>
166-
</SettingsCard>
171+
</SettingsCard>
172+
</form>
167173
);
168174
}
169175

@@ -186,8 +192,8 @@ function TeamAvatarFormControl(props: {
186192
});
187193

188194
function handleSave() {
189-
const promises = updateTeamAvatarMutation.mutateAsync(teamAvatar);
190-
toast.promise(promises, {
195+
const promise = updateTeamAvatarMutation.mutateAsync(teamAvatar);
196+
toast.promise(promise, {
191197
success: "Team avatar updated successfully",
192198
error: "Failed to update team avatar",
193199
});
@@ -263,8 +269,8 @@ export function LeaveTeamCard(props: {
263269
});
264270

265271
function handleLeave() {
266-
const promises = leaveTeam.mutateAsync();
267-
toast.promise(promises, {
272+
const promise = leaveTeam.mutateAsync();
273+
toast.promise(promise, {
268274
success: "Left team successfully",
269275
error: "Failed to leave team",
270276
});
@@ -308,8 +314,8 @@ export function DeleteTeamCard(props: {
308314
});
309315

310316
function handleDelete() {
311-
const promises = deleteTeam.mutateAsync();
312-
toast.promise(promises, {
317+
const promise = deleteTeam.mutateAsync();
318+
toast.promise(promise, {
313319
success: "Team deleted successfully",
314320
error: "Failed to delete team",
315321
});
Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,40 @@
1-
export const teamSlugRegex = /[^a-zA-Z0-9-]/;
1+
import { z } from "zod";
2+
3+
const teamSlugRegex = /^[a-z0-9]+(?:[-][a-z0-9]+)*$/;
4+
const teamNameRegex = /^[A-Za-z0-9]+(?:[ -][A-Za-z0-9]+)*$/;
5+
6+
export const maxTeamSlugLength = 48;
7+
const minTeamSlugLength = 3;
8+
9+
export const teamSlugSchema = z
10+
.string()
11+
.min(minTeamSlugLength, {
12+
message: `Team slug must be at least ${minTeamSlugLength} characters`,
13+
})
14+
.max(maxTeamSlugLength, {
15+
message: `Team slug must be at most ${maxTeamSlugLength} characters`,
16+
})
17+
.refine((slug) => !slug.includes(" "), {
18+
message: "Team URL cannot contain spaces",
19+
})
20+
.refine((slug) => teamSlugRegex.test(slug), {
21+
message: "Team URL can only contain lowercase letters, numbers and hyphens",
22+
});
23+
24+
export const maxTeamNameLength = 32;
25+
const minTeamNameLength = 3;
26+
27+
export const teamNameSchema = z
28+
.string()
29+
.min(minTeamNameLength, {
30+
message: `Team name must be at least ${minTeamNameLength} characters`,
31+
})
32+
.max(maxTeamNameLength, {
33+
message: `Team name must be at most ${maxTeamNameLength} characters`,
34+
})
35+
.refine((name) => name.trim() === name, {
36+
message: "Team name cannot contain leading or trailing spaces",
37+
})
38+
.refine((name) => teamNameRegex.test(name), {
39+
message: "Team name can only contain letters, numbers, spaces and hyphens",
40+
});

0 commit comments

Comments
 (0)