@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
@@ -21,7 +21,9 @@ import {
21
21
  BulkActionFailed,
22
22
  BulkActionOnClickProps,
23
23
  } from "../BulkUpdate/BulkUpdateForm";
24
- import { ButtonSize, ButtonStyleType } from "../Button/Button";
24
+ import Button, { ButtonSize, ButtonStyleType } from "../Button/Button";
25
+ import MoreMenu from "../MoreMenu/MoreMenu";
26
+ import MoreMenuItem from "../MoreMenu/MoreMenuItem";
25
27
  import Card, {
26
28
  CardButtonSchema,
27
29
  ComponentProps as CardComponentProps,
@@ -40,6 +42,7 @@ import { ListDetailProps } from "../List/ListRow";
40
42
  import ConfirmModal from "../Modal/ConfirmModal";
41
43
  import Modal, { ModalWidth } from "../Modal/Modal";
42
44
  import MarkdownViewer from "../Markdown.tsx/MarkdownViewer";
45
+ import Icon from "../Icon/Icon";
43
46
  import Filter from "../ModelFilter/Filter";
44
47
  import { DropdownOption, DropdownOptionLabel } from "../Dropdown/Dropdown";
45
48
  import OrderedStatesList from "../OrderedStatesList/OrderedStatesList";
@@ -61,6 +64,7 @@ import URL from "../../../Types/API/URL";
61
64
  import { ColumnAccessControl } from "../../../Types/BaseDatabase/AccessControl";
62
65
  import InBetween from "../../../Types/BaseDatabase/InBetween";
63
66
  import Search from "../../../Types/BaseDatabase/Search";
67
+ import MultiSearch from "../../../Types/BaseDatabase/MultiSearch";
64
68
  import SortOrder from "../../../Types/BaseDatabase/SortOrder";
65
69
  import SubscriptionPlan, {
66
70
  PlanType,
@@ -87,6 +91,7 @@ import React, {
87
91
  MutableRefObject,
88
92
  ReactElement,
89
93
  useEffect,
94
+ useMemo,
90
95
  useState,
91
96
  } from "react";
92
97
  import TableViewElement from "./TableView";
@@ -243,6 +248,15 @@ export interface BaseTableProps<
243
248
  | undefined
244
249
  | ((showAdvancedFilters: boolean) => void);
245
250
 
251
+ /*
252
+ * Fields to include in the inline search box above the table. When set, a
253
+ * search input renders in the card header and an ILIKE OR runs across all
254
+ * listed fields. Leave undefined to hide the search box entirely.
255
+ */
256
+ searchableFields?: Array<keyof TBaseModel> | undefined;
257
+
258
+ searchPlaceholder?: string | undefined;
259
+
246
260
  /*
247
261
  * this key is used to save table user preferences in local storage.
248
262
  * If you provide this key, the table will save the user preferences in local storage.
@@ -351,6 +365,189 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
351
365
  const [error, setError] = useState<string>("");
352
366
  const [tableFilterError, setTableFilterError] = useState<string>("");
353
367
 
368
+ const [searchText, setSearchText] = useState<string>("");
369
+ const [debouncedSearchText, setDebouncedSearchText] = useState<string>("");
370
+ const [isSearchFocused, setIsSearchFocused] = useState<boolean>(false);
371
+ const [isSearchExpanded, setIsSearchExpanded] = useState<boolean>(false);
372
+ const searchInputRef: React.RefObject<HTMLInputElement> =
373
+ React.useRef<HTMLInputElement>(null!);
374
+
375
+ interface SearchLabelOption {
376
+ id: string;
377
+ name: string;
378
+ color: string;
379
+ }
380
+
381
+ const [availableLabels, setAvailableLabels] = useState<
382
+ Array<SearchLabelOption>
383
+ >([]);
384
+ const [selectedLabels, setSelectedLabels] = useState<
385
+ Array<SearchLabelOption>
386
+ >([]);
387
+ const [isLabelsLoading, setIsLabelsLoading] = useState<boolean>(false);
388
+ const [labelsFetched, setLabelsFetched] = useState<boolean>(false);
389
+ const [labelDropdownIndex, setLabelDropdownIndex] = useState<number>(0);
390
+
391
+ useEffect(() => {
392
+ const handle: ReturnType<typeof setTimeout> = setTimeout(() => {
393
+ setDebouncedSearchText(searchText);
394
+ }, 300);
395
+ return () => {
396
+ clearTimeout(handle);
397
+ };
398
+ }, [searchText]);
399
+
400
+ /*
401
+ * "/" focuses the search input — same affordance as GitHub / Linear.
402
+ * Skip while the user is typing in another input/textarea or has a modal open.
403
+ */
404
+ useEffect(() => {
405
+ if (!props.searchableFields || props.searchableFields.length === 0) {
406
+ return undefined;
407
+ }
408
+ const handleKey: (e: KeyboardEvent) => void = (e: KeyboardEvent): void => {
409
+ if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) {
410
+ return;
411
+ }
412
+ const target: HTMLElement | null = e.target as HTMLElement | null;
413
+ if (
414
+ target &&
415
+ (target.tagName === "INPUT" ||
416
+ target.tagName === "TEXTAREA" ||
417
+ target.isContentEditable)
418
+ ) {
419
+ return;
420
+ }
421
+ e.preventDefault();
422
+ setIsSearchExpanded(true);
423
+ // Wait one frame so the input mounts/becomes visible before focusing.
424
+ requestAnimationFrame(() => {
425
+ searchInputRef.current?.focus();
426
+ });
427
+ };
428
+ document.addEventListener("keydown", handleKey);
429
+ return () => {
430
+ document.removeEventListener("keydown", handleKey);
431
+ };
432
+ }, [props.searchableFields]);
433
+
434
+ /*
435
+ * Keep the search expanded whenever there is an active search term — so
436
+ * results stay visible alongside the box. Collapsing only happens when the
437
+ * user blurs an empty input.
438
+ */
439
+ useEffect(() => {
440
+ if (
441
+ (debouncedSearchText.trim().length > 0 || selectedLabels.length > 0) &&
442
+ !isSearchExpanded
443
+ ) {
444
+ setIsSearchExpanded(true);
445
+ }
446
+ }, [debouncedSearchText, selectedLabels]);
447
+
448
+ useEffect(() => {
449
+ // reset to first page whenever the active search term or labels change
450
+ setCurrentPageNumber(1);
451
+ }, [debouncedSearchText, selectedLabels]);
452
+
453
+ /*
454
+ * Auto-detect label support from the existing filters array. We look for
455
+ * the filter whose `filterEntityType` class name is "Label" and reuse its
456
+ * dropdown wiring (entity type, fetch query, label/value field names) so
457
+ * search inherits whatever scoping the filter popup already had.
458
+ */
459
+ type LabelFilterConfig = {
460
+ fieldKey: string;
461
+ entityType: any;
462
+ fetchQuery: any;
463
+ labelField: string;
464
+ };
465
+
466
+ const labelFilterConfig: LabelFilterConfig | null = useMemo(() => {
467
+ const filter: Filter<TBaseModel> | undefined = props.filters.find(
468
+ (f: Filter<TBaseModel>) => {
469
+ return (
470
+ f.filterEntityType &&
471
+ (f.filterEntityType as any).name === "Label" &&
472
+ f.field &&
473
+ f.filterDropdownField
474
+ );
475
+ },
476
+ );
477
+ if (!filter || !filter.field || !filter.filterDropdownField) {
478
+ return null;
479
+ }
480
+ const fieldKey: string | undefined = Object.keys(filter.field)[0];
481
+ if (!fieldKey) {
482
+ return null;
483
+ }
484
+ return {
485
+ fieldKey,
486
+ entityType: filter.filterEntityType,
487
+ fetchQuery: filter.filterQuery || {},
488
+ labelField: filter.filterDropdownField.label,
489
+ };
490
+ }, [props.filters]);
491
+
492
+ // Fetch labels on first search expansion if this resource supports them.
493
+ useEffect(() => {
494
+ if (!isSearchExpanded || !labelFilterConfig || labelsFetched) {
495
+ return;
496
+ }
497
+ let cancelled: boolean = false;
498
+ setIsLabelsLoading(true);
499
+ (async () => {
500
+ try {
501
+ const result: ListResult<TBaseModel> = await props.callbacks.getList({
502
+ modelType: labelFilterConfig.entityType,
503
+ query: labelFilterConfig.fetchQuery,
504
+ limit: 200,
505
+ skip: 0,
506
+ select: {
507
+ _id: true,
508
+ [labelFilterConfig.labelField]: true,
509
+ color: true,
510
+ } as any,
511
+ sort: { [labelFilterConfig.labelField]: SortOrder.Ascending } as any,
512
+ });
513
+ if (cancelled) {
514
+ return;
515
+ }
516
+ const mapped: Array<SearchLabelOption> = (result.data || [])
517
+ .map((item: any) => {
518
+ const raw: any = item;
519
+ const colorAny: any = raw["color"];
520
+ const colorHex: string =
521
+ (colorAny &&
522
+ (typeof colorAny === "string"
523
+ ? colorAny
524
+ : colorAny.value ||
525
+ (colorAny.toString && colorAny.toString()))) ||
526
+ "#94a3b8";
527
+ return {
528
+ id: raw["_id"]?.toString() || "",
529
+ name: (raw[labelFilterConfig.labelField] as string) || "",
530
+ color: colorHex,
531
+ } as SearchLabelOption;
532
+ })
533
+ .filter((l: SearchLabelOption) => {
534
+ return l.id && l.name;
535
+ });
536
+ setAvailableLabels(mapped);
537
+ setLabelsFetched(true);
538
+ } catch {
539
+ // Silently fail — search still works without label suggestions.
540
+ } finally {
541
+ if (!cancelled) {
542
+ setIsLabelsLoading(false);
543
+ }
544
+ }
545
+ })();
546
+ return () => {
547
+ cancelled = true;
548
+ };
549
+ }, [isSearchExpanded, labelFilterConfig, labelsFetched]);
550
+
354
551
  const [showModel, setShowModal] = useState<boolean>(false);
355
552
  const [showFilterModal, setShowFilterModal] = useState<boolean>(false);
356
553
  const [modalType, setModalType] = useState<ModalType>(ModalType.Create);
@@ -841,6 +1038,64 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
841
1038
  setIsFilterFetchLoading(false);
842
1039
  };
843
1040
 
1041
+ type BuildSearchQueryFragmentFunction = () => Query<TBaseModel>;
1042
+
1043
+ const buildSearchQueryFragment: BuildSearchQueryFragmentFunction =
1044
+ (): Query<TBaseModel> => {
1045
+ const fragment: Query<TBaseModel> = {};
1046
+ /*
1047
+ * Strip the trailing @<prefix> mention before searching — that token
1048
+ * is a label-autocomplete trigger, not part of the user's free-text
1049
+ * query.
1050
+ */
1051
+ const stripTrailingMention: (v: string) => string = (
1052
+ v: string,
1053
+ ): string => {
1054
+ const atIndex: number = v.lastIndexOf("@");
1055
+ if (atIndex < 0) {
1056
+ return v;
1057
+ }
1058
+ if (atIndex > 0) {
1059
+ const prev: string = v[atIndex - 1] || "";
1060
+ if (prev !== " " && prev !== "\t") {
1061
+ return v;
1062
+ }
1063
+ }
1064
+ const after: string = v.substring(atIndex + 1);
1065
+ if (
1066
+ after.includes(" ") ||
1067
+ after.includes("\t") ||
1068
+ after.includes("\n")
1069
+ ) {
1070
+ return v;
1071
+ }
1072
+ return v.substring(0, atIndex).trimEnd();
1073
+ };
1074
+
1075
+ const effectiveSearch: string =
1076
+ stripTrailingMention(debouncedSearchText).trim();
1077
+ if (
1078
+ effectiveSearch.length > 0 &&
1079
+ props.searchableFields &&
1080
+ props.searchableFields.length > 0
1081
+ ) {
1082
+ (fragment as any)._multiFieldSearch = new MultiSearch({
1083
+ fields: props.searchableFields.map((f: keyof TBaseModel) => {
1084
+ return f as string;
1085
+ }),
1086
+ value: effectiveSearch,
1087
+ });
1088
+ }
1089
+ if (labelFilterConfig && selectedLabels.length > 0) {
1090
+ (fragment as any)[labelFilterConfig.fieldKey] = new Includes(
1091
+ selectedLabels.map((l: SearchLabelOption) => {
1092
+ return l.id;
1093
+ }),
1094
+ );
1095
+ }
1096
+ return fragment;
1097
+ };
1098
+
844
1099
  const fetchAllBulkItems: PromiseVoidFunction = async (): Promise<void> => {
845
1100
  setError("");
846
1101
  setIsLoading(true);
@@ -853,6 +1108,7 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
853
1108
  query: {
854
1109
  ...props.query,
855
1110
  ...query,
1111
+ ...buildSearchQueryFragment(),
856
1112
  },
857
1113
  limit: LIMIT_PER_PROJECT,
858
1114
  skip: 0,
@@ -892,6 +1148,7 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
892
1148
  query: {
893
1149
  ...props.query,
894
1150
  ...query,
1151
+ ...buildSearchQueryFragment(),
895
1152
  },
896
1153
  groupBy: {
897
1154
  ...props.groupBy,
@@ -1155,10 +1412,6 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1155
1412
  });
1156
1413
  }
1157
1414
 
1158
- if (props.saveFilterProps) {
1159
- headerbuttons.push(getSaveFilterDropdown());
1160
- }
1161
-
1162
1415
  setCardButtons(headerbuttons);
1163
1416
  };
1164
1417
 
@@ -1172,6 +1425,8 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1172
1425
  sortOrder,
1173
1426
  itemsOnPage,
1174
1427
  query,
1428
+ debouncedSearchText,
1429
+ selectedLabels,
1175
1430
  props.refreshToggle,
1176
1431
  ]);
1177
1432
 
@@ -1602,6 +1857,21 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1602
1857
  };
1603
1858
  };
1604
1859
 
1860
+ /*
1861
+ * When the load is driven by the search box, suppress the table-level
1862
+ * loading state so existing rows stay visible and the only spinner is
1863
+ * the one in the search bar. Sort / pagination / refresh loads still
1864
+ * show the table loader because there is no other indicator for those.
1865
+ */
1866
+ type ShouldSuppressTableLoadingFunction = () => boolean;
1867
+ const isSearchActive: ShouldSuppressTableLoadingFunction = (): boolean => {
1868
+ return debouncedSearchText.trim().length > 0 || selectedLabels.length > 0;
1869
+ };
1870
+
1871
+ const getTableLoadingState: () => boolean = (): boolean => {
1872
+ return isSearchActive() ? false : isLoading;
1873
+ };
1874
+
1605
1875
  const getTable: GetReactElementFunction = (): ReactElement => {
1606
1876
  return (
1607
1877
  <Table
@@ -1738,7 +2008,7 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1738
2008
  pluralLabel={props.pluralName || model.pluralName || "Items"}
1739
2009
  error={error}
1740
2010
  currentPageNumber={currentPageNumber}
1741
- isLoading={isLoading}
2011
+ isLoading={getTableLoadingState()}
1742
2012
  enableDragAndDrop={props.enableDragAndDrop}
1743
2013
  dragDropIdField={"_id"}
1744
2014
  dragDropIndexField={props.dragDropIndexField}
@@ -1897,7 +2167,7 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1897
2167
  }}
1898
2168
  dragDropIdField={"_id"}
1899
2169
  dragDropIndexField={props.dragDropIndexField}
1900
- isLoading={isLoading}
2170
+ isLoading={getTableLoadingState()}
1901
2171
  totalItemsCount={totalItemsCount}
1902
2172
  data={data}
1903
2173
  id={props.id}
@@ -1973,20 +2243,754 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1973
2243
  );
1974
2244
  };
1975
2245
 
2246
+ type CollapseSearchFunction = () => void;
2247
+
2248
+ const collapseSearch: CollapseSearchFunction = (): void => {
2249
+ setSearchText("");
2250
+ setSelectedLabels([]);
2251
+ setIsSearchExpanded(false);
2252
+ };
2253
+
2254
+ /*
2255
+ * Find the trailing `@<prefix>` mention in the input. A mention is only
2256
+ * recognised when `@` is at the start of the value or follows whitespace,
2257
+ * and only when there is no whitespace after it — matches typical chat
2258
+ * mention semantics.
2259
+ */
2260
+ type MentionParseResult = {
2261
+ hasMention: boolean;
2262
+ prefix: string;
2263
+ atIndex: number;
2264
+ };
2265
+
2266
+ const parseLabelMention: (value: string) => MentionParseResult = (
2267
+ value: string,
2268
+ ): MentionParseResult => {
2269
+ const atIndex: number = value.lastIndexOf("@");
2270
+ if (atIndex < 0) {
2271
+ return { hasMention: false, prefix: "", atIndex: -1 };
2272
+ }
2273
+ if (atIndex > 0) {
2274
+ const prev: string = value[atIndex - 1] || "";
2275
+ if (prev !== " " && prev !== "\t") {
2276
+ return { hasMention: false, prefix: "", atIndex: -1 };
2277
+ }
2278
+ }
2279
+ const after: string = value.substring(atIndex + 1);
2280
+ if (after.includes(" ") || after.includes("\t") || after.includes("\n")) {
2281
+ return { hasMention: false, prefix: "", atIndex: -1 };
2282
+ }
2283
+ return { hasMention: true, prefix: after, atIndex };
2284
+ };
2285
+
2286
+ type AddLabelFunction = (label: SearchLabelOption) => void;
2287
+
2288
+ const addLabel: AddLabelFunction = (label: SearchLabelOption): void => {
2289
+ setSelectedLabels((prev: Array<SearchLabelOption>) => {
2290
+ if (
2291
+ prev.find((l: SearchLabelOption) => {
2292
+ return l.id === label.id;
2293
+ })
2294
+ ) {
2295
+ return prev;
2296
+ }
2297
+ return [...prev, label];
2298
+ });
2299
+ // Strip the `@<prefix>` token from the input.
2300
+ const mention: MentionParseResult = parseLabelMention(searchText);
2301
+ if (mention.hasMention) {
2302
+ const before: string = searchText.substring(0, mention.atIndex);
2303
+ setSearchText(before.replace(/\s+$/, ""));
2304
+ }
2305
+ setLabelDropdownIndex(0);
2306
+ };
2307
+
2308
+ type RemoveLabelFunction = (labelId: string) => void;
2309
+
2310
+ const removeLabel: RemoveLabelFunction = (labelId: string): void => {
2311
+ setSelectedLabels((prev: Array<SearchLabelOption>) => {
2312
+ return prev.filter((l: SearchLabelOption) => {
2313
+ return l.id !== labelId;
2314
+ });
2315
+ });
2316
+ };
2317
+
2318
+ const getSearchControl: GetReactElementFunction = (): ReactElement => {
2319
+ if (!props.searchableFields || props.searchableFields.length === 0) {
2320
+ return <></>;
2321
+ }
2322
+
2323
+ const pluralLabel: string = (
2324
+ props.pluralName ||
2325
+ model.pluralName ||
2326
+ "items"
2327
+ ).toLowerCase();
2328
+
2329
+ const hasLabelSupport: boolean = Boolean(labelFilterConfig);
2330
+
2331
+ const defaultPlaceholder: string = hasLabelSupport
2332
+ ? `Search ${pluralLabel}… (try @ for labels)`
2333
+ : `Search ${pluralLabel} by name, description…`;
2334
+ const placeholder: string = props.searchPlaceholder || defaultPlaceholder;
2335
+
2336
+ /*
2337
+ * Effective search = input minus the trailing @<prefix> mention. The pill
2338
+ * + result-count UI should reflect this so typing "@bug" doesn't claim
2339
+ * "0 matches" before the user has actually committed the label.
2340
+ */
2341
+ const stripTrailingMentionForUi: (v: string) => string = (
2342
+ v: string,
2343
+ ): string => {
2344
+ const m: MentionParseResult = parseLabelMention(v);
2345
+ return m.hasMention ? v.substring(0, m.atIndex).trimEnd() : v;
2346
+ };
2347
+
2348
+ const trimmedSearch: string = stripTrailingMentionForUi(searchText).trim();
2349
+ const trimmedActive: string =
2350
+ stripTrailingMentionForUi(debouncedSearchText).trim();
2351
+ const hasActiveSearch: boolean = trimmedActive.length > 0;
2352
+ const hasSelectedLabels: boolean = selectedLabels.length > 0;
2353
+ /*
2354
+ * "isSearching" covers both phases — typing-in-flight (before the
2355
+ * debounce fires) AND the actual API request that follows. The table
2356
+ * loader is suppressed during the latter, so this spinner is the only
2357
+ * loading indicator the user sees during a search-driven fetch.
2358
+ */
2359
+ const isTypingInFlight: boolean =
2360
+ trimmedSearch.length > 0 && trimmedSearch !== trimmedActive;
2361
+ const isSearching: boolean =
2362
+ isTypingInFlight || (isLoading && (hasActiveSearch || hasSelectedLabels));
2363
+ const showMatchPill: boolean =
2364
+ !isSearching && (hasActiveSearch || hasSelectedLabels);
2365
+
2366
+ const expanded: boolean =
2367
+ isSearchExpanded || hasActiveSearch || hasSelectedLabels;
2368
+
2369
+ const mention: MentionParseResult = parseLabelMention(searchText);
2370
+ const showLabelDropdown: boolean =
2371
+ hasLabelSupport && isSearchFocused && mention.hasMention;
2372
+
2373
+ const lowerPrefix: string = mention.prefix.toLowerCase();
2374
+ const dropdownLabels: Array<SearchLabelOption> = availableLabels
2375
+ .filter((l: SearchLabelOption) => {
2376
+ return !selectedLabels.find((s: SearchLabelOption) => {
2377
+ return s.id === l.id;
2378
+ });
2379
+ })
2380
+ .filter((l: SearchLabelOption) => {
2381
+ return (
2382
+ lowerPrefix.length === 0 || l.name.toLowerCase().includes(lowerPrefix)
2383
+ );
2384
+ })
2385
+ .slice(0, 8);
2386
+
2387
+ const borderClass: string = isSearchFocused
2388
+ ? "border-gray-400 ring-4 ring-gray-100 shadow-sm"
2389
+ : hasActiveSearch || hasSelectedLabels
2390
+ ? "border-gray-300 shadow-sm"
2391
+ : "border-gray-200 shadow-sm";
2392
+
2393
+ const iconColorClass: string =
2394
+ isSearchFocused || hasActiveSearch || hasSelectedLabels
2395
+ ? "text-gray-700"
2396
+ : "text-gray-400";
2397
+
2398
+ type SelectDropdownItemAtIndexFunction = (idx: number) => void;
2399
+ const selectDropdownItemAt: SelectDropdownItemAtIndexFunction = (
2400
+ idx: number,
2401
+ ): void => {
2402
+ const item: SearchLabelOption | undefined = dropdownLabels[idx];
2403
+ if (item) {
2404
+ addLabel(item);
2405
+ }
2406
+ };
2407
+
2408
+ return (
2409
+ <div className="relative flex w-full items-center">
2410
+ {/* Expanded input + dropdown — sized to the full title slot */}
2411
+ <div className="relative flex w-full flex-col gap-1">
2412
+ <div
2413
+ 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}`}
2414
+ onClick={() => {
2415
+ searchInputRef.current?.focus();
2416
+ }}
2417
+ role="presentation"
2418
+ >
2419
+ <Icon
2420
+ icon={IconProp.Search}
2421
+ className={`h-4 w-4 flex-none transition-colors duration-200 ${iconColorClass}`}
2422
+ />
2423
+ {/* Pills + input wrap row */}
2424
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
2425
+ {selectedLabels.map((label: SearchLabelOption) => {
2426
+ return (
2427
+ <span
2428
+ key={label.id}
2429
+ 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"
2430
+ title={`Label: ${label.name}`}
2431
+ >
2432
+ <span
2433
+ className="h-2 w-2 flex-none rounded-full"
2434
+ style={{ backgroundColor: label.color }}
2435
+ aria-hidden="true"
2436
+ />
2437
+ <span className="max-w-[8rem] truncate">{label.name}</span>
2438
+ <button
2439
+ type="button"
2440
+ onMouseDown={(e: React.MouseEvent<HTMLButtonElement>) => {
2441
+ e.preventDefault();
2442
+ }}
2443
+ onClick={() => {
2444
+ removeLabel(label.id);
2445
+ }}
2446
+ title="Remove label"
2447
+ aria-label={`Remove ${label.name}`}
2448
+ className="ml-0.5 flex-none rounded-full p-0.5 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700"
2449
+ >
2450
+ <Icon icon={IconProp.Close} className="h-3 w-3" />
2451
+ </button>
2452
+ </span>
2453
+ );
2454
+ })}
2455
+ <input
2456
+ ref={searchInputRef}
2457
+ type="text"
2458
+ value={searchText}
2459
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
2460
+ setSearchText(e.target.value);
2461
+ setLabelDropdownIndex(0);
2462
+ }}
2463
+ onFocus={() => {
2464
+ setIsSearchFocused(true);
2465
+ }}
2466
+ onBlur={() => {
2467
+ setIsSearchFocused(false);
2468
+ /*
2469
+ * Collapse only when the user blurs with nothing active —
2470
+ * no text and no selected labels.
2471
+ */
2472
+ if (
2473
+ searchText.trim().length === 0 &&
2474
+ selectedLabels.length === 0
2475
+ ) {
2476
+ setIsSearchExpanded(false);
2477
+ }
2478
+ }}
2479
+ onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
2480
+ if (showLabelDropdown && dropdownLabels.length > 0) {
2481
+ if (e.key === "ArrowDown") {
2482
+ e.preventDefault();
2483
+ setLabelDropdownIndex((i: number) => {
2484
+ return Math.min(i + 1, dropdownLabels.length - 1);
2485
+ });
2486
+ return;
2487
+ }
2488
+ if (e.key === "ArrowUp") {
2489
+ e.preventDefault();
2490
+ setLabelDropdownIndex((i: number) => {
2491
+ return Math.max(i - 1, 0);
2492
+ });
2493
+ return;
2494
+ }
2495
+ if (e.key === "Enter" || e.key === "Tab") {
2496
+ e.preventDefault();
2497
+ selectDropdownItemAt(labelDropdownIndex);
2498
+ return;
2499
+ }
2500
+ }
2501
+ if (e.key === "Backspace" && searchText.length === 0) {
2502
+ // Pop last label when backspacing on empty input.
2503
+ if (selectedLabels.length > 0) {
2504
+ const last: SearchLabelOption | undefined =
2505
+ selectedLabels[selectedLabels.length - 1];
2506
+ if (last) {
2507
+ removeLabel(last.id);
2508
+ }
2509
+ }
2510
+ return;
2511
+ }
2512
+ if (e.key === "Escape") {
2513
+ if (showLabelDropdown) {
2514
+ // Just cancel the @ mention parse — clear @ prefix.
2515
+ const m: MentionParseResult =
2516
+ parseLabelMention(searchText);
2517
+ if (m.hasMention) {
2518
+ setSearchText(
2519
+ searchText
2520
+ .substring(0, m.atIndex)
2521
+ .replace(/\s+$/, ""),
2522
+ );
2523
+ }
2524
+ return;
2525
+ }
2526
+ if (searchText) {
2527
+ setSearchText("");
2528
+ } else if (selectedLabels.length > 0) {
2529
+ setSelectedLabels([]);
2530
+ } else {
2531
+ collapseSearch();
2532
+ searchInputRef.current?.blur();
2533
+ }
2534
+ }
2535
+ }}
2536
+ placeholder={
2537
+ selectedLabels.length === 0 ? placeholder : "Refine search…"
2538
+ }
2539
+ spellCheck={false}
2540
+ autoComplete="off"
2541
+ tabIndex={expanded ? 0 : -1}
2542
+ className="min-w-[6rem] flex-1 bg-transparent py-1 text-sm text-gray-900 placeholder-gray-400 outline-none"
2543
+ />
2544
+ </div>
2545
+ {isSearching && (
2546
+ <div className="flex-none text-gray-400" title="Searching…">
2547
+ <Icon
2548
+ icon={IconProp.Spinner}
2549
+ className="h-4 w-4 animate-spin"
2550
+ />
2551
+ </div>
2552
+ )}
2553
+ {showMatchPill && totalItemsCount >= 0 && (
2554
+ <span
2555
+ className="flex-none whitespace-nowrap rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700"
2556
+ title={`${totalItemsCount} ${totalItemsCount === 1 ? "result" : "results"}`}
2557
+ >
2558
+ {totalItemsCount} {totalItemsCount === 1 ? "match" : "matches"}
2559
+ </span>
2560
+ )}
2561
+ {searchText.length > 0 || selectedLabels.length > 0 ? (
2562
+ <button
2563
+ type="button"
2564
+ onMouseDown={(e: React.MouseEvent<HTMLButtonElement>) => {
2565
+ e.preventDefault();
2566
+ }}
2567
+ onClick={() => {
2568
+ collapseSearch();
2569
+ }}
2570
+ title="Clear search (Esc Esc)"
2571
+ aria-label="Clear search"
2572
+ className="flex-none rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
2573
+ >
2574
+ <Icon icon={IconProp.Close} className="h-3.5 w-3.5" />
2575
+ </button>
2576
+ ) : (
2577
+ <kbd
2578
+ 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"
2579
+ title="Press / to focus search"
2580
+ >
2581
+ /
2582
+ </kbd>
2583
+ )}
2584
+ </div>
2585
+
2586
+ {/* Label suggestion dropdown */}
2587
+ {showLabelDropdown && (
2588
+ <div
2589
+ 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"
2590
+ onMouseDown={(e: React.MouseEvent<HTMLDivElement>) => {
2591
+ // Prevent input blur on dropdown clicks.
2592
+ e.preventDefault();
2593
+ }}
2594
+ >
2595
+ <div className="flex items-center justify-between border-b border-gray-100 px-3 py-2">
2596
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">
2597
+ {isLabelsLoading ? "Loading labels…" : "Filter by label"}
2598
+ </span>
2599
+ <span className="text-[10px] text-gray-400">
2600
+ <kbd className="font-mono">↑</kbd>
2601
+ <kbd className="ml-0.5 font-mono">↓</kbd>
2602
+ <span className="ml-1">to navigate</span>
2603
+ <span className="mx-1.5">·</span>
2604
+ <kbd className="font-mono">↵</kbd>
2605
+ <span className="ml-1">to select</span>
2606
+ </span>
2607
+ </div>
2608
+ <div className="max-h-64 overflow-y-auto py-1">
2609
+ {!isLabelsLoading && dropdownLabels.length === 0 && (
2610
+ <div className="px-3 py-3 text-sm text-gray-500">
2611
+ {availableLabels.length === 0
2612
+ ? "No labels available for this resource."
2613
+ : `No labels matching "${mention.prefix}"`}
2614
+ </div>
2615
+ )}
2616
+ {dropdownLabels.map((label: SearchLabelOption, idx: number) => {
2617
+ const isActive: boolean = idx === labelDropdownIndex;
2618
+ return (
2619
+ <button
2620
+ key={label.id}
2621
+ type="button"
2622
+ onMouseEnter={() => {
2623
+ setLabelDropdownIndex(idx);
2624
+ }}
2625
+ onMouseDown={(e: React.MouseEvent<HTMLButtonElement>) => {
2626
+ e.preventDefault();
2627
+ }}
2628
+ onClick={() => {
2629
+ addLabel(label);
2630
+ searchInputRef.current?.focus();
2631
+ }}
2632
+ className={`flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors ${
2633
+ isActive
2634
+ ? "bg-gray-100 text-gray-900"
2635
+ : "text-gray-700 hover:bg-gray-50"
2636
+ }`}
2637
+ >
2638
+ <span
2639
+ className="h-2.5 w-2.5 flex-none rounded-full ring-1 ring-inset ring-black/5"
2640
+ style={{ backgroundColor: label.color }}
2641
+ aria-hidden="true"
2642
+ />
2643
+ <span className="flex-1 truncate">{label.name}</span>
2644
+ {isActive && (
2645
+ <span className="text-[10px] font-medium uppercase tracking-wide text-gray-500">
2646
+
2647
+ </span>
2648
+ )}
2649
+ </button>
2650
+ );
2651
+ })}
2652
+ </div>
2653
+ </div>
2654
+ )}
2655
+ </div>
2656
+ </div>
2657
+ );
2658
+ };
2659
+
2660
+ type GetHeaderButtonsFunction = () => Array<CardButtonSchema | ReactElement>;
2661
+
2662
+ /*
2663
+ * Returns the buttons array passed to Card. When search support is enabled
2664
+ * we add a standalone search-trigger icon to the end of the row. We do
2665
+ * NOT collapse the other buttons here — the expanded search bar lives in
2666
+ * the title slot (see getExpandedSearchTitle), so existing buttons like
2667
+ * Refresh / Filter / Create remain accessible while searching.
2668
+ */
2669
+ /*
2670
+ * The "primary" button is the one with NORMAL style and the Add icon — the
2671
+ * Create button that BaseModelTable injects automatically. We surface it
2672
+ * alongside the search bar and hide everything else behind a kebab menu.
2673
+ * If the page has no such button we fall back to the first NORMAL-styled
2674
+ * one, or the first button outright.
2675
+ */
2676
+ type FindMainButtonResult = {
2677
+ main: CardButtonSchema | null;
2678
+ rest: Array<CardButtonSchema | ReactElement>;
2679
+ };
2680
+
2681
+ const splitButtonsForHeader: (
2682
+ buttons: Array<CardButtonSchema | ReactElement>,
2683
+ ) => FindMainButtonResult = (
2684
+ buttons: Array<CardButtonSchema | ReactElement>,
2685
+ ): FindMainButtonResult => {
2686
+ let mainIndex: number = -1;
2687
+ for (let i: number = 0; i < buttons.length; i++) {
2688
+ const b: CardButtonSchema | ReactElement = buttons[i]!;
2689
+ if (React.isValidElement(b)) {
2690
+ continue;
2691
+ }
2692
+ const c: CardButtonSchema = b as CardButtonSchema;
2693
+ if (c.buttonStyle === ButtonStyleType.NORMAL && c.icon === IconProp.Add) {
2694
+ mainIndex = i;
2695
+ break;
2696
+ }
2697
+ }
2698
+ if (mainIndex < 0) {
2699
+ for (let i: number = 0; i < buttons.length; i++) {
2700
+ const b: CardButtonSchema | ReactElement = buttons[i]!;
2701
+ if (React.isValidElement(b)) {
2702
+ continue;
2703
+ }
2704
+ if ((b as CardButtonSchema).buttonStyle === ButtonStyleType.NORMAL) {
2705
+ mainIndex = i;
2706
+ break;
2707
+ }
2708
+ }
2709
+ }
2710
+ if (mainIndex < 0) {
2711
+ return { main: null, rest: buttons };
2712
+ }
2713
+ const main: CardButtonSchema = buttons[mainIndex] as CardButtonSchema;
2714
+ const rest: Array<CardButtonSchema | ReactElement> = [
2715
+ ...buttons.slice(0, mainIndex),
2716
+ ...buttons.slice(mainIndex + 1),
2717
+ ];
2718
+ return { main, rest };
2719
+ };
2720
+
2721
+ const renderMainButton: (b: CardButtonSchema) => ReactElement = (
2722
+ b: CardButtonSchema,
2723
+ ): ReactElement => {
2724
+ return (
2725
+ <Button
2726
+ key="model-table-main-action"
2727
+ title={b.title}
2728
+ buttonStyle={b.buttonStyle}
2729
+ buttonSize={b.buttonSize}
2730
+ className={b.className}
2731
+ onClick={() => {
2732
+ b.onClick?.();
2733
+ }}
2734
+ disabled={b.disabled}
2735
+ icon={b.icon}
2736
+ shortcutKey={b.shortcutKey}
2737
+ dataTestId="card-button"
2738
+ isLoading={b.isLoading}
2739
+ />
2740
+ );
2741
+ };
2742
+
2743
+ /*
2744
+ * Icon-only card buttons (Refresh, Filter, …) have empty titles, so derive
2745
+ * a label from the icon for the More menu.
2746
+ */
2747
+ // Fallback labels for icon-only buttons (Refresh, Filter, ...) in the More menu.
2748
+ const labelForIconButton: (icon: IconProp | undefined) => string = (
2749
+ icon: IconProp | undefined,
2750
+ ): string => {
2751
+ switch (icon) {
2752
+ case IconProp.Refresh:
2753
+ return "Refresh";
2754
+ case IconProp.Filter:
2755
+ return "Filter";
2756
+ case IconProp.Add:
2757
+ return "Add";
2758
+ case IconProp.Help:
2759
+ return "Help";
2760
+ case IconProp.Book:
2761
+ return "Documentation";
2762
+ case IconProp.Play:
2763
+ return "Watch Demo";
2764
+ case IconProp.Search:
2765
+ return "Search";
2766
+ default:
2767
+ return "Action";
2768
+ }
2769
+ };
2770
+
2771
+ const renderMoreMenu: (
2772
+ items: Array<CardButtonSchema | ReactElement>,
2773
+ ) => ReactElement | null = (
2774
+ items: Array<CardButtonSchema | ReactElement>,
2775
+ ): ReactElement | null => {
2776
+ if (items.length === 0) {
2777
+ return null;
2778
+ }
2779
+ const children: Array<ReactElement> = items.map(
2780
+ (item: CardButtonSchema | ReactElement, idx: number) => {
2781
+ if (React.isValidElement(item)) {
2782
+ return (
2783
+ <div key={`more-${idx}`} className="px-2 py-1">
2784
+ {item}
2785
+ </div>
2786
+ );
2787
+ }
2788
+ const b: CardButtonSchema = item as CardButtonSchema;
2789
+ const label: string = b.title || labelForIconButton(b.icon);
2790
+ return (
2791
+ <MoreMenuItem
2792
+ key={`more-${idx}`}
2793
+ text={label}
2794
+ icon={b.icon}
2795
+ onClick={() => {
2796
+ if (!b.disabled) {
2797
+ b.onClick?.();
2798
+ }
2799
+ }}
2800
+ className={b.disabled ? "opacity-40 pointer-events-none" : ""}
2801
+ />
2802
+ );
2803
+ },
2804
+ );
2805
+
2806
+ return (
2807
+ <MoreMenu
2808
+ key="model-table-more-menu"
2809
+ menuIcon={IconProp.EllipsisHorizontal}
2810
+ text=""
2811
+ >
2812
+ {children}
2813
+ </MoreMenu>
2814
+ );
2815
+ };
2816
+
2817
+ /*
2818
+ * Builds the right-hand side of the card header. All slots — search
2819
+ * trigger/bar, saved views, main button, more menu — stay mounted at all
2820
+ * times. State transitions are purely CSS so the inputs keep their focus,
2821
+ * the dropdown keeps its open state, and there's no mount/unmount flicker.
2822
+ *
2823
+ * Layout when collapsed: [🔍 trigger] [Saved Views] [main] [⋯]
2824
+ * Layout when expanded: [🔍 ━━━ wide search bar ━━━]
2825
+ * Saved views + main button + more menu fade and collapse-to-zero-width
2826
+ * when the search expands, freeing horizontal space for the bar.
2827
+ */
2828
+ const getHeaderButtonsWithSearch: GetHeaderButtonsFunction = (): Array<
2829
+ CardButtonSchema | ReactElement
2830
+ > => {
2831
+ const hasSearch: boolean = Boolean(
2832
+ props.searchableFields && props.searchableFields.length > 0,
2833
+ );
2834
+
2835
+ /*
2836
+ * Saved views get their own first-class slot in the header — never
2837
+ * collapsed into the overflow ⋯ menu. Render fresh on every call so the
2838
+ * inner TableViewElement always sees the current query / sort / itemsOnPage.
2839
+ */
2840
+ const savedViewsElement: ReactElement | null = props.saveFilterProps
2841
+ ? getSaveFilterDropdown()
2842
+ : null;
2843
+
2844
+ if (cardButtons.length === 0 && !hasSearch) {
2845
+ return savedViewsElement ? [savedViewsElement] : cardButtons;
2846
+ }
2847
+
2848
+ if (!hasSearch) {
2849
+ // Without search, just split into [Saved Views] [main] [⋯]; no special wrapping.
2850
+ const { main, rest }: FindMainButtonResult =
2851
+ splitButtonsForHeader(cardButtons);
2852
+ const composed: Array<ReactElement> = [];
2853
+ if (savedViewsElement) {
2854
+ composed.push(savedViewsElement);
2855
+ }
2856
+ if (main) {
2857
+ composed.push(renderMainButton(main));
2858
+ }
2859
+ const moreMenu: ReactElement | null = renderMoreMenu(rest);
2860
+ if (moreMenu) {
2861
+ composed.push(moreMenu);
2862
+ }
2863
+ return composed;
2864
+ }
2865
+
2866
+ const trimmedActive: string = debouncedSearchText.trim();
2867
+ const isExpanded: boolean =
2868
+ isSearchExpanded || trimmedActive.length > 0 || selectedLabels.length > 0;
2869
+
2870
+ const { main, rest }: FindMainButtonResult =
2871
+ splitButtonsForHeader(cardButtons);
2872
+ const moreMenu: ReactElement | null = renderMoreMenu(rest);
2873
+
2874
+ const wrapped: ReactElement = (
2875
+ <div
2876
+ key="model-table-header-actions"
2877
+ className="flex items-center gap-1.5"
2878
+ >
2879
+ {/*
2880
+ * Search slot — a compact pill showing "Search … /" by default,
2881
+ * grown into the full search bar when expanded. Always advertising
2882
+ * itself (rather than starting as a tiny icon) reads as a more
2883
+ * polished search affordance, in line with Stripe / Linear / GitHub.
2884
+ */}
2885
+ <div
2886
+ className={`relative shrink-0 transition-[width] duration-300 ease-out ${
2887
+ isExpanded ? "w-[22rem] sm:w-[26rem] lg:w-[32rem]" : "w-44 sm:w-56"
2888
+ }`}
2889
+ >
2890
+ {/* Trigger (collapsed state) */}
2891
+ <button
2892
+ type="button"
2893
+ onClick={() => {
2894
+ setIsSearchExpanded(true);
2895
+ requestAnimationFrame(() => {
2896
+ searchInputRef.current?.focus();
2897
+ });
2898
+ }}
2899
+ title="Search (/)"
2900
+ aria-label="Open search"
2901
+ tabIndex={isExpanded ? -1 : 0}
2902
+ 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 ${
2903
+ isExpanded
2904
+ ? "pointer-events-none border-gray-200 opacity-0"
2905
+ : "border-gray-200 opacity-100 hover:border-gray-300 hover:bg-gray-50"
2906
+ }`}
2907
+ >
2908
+ <Icon
2909
+ icon={IconProp.Search}
2910
+ className="h-4 w-4 flex-none text-gray-400"
2911
+ />
2912
+ <span className="flex-1 truncate text-left text-gray-400">
2913
+ Search…
2914
+ </span>
2915
+ <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">
2916
+ /
2917
+ </kbd>
2918
+ </button>
2919
+
2920
+ {/* Expanded search bar */}
2921
+ <div
2922
+ className={`transition-opacity duration-200 ease-out ${
2923
+ isExpanded
2924
+ ? "opacity-100 delay-150"
2925
+ : "pointer-events-none opacity-0"
2926
+ }`}
2927
+ >
2928
+ {getSearchControl()}
2929
+ </div>
2930
+ </div>
2931
+
2932
+ {/*
2933
+ * Buttons that collapse when the bar expands. We avoid
2934
+ * `overflow-hidden` here — the MoreMenu dropdown is an absolutely
2935
+ * positioned child of one of these buttons and would otherwise get
2936
+ * clipped by an `overflow-hidden` ancestor, leaving the user with
2937
+ * an apparent "no menu appears" bug when they click ⋯. The wrapper
2938
+ * is hidden via opacity + pointer-events instead, and its width
2939
+ * collapses by setting both `max-width` and `gap` to 0 so the
2940
+ * search slot to the left can grow into the freed space.
2941
+ */}
2942
+ <div
2943
+ className={`flex items-center transition-all duration-300 ease-out ${
2944
+ isExpanded
2945
+ ? "max-w-0 -ml-1.5 gap-0 opacity-0 pointer-events-none"
2946
+ : "max-w-[600px] gap-1.5 opacity-100"
2947
+ }`}
2948
+ aria-hidden={isExpanded}
2949
+ >
2950
+ {savedViewsElement}
2951
+ {main && renderMainButton(main)}
2952
+ {moreMenu}
2953
+ </div>
2954
+ </div>
2955
+ );
2956
+
2957
+ return [wrapped];
2958
+ };
2959
+
2960
+ /*
2961
+ * Title slot stays unchanged — the search bar lives in the buttons row.
2962
+ * The wrapping function is kept for symmetry / future styling but is a
2963
+ * pass-through today.
2964
+ */
2965
+ type GetCardHeaderTitleFunction = (
2966
+ originalTitle: ReactElement,
2967
+ ) => ReactElement;
2968
+
2969
+ const getCardHeaderTitle: GetCardHeaderTitleFunction = (
2970
+ originalTitle: ReactElement,
2971
+ ): ReactElement => {
2972
+ return originalTitle;
2973
+ };
2974
+
1976
2975
  const getCardComponent: GetReactElementFunction = (): ReactElement => {
2976
+ const headerButtons: Array<CardButtonSchema | ReactElement> =
2977
+ getHeaderButtonsWithSearch();
2978
+
1977
2979
  if (showAs === ShowAs.Table || showAs === ShowAs.List) {
1978
2980
  return (
1979
2981
  <div>
1980
2982
  {props.cardProps && (
1981
2983
  <Card
1982
2984
  {...props.cardProps}
1983
- buttons={cardButtons}
2985
+ buttons={headerButtons}
1984
2986
  bodyClassName={
1985
2987
  showAs === ShowAs.List
1986
2988
  ? "-ml-6 -mr-6 bg-gray-50 border-top"
1987
2989
  : ""
1988
2990
  }
1989
- title={getCardTitle(props.cardProps.title || "")}
2991
+ title={getCardHeaderTitle(
2992
+ getCardTitle(props.cardProps.title || ""),
2993
+ )}
1990
2994
  >
1991
2995
  {tableColumns.length === 0 && props.columns.length > 0 ? (
1992
2996
  <ErrorMessage
@@ -2011,6 +3015,18 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
2011
3015
  </Card>
2012
3016
  )}
2013
3017
 
3018
+ {/*
3019
+ * For card-less tables we expose the search beside the table header
3020
+ * via a thin right-aligned row.
3021
+ */}
3022
+ {!props.cardProps &&
3023
+ (showAs === ShowAs.Table || showAs === ShowAs.List) &&
3024
+ props.searchableFields &&
3025
+ props.searchableFields.length > 0 ? (
3026
+ <div className="mb-3 flex justify-end">{getSearchControl()}</div>
3027
+ ) : (
3028
+ <></>
3029
+ )}
2014
3030
  {!props.cardProps && showAs === ShowAs.Table ? getTable() : <></>}
2015
3031
  {!props.cardProps && showAs === ShowAs.List ? getList() : <></>}
2016
3032
  </div>
@@ -2022,7 +3038,7 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
2022
3038
  {props.cardProps && (
2023
3039
  <Card
2024
3040
  {...props.cardProps}
2025
- buttons={cardButtons}
3041
+ buttons={headerButtons}
2026
3042
  title={getCardTitle(props.cardProps.title || "")}
2027
3043
  >
2028
3044
  {getOrderedStatesList()}