Skip to content

Commit b086afb

Browse files
commrelayunitC-3PO
andauthored
Fix create vault validation and save handler (#127)
* Fix create vault validation and save handler * Match vault abstract placeholder font --------- Co-authored-by: C-3PO <c3po.bot@gmail.com>
1 parent 2b66565 commit b086afb

4 files changed

Lines changed: 103 additions & 37 deletions

File tree

src/components/vaults/VaultDialog.tsx

Lines changed: 53 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { useKeyboardContext } from '@/contexts/KeyboardContext';
3030
import { useHotkeys } from '@/hooks/useKeyboardNavigation';
3131
import { Lock, Users, Globe, Mail, Trash2, Copy, Check, Link2, X, Save, Plus, Bell, ChevronDown } from 'lucide-react';
3232
import { cn } from '@/lib/utils';
33+
import { createVaultPublicSlugCandidate, normalizeVaultPublicSlug } from '@/lib/vaultSlug';
3334

3435
type VaultVisibility = 'private' | 'protected' | 'public';
3536

@@ -78,7 +79,7 @@ interface VaultDialogProps {
7879
onOpenChange: (open: boolean) => void;
7980
vault?: Vault | null;
8081
initialRequestId?: string;
81-
onSave: (data: Partial<Vault>) => Promise<Vault | void>;
82+
onSave?: (data: Partial<Vault>) => Promise<Vault | void>;
8283
onUpdate?: () => void;
8384
onDelete?: (vault: Vault) => void;
8485
}
@@ -127,7 +128,7 @@ export function VaultDialog({ open, onOpenChange, vault, initialRequestId, onSav
127128
category,
128129
abstract,
129130
visibility: isForkedVault ? 'public' : visibility,
130-
publicSlug: (isForkedVault || visibility === 'public') ? (publicSlug || generateSlug(name)) : '',
131+
publicSlug: (isForkedVault || visibility === 'public') ? (publicSlug || createVaultPublicSlugCandidate(name)) : '',
131132
};
132133
setHasUnsavedChanges(false);
133134
}, [name, description, color, category, abstract, visibility, publicSlug, isForkedVault]);
@@ -236,13 +237,46 @@ export function VaultDialog({ open, onOpenChange, vault, initialRequestId, onSav
236237
return v.visibility || 'private';
237238
};
238239

239-
const generateSlug = (name: string) => {
240-
return name
241-
.toLowerCase()
242-
.replace(/[^a-z0-9]+/g, '-')
243-
.replace(/^-|-$/g, '')
244-
.slice(0, 50);
245-
};
240+
const ensureUniquePublicSlug = useCallback(async (desiredSlug: string) => {
241+
const baseSlug = normalizeVaultPublicSlug(desiredSlug) || createVaultPublicSlugCandidate(name);
242+
243+
for (let attempt = 0; attempt < 8; attempt += 1) {
244+
const suffix = attempt === 0 ? '' : `-${attempt + 1}`;
245+
const candidate = `${baseSlug.slice(0, 50 - suffix.length)}${suffix}`;
246+
247+
const { data, error } = await supabase
248+
.from('vaults')
249+
.select('id')
250+
.eq('public_slug', candidate)
251+
.maybeSingle();
252+
253+
if (error) throw error;
254+
if (!data || data.id === vault?.id) return candidate;
255+
}
256+
257+
return `${baseSlug.slice(0, 41)}-${Date.now().toString(36)}`;
258+
}, [name, vault?.id]);
259+
260+
const buildSavePayload = useCallback(async (): Promise<Partial<Vault>> => {
261+
const shouldHavePublicSlug = isForkedVault || visibility === 'public';
262+
const resolvedPublicSlug = shouldHavePublicSlug
263+
? await ensureUniquePublicSlug(publicSlug || name)
264+
: null;
265+
266+
if (shouldHavePublicSlug && resolvedPublicSlug !== publicSlug) {
267+
setPublicSlug(resolvedPublicSlug);
268+
}
269+
270+
return {
271+
name,
272+
description: description || null,
273+
color,
274+
category: category || null,
275+
abstract: abstract || null,
276+
visibility: isForkedVault ? 'public' : visibility,
277+
public_slug: resolvedPublicSlug,
278+
};
279+
}, [abstract, category, color, description, ensureUniquePublicSlug, isForkedVault, name, publicSlug, visibility]);
246280

247281
const fetchShares = useCallback(async (vaultId: string) => {
248282
const { data, error } = await supabase
@@ -398,7 +432,7 @@ export function VaultDialog({ open, onOpenChange, vault, initialRequestId, onSav
398432
const initialCategory = vault.category || '';
399433
const initialAbstract = vault.abstract || '';
400434
const initialVisibility = getVisibility(vault);
401-
const initialPublicSlug = vault.public_slug || generateSlug(vault.name);
435+
const initialPublicSlug = vault.public_slug || createVaultPublicSlugCandidate(vault.name);
402436

403437
setName(initialName);
404438
setDescription(initialDescription);
@@ -568,19 +602,11 @@ export function VaultDialog({ open, onOpenChange, vault, initialRequestId, onSav
568602

569603
// Handle save and close
570604
const handleSaveAndClose = useCallback(async () => {
571-
if (!name.trim()) return;
605+
if (!name.trim() || !onSave) return;
572606

573607
setSaving(true);
574608
try {
575-
await onSave({
576-
name,
577-
description,
578-
color,
579-
category: category || null,
580-
abstract: abstract || null,
581-
visibility: isForkedVault ? 'public' : visibility,
582-
public_slug: (isForkedVault || visibility === 'public') ? (publicSlug || generateSlug(name)) : null,
583-
});
609+
await onSave(await buildSavePayload());
584610
syncSavedValues();
585611
setShowUnsavedDialog(false);
586612
onOpenChange(false);
@@ -589,22 +615,14 @@ export function VaultDialog({ open, onOpenChange, vault, initialRequestId, onSav
589615
} finally {
590616
setSaving(false);
591617
}
592-
}, [name, description, color, category, abstract, visibility, publicSlug, isForkedVault, onSave, onOpenChange, syncSavedValues]);
618+
}, [buildSavePayload, name, onSave, onOpenChange, syncSavedValues]);
593619

594620
const handleSubmit = useCallback(async () => {
595-
if (!open || saving || !name.trim()) return;
621+
if (!open || saving || !name.trim() || !onSave) return;
596622

597623
setSaving(true);
598624
try {
599-
await onSave({
600-
name,
601-
description,
602-
color,
603-
category: category || null,
604-
abstract: abstract || null,
605-
visibility: isForkedVault ? 'public' : visibility,
606-
public_slug: (isForkedVault || visibility === 'public') ? (publicSlug || generateSlug(name)) : null,
607-
});
625+
await onSave(await buildSavePayload());
608626
syncSavedValues();
609627

610628
if (!vault) {
@@ -613,7 +631,7 @@ export function VaultDialog({ open, onOpenChange, vault, initialRequestId, onSav
613631
} finally {
614632
setSaving(false);
615633
}
616-
}, [open, saving, name, description, color, category, abstract, visibility, publicSlug, isForkedVault, onSave, syncSavedValues, vault, onOpenChange]);
634+
}, [buildSavePayload, open, saving, name, onSave, syncSavedValues, vault, onOpenChange]);
617635

618636
useHotkeys(
619637
'dialog',
@@ -907,7 +925,7 @@ export function VaultDialog({ open, onOpenChange, vault, initialRequestId, onSav
907925
onChange={(e) => setAbstract(e.target.value)}
908926
placeholder="describe_contents_and_purpose..."
909927
rows={3}
910-
className="text-sm"
928+
className="font-mono text-sm"
911929
/>
912930
<p className="text-xs text-muted-foreground font-mono">
913931
// shown_on_the_codex_when_published
@@ -975,7 +993,7 @@ export function VaultDialog({ open, onOpenChange, vault, initialRequestId, onSav
975993
<Input
976994
value={publicSlug}
977995
onChange={(e) => {
978-
setPublicSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''));
996+
setPublicSlug(normalizeVaultPublicSlug(e.target.value));
979997
setSlugAvailable(null); // Reset while typing
980998
}}
981999
placeholder="my-research-vault"
@@ -1273,7 +1291,7 @@ export function VaultDialog({ open, onOpenChange, vault, initialRequestId, onSav
12731291
<Button
12741292
type="submit"
12751293
variant="glow"
1276-
disabled={saving || !name.trim()}
1294+
disabled={saving || !name.trim() || !onSave}
12771295
className="font-mono w-full sm:w-auto text-xs sm:text-sm h-9 sm:h-10"
12781296
>
12791297
{saving ? (
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { createVaultPublicSlugCandidate, normalizeVaultPublicSlug } from '@/lib/vaultSlug';
3+
4+
describe('vault public slug helpers', () => {
5+
it('normalizes ASCII names for public URL slugs', () => {
6+
expect(normalizeVaultPublicSlug(' My Research: 2026! ')).toBe('my-research-2026');
7+
});
8+
9+
it('does not reject arbitrary Unicode-only vault names when deriving a public slug', () => {
10+
const originalCrypto = globalThis.crypto;
11+
vi.stubGlobal('crypto', {
12+
getRandomValues: (array: Uint8Array) => {
13+
array.set([0xab, 0xcd, 0x12, 0x34]);
14+
return array;
15+
},
16+
});
17+
18+
expect(createVaultPublicSlugCandidate('📚 重要な研究')).toBe('vault-abcd1234');
19+
20+
vi.stubGlobal('crypto', originalCrypto);
21+
});
22+
});

src/lib/vaultSlug.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export const normalizeVaultPublicSlug = (value: string) =>
2+
value
3+
.toLowerCase()
4+
.replace(/[^a-z0-9-]+/g, '-')
5+
.replace(/-+/g, '-')
6+
.replace(/^-|-$/g, '')
7+
.slice(0, 50)
8+
.replace(/-$/g, '');
9+
10+
export const createVaultPublicSlugCandidate = (name: string) => {
11+
const fromName = normalizeVaultPublicSlug(name);
12+
if (fromName) return fromName;
13+
14+
// Vault names may be arbitrary Unicode/emoji strings. Public URLs still need
15+
// a stable ASCII identifier, so derive one internally instead of rejecting
16+
// the user-facing name.
17+
const randomPart = typeof crypto !== 'undefined' && 'getRandomValues' in crypto
18+
? Array.from(crypto.getRandomValues(new Uint8Array(4)))
19+
.map((byte) => byte.toString(16).padStart(2, '0'))
20+
.join('')
21+
: Math.random().toString(36).slice(2, 10);
22+
23+
return `vault-${randomPart}`;
24+
};

src/pages/VaultDetail.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,10 +1346,12 @@ export default function VaultDetail() {
13461346
};
13471347

13481348
const handleSaveVault = async (data: Partial<Vault>) => {
1349-
if (!user || !vaultId || !isOwner) return; // Only allow saving if user is the owner
1349+
if (!user) return;
13501350

13511351
try {
13521352
if (editingVault) {
1353+
if (!vaultId || !isOwner) return; // Only allow editing the current vault if user is the owner
1354+
13531355
const { data: updatedVault, error } = await supabase
13541356
.from('vaults')
13551357
.update(data)
@@ -1978,7 +1980,7 @@ export default function VaultDetail() {
19781980
setIsVaultDialogOpen(open);
19791981
}}
19801982
vault={editingVault}
1981-
onSave={isOwner ? handleSaveVault : undefined}
1983+
onSave={!editingVault || isOwner ? handleSaveVault : undefined}
19821984
onUpdate={refetchVault}
19831985
onDelete={isOwner ? (vault) => {
19841986
setDeleteVaultConfirmation(vault);

0 commit comments

Comments
 (0)