@oneuptime/common 10.2.4 → 10.2.6

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.
Files changed (44) hide show
  1. package/Models/DatabaseModels/Service.ts +26 -0
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1778582583897-MigrationName.ts +15 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/OpenTelemetryIngestService.ts +15 -0
  5. package/Server/Services/ServiceService.ts +37 -0
  6. package/Server/Types/Database/QueryHelper.ts +38 -0
  7. package/Server/Types/Database/QueryUtil.ts +77 -0
  8. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +52 -0
  9. package/Types/BaseDatabase/MultiSearch.ts +53 -0
  10. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  11. package/Types/Dashboard/DashboardComponents/DashboardChartComponent.ts +2 -0
  12. package/Types/JSON.ts +3 -0
  13. package/Types/SerializableObjectDictionary.ts +2 -0
  14. package/UI/Components/ModelTable/BaseModelTable.tsx +988 -4
  15. package/Utils/Dashboard/Components/DashboardChartComponent.ts +11 -0
  16. package/build/dist/Models/DatabaseModels/Service.js +28 -0
  17. package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
  18. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778582583897-MigrationName.js +12 -0
  19. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778582583897-MigrationName.js.map +1 -0
  20. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  21. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  22. package/build/dist/Server/Services/OpenTelemetryIngestService.js +11 -0
  23. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  24. package/build/dist/Server/Services/ServiceService.js +34 -0
  25. package/build/dist/Server/Services/ServiceService.js.map +1 -1
  26. package/build/dist/Server/Types/Database/QueryHelper.js +33 -0
  27. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  28. package/build/dist/Server/Types/Database/QueryUtil.js +64 -0
  29. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  30. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +44 -0
  31. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  32. package/build/dist/Types/BaseDatabase/MultiSearch.js +44 -0
  33. package/build/dist/Types/BaseDatabase/MultiSearch.js.map +1 -0
  34. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  35. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  36. package/build/dist/Types/JSON.js +1 -0
  37. package/build/dist/Types/JSON.js.map +1 -1
  38. package/build/dist/Types/SerializableObjectDictionary.js +2 -0
  39. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  40. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +591 -7
  41. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  42. package/build/dist/Utils/Dashboard/Components/DashboardChartComponent.js +9 -0
  43. package/build/dist/Utils/Dashboard/Components/DashboardChartComponent.js.map +1 -1
  44. package/package.json +1 -1
@@ -8,7 +8,9 @@ import Navigation from "../../Utils/Navigation";
8
8
  import PermissionUtil from "../../Utils/Permission";
9
9
  import ProjectUtil from "../../Utils/Project";
10
10
  import User from "../../Utils/User";
11
- import { ButtonSize, ButtonStyleType } from "../Button/Button";
11
+ import Button, { ButtonSize, ButtonStyleType } from "../Button/Button";
12
+ import MoreMenu from "../MoreMenu/MoreMenu";
13
+ import MoreMenuItem from "../MoreMenu/MoreMenuItem";
12
14
  import Card from "../Card/Card";
13
15
  import { getRefreshButton } from "../Card/CardButtons/Refresh";
14
16
  import ErrorMessage from "../ErrorMessage/ErrorMessage";
@@ -16,6 +18,7 @@ import List from "../List/List";
16
18
  import ConfirmModal from "../Modal/ConfirmModal";
17
19
  import Modal, { ModalWidth } from "../Modal/Modal";
18
20
  import MarkdownViewer from "../Markdown.tsx/MarkdownViewer";
21
+ import Icon from "../Icon/Icon";
19
22
  import OrderedStatesList from "../OrderedStatesList/OrderedStatesList";
20
23
  import Pill from "../Pill/Pill";
21
24
  import Table from "../Table/Table";
@@ -26,6 +29,7 @@ import Route from "../../../Types/API/Route";
26
29
  import URL from "../../../Types/API/URL";
27
30
  import InBetween from "../../../Types/BaseDatabase/InBetween";
28
31
  import Search from "../../../Types/BaseDatabase/Search";
32
+ import MultiSearch from "../../../Types/BaseDatabase/MultiSearch";
29
33
  import SortOrder from "../../../Types/BaseDatabase/SortOrder";
30
34
  import SubscriptionPlan from "../../../Types/Billing/SubscriptionPlan";
31
35
  import { Yellow } from "../../../Types/BrandColors";
@@ -36,7 +40,7 @@ import IconProp from "../../../Types/Icon/IconProp";
36
40
  import ObjectID from "../../../Types/ObjectID";
37
41
  import Permission, { PermissionHelper, } from "../../../Types/Permission";
38
42
  import Typeof from "../../../Types/Typeof";
39
- import React, { useEffect, useState, } from "react";
43
+ import React, { useEffect, useMemo, useState, } from "react";
40
44
  import TableViewElement from "./TableView";
41
45
  import UserPreferences, { UserPreferenceType, } from "../../../Utils/UserPreferences";
42
46
  export var ShowAs;
@@ -105,6 +109,152 @@ const BaseModelTable = (props) => {
105
109
  const [isLoading, setIsLoading] = useState(true);
106
110
  const [error, setError] = useState("");
107
111
  const [tableFilterError, setTableFilterError] = useState("");
112
+ const [searchText, setSearchText] = useState("");
113
+ const [debouncedSearchText, setDebouncedSearchText] = useState("");
114
+ const [isSearchFocused, setIsSearchFocused] = useState(false);
115
+ const [isSearchExpanded, setIsSearchExpanded] = useState(false);
116
+ const searchInputRef = React.useRef(null);
117
+ const [availableLabels, setAvailableLabels] = useState([]);
118
+ const [selectedLabels, setSelectedLabels] = useState([]);
119
+ const [isLabelsLoading, setIsLabelsLoading] = useState(false);
120
+ const [labelsFetched, setLabelsFetched] = useState(false);
121
+ const [labelDropdownIndex, setLabelDropdownIndex] = useState(0);
122
+ useEffect(() => {
123
+ const handle = setTimeout(() => {
124
+ setDebouncedSearchText(searchText);
125
+ }, 300);
126
+ return () => {
127
+ clearTimeout(handle);
128
+ };
129
+ }, [searchText]);
130
+ /*
131
+ * "/" focuses the search input — same affordance as GitHub / Linear.
132
+ * Skip while the user is typing in another input/textarea or has a modal open.
133
+ */
134
+ useEffect(() => {
135
+ if (!props.searchableFields || props.searchableFields.length === 0) {
136
+ return undefined;
137
+ }
138
+ const handleKey = (e) => {
139
+ if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) {
140
+ return;
141
+ }
142
+ const target = e.target;
143
+ if (target &&
144
+ (target.tagName === "INPUT" ||
145
+ target.tagName === "TEXTAREA" ||
146
+ target.isContentEditable)) {
147
+ return;
148
+ }
149
+ e.preventDefault();
150
+ setIsSearchExpanded(true);
151
+ // Wait one frame so the input mounts/becomes visible before focusing.
152
+ requestAnimationFrame(() => {
153
+ var _a;
154
+ (_a = searchInputRef.current) === null || _a === void 0 ? void 0 : _a.focus();
155
+ });
156
+ };
157
+ document.addEventListener("keydown", handleKey);
158
+ return () => {
159
+ document.removeEventListener("keydown", handleKey);
160
+ };
161
+ }, [props.searchableFields]);
162
+ /*
163
+ * Keep the search expanded whenever there is an active search term — so
164
+ * results stay visible alongside the box. Collapsing only happens when the
165
+ * user blurs an empty input.
166
+ */
167
+ useEffect(() => {
168
+ if ((debouncedSearchText.trim().length > 0 || selectedLabels.length > 0) &&
169
+ !isSearchExpanded) {
170
+ setIsSearchExpanded(true);
171
+ }
172
+ }, [debouncedSearchText, selectedLabels]);
173
+ useEffect(() => {
174
+ // reset to first page whenever the active search term or labels change
175
+ setCurrentPageNumber(1);
176
+ }, [debouncedSearchText, selectedLabels]);
177
+ const labelFilterConfig = useMemo(() => {
178
+ const filter = props.filters.find((f) => {
179
+ return (f.filterEntityType &&
180
+ f.filterEntityType.name === "Label" &&
181
+ f.field &&
182
+ f.filterDropdownField);
183
+ });
184
+ if (!filter || !filter.field || !filter.filterDropdownField) {
185
+ return null;
186
+ }
187
+ const fieldKey = Object.keys(filter.field)[0];
188
+ if (!fieldKey) {
189
+ return null;
190
+ }
191
+ return {
192
+ fieldKey,
193
+ entityType: filter.filterEntityType,
194
+ fetchQuery: filter.filterQuery || {},
195
+ labelField: filter.filterDropdownField.label,
196
+ };
197
+ }, [props.filters]);
198
+ // Fetch labels on first search expansion if this resource supports them.
199
+ useEffect(() => {
200
+ if (!isSearchExpanded || !labelFilterConfig || labelsFetched) {
201
+ return;
202
+ }
203
+ let cancelled = false;
204
+ setIsLabelsLoading(true);
205
+ (async () => {
206
+ try {
207
+ const result = await props.callbacks.getList({
208
+ modelType: labelFilterConfig.entityType,
209
+ query: labelFilterConfig.fetchQuery,
210
+ limit: 200,
211
+ skip: 0,
212
+ select: {
213
+ _id: true,
214
+ [labelFilterConfig.labelField]: true,
215
+ color: true,
216
+ },
217
+ sort: { [labelFilterConfig.labelField]: SortOrder.Ascending },
218
+ });
219
+ if (cancelled) {
220
+ return;
221
+ }
222
+ const mapped = (result.data || [])
223
+ .map((item) => {
224
+ var _a;
225
+ const raw = item;
226
+ const colorAny = raw["color"];
227
+ const colorHex = (colorAny &&
228
+ (typeof colorAny === "string"
229
+ ? colorAny
230
+ : colorAny.value ||
231
+ (colorAny.toString && colorAny.toString()))) ||
232
+ "#94a3b8";
233
+ return {
234
+ id: ((_a = raw["_id"]) === null || _a === void 0 ? void 0 : _a.toString()) || "",
235
+ name: raw[labelFilterConfig.labelField] || "",
236
+ color: colorHex,
237
+ };
238
+ })
239
+ .filter((l) => {
240
+ return l.id && l.name;
241
+ });
242
+ setAvailableLabels(mapped);
243
+ setLabelsFetched(true);
244
+ }
245
+ catch (_a) {
246
+ // Silently fail — search still works without label suggestions.
247
+ }
248
+ finally {
249
+ if (!cancelled) {
250
+ setIsLabelsLoading(false);
251
+ }
252
+ }
253
+ })();
254
+ return () => {
255
+ cancelled = true;
256
+ };
257
+ }, [isSearchExpanded, labelFilterConfig, labelsFetched]);
108
258
  const [showModel, setShowModal] = useState(false);
109
259
  const [showFilterModal, setShowFilterModal] = useState(false);
110
260
  const [modalType, setModalType] = useState(ModalType.Create);
@@ -439,13 +589,57 @@ const BaseModelTable = (props) => {
439
589
  }
440
590
  setIsFilterFetchLoading(false);
441
591
  };
592
+ const buildSearchQueryFragment = () => {
593
+ const fragment = {};
594
+ /*
595
+ * Strip the trailing @<prefix> mention before searching — that token
596
+ * is a label-autocomplete trigger, not part of the user's free-text
597
+ * query.
598
+ */
599
+ const stripTrailingMention = (v) => {
600
+ const atIndex = v.lastIndexOf("@");
601
+ if (atIndex < 0) {
602
+ return v;
603
+ }
604
+ if (atIndex > 0) {
605
+ const prev = v[atIndex - 1] || "";
606
+ if (prev !== " " && prev !== "\t") {
607
+ return v;
608
+ }
609
+ }
610
+ const after = v.substring(atIndex + 1);
611
+ if (after.includes(" ") ||
612
+ after.includes("\t") ||
613
+ after.includes("\n")) {
614
+ return v;
615
+ }
616
+ return v.substring(0, atIndex).trimEnd();
617
+ };
618
+ const effectiveSearch = stripTrailingMention(debouncedSearchText).trim();
619
+ if (effectiveSearch.length > 0 &&
620
+ props.searchableFields &&
621
+ props.searchableFields.length > 0) {
622
+ fragment._multiFieldSearch = new MultiSearch({
623
+ fields: props.searchableFields.map((f) => {
624
+ return f;
625
+ }),
626
+ value: effectiveSearch,
627
+ });
628
+ }
629
+ if (labelFilterConfig && selectedLabels.length > 0) {
630
+ fragment[labelFilterConfig.fieldKey] = new Includes(selectedLabels.map((l) => {
631
+ return l.id;
632
+ }));
633
+ }
634
+ return fragment;
635
+ };
442
636
  const fetchAllBulkItems = async () => {
443
637
  setError("");
444
638
  setIsLoading(true);
445
639
  try {
446
640
  const listResult = await props.callbacks.getList({
447
641
  modelType: props.modelType,
448
- query: Object.assign(Object.assign({}, props.query), query),
642
+ query: Object.assign(Object.assign(Object.assign({}, props.query), query), buildSearchQueryFragment()),
449
643
  limit: LIMIT_PER_PROJECT,
450
644
  skip: 0,
451
645
  select: {
@@ -474,7 +668,7 @@ const BaseModelTable = (props) => {
474
668
  try {
475
669
  const listResult = await props.callbacks.getList({
476
670
  modelType: props.modelType,
477
- query: Object.assign(Object.assign({}, props.query), query),
671
+ query: Object.assign(Object.assign(Object.assign({}, props.query), query), buildSearchQueryFragment()),
478
672
  groupBy: Object.assign({}, props.groupBy),
479
673
  limit: itemsOnPage,
480
674
  skip: (currentPageNumber - 1) * itemsOnPage,
@@ -687,6 +881,8 @@ const BaseModelTable = (props) => {
687
881
  sortOrder,
688
882
  itemsOnPage,
689
883
  query,
884
+ debouncedSearchText,
885
+ selectedLabels,
690
886
  props.refreshToggle,
691
887
  ]);
692
888
  const shouldDisableSort = (columnName) => {
@@ -1137,20 +1333,408 @@ const BaseModelTable = (props) => {
1137
1333
  } },
1138
1334
  React.createElement(Pill, { text: `${planName} Plan`, color: Yellow })))));
1139
1335
  };
1336
+ const collapseSearch = () => {
1337
+ setSearchText("");
1338
+ setSelectedLabels([]);
1339
+ setIsSearchExpanded(false);
1340
+ };
1341
+ const parseLabelMention = (value) => {
1342
+ const atIndex = value.lastIndexOf("@");
1343
+ if (atIndex < 0) {
1344
+ return { hasMention: false, prefix: "", atIndex: -1 };
1345
+ }
1346
+ if (atIndex > 0) {
1347
+ const prev = value[atIndex - 1] || "";
1348
+ if (prev !== " " && prev !== "\t") {
1349
+ return { hasMention: false, prefix: "", atIndex: -1 };
1350
+ }
1351
+ }
1352
+ const after = value.substring(atIndex + 1);
1353
+ if (after.includes(" ") || after.includes("\t") || after.includes("\n")) {
1354
+ return { hasMention: false, prefix: "", atIndex: -1 };
1355
+ }
1356
+ return { hasMention: true, prefix: after, atIndex };
1357
+ };
1358
+ const addLabel = (label) => {
1359
+ setSelectedLabels((prev) => {
1360
+ if (prev.find((l) => {
1361
+ return l.id === label.id;
1362
+ })) {
1363
+ return prev;
1364
+ }
1365
+ return [...prev, label];
1366
+ });
1367
+ // Strip the `@<prefix>` token from the input.
1368
+ const mention = parseLabelMention(searchText);
1369
+ if (mention.hasMention) {
1370
+ const before = searchText.substring(0, mention.atIndex);
1371
+ setSearchText(before.replace(/\s+$/, ""));
1372
+ }
1373
+ setLabelDropdownIndex(0);
1374
+ };
1375
+ const removeLabel = (labelId) => {
1376
+ setSelectedLabels((prev) => {
1377
+ return prev.filter((l) => {
1378
+ return l.id !== labelId;
1379
+ });
1380
+ });
1381
+ };
1382
+ const getSearchControl = () => {
1383
+ if (!props.searchableFields || props.searchableFields.length === 0) {
1384
+ return React.createElement(React.Fragment, null);
1385
+ }
1386
+ const pluralLabel = (props.pluralName ||
1387
+ model.pluralName ||
1388
+ "items").toLowerCase();
1389
+ const hasLabelSupport = Boolean(labelFilterConfig);
1390
+ const defaultPlaceholder = hasLabelSupport
1391
+ ? `Search ${pluralLabel}… (try @ for labels)`
1392
+ : `Search ${pluralLabel} by name, description…`;
1393
+ const placeholder = props.searchPlaceholder || defaultPlaceholder;
1394
+ /*
1395
+ * Effective search = input minus the trailing @<prefix> mention. The pill
1396
+ * + result-count UI should reflect this so typing "@bug" doesn't claim
1397
+ * "0 matches" before the user has actually committed the label.
1398
+ */
1399
+ const stripTrailingMentionForUi = (v) => {
1400
+ const m = parseLabelMention(v);
1401
+ return m.hasMention ? v.substring(0, m.atIndex).trimEnd() : v;
1402
+ };
1403
+ const trimmedSearch = stripTrailingMentionForUi(searchText).trim();
1404
+ const trimmedActive = stripTrailingMentionForUi(debouncedSearchText).trim();
1405
+ const isSearching = trimmedSearch.length > 0 && trimmedSearch !== trimmedActive;
1406
+ const hasActiveSearch = trimmedActive.length > 0;
1407
+ const hasSelectedLabels = selectedLabels.length > 0;
1408
+ const showMatchPill = !isSearching && (hasActiveSearch || hasSelectedLabels);
1409
+ const expanded = isSearchExpanded || hasActiveSearch || hasSelectedLabels;
1410
+ const mention = parseLabelMention(searchText);
1411
+ const showLabelDropdown = hasLabelSupport && isSearchFocused && mention.hasMention;
1412
+ const lowerPrefix = mention.prefix.toLowerCase();
1413
+ const dropdownLabels = availableLabels
1414
+ .filter((l) => {
1415
+ return !selectedLabels.find((s) => {
1416
+ return s.id === l.id;
1417
+ });
1418
+ })
1419
+ .filter((l) => {
1420
+ return (lowerPrefix.length === 0 || l.name.toLowerCase().includes(lowerPrefix));
1421
+ })
1422
+ .slice(0, 8);
1423
+ const borderClass = isSearchFocused
1424
+ ? "border-gray-400 ring-4 ring-gray-100 shadow-sm"
1425
+ : hasActiveSearch || hasSelectedLabels
1426
+ ? "border-gray-300 shadow-sm"
1427
+ : "border-gray-200 shadow-sm";
1428
+ const iconColorClass = isSearchFocused || hasActiveSearch || hasSelectedLabels
1429
+ ? "text-gray-700"
1430
+ : "text-gray-400";
1431
+ const selectDropdownItemAt = (idx) => {
1432
+ const item = dropdownLabels[idx];
1433
+ if (item) {
1434
+ addLabel(item);
1435
+ }
1436
+ };
1437
+ return (React.createElement("div", { className: "relative flex w-full items-center" },
1438
+ React.createElement("div", { className: "relative flex w-full flex-col gap-1" },
1439
+ React.createElement("div", { className: `flex w-full items-center gap-2 rounded-lg border bg-white pl-3 pr-2 py-1.5 transition-all duration-200 ${borderClass}`, onClick: () => {
1440
+ var _a;
1441
+ (_a = searchInputRef.current) === null || _a === void 0 ? void 0 : _a.focus();
1442
+ }, role: "presentation" },
1443
+ React.createElement(Icon, { icon: IconProp.Search, className: `h-4 w-4 flex-none transition-colors duration-200 ${iconColorClass}` }),
1444
+ React.createElement("div", { className: "flex min-w-0 flex-1 flex-wrap items-center gap-1.5" },
1445
+ selectedLabels.map((label) => {
1446
+ return (React.createElement("span", { key: label.id, className: "inline-flex items-center gap-1 rounded-full bg-gray-50 py-0.5 pl-2 pr-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-all hover:bg-gray-100", title: `Label: ${label.name}` },
1447
+ React.createElement("span", { className: "h-2 w-2 flex-none rounded-full", style: { backgroundColor: label.color }, "aria-hidden": "true" }),
1448
+ React.createElement("span", { className: "max-w-[8rem] truncate" }, label.name),
1449
+ React.createElement("button", { type: "button", onMouseDown: (e) => {
1450
+ e.preventDefault();
1451
+ }, onClick: () => {
1452
+ removeLabel(label.id);
1453
+ }, title: "Remove label", "aria-label": `Remove ${label.name}`, className: "ml-0.5 flex-none rounded-full p-0.5 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700" },
1454
+ React.createElement(Icon, { icon: IconProp.Close, className: "h-3 w-3" }))));
1455
+ }),
1456
+ React.createElement("input", { ref: searchInputRef, type: "text", value: searchText, onChange: (e) => {
1457
+ setSearchText(e.target.value);
1458
+ setLabelDropdownIndex(0);
1459
+ }, onFocus: () => {
1460
+ setIsSearchFocused(true);
1461
+ }, onBlur: () => {
1462
+ setIsSearchFocused(false);
1463
+ /*
1464
+ * Collapse only when the user blurs with nothing active —
1465
+ * no text and no selected labels.
1466
+ */
1467
+ if (searchText.trim().length === 0 &&
1468
+ selectedLabels.length === 0) {
1469
+ setIsSearchExpanded(false);
1470
+ }
1471
+ }, onKeyDown: (e) => {
1472
+ var _a;
1473
+ if (showLabelDropdown && dropdownLabels.length > 0) {
1474
+ if (e.key === "ArrowDown") {
1475
+ e.preventDefault();
1476
+ setLabelDropdownIndex((i) => {
1477
+ return Math.min(i + 1, dropdownLabels.length - 1);
1478
+ });
1479
+ return;
1480
+ }
1481
+ if (e.key === "ArrowUp") {
1482
+ e.preventDefault();
1483
+ setLabelDropdownIndex((i) => {
1484
+ return Math.max(i - 1, 0);
1485
+ });
1486
+ return;
1487
+ }
1488
+ if (e.key === "Enter" || e.key === "Tab") {
1489
+ e.preventDefault();
1490
+ selectDropdownItemAt(labelDropdownIndex);
1491
+ return;
1492
+ }
1493
+ }
1494
+ if (e.key === "Backspace" && searchText.length === 0) {
1495
+ // Pop last label when backspacing on empty input.
1496
+ if (selectedLabels.length > 0) {
1497
+ const last = selectedLabels[selectedLabels.length - 1];
1498
+ if (last) {
1499
+ removeLabel(last.id);
1500
+ }
1501
+ }
1502
+ return;
1503
+ }
1504
+ if (e.key === "Escape") {
1505
+ if (showLabelDropdown) {
1506
+ // Just cancel the @ mention parse — clear @ prefix.
1507
+ const m = parseLabelMention(searchText);
1508
+ if (m.hasMention) {
1509
+ setSearchText(searchText
1510
+ .substring(0, m.atIndex)
1511
+ .replace(/\s+$/, ""));
1512
+ }
1513
+ return;
1514
+ }
1515
+ if (searchText) {
1516
+ setSearchText("");
1517
+ }
1518
+ else if (selectedLabels.length > 0) {
1519
+ setSelectedLabels([]);
1520
+ }
1521
+ else {
1522
+ collapseSearch();
1523
+ (_a = searchInputRef.current) === null || _a === void 0 ? void 0 : _a.blur();
1524
+ }
1525
+ }
1526
+ }, placeholder: selectedLabels.length === 0 ? placeholder : "Refine search…", spellCheck: false, autoComplete: "off", tabIndex: expanded ? 0 : -1, className: "min-w-[6rem] flex-1 bg-transparent py-1 text-sm text-gray-900 placeholder-gray-400 outline-none" })),
1527
+ isSearching && (React.createElement("div", { className: "flex-none text-gray-400", title: "Searching\u2026" },
1528
+ React.createElement(Icon, { icon: IconProp.Spinner, className: "h-4 w-4 animate-spin" }))),
1529
+ showMatchPill && totalItemsCount >= 0 && (React.createElement("span", { className: "flex-none whitespace-nowrap rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700", title: `${totalItemsCount} ${totalItemsCount === 1 ? "result" : "results"}` },
1530
+ totalItemsCount,
1531
+ " ",
1532
+ totalItemsCount === 1 ? "match" : "matches")),
1533
+ searchText.length > 0 || selectedLabels.length > 0 ? (React.createElement("button", { type: "button", onMouseDown: (e) => {
1534
+ e.preventDefault();
1535
+ }, onClick: () => {
1536
+ collapseSearch();
1537
+ }, title: "Clear search (Esc Esc)", "aria-label": "Clear search", className: "flex-none rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600" },
1538
+ React.createElement(Icon, { icon: IconProp.Close, className: "h-3.5 w-3.5" }))) : (React.createElement("kbd", { className: "hidden flex-none select-none items-center rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 font-mono text-[10px] font-medium text-gray-500 sm:inline-flex", title: "Press / to focus search" }, "/"))),
1539
+ showLabelDropdown && (React.createElement("div", { className: "absolute left-0 right-0 top-full z-20 mt-1.5 origin-top overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg ring-1 ring-black/5 transition-all duration-150 animate-in fade-in slide-in-from-top-1", onMouseDown: (e) => {
1540
+ // Prevent input blur on dropdown clicks.
1541
+ e.preventDefault();
1542
+ } },
1543
+ React.createElement("div", { className: "flex items-center justify-between border-b border-gray-100 px-3 py-2" },
1544
+ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-wide text-gray-500" }, isLabelsLoading ? "Loading labels…" : "Filter by label"),
1545
+ React.createElement("span", { className: "text-[10px] text-gray-400" },
1546
+ React.createElement("kbd", { className: "font-mono" }, "\u2191"),
1547
+ React.createElement("kbd", { className: "ml-0.5 font-mono" }, "\u2193"),
1548
+ React.createElement("span", { className: "ml-1" }, "to navigate"),
1549
+ React.createElement("span", { className: "mx-1.5" }, "\u00B7"),
1550
+ React.createElement("kbd", { className: "font-mono" }, "\u21B5"),
1551
+ React.createElement("span", { className: "ml-1" }, "to select"))),
1552
+ React.createElement("div", { className: "max-h-64 overflow-y-auto py-1" },
1553
+ !isLabelsLoading && dropdownLabels.length === 0 && (React.createElement("div", { className: "px-3 py-3 text-sm text-gray-500" }, availableLabels.length === 0
1554
+ ? "No labels available for this resource."
1555
+ : `No labels matching "${mention.prefix}"`)),
1556
+ dropdownLabels.map((label, idx) => {
1557
+ const isActive = idx === labelDropdownIndex;
1558
+ return (React.createElement("button", { key: label.id, type: "button", onMouseEnter: () => {
1559
+ setLabelDropdownIndex(idx);
1560
+ }, onMouseDown: (e) => {
1561
+ e.preventDefault();
1562
+ }, onClick: () => {
1563
+ var _a;
1564
+ addLabel(label);
1565
+ (_a = searchInputRef.current) === null || _a === void 0 ? void 0 : _a.focus();
1566
+ }, className: `flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors ${isActive
1567
+ ? "bg-gray-100 text-gray-900"
1568
+ : "text-gray-700 hover:bg-gray-50"}` },
1569
+ React.createElement("span", { className: "h-2.5 w-2.5 flex-none rounded-full ring-1 ring-inset ring-black/5", style: { backgroundColor: label.color }, "aria-hidden": "true" }),
1570
+ React.createElement("span", { className: "flex-1 truncate" }, label.name),
1571
+ isActive && (React.createElement("span", { className: "text-[10px] font-medium uppercase tracking-wide text-gray-500" }, "\u21B5"))));
1572
+ })))))));
1573
+ };
1574
+ const splitButtonsForHeader = (buttons) => {
1575
+ let mainIndex = -1;
1576
+ for (let i = 0; i < buttons.length; i++) {
1577
+ const b = buttons[i];
1578
+ if (React.isValidElement(b)) {
1579
+ continue;
1580
+ }
1581
+ const c = b;
1582
+ if (c.buttonStyle === ButtonStyleType.NORMAL && c.icon === IconProp.Add) {
1583
+ mainIndex = i;
1584
+ break;
1585
+ }
1586
+ }
1587
+ if (mainIndex < 0) {
1588
+ for (let i = 0; i < buttons.length; i++) {
1589
+ const b = buttons[i];
1590
+ if (React.isValidElement(b)) {
1591
+ continue;
1592
+ }
1593
+ if (b.buttonStyle === ButtonStyleType.NORMAL) {
1594
+ mainIndex = i;
1595
+ break;
1596
+ }
1597
+ }
1598
+ }
1599
+ if (mainIndex < 0) {
1600
+ return { main: null, rest: buttons };
1601
+ }
1602
+ const main = buttons[mainIndex];
1603
+ const rest = [
1604
+ ...buttons.slice(0, mainIndex),
1605
+ ...buttons.slice(mainIndex + 1),
1606
+ ];
1607
+ return { main, rest };
1608
+ };
1609
+ const renderMainButton = (b) => {
1610
+ return (React.createElement(Button, { key: "model-table-main-action", title: b.title, buttonStyle: b.buttonStyle, buttonSize: b.buttonSize, className: b.className, onClick: () => {
1611
+ var _a;
1612
+ (_a = b.onClick) === null || _a === void 0 ? void 0 : _a.call(b);
1613
+ }, disabled: b.disabled, icon: b.icon, shortcutKey: b.shortcutKey, dataTestId: "card-button", isLoading: b.isLoading }));
1614
+ };
1615
+ /*
1616
+ * Icon-only card buttons (Refresh, Filter, …) have empty titles, so derive
1617
+ * a label from the icon for the More menu.
1618
+ */
1619
+ // Fallback labels for icon-only buttons (Refresh, Filter, ...) in the More menu.
1620
+ const labelForIconButton = (icon) => {
1621
+ switch (icon) {
1622
+ case IconProp.Refresh:
1623
+ return "Refresh";
1624
+ case IconProp.Filter:
1625
+ return "Filter";
1626
+ case IconProp.Add:
1627
+ return "Add";
1628
+ case IconProp.Help:
1629
+ return "Help";
1630
+ case IconProp.Book:
1631
+ return "Documentation";
1632
+ case IconProp.Play:
1633
+ return "Watch Demo";
1634
+ case IconProp.Search:
1635
+ return "Search";
1636
+ default:
1637
+ return "Action";
1638
+ }
1639
+ };
1640
+ const renderMoreMenu = (items) => {
1641
+ if (items.length === 0) {
1642
+ return null;
1643
+ }
1644
+ const children = items.map((item, idx) => {
1645
+ if (React.isValidElement(item)) {
1646
+ return (React.createElement("div", { key: `more-${idx}`, className: "px-2 py-1" }, item));
1647
+ }
1648
+ const b = item;
1649
+ const label = b.title || labelForIconButton(b.icon);
1650
+ return (React.createElement(MoreMenuItem, { key: `more-${idx}`, text: label, icon: b.icon, onClick: () => {
1651
+ var _a;
1652
+ if (!b.disabled) {
1653
+ (_a = b.onClick) === null || _a === void 0 ? void 0 : _a.call(b);
1654
+ }
1655
+ }, className: b.disabled ? "opacity-40 pointer-events-none" : "" }));
1656
+ });
1657
+ return (React.createElement(MoreMenu, { key: "model-table-more-menu", menuIcon: IconProp.EllipsisHorizontal, text: "" }, children));
1658
+ };
1659
+ /*
1660
+ * Builds the right-hand side of the card header. All three slots — search
1661
+ * trigger/bar, main button, more menu — stay mounted at all times. State
1662
+ * transitions are purely CSS so the inputs keep their focus, the dropdown
1663
+ * keeps its open state, and there's no mount/unmount flicker.
1664
+ *
1665
+ * Layout when collapsed: [🔍 trigger] [main] [⋯]
1666
+ * Layout when expanded: [🔍 ━━━ wide search bar ━━━]
1667
+ * The main button + more menu fade and collapse-to-zero-width when the
1668
+ * search expands, freeing horizontal space for the bar.
1669
+ */
1670
+ const getHeaderButtonsWithSearch = () => {
1671
+ const hasSearch = Boolean(props.searchableFields && props.searchableFields.length > 0);
1672
+ if (cardButtons.length === 0 && !hasSearch) {
1673
+ return cardButtons;
1674
+ }
1675
+ if (!hasSearch) {
1676
+ // Without search, just split into [main] [⋯]; no special wrapping.
1677
+ const { main, rest } = splitButtonsForHeader(cardButtons);
1678
+ const composed = [];
1679
+ if (main) {
1680
+ composed.push(renderMainButton(main));
1681
+ }
1682
+ const moreMenu = renderMoreMenu(rest);
1683
+ if (moreMenu) {
1684
+ composed.push(moreMenu);
1685
+ }
1686
+ return composed;
1687
+ }
1688
+ const trimmedActive = debouncedSearchText.trim();
1689
+ const isExpanded = isSearchExpanded || trimmedActive.length > 0 || selectedLabels.length > 0;
1690
+ const { main, rest } = splitButtonsForHeader(cardButtons);
1691
+ const moreMenu = renderMoreMenu(rest);
1692
+ const wrapped = (React.createElement("div", { key: "model-table-header-actions", className: "flex items-center gap-1.5" },
1693
+ React.createElement("div", { className: `relative shrink-0 transition-[width] duration-300 ease-out ${isExpanded ? "w-[22rem] sm:w-[26rem] lg:w-[32rem]" : "w-44 sm:w-56"}` },
1694
+ React.createElement("button", { type: "button", onClick: () => {
1695
+ setIsSearchExpanded(true);
1696
+ requestAnimationFrame(() => {
1697
+ var _a;
1698
+ (_a = searchInputRef.current) === null || _a === void 0 ? void 0 : _a.focus();
1699
+ });
1700
+ }, title: "Search (/)", "aria-label": "Open search", tabIndex: isExpanded ? -1 : 0, className: `absolute inset-0 inline-flex items-center gap-2 rounded-lg border bg-white pl-3 pr-2 text-sm shadow-sm transition-all duration-200 ease-out ${isExpanded
1701
+ ? "pointer-events-none border-gray-200 opacity-0"
1702
+ : "border-gray-200 opacity-100 hover:border-gray-300 hover:bg-gray-50"}` },
1703
+ React.createElement(Icon, { icon: IconProp.Search, className: "h-4 w-4 flex-none text-gray-400" }),
1704
+ React.createElement("span", { className: "flex-1 truncate text-left text-gray-400" }, "Search\u2026"),
1705
+ React.createElement("kbd", { className: "hidden flex-none select-none items-center rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 font-mono text-[10px] font-medium text-gray-500 sm:inline-flex" }, "/")),
1706
+ React.createElement("div", { className: `transition-opacity duration-200 ease-out ${isExpanded
1707
+ ? "opacity-100 delay-150"
1708
+ : "pointer-events-none opacity-0"}` }, getSearchControl())),
1709
+ React.createElement("div", { className: `flex items-center transition-all duration-300 ease-out ${isExpanded
1710
+ ? "max-w-0 -ml-1.5 gap-0 opacity-0 pointer-events-none"
1711
+ : "max-w-[600px] gap-1.5 opacity-100"}`, "aria-hidden": isExpanded },
1712
+ main && renderMainButton(main),
1713
+ moreMenu)));
1714
+ return [wrapped];
1715
+ };
1716
+ const getCardHeaderTitle = (originalTitle) => {
1717
+ return originalTitle;
1718
+ };
1140
1719
  const getCardComponent = () => {
1720
+ const headerButtons = getHeaderButtonsWithSearch();
1141
1721
  if (showAs === ShowAs.Table || showAs === ShowAs.List) {
1142
1722
  return (React.createElement("div", null,
1143
- props.cardProps && (React.createElement(Card, Object.assign({}, props.cardProps, { buttons: cardButtons, bodyClassName: showAs === ShowAs.List
1723
+ props.cardProps && (React.createElement(Card, Object.assign({}, props.cardProps, { buttons: headerButtons, bodyClassName: showAs === ShowAs.List
1144
1724
  ? "-ml-6 -mr-6 bg-gray-50 border-top"
1145
- : "", title: getCardTitle(props.cardProps.title || "") }),
1725
+ : "", title: getCardHeaderTitle(getCardTitle(props.cardProps.title || "")) }),
1146
1726
  tableColumns.length === 0 && props.columns.length > 0 ? (React.createElement(ErrorMessage, { message: `You are not authorized to view this table. You need any one of these permissions: ${PermissionHelper.getPermissionTitles(model.getReadPermissions()).join(", ")}` })) : (React.createElement(React.Fragment, null)),
1147
1727
  tableColumns.length > 0 && showAs === ShowAs.Table ? (getTable()) : (React.createElement(React.Fragment, null)),
1148
1728
  tableColumns.length > 0 && showAs === ShowAs.List ? (getList()) : (React.createElement(React.Fragment, null)))),
1729
+ !props.cardProps &&
1730
+ (showAs === ShowAs.Table || showAs === ShowAs.List) &&
1731
+ props.searchableFields &&
1732
+ props.searchableFields.length > 0 ? (React.createElement("div", { className: "mb-3 flex justify-end" }, getSearchControl())) : (React.createElement(React.Fragment, null)),
1149
1733
  !props.cardProps && showAs === ShowAs.Table ? getTable() : React.createElement(React.Fragment, null),
1150
1734
  !props.cardProps && showAs === ShowAs.List ? getList() : React.createElement(React.Fragment, null)));
1151
1735
  }
1152
1736
  return (React.createElement("div", null,
1153
- props.cardProps && (React.createElement(Card, Object.assign({}, props.cardProps, { buttons: cardButtons, title: getCardTitle(props.cardProps.title || "") }), getOrderedStatesList())),
1737
+ props.cardProps && (React.createElement(Card, Object.assign({}, props.cardProps, { buttons: headerButtons, title: getCardTitle(props.cardProps.title || "") }), getOrderedStatesList())),
1154
1738
  !props.cardProps && getOrderedStatesList()));
1155
1739
  };
1156
1740
  return (React.createElement(React.Fragment, null,