diff --git a/studio/src/components/create-graph.tsx b/studio/src/components/create-graph.tsx index b8742cf298..cabe325db0 100644 --- a/studio/src/components/create-graph.tsx +++ b/studio/src/components/create-graph.tsx @@ -31,7 +31,7 @@ import { } from "@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery"; import Link from "next/link"; import { useRouter } from "next/router"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { z } from "zod"; import { EmptyState } from "./empty-state"; import { cn } from "@/lib/utils"; @@ -119,6 +119,13 @@ export const CreateGraphForm = ({ mode: "onChange", }); + // Sync form value when tags change (handles both direct updates and functional updaters) + useEffect(() => { + form.setValue("labelMatchers", tags as [Tag, ...Tag[]], { + shouldValidate: true, + }); + }, [tags, form]); + const { toast } = useToast(); const onSubmit: SubmitHandler = (data) => { @@ -261,36 +268,35 @@ export const CreateGraphForm = ({ { + // Pass through functional updaters unchanged so React can call them with latest state + // The useEffect above will sync form.setValue when tags actually changes setTags(newTags); - form.setValue( - "labelMatchers", - newTags as [Tag, ...Tag[]], - { - shouldValidate: true, - }, - ); }} - delimiterList={[" ", ",", "Enter"]} + // Commas are valid inside a matcher value list (e.g. team=A,team=B). + // Separate matchers with space or Enter (each matcher is AND-ed). + delimiterList={[" ", "Enter"]} activeTagIndex={activeTagIndex} setActiveTagIndex={setActiveTagIndex} allowDuplicates={false} /> - Comma-separated values in the form of key=value. These - will be used to match subgraphs for composition. Learn - more{" "} + Label matchers are used to select which subgraphs participate in this federated graph composition. + Enter space-separated key-value pairs in the format key=value. + To specify multiple values for the same key (OR condition), use commas within a single matcher (e.g., team=A,team=B matches subgraphs where team is either A or B). + {" "} - here. + Learn more + . diff --git a/studio/src/components/ui/tag-input/tag-input.tsx b/studio/src/components/ui/tag-input/tag-input.tsx index a2bd4b5aa7..0ac6f68f7d 100644 --- a/studio/src/components/ui/tag-input/tag-input.tsx +++ b/studio/src/components/ui/tag-input/tag-input.tsx @@ -26,7 +26,7 @@ export interface TagInputStyleClassesProps { export interface TagInputProps extends OmittedInputProps, - VariantProps { + VariantProps { placeholder?: string; tags: Tag[]; setTags: React.Dispatch>; @@ -93,7 +93,6 @@ const TagInput = React.forwardRef( } = props; const [inputValue, setInputValue] = React.useState(""); - const [tagCount, setTagCount] = React.useState(Math.max(0, tags.length)); const inputRef = React.useRef(null); if ( @@ -111,72 +110,127 @@ const TagInput = React.forwardRef( onInputChange?.(newValue); }; + const tryAddTag = + (rawText: string, nextTags: Tag[]) => { + const newTagText = rawText.trim(); + if (!newTagText) { + return nextTags; + } + if (!allowDuplicates && nextTags.some((tag) => tag.text === newTagText)) { + return nextTags; + } + if (maxTags !== undefined && nextTags.length >= maxTags) { + return nextTags; + } + const newTagId = crypto.randomUUID(); + onTagAdd?.(newTagText); + return [...nextTags, { id: newTagId, text: newTagText }]; + }; + + const escapeForCharClass = (value: string) => + value.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"); + + const commitInputValue = + (rawText: string, splitByDelimiters: boolean) => { + const trimmed = rawText.trim(); + if (!trimmed) { + setInputValue(""); + return; + } + + const charDelimiters = delimiterList.filter((d) => d.length === 1); + const nextTagTexts = + splitByDelimiters && charDelimiters.length + ? trimmed + .split( + new RegExp( + `[${charDelimiters.map(escapeForCharClass).join("")}]+`, + ), + ) + .map((t) => t.trim()) + .filter(Boolean) + : [trimmed]; + + // Use functional updater pattern to get latest state and avoid race conditions + setTags((prevTags) => { + let nextTags = prevTags; + for (const text of nextTagTexts) { + nextTags = tryAddTag(text, nextTags); + } + if (nextTags !== prevTags) { + return nextTags; + } + return prevTags; + }); + setInputValue(""); + }; + const handleInputFocus = (event: React.FocusEvent) => { setActiveTagIndex(null); // Reset active tag index when the input field gains focus onFocus?.(event); }; const handleInputBlur = (event: React.FocusEvent) => { + // If the user pasted/typed text and clicks outside (e.g. submit button), + // ensure the pending input becomes a tag. + commitInputValue(inputValue, true); onBlur?.(event); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (delimiterList.includes(e.key)) { e.preventDefault(); - const newTagText = inputValue.trim(); - - const newTagId = crypto.randomUUID(); - - if ( - newTagText && - (allowDuplicates || !tags.some((tag) => tag.text === newTagText)) && - (maxTags === undefined || tags.length < maxTags) - ) { - setTags([...tags, { id: newTagId, text: newTagText }]); - onTagAdd?.(newTagText); - setTagCount((prevTagCount) => prevTagCount + 1); - } - setInputValue(""); - } else { - switch (e.key) { - case "Backspace": - if (e.currentTarget.value === "") { - e.preventDefault(); - const newTags = [...tags]; - newTags.splice(tagCount - 1, 1); - setTags(newTags); - setTagCount(newTags.length); - } - break; - } + commitInputValue(inputValue, false); + } else if (e.key === "Backspace" && inputValue.length === 0) { + setTags((prevTags) => { + if (prevTags.length > 0) { + const removedTag = prevTags[prevTags.length - 1]; + const newTags = prevTags.slice(0, -1); + onTagRemove?.(removedTag.text); + return newTags; + } + return prevTags; + }); + e.preventDefault(); } }; + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const pastedText = e.clipboardData.getData("text/plain"); + commitInputValue(pastedText, true); + }; + const removeTag = (idToRemove: string) => { - setTags(tags.filter((tag) => tag.id !== idToRemove)); - onTagRemove?.(tags.find((tag) => tag.id === idToRemove)?.text || ""); - setTagCount((prevTagCount) => prevTagCount - 1); + setTags((prevTags) => { + const tagToRemove = prevTags.find((tag) => tag.id === idToRemove); + const newTags = prevTags.filter((tag) => tag.id !== idToRemove); + if (newTags.length !== prevTags.length) { + onTagRemove?.(tagToRemove?.text || ""); + return newTags; + } + return prevTags; + }); }; const truncatedTags = truncate ? tags.map((tag) => ({ - id: tag.id, - text: - tag.text?.length > truncate - ? `${tag.text.substring(0, truncate)}...` - : tag.text, - })) + id: tag.id, + text: + tag.text?.length > truncate + ? `${tag.text.substring(0, truncate)}...` + : tag.text, + })) : tags; return (
( onKeyDown={handleKeyDown} onFocus={handleInputFocus} onBlur={handleInputBlur} + onPaste={handlePaste} {...inputProps} className={cn( "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( {showCount && maxTags && (
- {`${tagCount}`}/{`${maxTags}`} + {`${tags.length}`}/{`${maxTags}`}
)}