@oneuptime/common 10.2.4 → 10.2.7

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 (59) hide show
  1. package/Models/DatabaseModels/Service.ts +26 -0
  2. package/Server/API/GlobalConfigAPI.ts +13 -16
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1778582583897-MigrationName.ts +15 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  5. package/Server/Services/OpenTelemetryIngestService.ts +15 -0
  6. package/Server/Services/ServiceService.ts +37 -0
  7. package/Server/Types/Database/QueryHelper.ts +38 -0
  8. package/Server/Types/Database/QueryUtil.ts +77 -0
  9. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +52 -0
  10. package/Types/BaseDatabase/MultiSearch.ts +53 -0
  11. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  12. package/Types/Dashboard/DashboardComponents/DashboardChartComponent.ts +2 -0
  13. package/Types/JSON.ts +3 -0
  14. package/Types/SerializableObjectDictionary.ts +2 -0
  15. package/UI/Components/Header/ProjectPicker/ProjectPicker.tsx +11 -6
  16. package/UI/Components/LogsViewer/components/ColumnSelector.tsx +58 -4
  17. package/UI/Components/ModelTable/BaseModelTable.tsx +1026 -10
  18. package/UI/Components/ModelTable/TableView.tsx +58 -32
  19. package/UI/Utils/GlobalConfig.ts +55 -0
  20. package/Utils/Dashboard/Components/DashboardChartComponent.ts +11 -0
  21. package/build/dist/Models/DatabaseModels/Service.js +28 -0
  22. package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
  23. package/build/dist/Server/API/GlobalConfigAPI.js +10 -16
  24. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  25. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778582583897-MigrationName.js +12 -0
  26. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778582583897-MigrationName.js.map +1 -0
  27. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  28. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  29. package/build/dist/Server/Services/OpenTelemetryIngestService.js +11 -0
  30. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  31. package/build/dist/Server/Services/ServiceService.js +34 -0
  32. package/build/dist/Server/Services/ServiceService.js.map +1 -1
  33. package/build/dist/Server/Types/Database/QueryHelper.js +33 -0
  34. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  35. package/build/dist/Server/Types/Database/QueryUtil.js +64 -0
  36. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  37. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +44 -0
  38. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  39. package/build/dist/Types/BaseDatabase/MultiSearch.js +44 -0
  40. package/build/dist/Types/BaseDatabase/MultiSearch.js.map +1 -0
  41. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  42. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  43. package/build/dist/Types/JSON.js +1 -0
  44. package/build/dist/Types/JSON.js.map +1 -1
  45. package/build/dist/Types/SerializableObjectDictionary.js +2 -0
  46. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  47. package/build/dist/UI/Components/Header/ProjectPicker/ProjectPicker.js +2 -2
  48. package/build/dist/UI/Components/Header/ProjectPicker/ProjectPicker.js.map +1 -1
  49. package/build/dist/UI/Components/LogsViewer/components/ColumnSelector.js +33 -3
  50. package/build/dist/UI/Components/LogsViewer/components/ColumnSelector.js.map +1 -1
  51. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +618 -12
  52. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  53. package/build/dist/UI/Components/ModelTable/TableView.js +25 -18
  54. package/build/dist/UI/Components/ModelTable/TableView.js.map +1 -1
  55. package/build/dist/UI/Utils/GlobalConfig.js +38 -0
  56. package/build/dist/UI/Utils/GlobalConfig.js.map +1 -0
  57. package/build/dist/Utils/Dashboard/Components/DashboardChartComponent.js +9 -0
  58. package/build/dist/Utils/Dashboard/Components/DashboardChartComponent.js.map +1 -1
  59. 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,
@@ -672,9 +866,6 @@ const BaseModelTable = (props) => {
672
866
  icon: IconProp.Filter,
673
867
  });
674
868
  }
675
- if (props.saveFilterProps) {
676
- headerbuttons.push(getSaveFilterDropdown());
677
- }
678
869
  setCardButtons(headerbuttons);
679
870
  };
680
871
  useEffect(() => {
@@ -687,6 +878,8 @@ const BaseModelTable = (props) => {
687
878
  sortOrder,
688
879
  itemsOnPage,
689
880
  query,
881
+ debouncedSearchText,
882
+ selectedLabels,
690
883
  props.refreshToggle,
691
884
  ]);
692
885
  const shouldDisableSort = (columnName) => {
@@ -961,6 +1154,12 @@ const BaseModelTable = (props) => {
961
1154
  },
962
1155
  };
963
1156
  };
1157
+ const isSearchActive = () => {
1158
+ return debouncedSearchText.trim().length > 0 || selectedLabels.length > 0;
1159
+ };
1160
+ const getTableLoadingState = () => {
1161
+ return isSearchActive() ? false : isLoading;
1162
+ };
964
1163
  const getTable = () => {
965
1164
  var _a;
966
1165
  return (React.createElement(Table, { onFilterChanged: (filterData) => {
@@ -1035,7 +1234,7 @@ const BaseModelTable = (props) => {
1035
1234
  setSortBy(sortBy);
1036
1235
  setSortOrder(sortOrder);
1037
1236
  setTableView(null);
1038
- }, singularLabel: props.singularName || model.singularName || "Item", pluralLabel: props.pluralName || model.pluralName || "Items", error: error, currentPageNumber: currentPageNumber, isLoading: isLoading, enableDragAndDrop: props.enableDragAndDrop, dragDropIdField: "_id", dragDropIndexField: props.dragDropIndexField, totalItemsCount: totalItemsCount, data: data, id: props.id, columns: tableColumns, itemsOnPage: itemsOnPage, onDragDrop: async (id, newOrder) => {
1237
+ }, singularLabel: props.singularName || model.singularName || "Item", pluralLabel: props.pluralName || model.pluralName || "Items", error: error, currentPageNumber: currentPageNumber, isLoading: getTableLoadingState(), enableDragAndDrop: props.enableDragAndDrop, dragDropIdField: "_id", dragDropIndexField: props.dragDropIndexField, totalItemsCount: totalItemsCount, data: data, id: props.id, columns: tableColumns, itemsOnPage: itemsOnPage, onDragDrop: async (id, newOrder) => {
1039
1238
  if (!props.dragDropIndexField) {
1040
1239
  return;
1041
1240
  }
@@ -1104,7 +1303,7 @@ const BaseModelTable = (props) => {
1104
1303
  },
1105
1304
  });
1106
1305
  await fetchItems();
1107
- }, dragDropIdField: "_id", dragDropIndexField: props.dragDropIndexField, isLoading: isLoading, totalItemsCount: totalItemsCount, data: data, id: props.id, fields: fields, itemsOnPage: itemsOnPage, disablePagination: props.disablePagination || false, onNavigateToPage: async (pageNumber, itemsOnPage) => {
1306
+ }, dragDropIdField: "_id", dragDropIndexField: props.dragDropIndexField, isLoading: getTableLoadingState(), totalItemsCount: totalItemsCount, data: data, id: props.id, fields: fields, itemsOnPage: itemsOnPage, disablePagination: props.disablePagination || false, onNavigateToPage: async (pageNumber, itemsOnPage) => {
1108
1307
  setCurrentPageNumber(pageNumber);
1109
1308
  setItemsOnPage(itemsOnPage);
1110
1309
  }, noItemsMessage: props.noItemsMessage || "", onRefreshClick: async () => {
@@ -1137,20 +1336,427 @@ const BaseModelTable = (props) => {
1137
1336
  } },
1138
1337
  React.createElement(Pill, { text: `${planName} Plan`, color: Yellow })))));
1139
1338
  };
1339
+ const collapseSearch = () => {
1340
+ setSearchText("");
1341
+ setSelectedLabels([]);
1342
+ setIsSearchExpanded(false);
1343
+ };
1344
+ const parseLabelMention = (value) => {
1345
+ const atIndex = value.lastIndexOf("@");
1346
+ if (atIndex < 0) {
1347
+ return { hasMention: false, prefix: "", atIndex: -1 };
1348
+ }
1349
+ if (atIndex > 0) {
1350
+ const prev = value[atIndex - 1] || "";
1351
+ if (prev !== " " && prev !== "\t") {
1352
+ return { hasMention: false, prefix: "", atIndex: -1 };
1353
+ }
1354
+ }
1355
+ const after = value.substring(atIndex + 1);
1356
+ if (after.includes(" ") || after.includes("\t") || after.includes("\n")) {
1357
+ return { hasMention: false, prefix: "", atIndex: -1 };
1358
+ }
1359
+ return { hasMention: true, prefix: after, atIndex };
1360
+ };
1361
+ const addLabel = (label) => {
1362
+ setSelectedLabels((prev) => {
1363
+ if (prev.find((l) => {
1364
+ return l.id === label.id;
1365
+ })) {
1366
+ return prev;
1367
+ }
1368
+ return [...prev, label];
1369
+ });
1370
+ // Strip the `@<prefix>` token from the input.
1371
+ const mention = parseLabelMention(searchText);
1372
+ if (mention.hasMention) {
1373
+ const before = searchText.substring(0, mention.atIndex);
1374
+ setSearchText(before.replace(/\s+$/, ""));
1375
+ }
1376
+ setLabelDropdownIndex(0);
1377
+ };
1378
+ const removeLabel = (labelId) => {
1379
+ setSelectedLabels((prev) => {
1380
+ return prev.filter((l) => {
1381
+ return l.id !== labelId;
1382
+ });
1383
+ });
1384
+ };
1385
+ const getSearchControl = () => {
1386
+ if (!props.searchableFields || props.searchableFields.length === 0) {
1387
+ return React.createElement(React.Fragment, null);
1388
+ }
1389
+ const pluralLabel = (props.pluralName ||
1390
+ model.pluralName ||
1391
+ "items").toLowerCase();
1392
+ const hasLabelSupport = Boolean(labelFilterConfig);
1393
+ const defaultPlaceholder = hasLabelSupport
1394
+ ? `Search ${pluralLabel}… (try @ for labels)`
1395
+ : `Search ${pluralLabel} by name, description…`;
1396
+ const placeholder = props.searchPlaceholder || defaultPlaceholder;
1397
+ /*
1398
+ * Effective search = input minus the trailing @<prefix> mention. The pill
1399
+ * + result-count UI should reflect this so typing "@bug" doesn't claim
1400
+ * "0 matches" before the user has actually committed the label.
1401
+ */
1402
+ const stripTrailingMentionForUi = (v) => {
1403
+ const m = parseLabelMention(v);
1404
+ return m.hasMention ? v.substring(0, m.atIndex).trimEnd() : v;
1405
+ };
1406
+ const trimmedSearch = stripTrailingMentionForUi(searchText).trim();
1407
+ const trimmedActive = stripTrailingMentionForUi(debouncedSearchText).trim();
1408
+ const hasActiveSearch = trimmedActive.length > 0;
1409
+ const hasSelectedLabels = selectedLabels.length > 0;
1410
+ /*
1411
+ * "isSearching" covers both phases — typing-in-flight (before the
1412
+ * debounce fires) AND the actual API request that follows. The table
1413
+ * loader is suppressed during the latter, so this spinner is the only
1414
+ * loading indicator the user sees during a search-driven fetch.
1415
+ */
1416
+ const isTypingInFlight = trimmedSearch.length > 0 && trimmedSearch !== trimmedActive;
1417
+ const isSearching = isTypingInFlight || (isLoading && (hasActiveSearch || hasSelectedLabels));
1418
+ const showMatchPill = !isSearching && (hasActiveSearch || hasSelectedLabels);
1419
+ const expanded = isSearchExpanded || hasActiveSearch || hasSelectedLabels;
1420
+ const mention = parseLabelMention(searchText);
1421
+ const showLabelDropdown = hasLabelSupport && isSearchFocused && mention.hasMention;
1422
+ const lowerPrefix = mention.prefix.toLowerCase();
1423
+ const dropdownLabels = availableLabels
1424
+ .filter((l) => {
1425
+ return !selectedLabels.find((s) => {
1426
+ return s.id === l.id;
1427
+ });
1428
+ })
1429
+ .filter((l) => {
1430
+ return (lowerPrefix.length === 0 || l.name.toLowerCase().includes(lowerPrefix));
1431
+ })
1432
+ .slice(0, 8);
1433
+ const borderClass = isSearchFocused
1434
+ ? "border-gray-400 ring-4 ring-gray-100 shadow-sm"
1435
+ : hasActiveSearch || hasSelectedLabels
1436
+ ? "border-gray-300 shadow-sm"
1437
+ : "border-gray-200 shadow-sm";
1438
+ const iconColorClass = isSearchFocused || hasActiveSearch || hasSelectedLabels
1439
+ ? "text-gray-700"
1440
+ : "text-gray-400";
1441
+ const selectDropdownItemAt = (idx) => {
1442
+ const item = dropdownLabels[idx];
1443
+ if (item) {
1444
+ addLabel(item);
1445
+ }
1446
+ };
1447
+ return (React.createElement("div", { className: "relative flex w-full items-center" },
1448
+ React.createElement("div", { className: "relative flex w-full flex-col gap-1" },
1449
+ 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: () => {
1450
+ var _a;
1451
+ (_a = searchInputRef.current) === null || _a === void 0 ? void 0 : _a.focus();
1452
+ }, role: "presentation" },
1453
+ React.createElement(Icon, { icon: IconProp.Search, className: `h-4 w-4 flex-none transition-colors duration-200 ${iconColorClass}` }),
1454
+ React.createElement("div", { className: "flex min-w-0 flex-1 flex-wrap items-center gap-1.5" },
1455
+ selectedLabels.map((label) => {
1456
+ 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}` },
1457
+ React.createElement("span", { className: "h-2 w-2 flex-none rounded-full", style: { backgroundColor: label.color }, "aria-hidden": "true" }),
1458
+ React.createElement("span", { className: "max-w-[8rem] truncate" }, label.name),
1459
+ React.createElement("button", { type: "button", onMouseDown: (e) => {
1460
+ e.preventDefault();
1461
+ }, onClick: () => {
1462
+ removeLabel(label.id);
1463
+ }, 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" },
1464
+ React.createElement(Icon, { icon: IconProp.Close, className: "h-3 w-3" }))));
1465
+ }),
1466
+ React.createElement("input", { ref: searchInputRef, type: "text", value: searchText, onChange: (e) => {
1467
+ setSearchText(e.target.value);
1468
+ setLabelDropdownIndex(0);
1469
+ }, onFocus: () => {
1470
+ setIsSearchFocused(true);
1471
+ }, onBlur: () => {
1472
+ setIsSearchFocused(false);
1473
+ /*
1474
+ * Collapse only when the user blurs with nothing active —
1475
+ * no text and no selected labels.
1476
+ */
1477
+ if (searchText.trim().length === 0 &&
1478
+ selectedLabels.length === 0) {
1479
+ setIsSearchExpanded(false);
1480
+ }
1481
+ }, onKeyDown: (e) => {
1482
+ var _a;
1483
+ if (showLabelDropdown && dropdownLabels.length > 0) {
1484
+ if (e.key === "ArrowDown") {
1485
+ e.preventDefault();
1486
+ setLabelDropdownIndex((i) => {
1487
+ return Math.min(i + 1, dropdownLabels.length - 1);
1488
+ });
1489
+ return;
1490
+ }
1491
+ if (e.key === "ArrowUp") {
1492
+ e.preventDefault();
1493
+ setLabelDropdownIndex((i) => {
1494
+ return Math.max(i - 1, 0);
1495
+ });
1496
+ return;
1497
+ }
1498
+ if (e.key === "Enter" || e.key === "Tab") {
1499
+ e.preventDefault();
1500
+ selectDropdownItemAt(labelDropdownIndex);
1501
+ return;
1502
+ }
1503
+ }
1504
+ if (e.key === "Backspace" && searchText.length === 0) {
1505
+ // Pop last label when backspacing on empty input.
1506
+ if (selectedLabels.length > 0) {
1507
+ const last = selectedLabels[selectedLabels.length - 1];
1508
+ if (last) {
1509
+ removeLabel(last.id);
1510
+ }
1511
+ }
1512
+ return;
1513
+ }
1514
+ if (e.key === "Escape") {
1515
+ if (showLabelDropdown) {
1516
+ // Just cancel the @ mention parse — clear @ prefix.
1517
+ const m = parseLabelMention(searchText);
1518
+ if (m.hasMention) {
1519
+ setSearchText(searchText
1520
+ .substring(0, m.atIndex)
1521
+ .replace(/\s+$/, ""));
1522
+ }
1523
+ return;
1524
+ }
1525
+ if (searchText) {
1526
+ setSearchText("");
1527
+ }
1528
+ else if (selectedLabels.length > 0) {
1529
+ setSelectedLabels([]);
1530
+ }
1531
+ else {
1532
+ collapseSearch();
1533
+ (_a = searchInputRef.current) === null || _a === void 0 ? void 0 : _a.blur();
1534
+ }
1535
+ }
1536
+ }, 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" })),
1537
+ isSearching && (React.createElement("div", { className: "flex-none text-gray-400", title: "Searching\u2026" },
1538
+ React.createElement(Icon, { icon: IconProp.Spinner, className: "h-4 w-4 animate-spin" }))),
1539
+ 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"}` },
1540
+ totalItemsCount,
1541
+ " ",
1542
+ totalItemsCount === 1 ? "match" : "matches")),
1543
+ searchText.length > 0 || selectedLabels.length > 0 ? (React.createElement("button", { type: "button", onMouseDown: (e) => {
1544
+ e.preventDefault();
1545
+ }, onClick: () => {
1546
+ collapseSearch();
1547
+ }, 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" },
1548
+ 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" }, "/"))),
1549
+ 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) => {
1550
+ // Prevent input blur on dropdown clicks.
1551
+ e.preventDefault();
1552
+ } },
1553
+ React.createElement("div", { className: "flex items-center justify-between border-b border-gray-100 px-3 py-2" },
1554
+ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-wide text-gray-500" }, isLabelsLoading ? "Loading labels…" : "Filter by label"),
1555
+ React.createElement("span", { className: "text-[10px] text-gray-400" },
1556
+ React.createElement("kbd", { className: "font-mono" }, "\u2191"),
1557
+ React.createElement("kbd", { className: "ml-0.5 font-mono" }, "\u2193"),
1558
+ React.createElement("span", { className: "ml-1" }, "to navigate"),
1559
+ React.createElement("span", { className: "mx-1.5" }, "\u00B7"),
1560
+ React.createElement("kbd", { className: "font-mono" }, "\u21B5"),
1561
+ React.createElement("span", { className: "ml-1" }, "to select"))),
1562
+ React.createElement("div", { className: "max-h-64 overflow-y-auto py-1" },
1563
+ !isLabelsLoading && dropdownLabels.length === 0 && (React.createElement("div", { className: "px-3 py-3 text-sm text-gray-500" }, availableLabels.length === 0
1564
+ ? "No labels available for this resource."
1565
+ : `No labels matching "${mention.prefix}"`)),
1566
+ dropdownLabels.map((label, idx) => {
1567
+ const isActive = idx === labelDropdownIndex;
1568
+ return (React.createElement("button", { key: label.id, type: "button", onMouseEnter: () => {
1569
+ setLabelDropdownIndex(idx);
1570
+ }, onMouseDown: (e) => {
1571
+ e.preventDefault();
1572
+ }, onClick: () => {
1573
+ var _a;
1574
+ addLabel(label);
1575
+ (_a = searchInputRef.current) === null || _a === void 0 ? void 0 : _a.focus();
1576
+ }, className: `flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors ${isActive
1577
+ ? "bg-gray-100 text-gray-900"
1578
+ : "text-gray-700 hover:bg-gray-50"}` },
1579
+ 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" }),
1580
+ React.createElement("span", { className: "flex-1 truncate" }, label.name),
1581
+ isActive && (React.createElement("span", { className: "text-[10px] font-medium uppercase tracking-wide text-gray-500" }, "\u21B5"))));
1582
+ })))))));
1583
+ };
1584
+ const splitButtonsForHeader = (buttons) => {
1585
+ let mainIndex = -1;
1586
+ for (let i = 0; i < buttons.length; i++) {
1587
+ const b = buttons[i];
1588
+ if (React.isValidElement(b)) {
1589
+ continue;
1590
+ }
1591
+ const c = b;
1592
+ if (c.buttonStyle === ButtonStyleType.NORMAL && c.icon === IconProp.Add) {
1593
+ mainIndex = i;
1594
+ break;
1595
+ }
1596
+ }
1597
+ if (mainIndex < 0) {
1598
+ for (let i = 0; i < buttons.length; i++) {
1599
+ const b = buttons[i];
1600
+ if (React.isValidElement(b)) {
1601
+ continue;
1602
+ }
1603
+ if (b.buttonStyle === ButtonStyleType.NORMAL) {
1604
+ mainIndex = i;
1605
+ break;
1606
+ }
1607
+ }
1608
+ }
1609
+ if (mainIndex < 0) {
1610
+ return { main: null, rest: buttons };
1611
+ }
1612
+ const main = buttons[mainIndex];
1613
+ const rest = [
1614
+ ...buttons.slice(0, mainIndex),
1615
+ ...buttons.slice(mainIndex + 1),
1616
+ ];
1617
+ return { main, rest };
1618
+ };
1619
+ const renderMainButton = (b) => {
1620
+ return (React.createElement(Button, { key: "model-table-main-action", title: b.title, buttonStyle: b.buttonStyle, buttonSize: b.buttonSize, className: b.className, onClick: () => {
1621
+ var _a;
1622
+ (_a = b.onClick) === null || _a === void 0 ? void 0 : _a.call(b);
1623
+ }, disabled: b.disabled, icon: b.icon, shortcutKey: b.shortcutKey, dataTestId: "card-button", isLoading: b.isLoading }));
1624
+ };
1625
+ /*
1626
+ * Icon-only card buttons (Refresh, Filter, …) have empty titles, so derive
1627
+ * a label from the icon for the More menu.
1628
+ */
1629
+ // Fallback labels for icon-only buttons (Refresh, Filter, ...) in the More menu.
1630
+ const labelForIconButton = (icon) => {
1631
+ switch (icon) {
1632
+ case IconProp.Refresh:
1633
+ return "Refresh";
1634
+ case IconProp.Filter:
1635
+ return "Filter";
1636
+ case IconProp.Add:
1637
+ return "Add";
1638
+ case IconProp.Help:
1639
+ return "Help";
1640
+ case IconProp.Book:
1641
+ return "Documentation";
1642
+ case IconProp.Play:
1643
+ return "Watch Demo";
1644
+ case IconProp.Search:
1645
+ return "Search";
1646
+ default:
1647
+ return "Action";
1648
+ }
1649
+ };
1650
+ const renderMoreMenu = (items) => {
1651
+ if (items.length === 0) {
1652
+ return null;
1653
+ }
1654
+ const children = items.map((item, idx) => {
1655
+ if (React.isValidElement(item)) {
1656
+ return (React.createElement("div", { key: `more-${idx}`, className: "px-2 py-1" }, item));
1657
+ }
1658
+ const b = item;
1659
+ const label = b.title || labelForIconButton(b.icon);
1660
+ return (React.createElement(MoreMenuItem, { key: `more-${idx}`, text: label, icon: b.icon, onClick: () => {
1661
+ var _a;
1662
+ if (!b.disabled) {
1663
+ (_a = b.onClick) === null || _a === void 0 ? void 0 : _a.call(b);
1664
+ }
1665
+ }, className: b.disabled ? "opacity-40 pointer-events-none" : "" }));
1666
+ });
1667
+ return (React.createElement(MoreMenu, { key: "model-table-more-menu", menuIcon: IconProp.EllipsisHorizontal, text: "" }, children));
1668
+ };
1669
+ /*
1670
+ * Builds the right-hand side of the card header. All slots — search
1671
+ * trigger/bar, saved views, main button, more menu — stay mounted at all
1672
+ * times. State transitions are purely CSS so the inputs keep their focus,
1673
+ * the dropdown keeps its open state, and there's no mount/unmount flicker.
1674
+ *
1675
+ * Layout when collapsed: [🔍 trigger] [Saved Views] [main] [⋯]
1676
+ * Layout when expanded: [🔍 ━━━ wide search bar ━━━]
1677
+ * Saved views + main button + more menu fade and collapse-to-zero-width
1678
+ * when the search expands, freeing horizontal space for the bar.
1679
+ */
1680
+ const getHeaderButtonsWithSearch = () => {
1681
+ const hasSearch = Boolean(props.searchableFields && props.searchableFields.length > 0);
1682
+ /*
1683
+ * Saved views get their own first-class slot in the header — never
1684
+ * collapsed into the overflow ⋯ menu. Render fresh on every call so the
1685
+ * inner TableViewElement always sees the current query / sort / itemsOnPage.
1686
+ */
1687
+ const savedViewsElement = props.saveFilterProps
1688
+ ? getSaveFilterDropdown()
1689
+ : null;
1690
+ if (cardButtons.length === 0 && !hasSearch) {
1691
+ return savedViewsElement ? [savedViewsElement] : cardButtons;
1692
+ }
1693
+ if (!hasSearch) {
1694
+ // Without search, just split into [Saved Views] [main] [⋯]; no special wrapping.
1695
+ const { main, rest } = splitButtonsForHeader(cardButtons);
1696
+ const composed = [];
1697
+ if (savedViewsElement) {
1698
+ composed.push(savedViewsElement);
1699
+ }
1700
+ if (main) {
1701
+ composed.push(renderMainButton(main));
1702
+ }
1703
+ const moreMenu = renderMoreMenu(rest);
1704
+ if (moreMenu) {
1705
+ composed.push(moreMenu);
1706
+ }
1707
+ return composed;
1708
+ }
1709
+ const trimmedActive = debouncedSearchText.trim();
1710
+ const isExpanded = isSearchExpanded || trimmedActive.length > 0 || selectedLabels.length > 0;
1711
+ const { main, rest } = splitButtonsForHeader(cardButtons);
1712
+ const moreMenu = renderMoreMenu(rest);
1713
+ const wrapped = (React.createElement("div", { key: "model-table-header-actions", className: "flex items-center gap-1.5" },
1714
+ 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"}` },
1715
+ React.createElement("button", { type: "button", onClick: () => {
1716
+ setIsSearchExpanded(true);
1717
+ requestAnimationFrame(() => {
1718
+ var _a;
1719
+ (_a = searchInputRef.current) === null || _a === void 0 ? void 0 : _a.focus();
1720
+ });
1721
+ }, 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
1722
+ ? "pointer-events-none border-gray-200 opacity-0"
1723
+ : "border-gray-200 opacity-100 hover:border-gray-300 hover:bg-gray-50"}` },
1724
+ React.createElement(Icon, { icon: IconProp.Search, className: "h-4 w-4 flex-none text-gray-400" }),
1725
+ React.createElement("span", { className: "flex-1 truncate text-left text-gray-400" }, "Search\u2026"),
1726
+ 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" }, "/")),
1727
+ React.createElement("div", { className: `transition-opacity duration-200 ease-out ${isExpanded
1728
+ ? "opacity-100 delay-150"
1729
+ : "pointer-events-none opacity-0"}` }, getSearchControl())),
1730
+ React.createElement("div", { className: `flex items-center transition-all duration-300 ease-out ${isExpanded
1731
+ ? "max-w-0 -ml-1.5 gap-0 opacity-0 pointer-events-none"
1732
+ : "max-w-[600px] gap-1.5 opacity-100"}`, "aria-hidden": isExpanded },
1733
+ savedViewsElement,
1734
+ main && renderMainButton(main),
1735
+ moreMenu)));
1736
+ return [wrapped];
1737
+ };
1738
+ const getCardHeaderTitle = (originalTitle) => {
1739
+ return originalTitle;
1740
+ };
1140
1741
  const getCardComponent = () => {
1742
+ const headerButtons = getHeaderButtonsWithSearch();
1141
1743
  if (showAs === ShowAs.Table || showAs === ShowAs.List) {
1142
1744
  return (React.createElement("div", null,
1143
- props.cardProps && (React.createElement(Card, Object.assign({}, props.cardProps, { buttons: cardButtons, bodyClassName: showAs === ShowAs.List
1745
+ props.cardProps && (React.createElement(Card, Object.assign({}, props.cardProps, { buttons: headerButtons, bodyClassName: showAs === ShowAs.List
1144
1746
  ? "-ml-6 -mr-6 bg-gray-50 border-top"
1145
- : "", title: getCardTitle(props.cardProps.title || "") }),
1747
+ : "", title: getCardHeaderTitle(getCardTitle(props.cardProps.title || "")) }),
1146
1748
  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
1749
  tableColumns.length > 0 && showAs === ShowAs.Table ? (getTable()) : (React.createElement(React.Fragment, null)),
1148
1750
  tableColumns.length > 0 && showAs === ShowAs.List ? (getList()) : (React.createElement(React.Fragment, null)))),
1751
+ !props.cardProps &&
1752
+ (showAs === ShowAs.Table || showAs === ShowAs.List) &&
1753
+ props.searchableFields &&
1754
+ props.searchableFields.length > 0 ? (React.createElement("div", { className: "mb-3 flex justify-end" }, getSearchControl())) : (React.createElement(React.Fragment, null)),
1149
1755
  !props.cardProps && showAs === ShowAs.Table ? getTable() : React.createElement(React.Fragment, null),
1150
1756
  !props.cardProps && showAs === ShowAs.List ? getList() : React.createElement(React.Fragment, null)));
1151
1757
  }
1152
1758
  return (React.createElement("div", null,
1153
- props.cardProps && (React.createElement(Card, Object.assign({}, props.cardProps, { buttons: cardButtons, title: getCardTitle(props.cardProps.title || "") }), getOrderedStatesList())),
1759
+ props.cardProps && (React.createElement(Card, Object.assign({}, props.cardProps, { buttons: headerButtons, title: getCardTitle(props.cardProps.title || "") }), getOrderedStatesList())),
1154
1760
  !props.cardProps && getOrderedStatesList()));
1155
1761
  };
1156
1762
  return (React.createElement(React.Fragment, null,