@burdenoff/microfe-vibecontrols 2026.524.7 → 2026.524.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,273 +1,292 @@
|
|
|
1
1
|
import { useTr as e } from "../../shared/hooks/useTr.js";
|
|
2
|
-
import { useAvailableTags as t, useCreateVibecontrolsTag as n, useGetEntityTags as
|
|
3
|
-
import { useCallback as
|
|
4
|
-
import { Loader2 as
|
|
5
|
-
import {
|
|
6
|
-
import { jsx as p, jsxs as m } from "react/jsx-runtime";
|
|
2
|
+
import { useAvailableTags as t, useCreateVibecontrolsTag as n, useGetEntityTags as ee, useSearchTags as te, useTagEntity as ne, useUntagEntity as re } from "../../hooks/useEntityTags.js";
|
|
3
|
+
import { useCallback as r, useEffect as i, useMemo as a, useRef as o, useState as s } from "react";
|
|
4
|
+
import { Loader2 as c, Plus as l, Search as ie, Tag as ae, X as u } from "lucide-react";
|
|
5
|
+
import { jsx as d, jsxs as f } from "react/jsx-runtime";
|
|
7
6
|
//#region src/components/shared/EntityTagPicker.tsx
|
|
8
|
-
var
|
|
9
|
-
function
|
|
10
|
-
let
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}, [
|
|
14
|
-
let { data: j, loading:
|
|
15
|
-
|
|
7
|
+
var oe = 4e3, p = 250;
|
|
8
|
+
function m({ entityType: m, entityId: h, onDraftChange: se, readOnly: g = !1 }) {
|
|
9
|
+
let _ = e(), v = !h, [y, b] = s(!1), [x, S] = s(""), [ce, C] = s(""), [w, T] = s(null), [E, D] = s([]), [O, k] = s([]), A = o(0);
|
|
10
|
+
i(() => {
|
|
11
|
+
v && se?.(E.map((e) => e.id));
|
|
12
|
+
}, [v, E]);
|
|
13
|
+
let { data: j, loading: M, refetch: N } = ee(m, h ?? ""), { data: P, loading: F } = t(200, { skip: v && !y }), [I, { data: L, loading: R }] = te(), [z, { loading: B }] = ne(), [V, { loading: le }] = re(), [H, { loading: U }] = n();
|
|
14
|
+
i(() => {
|
|
15
|
+
v || j?.vibecontrolsEntityTags === void 0 || A.current > 0 || k(j.vibecontrolsEntityTags);
|
|
16
|
+
}, [v, j]);
|
|
17
|
+
let W = a(() => {
|
|
18
|
+
let e = P?.availableVibecontrolsTags ?? [];
|
|
16
19
|
return new Map(e.map((e) => [e.id, e]));
|
|
17
|
-
}, [
|
|
20
|
+
}, [P]), G = a(() => v ? E.map((e) => ({
|
|
18
21
|
key: e.id,
|
|
19
22
|
tagId: e.id,
|
|
20
23
|
label: e.label
|
|
21
|
-
})) :
|
|
24
|
+
})) : O.map((e) => ({
|
|
22
25
|
key: e.id,
|
|
23
26
|
tagId: e.tagId,
|
|
24
|
-
label: e.
|
|
27
|
+
label: e.tagInfo?.label ?? W.get(e.tagId)?.label ?? _("vibecontrols.shared.entityTagPicker.untitledTag", "Untitled tag")
|
|
25
28
|
})), [
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
v,
|
|
30
|
+
E,
|
|
31
|
+
O,
|
|
28
32
|
W,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
let e = setTimeout(() => O(null), ae);
|
|
33
|
+
_
|
|
34
|
+
]), K = a(() => new Set(G.map((e) => e.tagId)), [G]), q = a(() => (x ? L?.searchVibecontrolsTags : P?.availableVibecontrolsTags) ?? [], [
|
|
35
|
+
x,
|
|
36
|
+
L,
|
|
37
|
+
P
|
|
38
|
+
]), J = a(() => q.filter((e) => !K.has(e.id)), [q, K]);
|
|
39
|
+
i(() => {
|
|
40
|
+
if (!w) return;
|
|
41
|
+
let e = setTimeout(() => T(null), oe);
|
|
39
42
|
return () => clearTimeout(e);
|
|
40
|
-
}, [
|
|
41
|
-
let
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
}, [w]);
|
|
44
|
+
let Y = o(null);
|
|
45
|
+
i(() => () => {
|
|
46
|
+
Y.current && clearTimeout(Y.current);
|
|
44
47
|
}, []);
|
|
45
|
-
let
|
|
46
|
-
|
|
48
|
+
let ue = r((e) => {
|
|
49
|
+
S(e), Y.current && clearTimeout(Y.current);
|
|
47
50
|
let t = e.trim();
|
|
48
51
|
if (t.length < 2) {
|
|
49
|
-
|
|
52
|
+
C("");
|
|
50
53
|
return;
|
|
51
54
|
}
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
Y.current = setTimeout(() => {
|
|
56
|
+
C(t), I({ variables: {
|
|
54
57
|
query: t,
|
|
55
58
|
limit: 10
|
|
56
59
|
} });
|
|
57
|
-
},
|
|
58
|
-
}, [
|
|
59
|
-
if (
|
|
60
|
-
|
|
60
|
+
}, p);
|
|
61
|
+
}, [I]), X = r(async (e) => {
|
|
62
|
+
if (v) {
|
|
63
|
+
D((t) => t.some((t) => t.id === e.id) ? t : [...t, e]), S(""), T(null);
|
|
61
64
|
return;
|
|
62
65
|
}
|
|
66
|
+
let t = `optimistic-${e.id}`, n = {
|
|
67
|
+
id: t,
|
|
68
|
+
tagId: e.id,
|
|
69
|
+
tagInfo: {
|
|
70
|
+
id: e.id,
|
|
71
|
+
key: e.key,
|
|
72
|
+
label: e.label,
|
|
73
|
+
color: e.color
|
|
74
|
+
},
|
|
75
|
+
entityType: m,
|
|
76
|
+
entityId: h ?? "",
|
|
77
|
+
source: "manual",
|
|
78
|
+
assignedBy: null,
|
|
79
|
+
assignedAt: null
|
|
80
|
+
};
|
|
81
|
+
k((t) => t.some((t) => t.tagId === e.id) ? t : [...t, n]), S(""), T(null), A.current++;
|
|
63
82
|
try {
|
|
64
|
-
await
|
|
65
|
-
entityType:
|
|
66
|
-
entityId:
|
|
83
|
+
let n = (await z({ variables: { input: {
|
|
84
|
+
entityType: m,
|
|
85
|
+
entityId: h ?? "",
|
|
67
86
|
tagId: e.id
|
|
68
|
-
} } })
|
|
69
|
-
|
|
70
|
-
fieldName: "vibecontrolsEntityTags"
|
|
71
|
-
}), b.cache.gc(), await M(), T(""), O(null);
|
|
87
|
+
} } })).data?.tagVibecontrolsEntity;
|
|
88
|
+
n && k((e) => e.map((e) => e.id === t ? n : e));
|
|
72
89
|
} catch (e) {
|
|
73
|
-
|
|
90
|
+
k((e) => e.filter((e) => e.id !== t)), T(e instanceof Error ? e.message : _("vibecontrols.shared.entityTagPicker.failedToAddTag", "Failed to add tag"));
|
|
91
|
+
} finally {
|
|
92
|
+
A.current--, N();
|
|
74
93
|
}
|
|
75
94
|
}, [
|
|
76
|
-
|
|
77
|
-
|
|
95
|
+
v,
|
|
96
|
+
z,
|
|
97
|
+
m,
|
|
78
98
|
h,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
A((t) => t.filter((t) => t.id !== e)), O(null);
|
|
99
|
+
N,
|
|
100
|
+
_
|
|
101
|
+
]), Z = r(async (e) => {
|
|
102
|
+
if (v) {
|
|
103
|
+
D((t) => t.filter((t) => t.id !== e)), T(null);
|
|
85
104
|
return;
|
|
86
105
|
}
|
|
106
|
+
k((t) => t.filter((t) => t.tagId !== e)), T(null), A.current++;
|
|
87
107
|
try {
|
|
88
|
-
await
|
|
89
|
-
entityType:
|
|
90
|
-
entityId:
|
|
108
|
+
await V({ variables: { input: {
|
|
109
|
+
entityType: m,
|
|
110
|
+
entityId: h ?? "",
|
|
91
111
|
tagId: e
|
|
92
|
-
} } })
|
|
93
|
-
id: "ROOT_QUERY",
|
|
94
|
-
fieldName: "vibecontrolsEntityTags"
|
|
95
|
-
}), b.cache.gc(), await M(), O(null);
|
|
112
|
+
} } });
|
|
96
113
|
} catch (e) {
|
|
97
|
-
|
|
114
|
+
T(e instanceof Error ? e.message : _("vibecontrols.shared.entityTagPicker.failedToRemoveTag", "Failed to remove tag"));
|
|
115
|
+
} finally {
|
|
116
|
+
A.current--, N();
|
|
98
117
|
}
|
|
99
118
|
}, [
|
|
100
|
-
|
|
101
|
-
|
|
119
|
+
v,
|
|
120
|
+
V,
|
|
121
|
+
m,
|
|
102
122
|
h,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
]), de = a(async (e) => {
|
|
123
|
+
N,
|
|
124
|
+
_
|
|
125
|
+
]), de = r(async (e) => {
|
|
107
126
|
let t = e.trim();
|
|
108
127
|
if (t) try {
|
|
109
128
|
let e = (await H({
|
|
110
129
|
variables: { input: { label: t } },
|
|
111
130
|
refetchQueries: ["EntityTagPickerAvailableTags"]
|
|
112
131
|
})).data?.createVibecontrolsTag;
|
|
113
|
-
if (!e) throw Error(
|
|
114
|
-
await
|
|
132
|
+
if (!e) throw Error(_("vibecontrols.shared.entityTagPicker.failedToCreateTag", "Failed to create tag"));
|
|
133
|
+
await X({
|
|
115
134
|
id: e.id,
|
|
116
135
|
key: e.key,
|
|
117
136
|
label: e.label,
|
|
118
137
|
color: e.color ?? null
|
|
119
138
|
});
|
|
120
139
|
} catch (e) {
|
|
121
|
-
|
|
140
|
+
T(e instanceof Error ? e.message : _("vibecontrols.shared.entityTagPicker.failedToCreateTag", "Failed to create tag"));
|
|
122
141
|
}
|
|
123
142
|
}, [
|
|
124
143
|
H,
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
]), Q =
|
|
144
|
+
X,
|
|
145
|
+
_
|
|
146
|
+
]), Q = x.trim(), fe = a(() => {
|
|
128
147
|
let e = Q.toLowerCase();
|
|
129
|
-
return e ?
|
|
148
|
+
return e ? q.some((t) => t.label.toLowerCase() === e) || G.some((t) => t.label.toLowerCase() === e) : !1;
|
|
130
149
|
}, [
|
|
131
|
-
|
|
132
|
-
|
|
150
|
+
q,
|
|
151
|
+
G,
|
|
133
152
|
Q
|
|
134
|
-
]), pe =
|
|
135
|
-
return
|
|
153
|
+
]), pe = ce === Q && !R, $ = !g && Q.length >= 2 && pe && !fe;
|
|
154
|
+
return M && !v ? /* @__PURE__ */ f("div", {
|
|
136
155
|
className: "flex items-center gap-2 text-text-secondary text-sm",
|
|
137
156
|
role: "status",
|
|
138
157
|
"aria-live": "polite",
|
|
139
|
-
children: [/* @__PURE__ */
|
|
158
|
+
children: [/* @__PURE__ */ d(c, {
|
|
140
159
|
className: "h-4 w-4 animate-spin",
|
|
141
160
|
"aria-hidden": "true"
|
|
142
|
-
}),
|
|
143
|
-
}) : /* @__PURE__ */
|
|
161
|
+
}), _("vibecontrols.shared.entityTagPicker.loadingTags", "Loading tags...")]
|
|
162
|
+
}) : /* @__PURE__ */ f("div", {
|
|
144
163
|
className: "space-y-3",
|
|
145
164
|
children: [
|
|
146
|
-
|
|
165
|
+
w && /* @__PURE__ */ f("div", {
|
|
147
166
|
role: "alert",
|
|
148
167
|
className: "flex items-start gap-2 rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700",
|
|
149
|
-
children: [/* @__PURE__ */
|
|
168
|
+
children: [/* @__PURE__ */ d("span", {
|
|
150
169
|
className: "flex-1",
|
|
151
|
-
children:
|
|
152
|
-
}), /* @__PURE__ */
|
|
170
|
+
children: w
|
|
171
|
+
}), /* @__PURE__ */ d("button", {
|
|
153
172
|
type: "button",
|
|
154
|
-
onClick: () =>
|
|
155
|
-
"aria-label":
|
|
173
|
+
onClick: () => T(null),
|
|
174
|
+
"aria-label": _("vibecontrols.shared.entityTagPicker.dismissError", "Dismiss error"),
|
|
156
175
|
className: "text-red-700 hover:text-red-900",
|
|
157
|
-
children: /* @__PURE__ */
|
|
176
|
+
children: /* @__PURE__ */ d(u, {
|
|
158
177
|
className: "h-3.5 w-3.5",
|
|
159
178
|
"aria-hidden": "true"
|
|
160
179
|
})
|
|
161
180
|
})]
|
|
162
181
|
}),
|
|
163
|
-
/* @__PURE__ */
|
|
182
|
+
/* @__PURE__ */ f("div", {
|
|
164
183
|
className: "flex flex-wrap gap-2",
|
|
165
|
-
"aria-label":
|
|
184
|
+
"aria-label": _("vibecontrols.shared.entityTagPicker.assignedTags", "Assigned tags"),
|
|
166
185
|
children: [
|
|
167
|
-
|
|
186
|
+
G.length === 0 && !y && /* @__PURE__ */ d("p", {
|
|
168
187
|
className: "text-text-secondary text-sm",
|
|
169
|
-
children:
|
|
188
|
+
children: _("vibecontrols.shared.entityTagPicker.noTagsAssigned", "No tags assigned")
|
|
170
189
|
}),
|
|
171
|
-
|
|
190
|
+
G.map((e) => /* @__PURE__ */ f("span", {
|
|
172
191
|
className: "inline-flex items-center gap-1 px-3 py-1 bg-action-primary-bg/10 text-action-primary-bg text-sm rounded-full",
|
|
173
192
|
children: [
|
|
174
|
-
/* @__PURE__ */
|
|
193
|
+
/* @__PURE__ */ d(ae, {
|
|
175
194
|
className: "h-3 w-3",
|
|
176
195
|
"aria-hidden": "true"
|
|
177
196
|
}),
|
|
178
197
|
e.label,
|
|
179
|
-
!
|
|
198
|
+
!g && /* @__PURE__ */ d("button", {
|
|
180
199
|
type: "button",
|
|
181
|
-
onClick: () =>
|
|
182
|
-
disabled:
|
|
200
|
+
onClick: () => Z(e.tagId),
|
|
201
|
+
disabled: le,
|
|
183
202
|
"aria-label": `Remove tag ${e.label}`,
|
|
184
203
|
className: "ml-1 hover:text-red-500 transition-colors disabled:opacity-50",
|
|
185
|
-
children: /* @__PURE__ */
|
|
204
|
+
children: /* @__PURE__ */ d(u, {
|
|
186
205
|
className: "h-3 w-3",
|
|
187
206
|
"aria-hidden": "true"
|
|
188
207
|
})
|
|
189
208
|
})
|
|
190
209
|
]
|
|
191
210
|
}, e.key)),
|
|
192
|
-
!
|
|
211
|
+
!g && !y && /* @__PURE__ */ f("button", {
|
|
193
212
|
type: "button",
|
|
194
|
-
onClick: () =>
|
|
195
|
-
"aria-label":
|
|
213
|
+
onClick: () => b(!0),
|
|
214
|
+
"aria-label": _("vibecontrols.shared.entityTagPicker.addTag", "Add tag"),
|
|
196
215
|
className: "inline-flex items-center gap-1 px-3 py-1 border border-dashed border-border-default text-text-secondary text-sm rounded-full hover:border-action-primary-bg hover:text-action-primary-bg transition-colors",
|
|
197
|
-
children: [/* @__PURE__ */
|
|
216
|
+
children: [/* @__PURE__ */ d(l, {
|
|
198
217
|
className: "h-3 w-3",
|
|
199
218
|
"aria-hidden": "true"
|
|
200
|
-
}),
|
|
219
|
+
}), _("vibecontrols.shared.entityTagPicker.addTag", "Add Tag")]
|
|
201
220
|
})
|
|
202
221
|
]
|
|
203
222
|
}),
|
|
204
|
-
|
|
223
|
+
y && /* @__PURE__ */ f("div", {
|
|
205
224
|
className: "border border-border-default rounded-lg p-3 space-y-2 bg-bg-surface",
|
|
206
225
|
children: [
|
|
207
|
-
/* @__PURE__ */
|
|
226
|
+
/* @__PURE__ */ f("div", {
|
|
208
227
|
className: "relative",
|
|
209
|
-
children: [/* @__PURE__ */
|
|
228
|
+
children: [/* @__PURE__ */ d(ie, {
|
|
210
229
|
className: "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text-secondary",
|
|
211
230
|
"aria-hidden": "true"
|
|
212
|
-
}), /* @__PURE__ */
|
|
231
|
+
}), /* @__PURE__ */ d("input", {
|
|
213
232
|
type: "text",
|
|
214
|
-
value:
|
|
215
|
-
onChange: (e) =>
|
|
216
|
-
placeholder:
|
|
233
|
+
value: x,
|
|
234
|
+
onChange: (e) => ue(e.target.value),
|
|
235
|
+
placeholder: _("vibecontrols.shared.entityTagPicker.searchPlaceholder", "Search tags..."),
|
|
217
236
|
autoFocus: !0,
|
|
218
|
-
"aria-label":
|
|
237
|
+
"aria-label": _("vibecontrols.shared.entityTagPicker.searchAvailableTags", "Search available tags"),
|
|
219
238
|
className: "w-full pl-9 pr-3 py-2 text-sm bg-bg-default border border-border-default rounded-md focus:outline-none focus:ring-2 focus:ring-action-primary-bg/50 text-text-primary placeholder:text-text-secondary"
|
|
220
239
|
})]
|
|
221
240
|
}),
|
|
222
|
-
/* @__PURE__ */
|
|
241
|
+
/* @__PURE__ */ f("div", {
|
|
223
242
|
className: "flex flex-wrap gap-2 max-h-32 overflow-y-auto",
|
|
224
|
-
"aria-label":
|
|
243
|
+
"aria-label": _("vibecontrols.shared.entityTagPicker.tagSuggestions", "Tag suggestions"),
|
|
225
244
|
children: [
|
|
226
|
-
(
|
|
245
|
+
(R || F && !x) && /* @__PURE__ */ d("span", {
|
|
227
246
|
className: "text-text-secondary text-xs",
|
|
228
|
-
children:
|
|
247
|
+
children: _("vibecontrols.shared.entityTagPicker.loading", "Loading...")
|
|
229
248
|
}),
|
|
230
|
-
!
|
|
249
|
+
!R && !F && J.length === 0 && !$ && /* @__PURE__ */ d("span", {
|
|
231
250
|
className: "text-text-secondary text-xs",
|
|
232
|
-
children: Q ?
|
|
251
|
+
children: Q ? _("vibecontrols.shared.entityTagPicker.noMatchingTags", "No matching tags") : _("vibecontrols.shared.entityTagPicker.noAvailableTags", "No tags yet — type a name to create one")
|
|
233
252
|
}),
|
|
234
|
-
|
|
253
|
+
J.map((e) => /* @__PURE__ */ f("button", {
|
|
235
254
|
type: "button",
|
|
236
|
-
onClick: () =>
|
|
237
|
-
disabled:
|
|
255
|
+
onClick: () => X(e),
|
|
256
|
+
disabled: B,
|
|
238
257
|
"aria-label": `Add tag ${e.label}`,
|
|
239
258
|
className: "inline-flex items-center gap-1 px-3 py-1 bg-bg-sunken text-text-primary text-sm rounded-full hover:bg-action-primary-bg/10 hover:text-action-primary-bg transition-colors cursor-pointer disabled:opacity-50",
|
|
240
|
-
children: [/* @__PURE__ */
|
|
259
|
+
children: [/* @__PURE__ */ d(l, {
|
|
241
260
|
className: "h-3 w-3",
|
|
242
261
|
"aria-hidden": "true"
|
|
243
262
|
}), e.label]
|
|
244
263
|
}, e.id))
|
|
245
264
|
]
|
|
246
265
|
}),
|
|
247
|
-
$ && /* @__PURE__ */
|
|
266
|
+
$ && /* @__PURE__ */ f("button", {
|
|
248
267
|
type: "button",
|
|
249
268
|
onClick: () => de(Q),
|
|
250
|
-
disabled: U ||
|
|
251
|
-
"aria-label":
|
|
269
|
+
disabled: U || B,
|
|
270
|
+
"aria-label": _("vibecontrols.shared.entityTagPicker.createTagAria", "Create tag {{label}}", { label: Q }),
|
|
252
271
|
className: "inline-flex w-full items-center gap-1.5 px-3 py-2 border border-dashed border-action-primary-bg/50 text-action-primary-bg text-sm rounded-md hover:bg-action-primary-bg/10 transition-colors cursor-pointer disabled:opacity-50",
|
|
253
|
-
children: [U ||
|
|
272
|
+
children: [U || B ? /* @__PURE__ */ d(c, {
|
|
254
273
|
className: "h-3.5 w-3.5 animate-spin",
|
|
255
274
|
"aria-hidden": "true"
|
|
256
|
-
}) : /* @__PURE__ */
|
|
275
|
+
}) : /* @__PURE__ */ d(l, {
|
|
257
276
|
className: "h-3.5 w-3.5",
|
|
258
277
|
"aria-hidden": "true"
|
|
259
|
-
}), /* @__PURE__ */
|
|
278
|
+
}), /* @__PURE__ */ d("span", {
|
|
260
279
|
className: "truncate",
|
|
261
|
-
children:
|
|
280
|
+
children: _("vibecontrols.shared.entityTagPicker.createTag", "Create \"{{label}}\"", { label: Q })
|
|
262
281
|
})]
|
|
263
282
|
}),
|
|
264
|
-
/* @__PURE__ */
|
|
283
|
+
/* @__PURE__ */ d("button", {
|
|
265
284
|
type: "button",
|
|
266
285
|
onClick: () => {
|
|
267
|
-
|
|
286
|
+
b(!1), S("");
|
|
268
287
|
},
|
|
269
288
|
className: "text-xs text-text-secondary hover:text-text-primary",
|
|
270
|
-
children:
|
|
289
|
+
children: _("vibecontrols.shared.entityTagPicker.done", "Done")
|
|
271
290
|
})
|
|
272
291
|
]
|
|
273
292
|
})
|
|
@@ -275,6 +294,6 @@ function h({ entityType: h, entityId: g, onDraftChange: _, readOnly: v = !1 }) {
|
|
|
275
294
|
});
|
|
276
295
|
}
|
|
277
296
|
//#endregion
|
|
278
|
-
export {
|
|
297
|
+
export { m as EntityTagPicker };
|
|
279
298
|
|
|
280
299
|
//# sourceMappingURL=EntityTagPicker.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"EntityTagPicker.js","names":[],"sources":["../../../src/components/shared/EntityTagPicker.tsx"],"sourcesContent":["/**\n * EntityTagPicker — Reusable tag picker for any VibeControls entity\n *\n * Two modes:\n * - Persisted (entityId provided): self-fetches assignments and writes\n * immediately via tag/untag mutations. Used on detail/edit surfaces.\n * - Draft (entityId omitted/empty): no queries or mutations — selections\n * are held locally and surfaced via `onDraftChange(tagIds)`. The parent\n * create form persists them after the entity is created (e.g. via\n * replaceVibecontrolsEntityTags with the new id). Used in create\n * forms/wizards where no entity id exists yet.\n *\n * Usage:\n * <EntityTagPicker entityType=\"VIBECONTROLS_VIBE\" entityId={vibe.id} />\n * <EntityTagPicker entityType=\"VIBECONTROLS_VIBE\" onDraftChange={setTagIds} />\n */\nimport { useState, useCallback, useMemo, useEffect, useRef } from 'react';\nimport { Tag, Plus, X, Search, Loader2 } from 'lucide-react';\nimport { useApolloClient } from '@apollo/client/react';\nimport { useTr } from '../../shared/hooks/useTr';\nimport {\n useGetEntityTags,\n useAvailableTags,\n useSearchTags,\n useTagEntity,\n useUntagEntity,\n useCreateVibecontrolsTag,\n} from '../../hooks/useEntityTags';\nimport type { AvailableTag, EntityTagAssignment } from '../../hooks/useEntityTags';\n\ninterface EntityTagPickerProps {\n entityType: string;\n /**\n * Existing entity id. Omit (or pass empty) to run in draft mode for\n * create forms — selections are emitted via `onDraftChange` instead of\n * being persisted immediately.\n */\n entityId?: string;\n /**\n * Draft-mode callback. Receives the current selected tag ids whenever\n * the draft selection changes. Only invoked when `entityId` is absent.\n */\n onDraftChange?: (tagIds: string[]) => void;\n readOnly?: boolean;\n}\n\nconst ERROR_DISMISS_MS = 4000;\nconst SEARCH_DEBOUNCE_MS = 250;\n\nexport function EntityTagPicker({\n entityType,\n entityId,\n onDraftChange,\n readOnly = false,\n}: EntityTagPickerProps) {\n const tr = useTr();\n const apolloClient = useApolloClient();\n const draftMode = !entityId;\n const [isAdding, setIsAdding] = useState(false);\n const [searchQuery, setSearchQuery] = useState('');\n // The trimmed query the most recent debounced search actually ran for.\n // Used to gate inline creation: we only offer \"Create\" once we have search\n // results that correspond to the current query (avoids offering Create\n // against stale/empty results while the debounced search is still pending).\n const [searchedQuery, setSearchedQuery] = useState('');\n const [errorMessage, setErrorMessage] = useState<string | null>(null);\n // Draft-mode local selection (no persistence until the parent flushes it).\n const [draftTags, setDraftTags] = useState<AvailableTag[]>([]);\n\n // In draft mode, propagate the local selection to the parent whenever it\n // changes — and reset it to [] on mount, so a fresh create form never\n // inherits tag ids left over from a previous create on the same page-level\n // hook. Emitting from an effect (rather than inside a setDraftTags updater)\n // keeps the updater pure — no \"setState while rendering\" warning — and lets\n // the handlers use functional updates, which avoids lost-update races when\n // async create/add flows overlap.\n useEffect(() => {\n if (draftMode) onDraftChange?.(draftTags.map((t) => t.id));\n // onDraftChange is a stable setter from the parent's page-level hook;\n // excluded to avoid re-emitting on unrelated parent re-renders.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [draftMode, draftTags]);\n\n const {\n data: tagsData,\n loading: tagsLoading,\n refetch: refetchTags,\n } = useGetEntityTags(entityType, entityId ?? '');\n // Fetch all available tags in persisted mode so we can resolve labels for\n // assigned tags when the federation doesn't return the nested tag object.\n // Also fetched when the add panel is open (suggestions list).\n const { data: availableData, loading: availableLoading } = useAvailableTags(200, {\n skip: draftMode && !isAdding,\n });\n const [searchTags, { data: searchData, loading: searchLoading }] = useSearchTags();\n const [tagEntity, { loading: tagging }] = useTagEntity();\n const [untagEntity, { loading: untagging }] = useUntagEntity();\n const [createTag, { loading: creatingTag }] = useCreateVibecontrolsTag();\n\n // Apollo Client v4 returns DeepPartialObject from useQuery data, but our\n // hook types declare full shapes — we cast back to the declared types since\n // the resolver is guaranteed to return non-null fields per the schema.\n const assignedTags = useMemo(\n () => (tagsData?.vibecontrolsEntityTags ?? []) as EntityTagAssignment[],\n [tagsData]\n );\n\n // Label lookup by tagId — fallback when federation doesn't return nested tag\n const availableTagMap = useMemo(() => {\n const tags = (availableData?.availableVibecontrolsTags as AvailableTag[] | undefined) ?? [];\n return new Map<string, AvailableTag>(tags.map((t) => [t.id, t]));\n }, [availableData]);\n\n // Unified chip list — draft selections in draft mode, persisted\n // assignments otherwise. `tagId` drives removal; `key` is render-stable.\n const chipItems = useMemo(\n () =>\n draftMode\n ? draftTags.map((t) => ({ key: t.id, tagId: t.id, label: t.label }))\n : assignedTags.map((a) => ({\n key: a.id,\n tagId: a.tagId,\n label:\n a.tag?.label ??\n availableTagMap.get(a.tagId)?.label ??\n tr('vibecontrols.shared.entityTagPicker.untitledTag', 'Untitled tag'),\n })),\n [draftMode, draftTags, assignedTags, availableTagMap, tr]\n );\n\n const assignedTagIds = useMemo(() => new Set(chipItems.map((c) => c.tagId)), [chipItems]);\n\n // Unfiltered tag options from the active source (search results when\n // searching, otherwise the available-tags list). Kept separate from\n // `suggestions` so the create affordance can detect an exact match against\n // ALL existing tags, including ones already assigned to this entity.\n const rawTagOptions: AvailableTag[] = useMemo(() => {\n const source = searchQuery\n ? (searchData?.searchVibecontrolsTags as AvailableTag[] | undefined)\n : (availableData?.availableVibecontrolsTags as AvailableTag[] | undefined);\n return source ?? [];\n }, [searchQuery, searchData, availableData]);\n\n const suggestions: AvailableTag[] = useMemo(\n () => rawTagOptions.filter((t) => !assignedTagIds.has(t.id)),\n [rawTagOptions, assignedTagIds]\n );\n\n // Auto-dismiss error toast after a few seconds\n useEffect(() => {\n if (!errorMessage) return;\n const timer = setTimeout(() => setErrorMessage(null), ERROR_DISMISS_MS);\n return () => clearTimeout(timer);\n }, [errorMessage]);\n\n // Debounce the search query so we don't fire a request on every keystroke.\n const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n useEffect(() => {\n return () => {\n if (searchTimerRef.current) clearTimeout(searchTimerRef.current);\n };\n }, []);\n\n const handleSearch = useCallback(\n (query: string) => {\n setSearchQuery(query);\n if (searchTimerRef.current) clearTimeout(searchTimerRef.current);\n const trimmed = query.trim();\n // No search runs for short queries, so there are no authoritative results\n // for this input — clear the marker to keep the Create button gated.\n if (trimmed.length < 2) {\n setSearchedQuery('');\n return;\n }\n searchTimerRef.current = setTimeout(() => {\n setSearchedQuery(trimmed);\n searchTags({ variables: { query: trimmed, limit: 10 } });\n }, SEARCH_DEBOUNCE_MS);\n },\n [searchTags]\n );\n\n const handleAddTag = useCallback(\n async (tag: AvailableTag) => {\n if (draftMode) {\n // Functional update so overlapping async flows (e.g. an in-flight\n // create plus a click on another suggestion) can't overwrite each\n // other from a stale snapshot. The draftTags-watching effect emits\n // onDraftChange for us.\n setDraftTags((prev) => (prev.some((t) => t.id === tag.id) ? prev : [...prev, tag]));\n setSearchQuery('');\n setErrorMessage(null);\n return;\n }\n try {\n await tagEntity({\n variables: { input: { entityType, entityId: entityId ?? '', tagId: tag.id } },\n });\n apolloClient.cache.evict({ id: 'ROOT_QUERY', fieldName: 'vibecontrolsEntityTags' });\n apolloClient.cache.gc();\n await refetchTags();\n setSearchQuery('');\n setErrorMessage(null);\n } catch (err) {\n setErrorMessage(\n err instanceof Error\n ? err.message\n : tr('vibecontrols.shared.entityTagPicker.failedToAddTag', 'Failed to add tag')\n );\n }\n },\n [draftMode, tagEntity, entityType, entityId, refetchTags, tr]\n );\n\n const handleRemoveTag = useCallback(\n async (tagId: string) => {\n if (draftMode) {\n setDraftTags((prev) => prev.filter((t) => t.id !== tagId));\n setErrorMessage(null);\n return;\n }\n try {\n await untagEntity({\n variables: { input: { entityType, entityId: entityId ?? '', tagId } },\n });\n apolloClient.cache.evict({ id: 'ROOT_QUERY', fieldName: 'vibecontrolsEntityTags' });\n apolloClient.cache.gc();\n await refetchTags();\n setErrorMessage(null);\n } catch (err) {\n setErrorMessage(\n err instanceof Error\n ? err.message\n : tr('vibecontrols.shared.entityTagPicker.failedToRemoveTag', 'Failed to remove tag')\n );\n }\n },\n [draftMode, untagEntity, entityType, entityId, refetchTags, tr]\n );\n\n // Inline tag creation. No vocabularyId is sent — the backend resolves/creates\n // a default workspace VibeControls vocabulary. The freshly created tag is\n // immediately selected via handleAddTag (draft → local; persisted → tagEntity).\n const handleCreateTag = useCallback(\n async (rawLabel: string) => {\n const label = rawLabel.trim();\n if (!label) return;\n try {\n // Refetch the available-tags list so the new tag shows up as a\n // suggestion on subsequent searches (not just as the selected chip).\n const result = await createTag({\n variables: { input: { label } },\n refetchQueries: ['EntityTagPickerAvailableTags'],\n });\n const created = result.data?.createVibecontrolsTag;\n if (!created) {\n throw new Error(\n tr('vibecontrols.shared.entityTagPicker.failedToCreateTag', 'Failed to create tag')\n );\n }\n await handleAddTag({\n id: created.id,\n key: created.key,\n label: created.label,\n color: created.color ?? null,\n });\n } catch (err) {\n setErrorMessage(\n err instanceof Error\n ? err.message\n : tr('vibecontrols.shared.entityTagPicker.failedToCreateTag', 'Failed to create tag')\n );\n }\n },\n [createTag, handleAddTag, tr]\n );\n\n // Offer inline creation when the typed label doesn't already exist (covers\n // both \"no tags yet\" and \"no match\" cases). Match against the unfiltered\n // options AND the already-selected chips — checking only `suggestions` would\n // miss tags already assigned to this entity (filtered out of suggestions),\n // wrongly offering \"Create\" for a label that already exists.\n const trimmedQuery = searchQuery.trim();\n const hasExactMatch = useMemo(() => {\n const q = trimmedQuery.toLowerCase();\n if (!q) return false;\n return (\n rawTagOptions.some((t) => t.label.toLowerCase() === q) ||\n chipItems.some((c) => c.label.toLowerCase() === q)\n );\n }, [rawTagOptions, chipItems, trimmedQuery]);\n // Only offer Create once the debounced search has actually run and settled\n // for the current query — otherwise rawTagOptions may be stale/empty and we\n // could offer Create for a label that already exists. (Search runs only for\n // queries of length >= 2, which also bounds inline-created tag labels.)\n const searchSettledForQuery = searchedQuery === trimmedQuery && !searchLoading;\n const canCreateTag =\n !readOnly && trimmedQuery.length >= 2 && searchSettledForQuery && !hasExactMatch;\n\n if (tagsLoading && !draftMode) {\n return (\n <div\n className=\"flex items-center gap-2 text-text-secondary text-sm\"\n role=\"status\"\n aria-live=\"polite\"\n >\n <Loader2 className=\"h-4 w-4 animate-spin\" aria-hidden=\"true\" />\n {tr('vibecontrols.shared.entityTagPicker.loadingTags', 'Loading tags...')}\n </div>\n );\n }\n\n return (\n <div className=\"space-y-3\">\n {errorMessage && (\n <div\n role=\"alert\"\n className=\"flex items-start gap-2 rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700\"\n >\n <span className=\"flex-1\">{errorMessage}</span>\n <button\n type=\"button\"\n onClick={() => setErrorMessage(null)}\n aria-label={tr('vibecontrols.shared.entityTagPicker.dismissError', 'Dismiss error')}\n className=\"text-red-700 hover:text-red-900\"\n >\n <X className=\"h-3.5 w-3.5\" aria-hidden=\"true\" />\n </button>\n </div>\n )}\n\n <div\n className=\"flex flex-wrap gap-2\"\n aria-label={tr('vibecontrols.shared.entityTagPicker.assignedTags', 'Assigned tags')}\n >\n {chipItems.length === 0 && !isAdding && (\n <p className=\"text-text-secondary text-sm\">\n {tr('vibecontrols.shared.entityTagPicker.noTagsAssigned', 'No tags assigned')}\n </p>\n )}\n {chipItems.map((chip) => (\n <span\n key={chip.key}\n className=\"inline-flex items-center gap-1 px-3 py-1 bg-action-primary-bg/10 text-action-primary-bg text-sm rounded-full\"\n >\n <Tag className=\"h-3 w-3\" aria-hidden=\"true\" />\n {chip.label}\n {!readOnly && (\n <button\n type=\"button\"\n onClick={() => handleRemoveTag(chip.tagId)}\n disabled={untagging}\n aria-label={`Remove tag ${chip.label}`}\n className=\"ml-1 hover:text-red-500 transition-colors disabled:opacity-50\"\n >\n <X className=\"h-3 w-3\" aria-hidden=\"true\" />\n </button>\n )}\n </span>\n ))}\n {!readOnly && !isAdding && (\n <button\n type=\"button\"\n onClick={() => setIsAdding(true)}\n aria-label={tr('vibecontrols.shared.entityTagPicker.addTag', 'Add tag')}\n className=\"inline-flex items-center gap-1 px-3 py-1 border border-dashed border-border-default text-text-secondary text-sm rounded-full hover:border-action-primary-bg hover:text-action-primary-bg transition-colors\"\n >\n <Plus className=\"h-3 w-3\" aria-hidden=\"true\" />\n {tr('vibecontrols.shared.entityTagPicker.addTag', 'Add Tag')}\n </button>\n )}\n </div>\n\n {isAdding && (\n <div className=\"border border-border-default rounded-lg p-3 space-y-2 bg-bg-surface\">\n <div className=\"relative\">\n <Search\n className=\"absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text-secondary\"\n aria-hidden=\"true\"\n />\n <input\n type=\"text\"\n value={searchQuery}\n onChange={(e) => handleSearch(e.target.value)}\n placeholder={tr(\n 'vibecontrols.shared.entityTagPicker.searchPlaceholder',\n 'Search tags...'\n )}\n autoFocus\n aria-label={tr(\n 'vibecontrols.shared.entityTagPicker.searchAvailableTags',\n 'Search available tags'\n )}\n className=\"w-full pl-9 pr-3 py-2 text-sm bg-bg-default border border-border-default rounded-md focus:outline-none focus:ring-2 focus:ring-action-primary-bg/50 text-text-primary placeholder:text-text-secondary\"\n />\n </div>\n <div\n className=\"flex flex-wrap gap-2 max-h-32 overflow-y-auto\"\n aria-label={tr('vibecontrols.shared.entityTagPicker.tagSuggestions', 'Tag suggestions')}\n >\n {(searchLoading || (availableLoading && !searchQuery)) && (\n <span className=\"text-text-secondary text-xs\">\n {tr('vibecontrols.shared.entityTagPicker.loading', 'Loading...')}\n </span>\n )}\n {!searchLoading && !availableLoading && suggestions.length === 0 && !canCreateTag && (\n <span className=\"text-text-secondary text-xs\">\n {trimmedQuery\n ? tr('vibecontrols.shared.entityTagPicker.noMatchingTags', 'No matching tags')\n : tr(\n 'vibecontrols.shared.entityTagPicker.noAvailableTags',\n 'No tags yet — type a name to create one'\n )}\n </span>\n )}\n {suggestions.map((tag) => (\n <button\n key={tag.id}\n type=\"button\"\n onClick={() => handleAddTag(tag)}\n disabled={tagging}\n aria-label={`Add tag ${tag.label}`}\n className=\"inline-flex items-center gap-1 px-3 py-1 bg-bg-sunken text-text-primary text-sm rounded-full hover:bg-action-primary-bg/10 hover:text-action-primary-bg transition-colors cursor-pointer disabled:opacity-50\"\n >\n <Plus className=\"h-3 w-3\" aria-hidden=\"true\" />\n {tag.label}\n </button>\n ))}\n </div>\n {canCreateTag && (\n <button\n type=\"button\"\n onClick={() => handleCreateTag(trimmedQuery)}\n // Stay disabled through the follow-up assignment too: in persisted\n // mode handleCreateTag runs createTag (creatingTag) then tagEntity\n // (tagging); without `tagging` the button would re-enable between\n // the two, letting a second click duplicate the create.\n disabled={creatingTag || tagging}\n aria-label={tr(\n 'vibecontrols.shared.entityTagPicker.createTagAria',\n 'Create tag {{label}}',\n { label: trimmedQuery }\n )}\n className=\"inline-flex w-full items-center gap-1.5 px-3 py-2 border border-dashed border-action-primary-bg/50 text-action-primary-bg text-sm rounded-md hover:bg-action-primary-bg/10 transition-colors cursor-pointer disabled:opacity-50\"\n >\n {creatingTag || tagging ? (\n <Loader2 className=\"h-3.5 w-3.5 animate-spin\" aria-hidden=\"true\" />\n ) : (\n <Plus className=\"h-3.5 w-3.5\" aria-hidden=\"true\" />\n )}\n <span className=\"truncate\">\n {tr('vibecontrols.shared.entityTagPicker.createTag', 'Create \"{{label}}\"', {\n label: trimmedQuery,\n })}\n </span>\n </button>\n )}\n <button\n type=\"button\"\n onClick={() => {\n setIsAdding(false);\n setSearchQuery('');\n }}\n className=\"text-xs text-text-secondary hover:text-text-primary\"\n >\n {tr('vibecontrols.shared.entityTagPicker.done', 'Done')}\n </button>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"EntityTagPicker.js","names":[],"sources":["../../../src/components/shared/EntityTagPicker.tsx"],"sourcesContent":["/**\n * EntityTagPicker — Reusable tag picker for any VibeControls entity\n *\n * Two modes:\n * - Persisted (entityId provided): self-fetches assignments and writes\n * immediately via tag/untag mutations. Used on detail/edit surfaces.\n * - Draft (entityId omitted/empty): no queries or mutations — selections\n * are held locally and surfaced via `onDraftChange(tagIds)`. The parent\n * create form persists them after the entity is created (e.g. via\n * replaceVibecontrolsEntityTags with the new id). Used in create\n * forms/wizards where no entity id exists yet.\n *\n * Usage:\n * <EntityTagPicker entityType=\"VIBECONTROLS_VIBE\" entityId={vibe.id} />\n * <EntityTagPicker entityType=\"VIBECONTROLS_VIBE\" onDraftChange={setTagIds} />\n */\nimport { useState, useCallback, useMemo, useEffect, useRef } from 'react';\nimport { Tag, Plus, X, Search, Loader2 } from 'lucide-react';\nimport { useTr } from '../../shared/hooks/useTr';\nimport {\n useGetEntityTags,\n useAvailableTags,\n useSearchTags,\n useTagEntity,\n useUntagEntity,\n useCreateVibecontrolsTag,\n} from '../../hooks/useEntityTags';\nimport type { AvailableTag, EntityTagAssignment } from '../../hooks/useEntityTags';\n\ninterface EntityTagPickerProps {\n entityType: string;\n /**\n * Existing entity id. Omit (or pass empty) to run in draft mode for\n * create forms — selections are emitted via `onDraftChange` instead of\n * being persisted immediately.\n */\n entityId?: string;\n /**\n * Draft-mode callback. Receives the current selected tag ids whenever\n * the draft selection changes. Only invoked when `entityId` is absent.\n */\n onDraftChange?: (tagIds: string[]) => void;\n readOnly?: boolean;\n}\n\nconst ERROR_DISMISS_MS = 4000;\nconst SEARCH_DEBOUNCE_MS = 250;\n\nexport function EntityTagPicker({\n entityType,\n entityId,\n onDraftChange,\n readOnly = false,\n}: EntityTagPickerProps) {\n const tr = useTr();\n const draftMode = !entityId;\n const [isAdding, setIsAdding] = useState(false);\n const [searchQuery, setSearchQuery] = useState('');\n // The trimmed query the most recent debounced search actually ran for.\n // Used to gate inline creation: we only offer \"Create\" once we have search\n // results that correspond to the current query (avoids offering Create\n // against stale/empty results while the debounced search is still pending).\n const [searchedQuery, setSearchedQuery] = useState('');\n const [errorMessage, setErrorMessage] = useState<string | null>(null);\n // Draft-mode local selection (no persistence until the parent flushes it).\n const [draftTags, setDraftTags] = useState<AvailableTag[]>([]);\n // Optimistic local list for persisted mode — updated immediately on add/remove\n // so the chip appears/disappears without waiting for a server refetch.\n const [localTags, setLocalTags] = useState<EntityTagAssignment[]>([]);\n // Counts in-flight add/remove mutations; prevents server refetch from\n // overwriting optimistic state before the mutation has completed.\n const inFlightCount = useRef(0);\n\n // In draft mode, propagate the local selection to the parent whenever it\n // changes — and reset it to [] on mount, so a fresh create form never\n // inherits tag ids left over from a previous create on the same page-level\n // hook. Emitting from an effect (rather than inside a setDraftTags updater)\n // keeps the updater pure — no \"setState while rendering\" warning — and lets\n // the handlers use functional updates, which avoids lost-update races when\n // async create/add flows overlap.\n useEffect(() => {\n if (draftMode) onDraftChange?.(draftTags.map((t) => t.id));\n // onDraftChange is a stable setter from the parent's page-level hook;\n // excluded to avoid re-emitting on unrelated parent re-renders.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [draftMode, draftTags]);\n\n const {\n data: tagsData,\n loading: tagsLoading,\n refetch: refetchTags,\n } = useGetEntityTags(entityType, entityId ?? '');\n // Fetch all available tags in persisted mode so we can resolve labels for\n // assigned tags when the federation doesn't return the nested tag object.\n // Also fetched when the add panel is open (suggestions list).\n const { data: availableData, loading: availableLoading } = useAvailableTags(200, {\n skip: draftMode && !isAdding,\n });\n const [searchTags, { data: searchData, loading: searchLoading }] = useSearchTags();\n const [tagEntity, { loading: tagging }] = useTagEntity();\n const [untagEntity, { loading: untagging }] = useUntagEntity();\n const [createTag, { loading: creatingTag }] = useCreateVibecontrolsTag();\n\n // Sync localTags from server data whenever it arrives (initial load or\n // background refetch), but only when no mutation is currently in flight —\n // otherwise we'd overwrite optimistic state mid-mutation.\n useEffect(() => {\n if (draftMode || tagsData?.vibecontrolsEntityTags === undefined) return;\n if (inFlightCount.current > 0) return;\n setLocalTags(tagsData.vibecontrolsEntityTags as EntityTagAssignment[]);\n }, [draftMode, tagsData]);\n\n // Label lookup by tagId — fallback when federation doesn't return nested tag\n const availableTagMap = useMemo(() => {\n const tags = (availableData?.availableVibecontrolsTags as AvailableTag[] | undefined) ?? [];\n return new Map<string, AvailableTag>(tags.map((t) => [t.id, t]));\n }, [availableData]);\n\n // Unified chip list — draft selections in draft mode, persisted\n // assignments otherwise. `tagId` drives removal; `key` is render-stable.\n // Uses localTags (optimistic) so chips appear/disappear immediately on\n // user action — server refetch syncs in the background via the effect above.\n const chipItems = useMemo(\n () =>\n draftMode\n ? draftTags.map((t) => ({ key: t.id, tagId: t.id, label: t.label }))\n : localTags.map((a) => ({\n key: a.id,\n tagId: a.tagId,\n label:\n a.tagInfo?.label ??\n availableTagMap.get(a.tagId)?.label ??\n tr('vibecontrols.shared.entityTagPicker.untitledTag', 'Untitled tag'),\n })),\n [draftMode, draftTags, localTags, availableTagMap, tr]\n );\n\n const assignedTagIds = useMemo(() => new Set(chipItems.map((c) => c.tagId)), [chipItems]);\n\n // Unfiltered tag options from the active source (search results when\n // searching, otherwise the available-tags list). Kept separate from\n // `suggestions` so the create affordance can detect an exact match against\n // ALL existing tags, including ones already assigned to this entity.\n const rawTagOptions: AvailableTag[] = useMemo(() => {\n const source = searchQuery\n ? (searchData?.searchVibecontrolsTags as AvailableTag[] | undefined)\n : (availableData?.availableVibecontrolsTags as AvailableTag[] | undefined);\n return source ?? [];\n }, [searchQuery, searchData, availableData]);\n\n const suggestions: AvailableTag[] = useMemo(\n () => rawTagOptions.filter((t) => !assignedTagIds.has(t.id)),\n [rawTagOptions, assignedTagIds]\n );\n\n // Auto-dismiss error toast after a few seconds\n useEffect(() => {\n if (!errorMessage) return;\n const timer = setTimeout(() => setErrorMessage(null), ERROR_DISMISS_MS);\n return () => clearTimeout(timer);\n }, [errorMessage]);\n\n // Debounce the search query so we don't fire a request on every keystroke.\n const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n useEffect(() => {\n return () => {\n if (searchTimerRef.current) clearTimeout(searchTimerRef.current);\n };\n }, []);\n\n const handleSearch = useCallback(\n (query: string) => {\n setSearchQuery(query);\n if (searchTimerRef.current) clearTimeout(searchTimerRef.current);\n const trimmed = query.trim();\n // No search runs for short queries, so there are no authoritative results\n // for this input — clear the marker to keep the Create button gated.\n if (trimmed.length < 2) {\n setSearchedQuery('');\n return;\n }\n searchTimerRef.current = setTimeout(() => {\n setSearchedQuery(trimmed);\n searchTags({ variables: { query: trimmed, limit: 10 } });\n }, SEARCH_DEBOUNCE_MS);\n },\n [searchTags]\n );\n\n const handleAddTag = useCallback(\n async (tag: AvailableTag) => {\n if (draftMode) {\n // Functional update so overlapping async flows (e.g. an in-flight\n // create plus a click on another suggestion) can't overwrite each\n // other from a stale snapshot. The draftTags-watching effect emits\n // onDraftChange for us.\n setDraftTags((prev) => (prev.some((t) => t.id === tag.id) ? prev : [...prev, tag]));\n setSearchQuery('');\n setErrorMessage(null);\n return;\n }\n // Optimistic: add chip immediately so the user sees it without waiting\n // for the mutation + refetch round-trip. A temporary id is used until\n // the server response replaces it with the real EntityTagAssignment id.\n const optimisticId = `optimistic-${tag.id}`;\n const optimistic: EntityTagAssignment = {\n id: optimisticId,\n tagId: tag.id,\n tagInfo: { id: tag.id, key: tag.key, label: tag.label, color: tag.color },\n entityType,\n entityId: entityId ?? '',\n source: 'manual',\n assignedBy: null,\n assignedAt: null,\n };\n setLocalTags((prev) => (prev.some((t) => t.tagId === tag.id) ? prev : [...prev, optimistic]));\n setSearchQuery('');\n setErrorMessage(null);\n inFlightCount.current++;\n try {\n const result = await tagEntity({\n variables: { input: { entityType, entityId: entityId ?? '', tagId: tag.id } },\n });\n // Swap the optimistic entry for the real server assignment\n const real = result.data?.tagVibecontrolsEntity as EntityTagAssignment | undefined;\n if (real) {\n setLocalTags((prev) => prev.map((t) => (t.id === optimisticId ? real : t)));\n }\n } catch (err) {\n // Roll back the optimistic add\n setLocalTags((prev) => prev.filter((t) => t.id !== optimisticId));\n setErrorMessage(\n err instanceof Error\n ? err.message\n : tr('vibecontrols.shared.entityTagPicker.failedToAddTag', 'Failed to add tag')\n );\n } finally {\n inFlightCount.current--;\n // Background sync: let Apollo reconcile cache with server state\n refetchTags();\n }\n },\n [draftMode, tagEntity, entityType, entityId, refetchTags, tr]\n );\n\n const handleRemoveTag = useCallback(\n async (tagId: string) => {\n if (draftMode) {\n setDraftTags((prev) => prev.filter((t) => t.id !== tagId));\n setErrorMessage(null);\n return;\n }\n // Optimistic: remove chip immediately without waiting for the server\n setLocalTags((prev) => prev.filter((t) => t.tagId !== tagId));\n setErrorMessage(null);\n inFlightCount.current++;\n try {\n await untagEntity({\n variables: { input: { entityType, entityId: entityId ?? '', tagId } },\n });\n } catch (err) {\n setErrorMessage(\n err instanceof Error\n ? err.message\n : tr('vibecontrols.shared.entityTagPicker.failedToRemoveTag', 'Failed to remove tag')\n );\n } finally {\n inFlightCount.current--;\n // Background sync: re-fetch so the local state converges with server\n // (also restores the chip if the mutation failed)\n refetchTags();\n }\n },\n [draftMode, untagEntity, entityType, entityId, refetchTags, tr]\n );\n\n // Inline tag creation. No vocabularyId is sent — the backend resolves/creates\n // a default workspace VibeControls vocabulary. The freshly created tag is\n // immediately selected via handleAddTag (draft → local; persisted → tagEntity).\n const handleCreateTag = useCallback(\n async (rawLabel: string) => {\n const label = rawLabel.trim();\n if (!label) return;\n try {\n // Refetch the available-tags list so the new tag shows up as a\n // suggestion on subsequent searches (not just as the selected chip).\n const result = await createTag({\n variables: { input: { label } },\n refetchQueries: ['EntityTagPickerAvailableTags'],\n });\n const created = result.data?.createVibecontrolsTag;\n if (!created) {\n throw new Error(\n tr('vibecontrols.shared.entityTagPicker.failedToCreateTag', 'Failed to create tag')\n );\n }\n await handleAddTag({\n id: created.id,\n key: created.key,\n label: created.label,\n color: created.color ?? null,\n });\n } catch (err) {\n setErrorMessage(\n err instanceof Error\n ? err.message\n : tr('vibecontrols.shared.entityTagPicker.failedToCreateTag', 'Failed to create tag')\n );\n }\n },\n [createTag, handleAddTag, tr]\n );\n\n // Offer inline creation when the typed label doesn't already exist (covers\n // both \"no tags yet\" and \"no match\" cases). Match against the unfiltered\n // options AND the already-selected chips — checking only `suggestions` would\n // miss tags already assigned to this entity (filtered out of suggestions),\n // wrongly offering \"Create\" for a label that already exists.\n const trimmedQuery = searchQuery.trim();\n const hasExactMatch = useMemo(() => {\n const q = trimmedQuery.toLowerCase();\n if (!q) return false;\n return (\n rawTagOptions.some((t) => t.label.toLowerCase() === q) ||\n chipItems.some((c) => c.label.toLowerCase() === q)\n );\n }, [rawTagOptions, chipItems, trimmedQuery]);\n // Only offer Create once the debounced search has actually run and settled\n // for the current query — otherwise rawTagOptions may be stale/empty and we\n // could offer Create for a label that already exists. (Search runs only for\n // queries of length >= 2, which also bounds inline-created tag labels.)\n const searchSettledForQuery = searchedQuery === trimmedQuery && !searchLoading;\n const canCreateTag =\n !readOnly && trimmedQuery.length >= 2 && searchSettledForQuery && !hasExactMatch;\n\n if (tagsLoading && !draftMode) {\n return (\n <div\n className=\"flex items-center gap-2 text-text-secondary text-sm\"\n role=\"status\"\n aria-live=\"polite\"\n >\n <Loader2 className=\"h-4 w-4 animate-spin\" aria-hidden=\"true\" />\n {tr('vibecontrols.shared.entityTagPicker.loadingTags', 'Loading tags...')}\n </div>\n );\n }\n\n return (\n <div className=\"space-y-3\">\n {errorMessage && (\n <div\n role=\"alert\"\n className=\"flex items-start gap-2 rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700\"\n >\n <span className=\"flex-1\">{errorMessage}</span>\n <button\n type=\"button\"\n onClick={() => setErrorMessage(null)}\n aria-label={tr('vibecontrols.shared.entityTagPicker.dismissError', 'Dismiss error')}\n className=\"text-red-700 hover:text-red-900\"\n >\n <X className=\"h-3.5 w-3.5\" aria-hidden=\"true\" />\n </button>\n </div>\n )}\n\n <div\n className=\"flex flex-wrap gap-2\"\n aria-label={tr('vibecontrols.shared.entityTagPicker.assignedTags', 'Assigned tags')}\n >\n {chipItems.length === 0 && !isAdding && (\n <p className=\"text-text-secondary text-sm\">\n {tr('vibecontrols.shared.entityTagPicker.noTagsAssigned', 'No tags assigned')}\n </p>\n )}\n {chipItems.map((chip) => (\n <span\n key={chip.key}\n className=\"inline-flex items-center gap-1 px-3 py-1 bg-action-primary-bg/10 text-action-primary-bg text-sm rounded-full\"\n >\n <Tag className=\"h-3 w-3\" aria-hidden=\"true\" />\n {chip.label}\n {!readOnly && (\n <button\n type=\"button\"\n onClick={() => handleRemoveTag(chip.tagId)}\n disabled={untagging}\n aria-label={`Remove tag ${chip.label}`}\n className=\"ml-1 hover:text-red-500 transition-colors disabled:opacity-50\"\n >\n <X className=\"h-3 w-3\" aria-hidden=\"true\" />\n </button>\n )}\n </span>\n ))}\n {!readOnly && !isAdding && (\n <button\n type=\"button\"\n onClick={() => setIsAdding(true)}\n aria-label={tr('vibecontrols.shared.entityTagPicker.addTag', 'Add tag')}\n className=\"inline-flex items-center gap-1 px-3 py-1 border border-dashed border-border-default text-text-secondary text-sm rounded-full hover:border-action-primary-bg hover:text-action-primary-bg transition-colors\"\n >\n <Plus className=\"h-3 w-3\" aria-hidden=\"true\" />\n {tr('vibecontrols.shared.entityTagPicker.addTag', 'Add Tag')}\n </button>\n )}\n </div>\n\n {isAdding && (\n <div className=\"border border-border-default rounded-lg p-3 space-y-2 bg-bg-surface\">\n <div className=\"relative\">\n <Search\n className=\"absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text-secondary\"\n aria-hidden=\"true\"\n />\n <input\n type=\"text\"\n value={searchQuery}\n onChange={(e) => handleSearch(e.target.value)}\n placeholder={tr(\n 'vibecontrols.shared.entityTagPicker.searchPlaceholder',\n 'Search tags...'\n )}\n autoFocus\n aria-label={tr(\n 'vibecontrols.shared.entityTagPicker.searchAvailableTags',\n 'Search available tags'\n )}\n className=\"w-full pl-9 pr-3 py-2 text-sm bg-bg-default border border-border-default rounded-md focus:outline-none focus:ring-2 focus:ring-action-primary-bg/50 text-text-primary placeholder:text-text-secondary\"\n />\n </div>\n <div\n className=\"flex flex-wrap gap-2 max-h-32 overflow-y-auto\"\n aria-label={tr('vibecontrols.shared.entityTagPicker.tagSuggestions', 'Tag suggestions')}\n >\n {(searchLoading || (availableLoading && !searchQuery)) && (\n <span className=\"text-text-secondary text-xs\">\n {tr('vibecontrols.shared.entityTagPicker.loading', 'Loading...')}\n </span>\n )}\n {!searchLoading && !availableLoading && suggestions.length === 0 && !canCreateTag && (\n <span className=\"text-text-secondary text-xs\">\n {trimmedQuery\n ? tr('vibecontrols.shared.entityTagPicker.noMatchingTags', 'No matching tags')\n : tr(\n 'vibecontrols.shared.entityTagPicker.noAvailableTags',\n 'No tags yet — type a name to create one'\n )}\n </span>\n )}\n {suggestions.map((tag) => (\n <button\n key={tag.id}\n type=\"button\"\n onClick={() => handleAddTag(tag)}\n disabled={tagging}\n aria-label={`Add tag ${tag.label}`}\n className=\"inline-flex items-center gap-1 px-3 py-1 bg-bg-sunken text-text-primary text-sm rounded-full hover:bg-action-primary-bg/10 hover:text-action-primary-bg transition-colors cursor-pointer disabled:opacity-50\"\n >\n <Plus className=\"h-3 w-3\" aria-hidden=\"true\" />\n {tag.label}\n </button>\n ))}\n </div>\n {canCreateTag && (\n <button\n type=\"button\"\n onClick={() => handleCreateTag(trimmedQuery)}\n // Stay disabled through the follow-up assignment too: in persisted\n // mode handleCreateTag runs createTag (creatingTag) then tagEntity\n // (tagging); without `tagging` the button would re-enable between\n // the two, letting a second click duplicate the create.\n disabled={creatingTag || tagging}\n aria-label={tr(\n 'vibecontrols.shared.entityTagPicker.createTagAria',\n 'Create tag {{label}}',\n { label: trimmedQuery }\n )}\n className=\"inline-flex w-full items-center gap-1.5 px-3 py-2 border border-dashed border-action-primary-bg/50 text-action-primary-bg text-sm rounded-md hover:bg-action-primary-bg/10 transition-colors cursor-pointer disabled:opacity-50\"\n >\n {creatingTag || tagging ? (\n <Loader2 className=\"h-3.5 w-3.5 animate-spin\" aria-hidden=\"true\" />\n ) : (\n <Plus className=\"h-3.5 w-3.5\" aria-hidden=\"true\" />\n )}\n <span className=\"truncate\">\n {tr('vibecontrols.shared.entityTagPicker.createTag', 'Create \"{{label}}\"', {\n label: trimmedQuery,\n })}\n </span>\n </button>\n )}\n <button\n type=\"button\"\n onClick={() => {\n setIsAdding(false);\n setSearchQuery('');\n }}\n className=\"text-xs text-text-secondary hover:text-text-primary\"\n >\n {tr('vibecontrols.shared.entityTagPicker.done', 'Done')}\n </button>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -199,12 +199,15 @@ var n = {}, r = e`
|
|
|
199
199
|
fragment EntityTagPickerAssignmentFields on EntityTagAssignment {
|
|
200
200
|
id
|
|
201
201
|
tagId
|
|
202
|
-
|
|
202
|
+
tagInfo {
|
|
203
203
|
id
|
|
204
204
|
key
|
|
205
205
|
label
|
|
206
206
|
color
|
|
207
207
|
}
|
|
208
|
+
tag {
|
|
209
|
+
id
|
|
210
|
+
}
|
|
208
211
|
entityType
|
|
209
212
|
entityId
|
|
210
213
|
source
|