Highly ambitious ATProtocol AppView service and sdks
at main 392 lines 12 kB view raw
1import { useState } from "react"; 2import { graphql, useMutation, ConnectionHandler } from "react-relay"; 3import { isValidNsid } from "@slices/lexicon"; 4import { Dialog } from "./Dialog.tsx"; 5import { FormControl } from "./FormControl.tsx"; 6import { Textarea } from "./Textarea.tsx"; 7import { Button } from "./Button.tsx"; 8import { PublishedLexiconsList } from "./PublishedLexiconsList.tsx"; 9import type { CreateLexiconDialogMutation } from "../__generated__/CreateLexiconDialogMutation.graphql.ts"; 10import "../components/LexiconTree.tsx"; // Import for fragment 11 12interface CreateLexiconDialogProps { 13 open: boolean; 14 onClose: () => void; 15 sliceUri: string; 16 existingNsids: string[]; 17} 18 19type SourceType = 'published' | 'new' | null; 20 21export function CreateLexiconDialog({ 22 open, 23 onClose, 24 sliceUri, 25 existingNsids, 26}: CreateLexiconDialogProps) { 27 const [step, setStep] = useState<1 | 2>(1); 28 const [sourceType, setSourceType] = useState<SourceType>(null); 29 const [lexiconJson, setLexiconJson] = useState(""); 30 const [error, setError] = useState(""); 31 const [isValidating, setIsValidating] = useState(false); 32 33 const [commitMutation, isMutationInFlight] = 34 useMutation<CreateLexiconDialogMutation>( 35 graphql` 36 mutation CreateLexiconDialogMutation( 37 $input: NetworkSlicesLexiconInput! 38 ) { 39 createNetworkSlicesLexicon(input: $input) { 40 id 41 uri 42 cid 43 nsid 44 description 45 definitions 46 createdAt 47 updatedAt 48 excludedFromSync 49 slice 50 ...LexiconTree_lexicon 51 } 52 } 53 ` 54 ); 55 56 const handleSubmit = async (e: React.FormEvent) => { 57 e.preventDefault(); 58 e.stopPropagation(); 59 setError(""); 60 setIsValidating(true); 61 62 try { 63 if (!lexiconJson.trim()) { 64 setError("Lexicon JSON is required"); 65 return; 66 } 67 68 let lexiconData; 69 try { 70 lexiconData = JSON.parse(lexiconJson); 71 } catch (parseError) { 72 setError( 73 `Failed to parse lexicon JSON: ${ 74 parseError instanceof Error ? parseError.message : String(parseError) 75 }` 76 ); 77 return; 78 } 79 80 // Basic validation for data integrity 81 const defs = lexiconData.defs || lexiconData.definitions; 82 const id = lexiconData.id || lexiconData.nsid; 83 84 if (!id) { 85 setError("Lexicon must have an 'id' field"); 86 return; 87 } 88 89 if (!defs) { 90 setError("Lexicon must have a 'defs' field"); 91 return; 92 } 93 94 // Validate NSID format (needs at least 3 segments like "com.example.collection") 95 if (!(await isValidNsid(id))) { 96 setError(`Invalid lexicon ID: "${id}". Must be a valid NSID with at least 3 segments (e.g., "com.example.post")`); 97 return; 98 } 99 100 // Check for duplicate NSID 101 if (existingNsids.includes(id)) { 102 setError(`A lexicon with NSID "${id}" already exists in this slice. Please use a different NSID.`); 103 return; 104 } 105 106 const nsid = id; 107 const definitionsString = JSON.stringify(defs); 108 109 commitMutation({ 110 variables: { 111 input: { 112 nsid, 113 description: lexiconData.description || "", 114 definitions: definitionsString, 115 slice: sliceUri, 116 createdAt: new Date().toISOString(), 117 excludedFromSync: false, 118 }, 119 }, 120 onCompleted: () => { 121 setLexiconJson(""); 122 setError(""); 123 onClose(); 124 }, 125 onError: (err) => { 126 setError(err.message || "Failed to create lexicon"); 127 }, 128 updater: (store) => { 129 const newLexicon = store.getRootField("createNetworkSlicesLexicon"); 130 if (!newLexicon) return; 131 132 // Extract the rkey from the slice URI (e.g., "at://did/collection/rkey" -> "rkey") 133 const sliceRkey = sliceUri.split("/").pop(); 134 if (!sliceRkey) return; 135 136 // Use ConnectionHandler to get the connection 137 const root = store.getRoot(); 138 const connection = ConnectionHandler.getConnection( 139 root, 140 "SliceOverview_networkSlicesLexicons", 141 { 142 where: { 143 slice: { contains: sliceRkey } 144 } 145 } 146 ); 147 148 if (connection) { 149 // Create and insert a new edge 150 const newEdge = ConnectionHandler.createEdge( 151 store, 152 connection, 153 newLexicon, 154 "NetworkSlicesLexiconEdge" 155 ); 156 ConnectionHandler.insertEdgeAfter(connection, newEdge); 157 } 158 }, 159 }); 160 } finally { 161 setIsValidating(false); 162 } 163 }; 164 165 const handleClose = () => { 166 if (isValidating) { 167 return; // Prevent closing while validation is in progress 168 } 169 setStep(1); 170 setSourceType(null); 171 setLexiconJson(""); 172 setError(""); 173 setIsValidating(false); 174 onClose(); 175 }; 176 177 const handleSourceSelect = (type: SourceType) => { 178 setSourceType(type); 179 setStep(2); 180 setError(""); 181 }; 182 183 const handleBack = () => { 184 setStep(1); 185 setSourceType(null); 186 setLexiconJson(""); 187 setError(""); 188 }; 189 190 return ( 191 <Dialog 192 open={open} 193 onClose={handleClose} 194 title={step === 1 ? "Add Lexicon Definition" : sourceType === 'published' ? "Select Published Lexicon" : "Create New Lexicon"} 195 maxWidth="xl" 196 > 197 {error && ( 198 <div className="mb-4 p-3 bg-red-900/20 border border-red-800 text-red-300 rounded text-sm whitespace-pre-wrap"> 199 {error} 200 </div> 201 )} 202 203 {step === 1 ? ( 204 <div className="space-y-4"> 205 <p className="text-sm text-zinc-400 mb-4"> 206 Choose how you'd like to add a lexicon: 207 </p> 208 209 <div className="space-y-3"> 210 <button 211 type="button" 212 onClick={() => handleSourceSelect('published')} 213 className="w-full text-left p-4 bg-zinc-900/50 hover:bg-zinc-800/50 border border-zinc-800 hover:border-zinc-700 rounded transition-colors" 214 > 215 <h3 className="text-sm font-medium text-zinc-200 mb-1"> 216 Add from Published Lexicons 217 </h3> 218 <p className="text-xs text-zinc-500"> 219 Browse and select from community-published AT Protocol lexicons 220 </p> 221 </button> 222 223 <button 224 type="button" 225 onClick={() => handleSourceSelect('new')} 226 className="w-full text-left p-4 bg-zinc-900/50 hover:bg-zinc-800/50 border border-zinc-800 hover:border-zinc-700 rounded transition-colors" 227 > 228 <h3 className="text-sm font-medium text-zinc-200 mb-1"> 229 Create New Lexicon 230 </h3> 231 <p className="text-xs text-zinc-500"> 232 Write a custom lexicon definition from scratch 233 </p> 234 </button> 235 </div> 236 237 <div className="flex justify-end gap-3 pt-4"> 238 <Button 239 type="button" 240 variant="default" 241 onClick={handleClose} 242 > 243 Cancel 244 </Button> 245 </div> 246 </div> 247 ) : sourceType === 'new' ? ( 248 <form className="space-y-4"> 249 <FormControl label="Lexicon JSON"> 250 <Textarea 251 value={lexiconJson} 252 onChange={(e) => setLexiconJson(e.target.value)} 253 rows={16} 254 className="font-mono" 255 placeholder={`{ 256 "lexicon": 1, 257 "id": "network.slices.example", 258 "description": "Example record type", 259 "defs": { 260 "main": { 261 "type": "record", 262 "key": "tid", 263 "record": { 264 "type": "object", 265 "required": ["text", "createdAt"], 266 "properties": { 267 "text": { 268 "type": "string", 269 "maxLength": 300 270 }, 271 "createdAt": { 272 "type": "string", 273 "format": "datetime" 274 } 275 } 276 } 277 } 278 } 279}`} 280 disabled={isMutationInFlight} 281 /> 282 <p className="mt-1 text-xs text-zinc-500"> 283 Paste a valid AT Protocol lexicon definition in JSON format 284 </p> 285 </FormControl> 286 287 <div className="flex justify-between gap-3 pt-4"> 288 <Button 289 type="button" 290 variant="default" 291 onClick={handleBack} 292 disabled={isMutationInFlight} 293 > 294 Back 295 </Button> 296 <div className="flex gap-3"> 297 <Button 298 type="button" 299 variant="default" 300 onClick={handleClose} 301 disabled={isMutationInFlight} 302 > 303 Cancel 304 </Button> 305 <Button 306 type="button" 307 variant="primary" 308 onClick={(e) => { 309 e.preventDefault(); 310 e.stopPropagation(); 311 handleSubmit(e); 312 }} 313 disabled={isMutationInFlight || isValidating} 314 > 315 {isMutationInFlight ? "Adding..." : "Add Lexicon"} 316 </Button> 317 </div> 318 </div> 319 </form> 320 ) : ( 321 <PublishedLexiconsList 322 existingNsids={existingNsids} 323 onSelect={(lexicons) => { 324 // Add all lexicons directly without going to JSON editor 325 lexicons.forEach((lexicon) => { 326 const lexiconData = lexicon.data as Record<string, unknown>; 327 const defs = lexiconData.defs || lexiconData.definitions; 328 const nsid = lexicon.nsid; 329 const definitionsString = JSON.stringify(defs); 330 331 commitMutation({ 332 variables: { 333 input: { 334 nsid, 335 description: (lexiconData.description as string) || "", 336 definitions: definitionsString, 337 slice: sliceUri, 338 createdAt: new Date().toISOString(), 339 excludedFromSync: false, 340 }, 341 }, 342 onCompleted: () => { 343 // Only close dialog after all mutations complete 344 // (This will be called for each lexicon) 345 }, 346 onError: (err) => { 347 setError(err.message || "Failed to create lexicon"); 348 }, 349 updater: (store) => { 350 const newLexicon = store.getRootField("createNetworkSlicesLexicon"); 351 if (!newLexicon) return; 352 353 // Extract the rkey from the slice URI (e.g., "at://did/collection/rkey" -> "rkey") 354 const sliceRkey = sliceUri.split("/").pop(); 355 if (!sliceRkey) return; 356 357 // Use ConnectionHandler to get the connection 358 const root = store.getRoot(); 359 const connection = ConnectionHandler.getConnection( 360 root, 361 "SliceOverview_networkSlicesLexicons", 362 { 363 where: { 364 slice: { contains: sliceRkey } 365 } 366 } 367 ); 368 369 if (connection) { 370 // Create and insert a new edge 371 const newEdge = ConnectionHandler.createEdge( 372 store, 373 connection, 374 newLexicon, 375 "NetworkSlicesLexiconEdge" 376 ); 377 ConnectionHandler.insertEdgeAfter(connection, newEdge); 378 } 379 }, 380 }); 381 }); 382 383 // Close dialog after submitting all mutations 384 handleClose(); 385 }} 386 onBack={handleBack} 387 onCancel={handleClose} 388 /> 389 )} 390 </Dialog> 391 ); 392}