diff --git a/src/ui/components/NodeList/NodeRow.tsx b/src/ui/components/NodeList/NodeRow.tsx index 46b89e90..e70e256c 100644 --- a/src/ui/components/NodeList/NodeRow.tsx +++ b/src/ui/components/NodeList/NodeRow.tsx @@ -9,6 +9,7 @@ import { HtmlText } from "../Shared/HtmlText"; type Props = { node: PartialNodeInfo; + duplicatesCount?: number; action?: ComponentChildren; keyComponent?: ComponentChildren; nsComponent?: ComponentChildren; @@ -18,6 +19,7 @@ type Props = { export const NodeRow = ({ node, + duplicatesCount, action, keyComponent, nsComponent, @@ -64,6 +66,9 @@ export const NodeRow = ({ {Object.keys(node.paramsValues ?? {}).length > 0 && ( parameters )} + {typeof duplicatesCount === "number" && duplicatesCount > 1 && ( + {`${duplicatesCount}x`} + )} )}
diff --git a/src/ui/views/Connect/Connect.tsx b/src/ui/views/Connect/Connect.tsx index 76f6e44a..33129a57 100644 --- a/src/ui/views/Connect/Connect.tsx +++ b/src/ui/views/Connect/Connect.tsx @@ -24,13 +24,16 @@ import { useAllTranslations } from "@/ui/hooks/useAllTranslations"; type Props = RouteParam<"connect">; -export const Connect = ({ node }: Props) => { +export const Connect = ({ nodes }: Props) => { const { setRoute } = useGlobalActions(); const config = useGlobalState((c) => c.config); const language = useGlobalState((c) => c.config?.language); - const [search, setSearch] = useState(node.key || node.characters); + const primaryNode = nodes[0]; + const [search, setSearch] = useState( + primaryNode?.key || primaryNode?.characters || "" + ); const [debouncedSearch] = useDebounce(search, 1000); @@ -59,6 +62,10 @@ export const Connect = ({ node }: Props) => { ns: string | undefined, translation: string | undefined ) => { + if (nodes.length === 0) { + setRoute("index"); + return; + } if ( !allTranslationsLoadable.isLoading && allTranslationsLoadable.translationsData == null @@ -72,46 +79,40 @@ export const Connect = ({ node }: Props) => { if (tolgeeTranslation) { translation = tolgeeTranslation.translation; await setNodesDataMutation.mutateAsync({ - nodes: [ - { - ...node, - translation: tolgeeTranslation.translation || node.characters, - isPlural: tolgeeTranslation.keyIsPlural, - pluralParamValue: tolgeeTranslation.keyPluralArgName, - key, - ns: ns || "", - connected: true, - }, - ], + nodes: nodes.map((node) => ({ + ...node, + translation: tolgeeTranslation.translation || node.characters, + isPlural: tolgeeTranslation.keyIsPlural, + pluralParamValue: tolgeeTranslation.keyPluralArgName, + key, + ns: ns || "", + connected: true, + })), }); setRoute("index"); return; } } await setNodesDataMutation.mutateAsync({ - nodes: [ - { - ...node, - translation: translation || node.characters, - key, - ns: ns || "", - connected: true, - }, - ], + nodes: nodes.map((node) => ({ + ...node, + translation: translation || node.characters, + key, + ns: ns || "", + connected: true, + })), }); setRoute("index"); }; const handleRemoveConnection = async () => { await setNodesDataMutation.mutateAsync({ - nodes: [ - { - ...node, - key: "", - ns: undefined, - connected: false, - }, - ], + nodes: nodes.map((node) => ({ + ...node, + key: "", + ns: undefined, + connected: false, + })), }); setRoute("index"); }; @@ -158,7 +159,7 @@ export const Connect = ({ node }: Props) => { ))}
- {node.connected && ( + {primaryNode?.connected && ( diff --git a/src/ui/views/Index/Index.tsx b/src/ui/views/Index/Index.tsx index fd86e9ee..33cd6779 100644 --- a/src/ui/views/Index/Index.tsx +++ b/src/ui/views/Index/Index.tsx @@ -24,6 +24,7 @@ import styles from "./Index.css"; import { ListItem } from "./ListItem"; import { useEditorMode } from "../../hooks/useEditorMode"; import { useHasNamespacesEnabled } from "../../hooks/useHasNamespacesEnabled"; +import { NodeInfo } from "@/types"; export const Index = () => { const selectionLoadable = useSelectedNodes(); @@ -93,6 +94,31 @@ export const Index = () => { const nothingSelected = !selectionLoadable.data?.somethingSelected; + const groupedSelection = useMemo(() => { + const groups = new Map(); + selection.forEach((node) => { + const textKey = node.characters.trim(); + const groupKey = node.connected + ? `connected:${node.key}::${node.ns ?? ""}::${textKey}` + : `unconnected:${textKey}`; + const group = groups.get(groupKey); + if (group) { + group.push(node); + } else { + groups.set(groupKey, [node]); + } + }); + + return Array.from(groups.values()) + .map((nodes, index) => ({ + id: `group-${index}-${nodes[0]?.id ?? ""}`, + key: nodes[0]?.characters.trim() ?? "", + nodes, + duplicatesCount: nodes.length, + })) + .sort((a, b) => a.key.localeCompare(b.key)); + }, [selection]); + const handleLanguageChange = (lang: string) => { if (lang !== language) { setRoute("pull", { lang }); @@ -102,10 +128,12 @@ export const Index = () => { const defaultNamespace = useGlobalState((c) => c.config?.namespace); const handlePush = () => { - const subjectNodes = selection.map((node) => ({ - ...node, - ns: node.ns ?? defaultNamespace, - })); + const subjectNodes = groupedSelection + .flatMap((group) => group.nodes) + .map((node) => ({ + ...node, + ns: node.ns ?? defaultNamespace, + })); const conflicts = getConflictingNodes(subjectNodes); if (conflicts.length > 0) { const keys = Array.from(new Set(conflicts.map((n) => n.key))); @@ -131,7 +159,7 @@ export const Index = () => { useEffect(() => { setError(undefined); - }, [selection]); + }, [groupedSelection]); const editorMode = useEditorMode(); @@ -235,11 +263,13 @@ export const Index = () => { ) : ( ( + items={groupedSelection} + row={(group) => ( ({ name, }))} diff --git a/src/ui/views/Index/ListItem.tsx b/src/ui/views/Index/ListItem.tsx index 57985b61..efc1ead6 100644 --- a/src/ui/views/Index/ListItem.tsx +++ b/src/ui/views/Index/ListItem.tsx @@ -17,6 +17,8 @@ type UsedNamespaceModel = components["schemas"]["UsedNamespaceModel"]; type Props = { node: NodeInfo; + groupedNodes?: NodeInfo[]; + duplicatesCount?: number; loadedNamespaces: UsedNamespaceModel[] | undefined; hasNamespacesEnabled: boolean; onRefreshNamespaces?: () => void; @@ -24,11 +26,20 @@ type Props = { export const ListItem = ({ node, + groupedNodes, + duplicatesCount, loadedNamespaces, hasNamespacesEnabled, onRefreshNamespaces, }: Props) => { const nodeId = node?.id ?? ""; + const effectiveNodes = useMemo( + () => + groupedNodes && groupedNodes.length > 0 + ? groupedNodes + : [node], + [groupedNodes, node] + ); const tolgeeConfig = useGlobalState((c) => c.config); const prefilledKey = usePrefilledKey( @@ -62,15 +73,26 @@ export const ListItem = ({ // Debounced mutation: only update Figma nodes after user stops typing useEffect(() => { - if (!node.connected && debouncedKeyName !== (node.key || "")) { + const hasConnected = effectiveNodes.some((current) => current.connected); + if (hasConnected) { + return; + } + const shouldUpdate = effectiveNodes.some( + (current) => debouncedKeyName !== (current.key || "") + ); + if (shouldUpdate) { setNodesDataMutation.mutate({ - nodes: [{ ...node, key: debouncedKeyName, ns: namespace }], + nodes: effectiveNodes.map((current) => ({ + ...current, + key: debouncedKeyName, + ns: namespace, + })), }); } - }, [debouncedKeyName, namespace, node, node.connected]); + }, [debouncedKeyName, namespace, effectiveNodes]); - const handleConnect = (node: NodeInfo) => { - setRoute("connect", { node }); + const handleConnect = () => { + setRoute("connect", { nodes: effectiveNodes }); }; const namespaces = useMemo( @@ -89,25 +111,43 @@ export const ListItem = ({ }; useEffect(() => { - if (!node.connected && keyName && namespace !== node.ns) { + const hasConnected = effectiveNodes.some((current) => current.connected); + if (hasConnected || !keyName) { + return; + } + const shouldUpdate = effectiveNodes.some( + (current) => current.ns !== namespace + ); + if (shouldUpdate) { setNodesDataMutation.mutate({ - nodes: [{ ...node, key: keyName, ns: namespace }], + nodes: effectiveNodes.map((current) => ({ + ...current, + key: keyName, + ns: namespace, + })), }); } - }, [namespace, node.connected]); + }, [namespace, keyName, effectiveNodes]); - const handleNsChange = (node: NodeInfo) => (value: string) => { + const handleNsChange = () => (value: string) => { setNamespace(value); setNodesDataMutation.mutate({ - nodes: [{ ...node, key: keyName, ns: value }], + nodes: effectiveNodes.map((current) => ({ + ...current, + key: keyName, + ns: value, + })), + }); + effectiveNodes.forEach((current) => { + current.key = keyName; + current.ns = value; }); - node.key = keyName; - node.ns = value; }; return ( @@ -119,7 +159,7 @@ export const ListItem = ({ ) @@ -134,7 +174,7 @@ export const ListItem = ({ ? "Connect to existing key" : "Edit key connection" } - onClick={() => handleConnect(node)} + onClick={handleConnect} className={styles.connectButton} > {node.connected ? ( diff --git a/src/ui/views/routes.ts b/src/ui/views/routes.ts index 0ef06917..6cf3a416 100644 --- a/src/ui/views/routes.ts +++ b/src/ui/views/routes.ts @@ -6,7 +6,7 @@ export type Route = | ["push"] | ["string_details", { node: NodeInfo }] | ["pull", { lang: string }] - | ["connect", { node: NodeInfo }] + | ["connect", { nodes: NodeInfo[] }] | ["create_copy"]; export type RouteKey = Route[0];