Skip to content

Commit 7384a11

Browse files
feat: simplify admin create-workspace form (#1733)
1 parent bcc6d4c commit 7384a11

3 files changed

Lines changed: 70 additions & 101 deletions

File tree

web/sdk/admin/utils/helper.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,25 @@ export function capitalizeFirstLetter(str: string) {
2222
return str.charAt(0).toUpperCase() + str.slice(1);
2323
}
2424

25+
// Generate a URL-safe slug from a title.
26+
export function generateSlug(title: string): string {
27+
return title
28+
.toLowerCase()
29+
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric chars with hyphens
30+
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
31+
.substring(0, 30); // Limit length
32+
}
33+
34+
// Short random suffix to keep auto-generated slugs unique.
35+
export function randomSuffix(length = 4): string {
36+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
37+
let out = '';
38+
for (let i = 0; i < length; i++) {
39+
out += chars[Math.floor(Math.random() * chars.length)];
40+
}
41+
return out;
42+
}
43+
2544
export function convertBillingAddressToString(address?: {
2645
line1?: string;
2746
line2?: string;

web/sdk/admin/views/organizations/list/create.tsx

Lines changed: 50 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { useEffect, useState } from "react";
1+
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from "react";
22
import styles from "./list.module.css";
33
import { useTerminology } from "../../../hooks/useTerminology";
4+
import { generateSlug, randomSuffix } from "~/admin/utils/helper";
45
import {
56
Button,
67
Field,
@@ -14,49 +15,35 @@ import {
1415
} from "@raystack/apsara";
1516
import { Cross1Icon } from "@radix-ui/react-icons";
1617
import { ImageUpload } from "~/client/components/image-upload";
18+
import { debounce } from "lodash";
1719
import { z } from "zod";
1820
import { Controller, useForm } from "react-hook-form";
1921
import { zodResolver } from "@hookform/resolvers/zod";
2022
import { useMutation } from "@connectrpc/connect-query";
2123
import { Code } from "@connectrpc/connect";
2224
import { AdminServiceQueries } from "@raystack/proton/frontier";
2325

24-
const orgCreateSchema = z
25-
.object({
26-
avatar: z.string().optional(),
27-
title: z.string().min(1, "Title is required"),
28-
name: z
29-
.string()
30-
.min(1, "URL is required")
31-
.regex(
32-
/^[a-z0-9-]+$/,
33-
"Use only lowercase letters, numbers, and hyphens",
34-
),
35-
orgOwnerEmail: z
36-
.string()
37-
.min(1, "Owner email is required")
38-
.email("Enter a valid email address"),
39-
size: z
40-
.string()
41-
.min(1, "Size is required")
42-
.refine(
43-
(value) => Number.isInteger(Number(value)) && Number(value) > 0,
44-
"Enter a valid size greater than 0",
45-
)
46-
.transform((value) => parseInt(value)),
47-
type: z.string().min(1, "Industry is required"),
48-
otherType: z.string().optional(),
49-
country: z.string().min(1, "Country is required"),
50-
})
51-
.refine((data) => data.type !== "other" || Boolean(data.otherType?.trim()), {
52-
message: "Please specify the industry",
53-
path: ["otherType"],
54-
});
26+
const orgCreateSchema = z.object({
27+
avatar: z.string().optional(),
28+
title: z.string().min(1, "Title is required"),
29+
name: z
30+
.string()
31+
.min(1, "URL is required")
32+
.min(3, "URL not valid, Min 3 characters allowed")
33+
.max(50, "URL not valid, Max 50 characters allowed")
34+
.regex(
35+
/^[a-zA-Z0-9_-]{3,50}$/,
36+
"Only numbers, letters, '-', and '_' are allowed. Spaces are not allowed.",
37+
),
38+
orgOwnerEmail: z
39+
.string()
40+
.min(1, "Owner email is required")
41+
.email("Enter a valid email address"),
42+
country: z.string().min(1, "Country is required"),
43+
});
5544

5645
type OrgCreateSchema = z.infer<typeof orgCreateSchema>;
5746

58-
const otherTypePrefix = "Other - ";
59-
6047
export type CreateOrganizationPanelProps = {
6148
open?: boolean;
6249
onClose: () => void;
@@ -69,14 +56,12 @@ export type CreateOrganizationPanelProps = {
6956
export function CreateOrganizationPanel({
7057
open = false,
7158
onClose,
72-
organizationTypes = [],
7359
appUrl = "",
7460
countries: countriesProp = [],
7561
onSuccess,
7662
}: CreateOrganizationPanelProps) {
7763
const t = useTerminology();
7864
const [countries, setCountries] = useState<string[]>(countriesProp);
79-
const industries = organizationTypes;
8065

8166
useEffect(() => {
8267
if (countriesProp.length > 0) {
@@ -88,22 +73,41 @@ export function CreateOrganizationPanel({
8873
handleSubmit,
8974
control,
9075
setError,
91-
formState: { isSubmitting, errors },
92-
watch,
76+
setValue,
77+
formState: { isSubmitting, errors, dirtyFields },
9378
register,
9479
} = useForm<OrgCreateSchema>({
9580
defaultValues: {
9681
avatar: "",
9782
title: "",
9883
name: "",
9984
orgOwnerEmail: "",
100-
type: "",
101-
otherType: "",
10285
country: "",
10386
},
10487
resolver: zodResolver(orgCreateSchema),
10588
});
10689

90+
const uniqueSuffix = useMemo(() => randomSuffix(), []);
91+
92+
const debouncedGenerateSlug = useMemo(
93+
() =>
94+
debounce((title: string) => {
95+
const baseSlug = generateSlug(title);
96+
setValue("name", baseSlug ? `${baseSlug}-${uniqueSuffix}` : "");
97+
}, 300),
98+
[setValue, uniqueSuffix],
99+
);
100+
101+
useEffect(() => () => debouncedGenerateSlug.cancel(), [debouncedGenerateSlug]);
102+
103+
const handleTitleChange = useCallback(
104+
(e: ChangeEvent<HTMLInputElement>) => {
105+
if (dirtyFields.name) return;
106+
debouncedGenerateSlug(e.target.value);
107+
},
108+
[debouncedGenerateSlug, dirtyFields.name],
109+
);
110+
107111
const { mutateAsync: createOrganization, isPending } = useMutation(
108112
AdminServiceQueries.adminCreateOrganization,
109113
{
@@ -127,10 +131,6 @@ export function CreateOrganizationPanel({
127131
title: data.title,
128132
orgOwnerEmail: data.orgOwnerEmail,
129133
metadata: {
130-
size: data.size.toString(),
131-
type: data.otherType
132-
? `${otherTypePrefix}${data.otherType}`
133-
: data.type,
134134
country: data.country,
135135
},
136136
};
@@ -145,8 +145,6 @@ export function CreateOrganizationPanel({
145145
}
146146
}
147147

148-
const showOtherTypeField = watch("type") === "other";
149-
150148
return (
151149
<Drawer open={open} onOpenChange={(open) => !open && onClose()}>
152150
<Drawer.Content showCloseButton={false} className={styles["drawer-content"]}>
@@ -169,6 +167,7 @@ export function CreateOrganizationPanel({
169167
<form
170168
className={styles["side-panel-form"]}
171169
onSubmit={handleSubmit(onSubmit)}
170+
noValidate
172171
>
173172
<Flex
174173
direction="column"
@@ -192,14 +191,16 @@ export function CreateOrganizationPanel({
192191
error={errors.title?.message}
193192
required
194193
>
195-
<Input {...register("title")} />
194+
<Input
195+
{...register("title", { onChange: handleTitleChange })}
196+
/>
196197
</Field>
197198
<Field
198-
label={`${t.organization({ case: "capital" })} owner`}
199+
label={`${t.organization({ case: "capital" })} owner email`}
199200
error={errors.orgOwnerEmail?.message}
200201
required
201202
>
202-
<Input {...register("orgOwnerEmail")} type="email" />
203+
<Input {...register("orgOwnerEmail")} type="email"/>
203204
</Field>
204205
<Field
205206
label={`${t.organization({ case: "capital" })} URL`}
@@ -209,57 +210,6 @@ export function CreateOrganizationPanel({
209210
>
210211
<Input {...register("name")} prefix={appUrl} />
211212
</Field>
212-
<Field
213-
label={`${t.organization({ case: "capital" })} size`}
214-
error={errors.size?.message}
215-
required
216-
>
217-
<Input {...register("size")} type="number" min={1} />
218-
</Field>
219-
<Controller
220-
name="type"
221-
control={control}
222-
render={({ field }) => {
223-
return (
224-
<Field
225-
label={`${t.organization({ case: "capital" })} industry`}
226-
error={errors.type?.message}
227-
required
228-
>
229-
<Select
230-
{...field}
231-
value={field?.value?.toString()}
232-
onValueChange={(value) => {
233-
field?.onChange({ target: { value } });
234-
}}
235-
>
236-
<Select.Trigger>
237-
<Select.Value
238-
placeholder="Select an industry"
239-
id="org-type-select"
240-
/>
241-
</Select.Trigger>
242-
<Select.Content className={styles["select-content"]}>
243-
{industries.map((industry) => (
244-
<Select.Item key={industry} value={industry}>
245-
{industry}
246-
</Select.Item>
247-
))}
248-
<Select.Item value="other">Other</Select.Item>
249-
</Select.Content>
250-
</Select>
251-
</Field>
252-
);
253-
}}
254-
/>
255-
{showOtherTypeField ? (
256-
<Field
257-
label={`${t.organization({ case: "capital" })} industry (other)`}
258-
error={errors.otherType?.message}
259-
>
260-
<Input {...register("otherType")} />
261-
</Field>
262-
) : null}
263213
<Controller
264214
name="country"
265215
control={control}

web/sdk/client/views/create-organization/create-organization-view.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export const CreateOrganizationView = ({
112112
<Input
113113
size="large"
114114
{...register('name')}
115-
placeholder="raystack.org/"
115+
placeholder={`Provide ${t.organization({ case: 'lower' })} URL`}
116116
/>
117117
</Field>
118118
<Button

0 commit comments

Comments
 (0)