Skip to content

Commit 1dc3dd1

Browse files
committed
fix: clean up organization dialog
1 parent 142452d commit 1dc3dd1

File tree

1 file changed

+119
-74
lines changed

1 file changed

+119
-74
lines changed

apps/dashboard/components/organizations/create-organization-dialog.tsx

Lines changed: 119 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
'use client';
22

3-
import { BuildingsIcon, UploadSimple, UsersIcon } from '@phosphor-icons/react';
3+
import {
4+
BuildingsIcon,
5+
UploadSimpleIcon,
6+
UsersIcon,
7+
} from '@phosphor-icons/react';
8+
import Image from 'next/image';
49
import { useRouter } from 'next/navigation';
510
import { useEffect, useMemo, useRef, useState } from 'react';
611
import ReactCrop, {
@@ -32,11 +37,19 @@ import { useOrganizations } from '@/hooks/use-organizations';
3237
import { getCroppedImage } from '@/lib/canvas-utils';
3338
import 'react-image-crop/dist/ReactCrop.css';
3439

40+
// Top-level regex literals for performance and lint compliance
41+
const SLUG_ALLOWED_REGEX = /^[a-z0-9-]+$/;
42+
const REGEX_NON_SLUG_NAME_CHARS = /[^a-z0-9\s-]/g;
43+
const REGEX_SPACES_TO_DASH = /\s+/g;
44+
const REGEX_MULTI_DASH = /-+/g;
45+
const REGEX_TRIM_DASH = /^-+|-+$/g;
46+
const REGEX_INVALID_SLUG_CHARS = /[^a-z0-9-]/g;
47+
3548
interface CreateOrganizationData {
3649
name: string;
3750
slug: string;
3851
logo: string;
39-
metadata: Record<string, any>;
52+
metadata: Record<string, unknown>;
4053
}
4154

4255
interface CreateOrganizationDialogProps {
@@ -48,7 +61,8 @@ export function CreateOrganizationDialog({
4861
isOpen,
4962
onClose,
5063
}: CreateOrganizationDialogProps) {
51-
const { createOrganization, isCreatingOrganization } = useOrganizations();
64+
const { createOrganizationAsync, isCreatingOrganization } =
65+
useOrganizations();
5266
const router = useRouter();
5367

5468
// Form state
@@ -67,17 +81,17 @@ export function CreateOrganizationDialog({
6781
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
6882
const [isCropModalOpen, setIsCropModalOpen] = useState(false);
6983
const fileInputRef = useRef<HTMLInputElement>(null);
70-
const imageRef = useRef<HTMLImageElement>(null);
84+
const imageRef = useRef<HTMLImageElement | null>(null);
7185

7286
// Slug auto-generation
7387
useEffect(() => {
7488
if (!(slugManuallyEdited && formData.slug)) {
7589
const generatedSlug = formData.name
7690
.toLowerCase()
77-
.replace(/[^a-z0-9\s-]/g, '')
78-
.replace(/\s+/g, '-')
79-
.replace(/-+/g, '-')
80-
.replace(/^-+|-+$/g, '');
91+
.replace(REGEX_NON_SLUG_NAME_CHARS, '')
92+
.replace(REGEX_SPACES_TO_DASH, '-')
93+
.replace(REGEX_MULTI_DASH, '-')
94+
.replace(REGEX_TRIM_DASH, '');
8195
setFormData((prev) => ({ ...prev, slug: generatedSlug }));
8296
}
8397
}, [formData.name, formData.slug, slugManuallyEdited]);
@@ -100,9 +114,9 @@ export function CreateOrganizationDialog({
100114
setSlugManuallyEdited(true);
101115
const cleanSlug = value
102116
.toLowerCase()
103-
.replace(/[^a-z0-9-]/g, '')
104-
.replace(/-+/g, '-')
105-
.replace(/^-+|-+$/g, '');
117+
.replace(REGEX_INVALID_SLUG_CHARS, '')
118+
.replace(REGEX_MULTI_DASH, '-')
119+
.replace(REGEX_TRIM_DASH, '');
106120
setFormData((prev) => ({ ...prev, slug: cleanSlug }));
107121
if (cleanSlug === '') {
108122
setSlugManuallyEdited(false);
@@ -114,25 +128,26 @@ export function CreateOrganizationDialog({
114128
() =>
115129
formData.name.trim().length >= 2 &&
116130
(formData.slug || '').trim().length >= 2 &&
117-
/^[a-z0-9-]+$/.test(formData.slug || ''),
131+
SLUG_ALLOWED_REGEX.test(formData.slug || ''),
118132
[formData.name, formData.slug]
119133
);
120134

121135
// Image crop modal handlers
122-
const handleCropModalOpenChange = (isOpen: boolean) => {
123-
if (!isOpen && fileInputRef.current) {
136+
const handleCropModalOpenChange = (open: boolean) => {
137+
if (!open && fileInputRef.current) {
124138
fileInputRef.current.value = '';
125139
}
126-
if (!isOpen) {
140+
if (!open) {
127141
setImageSrc(null);
128142
setCrop(undefined);
129143
setCompletedCrop(undefined);
130144
}
131-
setIsCropModalOpen(isOpen);
145+
setIsCropModalOpen(open);
132146
};
133147

134-
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
135-
const { width, height } = e.currentTarget;
148+
function onImageLoad(img: HTMLImageElement) {
149+
const width = img.naturalWidth;
150+
const height = img.naturalHeight;
136151
const percentCrop = centerCrop(
137152
makeAspectCrop({ unit: '%', width: 90 }, 1, width, height),
138153
width,
@@ -146,6 +161,7 @@ export function CreateOrganizationDialog({
146161
width: Math.round((percentCrop.width / 100) * width),
147162
height: Math.round((percentCrop.height / 100) * height),
148163
});
164+
imageRef.current = img;
149165
}
150166

151167
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -180,9 +196,8 @@ export function CreateOrganizationDialog({
180196
toast.success('Logo saved successfully!');
181197
};
182198
reader.readAsDataURL(croppedFile);
183-
} catch (e) {
199+
} catch {
184200
toast.error('Failed to crop image.');
185-
console.error(e);
186201
}
187202
};
188203

@@ -201,11 +216,11 @@ export function CreateOrganizationDialog({
201216
return;
202217
}
203218
try {
204-
createOrganization(formData);
219+
await createOrganizationAsync(formData);
205220
handleClose();
206221
router.push('/organizations');
207222
} catch {
208-
// Error handled by mutation
223+
// handled by mutation toast
209224
}
210225
};
211226

@@ -221,7 +236,6 @@ export function CreateOrganizationDialog({
221236
<div className="self-start rounded border border-primary/20 bg-primary/10 p-3 sm:self-center">
222237
<BuildingsIcon
223238
className="h-6 w-6 text-primary"
224-
size={16}
225239
weight="duotone"
226240
/>
227241
</div>
@@ -245,19 +259,36 @@ export function CreateOrganizationDialog({
245259
>
246260
Organization Name *
247261
</Label>
248-
<Input
249-
className="rounded border-border/50 focus:border-primary/50 focus:ring-primary/20"
250-
id="org-name"
251-
maxLength={100}
252-
onChange={(e) =>
253-
setFormData((prev) => ({ ...prev, name: e.target.value }))
254-
}
255-
placeholder="e.g., Acme Corporation"
256-
value={formData.name}
257-
/>
258-
<p className="text-muted-foreground text-xs">
259-
This is the display name for your organization
260-
</p>
262+
{(() => {
263+
const isNameValid = formData.name.trim().length >= 2;
264+
return (
265+
<>
266+
<Input
267+
aria-describedby="org-name-help"
268+
aria-invalid={!isNameValid}
269+
className={`rounded border-border/50 focus:border-primary/50 focus:ring-primary/20 ${
270+
isNameValid ? '' : 'border-destructive'
271+
}`}
272+
id="org-name"
273+
maxLength={100}
274+
onChange={(e) =>
275+
setFormData((prev) => ({
276+
...prev,
277+
name: e.target.value,
278+
}))
279+
}
280+
placeholder="e.g., Acme Corporation"
281+
value={formData.name}
282+
/>
283+
<p
284+
className="text-muted-foreground text-xs"
285+
id="org-name-help"
286+
>
287+
This is the display name for your organization
288+
</p>
289+
</>
290+
);
291+
})()}
261292
</div>
262293

263294
<div className="space-y-2">
@@ -267,18 +298,34 @@ export function CreateOrganizationDialog({
267298
>
268299
Organization Slug *
269300
</Label>
270-
<Input
271-
className="rounded border-border/50 focus:border-primary/50 focus:ring-primary/20"
272-
id="org-slug"
273-
maxLength={50}
274-
onChange={(e) => handleSlugChange(e.target.value)}
275-
placeholder="e.g., acme-corp"
276-
value={formData.slug}
277-
/>
278-
<p className="text-muted-foreground text-xs">
279-
Used in URLs and must be unique. Only lowercase letters,
280-
numbers, and hyphens allowed.
281-
</p>
301+
{(() => {
302+
const isSlugValid =
303+
SLUG_ALLOWED_REGEX.test(formData.slug || '') &&
304+
(formData.slug || '').trim().length >= 2;
305+
return (
306+
<>
307+
<Input
308+
aria-describedby="org-slug-help"
309+
aria-invalid={!isSlugValid}
310+
className={`rounded border-border/50 focus:border-primary/50 focus:ring-primary/20 ${
311+
isSlugValid ? '' : 'border-destructive'
312+
}`}
313+
id="org-slug"
314+
maxLength={50}
315+
onChange={(e) => handleSlugChange(e.target.value)}
316+
placeholder="e.g., acme-corp"
317+
value={formData.slug}
318+
/>
319+
<p
320+
className="text-muted-foreground text-xs"
321+
id="org-slug-help"
322+
>
323+
Used in URLs and must be unique. Only lowercase letters,
324+
numbers, and hyphens allowed.
325+
</p>
326+
</>
327+
);
328+
})()}
282329
</div>
283330

284331
<div className="space-y-2">
@@ -302,11 +349,18 @@ export function CreateOrganizationDialog({
302349
</Avatar>
303350
<button
304351
aria-label="Upload organization logo"
305-
className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black bg-opacity-50 opacity-0 transition-opacity group-hover:opacity-100"
352+
className="absolute inset-0 flex cursor-pointer items-center justify-center rounded bg-black bg-opacity-50 opacity-0 transition-opacity group-hover:opacity-100"
306353
onClick={() => fileInputRef.current?.click()}
354+
onKeyDown={(e) => {
355+
if (e.key === 'Enter' || e.key === ' ') {
356+
e.preventDefault();
357+
fileInputRef.current?.click();
358+
}
359+
}}
307360
type="button"
308361
>
309-
<UploadSimple className="text-white" size={20} />
362+
<UploadSimpleIcon className="h-5 w-5 text-white" />
363+
<span className="sr-only">Upload organization logo</span>
310364
</button>
311365
</div>
312366
<div className="min-w-0 flex-1">
@@ -328,11 +382,7 @@ export function CreateOrganizationDialog({
328382

329383
<div className="space-y-4">
330384
<div className="flex items-center gap-2">
331-
<UsersIcon
332-
className="h-5 w-5 text-primary"
333-
size={16}
334-
weight="duotone"
335-
/>
385+
<UsersIcon className="h-5 w-5 text-primary" weight="duotone" />
336386
<Label className="font-semibold text-base text-foreground">
337387
Getting Started
338388
</Label>
@@ -341,19 +391,10 @@ export function CreateOrganizationDialog({
341391
<p className="text-muted-foreground text-sm">
342392
After creating your organization, you'll be able to:
343393
</p>
344-
<ul className="mt-2 space-y-1 text-muted-foreground text-sm">
345-
<li className="flex items-start gap-2">
346-
<span className="mt-0.5 text-primary"></span>
347-
Invite team members with different roles
348-
</li>
349-
<li className="flex items-start gap-2">
350-
<span className="mt-0.5 text-primary"></span>
351-
Share websites and analytics data
352-
</li>
353-
<li className="flex items-start gap-2">
354-
<span className="mt-0.5 text-primary"></span>
355-
Manage organization settings and permissions
356-
</li>
394+
<ul className="mt-2 list-disc space-y-1 pl-5 text-muted-foreground text-sm">
395+
<li>Invite team members with different roles</li>
396+
<li>Share websites and analytics data</li>
397+
<li>Manage organization settings and permissions</li>
357398
</ul>
358399
</div>
359400
</div>
@@ -372,6 +413,7 @@ export function CreateOrganizationDialog({
372413
className="relative order-1 rounded sm:order-2"
373414
disabled={!isFormValid || isCreatingOrganization}
374415
onClick={handleSubmit}
416+
type="button"
375417
>
376418
{isCreatingOrganization && (
377419
<div className="absolute left-3">
@@ -392,25 +434,26 @@ export function CreateOrganizationDialog({
392434
<Dialog onOpenChange={handleCropModalOpenChange} open={isCropModalOpen}>
393435
<DialogContent className="max-h-[95vh] max-w-[95vw] overflow-auto">
394436
<DialogHeader>
395-
<DialogTitle>Crop your organization logo</DialogTitle>
437+
<DialogTitle>Crop organization logo</DialogTitle>
396438
</DialogHeader>
397439
{imageSrc && (
398440
<div className="flex justify-center">
399441
<ReactCrop
400442
aspect={1}
401-
circularCrop={true}
443+
circularCrop
402444
crop={crop}
403445
onChange={(pixelCrop, percentCrop) => {
404446
setCrop(percentCrop);
405447
setCompletedCrop(pixelCrop);
406448
}}
407449
>
408-
<img
450+
<Image
409451
alt="Crop preview"
410452
className="max-h-[60vh] max-w-full object-contain"
411-
onLoad={onImageLoad}
412-
ref={imageRef}
413-
src={imageSrc}
453+
height={600}
454+
onLoadingComplete={onImageLoad}
455+
src={imageSrc as string}
456+
width={800}
414457
/>
415458
</ReactCrop>
416459
</div>
@@ -419,6 +462,7 @@ export function CreateOrganizationDialog({
419462
<Button
420463
className="w-full sm:w-auto"
421464
onClick={() => handleCropModalOpenChange(false)}
465+
type="button"
422466
variant="outline"
423467
>
424468
Cancel
@@ -427,6 +471,7 @@ export function CreateOrganizationDialog({
427471
className="w-full sm:w-auto"
428472
disabled={!(imageSrc && completedCrop)}
429473
onClick={handleCropSave}
474+
type="button"
430475
>
431476
Save Logo
432477
</Button>

0 commit comments

Comments
 (0)