Skip to content

Commit b188b46

Browse files
authored
feat: improve create graph form UX (#2468)
1 parent cfd93c1 commit b188b46

File tree

2 files changed

+118
-57
lines changed

2 files changed

+118
-57
lines changed

studio/src/components/create-graph.tsx

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
} from "@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery";
3232
import Link from "next/link";
3333
import { useRouter } from "next/router";
34-
import { useState } from "react";
34+
import { useState, useEffect } from "react";
3535
import { z } from "zod";
3636
import { EmptyState } from "./empty-state";
3737
import { cn } from "@/lib/utils";
@@ -119,6 +119,13 @@ export const CreateGraphForm = ({
119119
mode: "onChange",
120120
});
121121

122+
// Sync form value when tags change (handles both direct updates and functional updaters)
123+
useEffect(() => {
124+
form.setValue("labelMatchers", tags as [Tag, ...Tag[]], {
125+
shouldValidate: true,
126+
});
127+
}, [tags, form]);
128+
122129
const { toast } = useToast();
123130

124131
const onSubmit: SubmitHandler<GraphDetailsInput> = (data) => {
@@ -261,36 +268,35 @@ export const CreateGraphForm = ({
261268
<TagInput
262269
{...field}
263270
size="sm"
264-
placeholder="key=value, ..."
271+
placeholder="key1=value1,key2=value2 ..."
265272
tags={tags}
266273
setTags={(newTags) => {
274+
// Pass through functional updaters unchanged so React can call them with latest state
275+
// The useEffect above will sync form.setValue when tags actually changes
267276
setTags(newTags);
268-
form.setValue(
269-
"labelMatchers",
270-
newTags as [Tag, ...Tag[]],
271-
{
272-
shouldValidate: true,
273-
},
274-
);
275277
}}
276-
delimiterList={[" ", ",", "Enter"]}
278+
// Commas are valid inside a matcher value list (e.g. team=A,team=B).
279+
// Separate matchers with space or Enter (each matcher is AND-ed).
280+
delimiterList={[" ", "Enter"]}
277281
activeTagIndex={activeTagIndex}
278282
setActiveTagIndex={setActiveTagIndex}
279283
allowDuplicates={false}
280284
/>
281285
</FormControl>
282286
<FormDescription className="text-left">
283-
Comma-separated values in the form of key=value. These
284-
will be used to match subgraphs for composition. Learn
285-
more{" "}
287+
Label matchers are used to select which subgraphs participate in this federated graph composition.
288+
Enter space-separated key-value pairs in the format <code>key=value</code>.
289+
To specify multiple values for the same key (OR condition), use commas within a single matcher (e.g., <code>team=A,team=B</code> matches subgraphs where team is either A or B).
290+
{" "}
286291
<Link
287292
href={docsBaseURL + "/cli/essentials#label-matcher"}
288293
className="text-primary"
289294
target="_blank"
290295
rel="noreferrer"
291296
>
292-
here.
297+
Learn more
293298
</Link>
299+
.
294300
</FormDescription>
295301
<FormMessage />
296302
</FormItem>

studio/src/components/ui/tag-input/tag-input.tsx

Lines changed: 98 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export interface TagInputStyleClassesProps {
2626

2727
export interface TagInputProps
2828
extends OmittedInputProps,
29-
VariantProps<typeof tagVariants> {
29+
VariantProps<typeof tagVariants> {
3030
placeholder?: string;
3131
tags: Tag[];
3232
setTags: React.Dispatch<React.SetStateAction<Tag[]>>;
@@ -93,7 +93,6 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
9393
} = props;
9494

9595
const [inputValue, setInputValue] = React.useState("");
96-
const [tagCount, setTagCount] = React.useState(Math.max(0, tags.length));
9796
const inputRef = React.useRef<HTMLInputElement>(null);
9897

9998
if (
@@ -111,72 +110,127 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
111110
onInputChange?.(newValue);
112111
};
113112

113+
const tryAddTag =
114+
(rawText: string, nextTags: Tag[]) => {
115+
const newTagText = rawText.trim();
116+
if (!newTagText) {
117+
return nextTags;
118+
}
119+
if (!allowDuplicates && nextTags.some((tag) => tag.text === newTagText)) {
120+
return nextTags;
121+
}
122+
if (maxTags !== undefined && nextTags.length >= maxTags) {
123+
return nextTags;
124+
}
125+
const newTagId = crypto.randomUUID();
126+
onTagAdd?.(newTagText);
127+
return [...nextTags, { id: newTagId, text: newTagText }];
128+
};
129+
130+
const escapeForCharClass = (value: string) =>
131+
value.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&");
132+
133+
const commitInputValue =
134+
(rawText: string, splitByDelimiters: boolean) => {
135+
const trimmed = rawText.trim();
136+
if (!trimmed) {
137+
setInputValue("");
138+
return;
139+
}
140+
141+
const charDelimiters = delimiterList.filter((d) => d.length === 1);
142+
const nextTagTexts =
143+
splitByDelimiters && charDelimiters.length
144+
? trimmed
145+
.split(
146+
new RegExp(
147+
`[${charDelimiters.map(escapeForCharClass).join("")}]+`,
148+
),
149+
)
150+
.map((t) => t.trim())
151+
.filter(Boolean)
152+
: [trimmed];
153+
154+
// Use functional updater pattern to get latest state and avoid race conditions
155+
setTags((prevTags) => {
156+
let nextTags = prevTags;
157+
for (const text of nextTagTexts) {
158+
nextTags = tryAddTag(text, nextTags);
159+
}
160+
if (nextTags !== prevTags) {
161+
return nextTags;
162+
}
163+
return prevTags;
164+
});
165+
setInputValue("");
166+
};
167+
114168
const handleInputFocus = (event: React.FocusEvent<HTMLInputElement>) => {
115169
setActiveTagIndex(null); // Reset active tag index when the input field gains focus
116170
onFocus?.(event);
117171
};
118172

119173
const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => {
174+
// If the user pasted/typed text and clicks outside (e.g. submit button),
175+
// ensure the pending input becomes a tag.
176+
commitInputValue(inputValue, true);
120177
onBlur?.(event);
121178
};
122179

123180
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
124181
if (delimiterList.includes(e.key)) {
125182
e.preventDefault();
126-
const newTagText = inputValue.trim();
127-
128-
const newTagId = crypto.randomUUID();
129-
130-
if (
131-
newTagText &&
132-
(allowDuplicates || !tags.some((tag) => tag.text === newTagText)) &&
133-
(maxTags === undefined || tags.length < maxTags)
134-
) {
135-
setTags([...tags, { id: newTagId, text: newTagText }]);
136-
onTagAdd?.(newTagText);
137-
setTagCount((prevTagCount) => prevTagCount + 1);
138-
}
139-
setInputValue("");
140-
} else {
141-
switch (e.key) {
142-
case "Backspace":
143-
if (e.currentTarget.value === "") {
144-
e.preventDefault();
145-
const newTags = [...tags];
146-
newTags.splice(tagCount - 1, 1);
147-
setTags(newTags);
148-
setTagCount(newTags.length);
149-
}
150-
break;
151-
}
183+
commitInputValue(inputValue, false);
184+
} else if (e.key === "Backspace" && inputValue.length === 0) {
185+
setTags((prevTags) => {
186+
if (prevTags.length > 0) {
187+
const removedTag = prevTags[prevTags.length - 1];
188+
const newTags = prevTags.slice(0, -1);
189+
onTagRemove?.(removedTag.text);
190+
return newTags;
191+
}
192+
return prevTags;
193+
});
194+
e.preventDefault();
152195
}
153196
};
154197

198+
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
199+
e.preventDefault();
200+
const pastedText = e.clipboardData.getData("text/plain");
201+
commitInputValue(pastedText, true);
202+
};
203+
155204
const removeTag = (idToRemove: string) => {
156-
setTags(tags.filter((tag) => tag.id !== idToRemove));
157-
onTagRemove?.(tags.find((tag) => tag.id === idToRemove)?.text || "");
158-
setTagCount((prevTagCount) => prevTagCount - 1);
205+
setTags((prevTags) => {
206+
const tagToRemove = prevTags.find((tag) => tag.id === idToRemove);
207+
const newTags = prevTags.filter((tag) => tag.id !== idToRemove);
208+
if (newTags.length !== prevTags.length) {
209+
onTagRemove?.(tagToRemove?.text || "");
210+
return newTags;
211+
}
212+
return prevTags;
213+
});
159214
};
160215

161216
const truncatedTags = truncate
162217
? tags.map((tag) => ({
163-
id: tag.id,
164-
text:
165-
tag.text?.length > truncate
166-
? `${tag.text.substring(0, truncate)}...`
167-
: tag.text,
168-
}))
218+
id: tag.id,
219+
text:
220+
tag.text?.length > truncate
221+
? `${tag.text.substring(0, truncate)}...`
222+
: tag.text,
223+
}))
169224
: tags;
170225

171226
return (
172227
<div
173-
className={`flex w-full ${
174-
inputFieldPosition === "bottom"
175-
? "flex-col"
176-
: inputFieldPosition === "top"
228+
className={`flex w-full ${inputFieldPosition === "bottom"
229+
? "flex-col"
230+
: inputFieldPosition === "top"
177231
? "flex-col-reverse"
178232
: "flex-row"
179-
}`}
233+
}`}
180234
>
181235
<div className="w-full">
182236
<div
@@ -220,6 +274,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
220274
onKeyDown={handleKeyDown}
221275
onFocus={handleInputFocus}
222276
onBlur={handleInputBlur}
277+
onPaste={handlePaste}
223278
{...inputProps}
224279
className={cn(
225280
"h-5 w-fit flex-1 border-0 bg-transparent px-1.5 focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0",
@@ -235,7 +290,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
235290
{showCount && maxTags && (
236291
<div className="flex">
237292
<span className="ml-auto mt-1 text-sm text-muted-foreground">
238-
{`${tagCount}`}/{`${maxTags}`}
293+
{`${tags.length}`}/{`${maxTags}`}
239294
</span>
240295
</div>
241296
)}

0 commit comments

Comments
 (0)