Highly ambitious ATProtocol AppView service and sdks
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}