Sifa professional network frontend (Next.js, React, TailwindCSS) sifa.id/

fix(profile): make completion bar links open edit dialogs (#414)

Three completion bar items (avatar, headline, website/verification) used
hash links targeting elements inside a closed dialog, so clicking them
did nothing. Replace with an editRequest signal through ProfileEditProvider
context that opens the correct edit dialog when triggered.

authored by

Guido X Jansen and committed by
GitHub
6d7cf4ef 1c61dc14

+111 -21
+52 -19
src/components/completion-bar.tsx
··· 1 1 'use client'; 2 2 3 - import { useMemo } from 'react'; 3 + import { useMemo, useCallback } from 'react'; 4 4 import { CheckCircle, Circle, ArrowRight } from '@phosphor-icons/react'; 5 5 import { Progress } from '@/components/ui/progress'; 6 + import { useProfileEdit } from '@/components/profile-edit-provider'; 6 7 import type { Profile } from '@/lib/types'; 7 8 8 9 interface CompletionItem { 9 10 key: string; 10 11 label: string; 11 12 completed: boolean; 12 - action: string; 13 + /** Hash anchor for scroll-to-section items. */ 14 + scrollTo?: string; 15 + /** Edit request key for items that open a dialog. */ 16 + editKey?: string; 13 17 } 14 18 15 19 function getCompletionItems(profile: Profile): CompletionItem[] { ··· 18 22 key: 'avatar', 19 23 label: 'Add a profile photo', 20 24 completed: Boolean(profile.avatar), 21 - action: '#edit-avatar', 25 + editKey: 'identity', 22 26 }, 23 27 { 24 28 key: 'headline', 25 29 label: 'Write a headline', 26 30 completed: Boolean(profile.headline), 27 - action: '#edit-about', 31 + editKey: 'identity', 28 32 }, 29 33 { 30 34 key: 'about', 31 35 label: 'Add a professional summary', 32 36 completed: Boolean(profile.about), 33 - action: '#about', 37 + scrollTo: '#about', 34 38 }, 35 39 { 36 40 key: 'current-position', 37 41 label: 'Add your current position', 38 42 completed: profile.positions.some((p) => p.current), 39 - action: '#career', 43 + scrollTo: '#career', 40 44 }, 41 45 { 42 46 key: 'past-position', 43 47 label: 'Add a past position', 44 48 completed: profile.positions.filter((p) => !p.current).length > 0, 45 - action: '#career', 49 + scrollTo: '#career', 46 50 }, 47 51 { 48 52 key: 'skills', 49 53 label: 'Add 3+ skills', 50 54 completed: profile.skills.length >= 3, 51 - action: '#skills', 55 + scrollTo: '#skills', 52 56 }, 53 57 { 54 58 key: 'education', 55 59 label: 'Add education', 56 60 completed: profile.education.length > 0, 57 - action: '#education', 61 + scrollTo: '#education', 58 62 }, 59 63 { 60 64 key: 'website', 61 65 label: 'Add a website or verification', 62 66 completed: Boolean(profile.website) || (profile.externalAccounts ?? []).length > 0, 63 - action: '#edit-about', 67 + editKey: 'externalAccounts', 64 68 }, 65 69 ]; 66 70 } ··· 70 74 } 71 75 72 76 export function CompletionBar({ profile }: CompletionBarProps) { 77 + const { requestEdit } = useProfileEdit(); 73 78 const items = useMemo(() => getCompletionItems(profile), [profile]); 74 79 const completedCount = items.filter((i) => i.completed).length; 75 80 const percentage = Math.round((completedCount / items.length) * 100); 76 81 82 + const handleAction = useCallback( 83 + (item: CompletionItem) => { 84 + if (item.editKey) { 85 + requestEdit(item.editKey); 86 + } 87 + }, 88 + [requestEdit], 89 + ); 90 + 77 91 if (!profile.isOwnProfile || percentage === 100) return null; 78 92 79 93 const nextItem = items.find((i) => !i.completed); ··· 86 100 </div> 87 101 <Progress value={percentage} className="h-2" aria-label={`Profile ${percentage}% complete`} /> 88 102 89 - {nextItem && ( 90 - <a 91 - href={nextItem.action} 92 - className="mt-3 flex items-center gap-2 text-sm text-primary underline-offset-4 hover:underline" 93 - > 94 - <ArrowRight className="h-4 w-4" weight="bold" aria-hidden="true" /> 95 - {nextItem.label} 96 - </a> 97 - )} 103 + {nextItem && <CompletionAction item={nextItem} onAction={handleAction} />} 98 104 99 105 <details className="mt-3"> 100 106 <summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground"> ··· 118 124 </div> 119 125 ); 120 126 } 127 + 128 + function CompletionAction({ 129 + item, 130 + onAction, 131 + }: { 132 + item: CompletionItem; 133 + onAction: (item: CompletionItem) => void; 134 + }) { 135 + const className = 136 + 'mt-3 flex items-center gap-2 text-sm text-primary underline-offset-4 hover:underline'; 137 + 138 + if (item.scrollTo) { 139 + return ( 140 + <a href={item.scrollTo} className={className}> 141 + <ArrowRight className="h-4 w-4" weight="bold" aria-hidden="true" /> 142 + {item.label} 143 + </a> 144 + ); 145 + } 146 + 147 + return ( 148 + <button type="button" onClick={() => onAction(item)} className={`${className} cursor-pointer`}> 149 + <ArrowRight className="h-4 w-4" weight="bold" aria-hidden="true" /> 150 + {item.label} 151 + </button> 152 + ); 153 + }
+8 -1
src/components/identity-card.tsx
··· 145 145 const { session } = useAuth(); 146 146 const tProfile = useTranslations('profile'); 147 147 const isEmbed = variant === 'embed'; 148 - const { isActualOwner, previewMode, togglePreview } = useProfileEdit(); 148 + const { isActualOwner, previewMode, togglePreview, editRequest, clearEditRequest } = 149 + useProfileEdit(); 149 150 const isOwn = isOwnProfile || Boolean(session?.did && session.did === did); 151 + const shouldOpenEdit = editRequest === 'identity' && isOwn; 150 152 const [editing, setEditing] = useState(false); 153 + 154 + if (shouldOpenEdit && !editing) { 155 + setEditing(true); 156 + clearEditRequest(); 157 + } 151 158 const [copied, setCopied] = useState(false); 152 159 const label = getDisplayLabel(displayName, handle); 153 160
+21
src/components/profile-edit-provider.tsx
··· 16 16 addItem: (key: string, item: Record<string, unknown> & { rkey: string }) => void; 17 17 updateItem: (key: string, rkey: string, fields: Record<string, unknown>) => void; 18 18 removeItem: (key: string, rkey: string) => void; 19 + /** Which edit dialog to open, e.g. 'identity' or 'externalAccounts'. */ 20 + editRequest: string | null; 21 + /** Request a specific edit dialog to open. */ 22 + requestEdit: (section: string) => void; 23 + /** Clear the edit request after consuming it. */ 24 + clearEditRequest: () => void; 19 25 } 20 26 21 27 const ProfileEditContext = createContext<ProfileEditContextValue | null>(null); ··· 29 35 const { session } = useAuth(); 30 36 const isActualOwner = Boolean(session?.did && session.did === initialProfile.did); 31 37 const [previewMode, setPreviewMode] = useState(false); 38 + const [editRequest, setEditRequest] = useState<string | null>(null); 39 + 40 + const requestEdit = useCallback((section: string) => { 41 + setEditRequest(section); 42 + }, []); 43 + 44 + const clearEditRequest = useCallback(() => { 45 + setEditRequest(null); 46 + }, []); 32 47 33 48 const isOwnProfile = isActualOwner && !previewMode; 34 49 ··· 111 126 addItem, 112 127 updateItem, 113 128 removeItem, 129 + editRequest, 130 + requestEdit, 131 + clearEditRequest, 114 132 }} 115 133 > 116 134 {children} ··· 131 149 addItem: NO_OP, 132 150 updateItem: NO_OP, 133 151 removeItem: NO_OP, 152 + editRequest: null, 153 + requestEdit: NO_OP, 154 + clearEditRequest: NO_OP, 134 155 }; 135 156 136 157 export function useProfileEdit(): ProfileEditContextValue {
+11 -1
src/components/profile-editor/editable-section.tsx
··· 80 80 value: string | boolean, 81 81 currentValues: Record<string, string | boolean>, 82 82 ) => Record<string, string | boolean> | undefined; 83 + /** When this matches editRequest from ProfileEditProvider, auto-open the add dialog. */ 84 + editRequestKey?: string; 83 85 } 84 86 85 87 type DialogState<T> = { mode: 'add' } | { mode: 'edit'; item: T }; ··· 98 100 disableOverflow, 99 101 onPostSave, 100 102 onFieldChange, 103 + editRequestKey, 101 104 }: EditableSectionProps<T>) { 102 - const { profile, addItem, updateItem, removeItem } = useProfileEdit(); 105 + const { profile, addItem, updateItem, removeItem, editRequest, clearEditRequest } = 106 + useProfileEdit(); 107 + const shouldOpenAdd = Boolean(editRequestKey && editRequest === editRequestKey && isOwnProfile); 103 108 const [dialog, setDialog] = useState<DialogState<T> | null>(null); 109 + 110 + if (shouldOpenAdd && !dialog) { 111 + setDialog({ mode: 'add' }); 112 + clearEditRequest(); 113 + } 104 114 105 115 const rawItems = (profile[profileKey as keyof typeof profile] as T[] | undefined) ?? []; 106 116 const items = sortItems ? sortItems(rawItems) : rawItems;
+1
src/components/profile-sections/external-accounts-section.tsx
··· 156 156 profileKey="externalAccounts" 157 157 isOwnProfile={isOwnProfile} 158 158 fields={externalAccountFields} 159 + editRequestKey="externalAccounts" 159 160 toValues={externalAccountToValues} 160 161 fromValues={ 161 162 valuesToExternalAccount as (
+3
tests/components/career-section.test.tsx
··· 78 78 updateItem: mockUpdateItem, 79 79 removeItem: mockRemoveItem, 80 80 updateProfile: vi.fn(), 81 + editRequest: null, 82 + requestEdit: vi.fn(), 83 + clearEditRequest: vi.fn(), 81 84 }), 82 85 })); 83 86
+3
tests/components/data-transparency-card.test.tsx
··· 12 12 addItem: vi.fn(), 13 13 updateItem: vi.fn(), 14 14 removeItem: vi.fn(), 15 + editRequest: null, 16 + requestEdit: vi.fn(), 17 + clearEditRequest: vi.fn(), 15 18 }), 16 19 })); 17 20
+3
tests/components/editable-section.test.tsx
··· 28 28 updateItem: mockUpdateItem, 29 29 removeItem: mockRemoveItem, 30 30 updateProfile: vi.fn(), 31 + editRequest: null, 32 + requestEdit: vi.fn(), 33 + clearEditRequest: vi.fn(), 31 34 }), 32 35 })); 33 36
+3
tests/components/identity-card.a11y.test.tsx
··· 15 15 addItem: vi.fn(), 16 16 updateItem: vi.fn(), 17 17 removeItem: vi.fn(), 18 + editRequest: null, 19 + requestEdit: vi.fn(), 20 + clearEditRequest: vi.fn(), 18 21 }), 19 22 })); 20 23
+3
tests/components/identity-card.test.tsx
··· 61 61 addItem: vi.fn(), 62 62 updateItem: vi.fn(), 63 63 removeItem: vi.fn(), 64 + editRequest: null, 65 + requestEdit: vi.fn(), 66 + clearEditRequest: vi.fn(), 64 67 }), 65 68 })); 66 69
+3
tests/components/position-edit-dialog.test.tsx
··· 35 35 addItem: vi.fn(), 36 36 updateItem: vi.fn(), 37 37 removeItem: vi.fn(), 38 + editRequest: null, 39 + requestEdit: vi.fn(), 40 + clearEditRequest: vi.fn(), 38 41 }), 39 42 })); 40 43