@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
@@ -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,
@@ -1172,6 +1429,8 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1172
1429
  sortOrder,
1173
1430
  itemsOnPage,
1174
1431
  query,
1432
+ debouncedSearchText,
1433
+ selectedLabels,
1175
1434
  props.refreshToggle,
1176
1435
  ]);
1177
1436
 
@@ -1973,20 +2232,733 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1973
2232
  );
1974
2233
  };
1975
2234
 
2235
+ type CollapseSearchFunction = () => void;
2236
+
2237
+ const collapseSearch: CollapseSearchFunction = (): void => {
2238
+ setSearchText("");
2239
+ setSelectedLabels([]);
2240
+ setIsSearchExpanded(false);
2241
+ };
2242
+
2243
+ /*
2244
+ * Find the trailing `@<prefix>` mention in the input. A mention is only
2245
+ * recognised when `@` is at the start of the value or follows whitespace,
2246
+ * and only when there is no whitespace after it — matches typical chat
2247
+ * mention semantics.
2248
+ */
2249
+ type MentionParseResult = {
2250
+ hasMention: boolean;
2251
+ prefix: string;
2252
+ atIndex: number;
2253
+ };
2254
+
2255
+ const parseLabelMention: (value: string) => MentionParseResult = (
2256
+ value: string,
2257
+ ): MentionParseResult => {
2258
+ const atIndex: number = value.lastIndexOf("@");
2259
+ if (atIndex < 0) {
2260
+ return { hasMention: false, prefix: "", atIndex: -1 };
2261
+ }
2262
+ if (atIndex > 0) {
2263
+ const prev: string = value[atIndex - 1] || "";
2264
+ if (prev !== " " && prev !== "\t") {
2265
+ return { hasMention: false, prefix: "", atIndex: -1 };
2266
+ }
2267
+ }
2268
+ const after: string = value.substring(atIndex + 1);
2269
+ if (after.includes(" ") || after.includes("\t") || after.includes("\n")) {
2270
+ return { hasMention: false, prefix: "", atIndex: -1 };
2271
+ }
2272
+ return { hasMention: true, prefix: after, atIndex };
2273
+ };
2274
+
2275
+ type AddLabelFunction = (label: SearchLabelOption) => void;
2276
+
2277
+ const addLabel: AddLabelFunction = (label: SearchLabelOption): void => {
2278
+ setSelectedLabels((prev: Array<SearchLabelOption>) => {
2279
+ if (
2280
+ prev.find((l: SearchLabelOption) => {
2281
+ return l.id === label.id;
2282
+ })
2283
+ ) {
2284
+ return prev;
2285
+ }
2286
+ return [...prev, label];
2287
+ });
2288
+ // Strip the `@<prefix>` token from the input.
2289
+ const mention: MentionParseResult = parseLabelMention(searchText);
2290
+ if (mention.hasMention) {
2291
+ const before: string = searchText.substring(0, mention.atIndex);
2292
+ setSearchText(before.replace(/\s+$/, ""));
2293
+ }
2294
+ setLabelDropdownIndex(0);
2295
+ };
2296
+
2297
+ type RemoveLabelFunction = (labelId: string) => void;
2298
+
2299
+ const removeLabel: RemoveLabelFunction = (labelId: string): void => {
2300
+ setSelectedLabels((prev: Array<SearchLabelOption>) => {
2301
+ return prev.filter((l: SearchLabelOption) => {
2302
+ return l.id !== labelId;
2303
+ });
2304
+ });
2305
+ };
2306
+
2307
+ const getSearchControl: GetReactElementFunction = (): ReactElement => {
2308
+ if (!props.searchableFields || props.searchableFields.length === 0) {
2309
+ return <></>;
2310
+ }
2311
+
2312
+ const pluralLabel: string = (
2313
+ props.pluralName ||
2314
+ model.pluralName ||
2315
+ "items"
2316
+ ).toLowerCase();
2317
+
2318
+ const hasLabelSupport: boolean = Boolean(labelFilterConfig);
2319
+
2320
+ const defaultPlaceholder: string = hasLabelSupport
2321
+ ? `Search ${pluralLabel}… (try @ for labels)`
2322
+ : `Search ${pluralLabel} by name, description…`;
2323
+ const placeholder: string = props.searchPlaceholder || defaultPlaceholder;
2324
+
2325
+ /*
2326
+ * Effective search = input minus the trailing @<prefix> mention. The pill
2327
+ * + result-count UI should reflect this so typing "@bug" doesn't claim
2328
+ * "0 matches" before the user has actually committed the label.
2329
+ */
2330
+ const stripTrailingMentionForUi: (v: string) => string = (
2331
+ v: string,
2332
+ ): string => {
2333
+ const m: MentionParseResult = parseLabelMention(v);
2334
+ return m.hasMention ? v.substring(0, m.atIndex).trimEnd() : v;
2335
+ };
2336
+
2337
+ const trimmedSearch: string = stripTrailingMentionForUi(searchText).trim();
2338
+ const trimmedActive: string =
2339
+ stripTrailingMentionForUi(debouncedSearchText).trim();
2340
+ const isSearching: boolean =
2341
+ trimmedSearch.length > 0 && trimmedSearch !== trimmedActive;
2342
+ const hasActiveSearch: boolean = trimmedActive.length > 0;
2343
+ const hasSelectedLabels: boolean = selectedLabels.length > 0;
2344
+ const showMatchPill: boolean =
2345
+ !isSearching && (hasActiveSearch || hasSelectedLabels);
2346
+
2347
+ const expanded: boolean =
2348
+ isSearchExpanded || hasActiveSearch || hasSelectedLabels;
2349
+
2350
+ const mention: MentionParseResult = parseLabelMention(searchText);
2351
+ const showLabelDropdown: boolean =
2352
+ hasLabelSupport && isSearchFocused && mention.hasMention;
2353
+
2354
+ const lowerPrefix: string = mention.prefix.toLowerCase();
2355
+ const dropdownLabels: Array<SearchLabelOption> = availableLabels
2356
+ .filter((l: SearchLabelOption) => {
2357
+ return !selectedLabels.find((s: SearchLabelOption) => {
2358
+ return s.id === l.id;
2359
+ });
2360
+ })
2361
+ .filter((l: SearchLabelOption) => {
2362
+ return (
2363
+ lowerPrefix.length === 0 || l.name.toLowerCase().includes(lowerPrefix)
2364
+ );
2365
+ })
2366
+ .slice(0, 8);
2367
+
2368
+ const borderClass: string = isSearchFocused
2369
+ ? "border-gray-400 ring-4 ring-gray-100 shadow-sm"
2370
+ : hasActiveSearch || hasSelectedLabels
2371
+ ? "border-gray-300 shadow-sm"
2372
+ : "border-gray-200 shadow-sm";
2373
+
2374
+ const iconColorClass: string =
2375
+ isSearchFocused || hasActiveSearch || hasSelectedLabels
2376
+ ? "text-gray-700"
2377
+ : "text-gray-400";
2378
+
2379
+ type SelectDropdownItemAtIndexFunction = (idx: number) => void;
2380
+ const selectDropdownItemAt: SelectDropdownItemAtIndexFunction = (
2381
+ idx: number,
2382
+ ): void => {
2383
+ const item: SearchLabelOption | undefined = dropdownLabels[idx];
2384
+ if (item) {
2385
+ addLabel(item);
2386
+ }
2387
+ };
2388
+
2389
+ return (
2390
+ <div className="relative flex w-full items-center">
2391
+ {/* Expanded input + dropdown — sized to the full title slot */}
2392
+ <div className="relative flex w-full flex-col gap-1">
2393
+ <div
2394
+ 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}`}
2395
+ onClick={() => {
2396
+ searchInputRef.current?.focus();
2397
+ }}
2398
+ role="presentation"
2399
+ >
2400
+ <Icon
2401
+ icon={IconProp.Search}
2402
+ className={`h-4 w-4 flex-none transition-colors duration-200 ${iconColorClass}`}
2403
+ />
2404
+ {/* Pills + input wrap row */}
2405
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
2406
+ {selectedLabels.map((label: SearchLabelOption) => {
2407
+ return (
2408
+ <span
2409
+ key={label.id}
2410
+ 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"
2411
+ title={`Label: ${label.name}`}
2412
+ >
2413
+ <span
2414
+ className="h-2 w-2 flex-none rounded-full"
2415
+ style={{ backgroundColor: label.color }}
2416
+ aria-hidden="true"
2417
+ />
2418
+ <span className="max-w-[8rem] truncate">{label.name}</span>
2419
+ <button
2420
+ type="button"
2421
+ onMouseDown={(e: React.MouseEvent<HTMLButtonElement>) => {
2422
+ e.preventDefault();
2423
+ }}
2424
+ onClick={() => {
2425
+ removeLabel(label.id);
2426
+ }}
2427
+ title="Remove label"
2428
+ aria-label={`Remove ${label.name}`}
2429
+ className="ml-0.5 flex-none rounded-full p-0.5 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700"
2430
+ >
2431
+ <Icon icon={IconProp.Close} className="h-3 w-3" />
2432
+ </button>
2433
+ </span>
2434
+ );
2435
+ })}
2436
+ <input
2437
+ ref={searchInputRef}
2438
+ type="text"
2439
+ value={searchText}
2440
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
2441
+ setSearchText(e.target.value);
2442
+ setLabelDropdownIndex(0);
2443
+ }}
2444
+ onFocus={() => {
2445
+ setIsSearchFocused(true);
2446
+ }}
2447
+ onBlur={() => {
2448
+ setIsSearchFocused(false);
2449
+ /*
2450
+ * Collapse only when the user blurs with nothing active —
2451
+ * no text and no selected labels.
2452
+ */
2453
+ if (
2454
+ searchText.trim().length === 0 &&
2455
+ selectedLabels.length === 0
2456
+ ) {
2457
+ setIsSearchExpanded(false);
2458
+ }
2459
+ }}
2460
+ onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
2461
+ if (showLabelDropdown && dropdownLabels.length > 0) {
2462
+ if (e.key === "ArrowDown") {
2463
+ e.preventDefault();
2464
+ setLabelDropdownIndex((i: number) => {
2465
+ return Math.min(i + 1, dropdownLabels.length - 1);
2466
+ });
2467
+ return;
2468
+ }
2469
+ if (e.key === "ArrowUp") {
2470
+ e.preventDefault();
2471
+ setLabelDropdownIndex((i: number) => {
2472
+ return Math.max(i - 1, 0);
2473
+ });
2474
+ return;
2475
+ }
2476
+ if (e.key === "Enter" || e.key === "Tab") {
2477
+ e.preventDefault();
2478
+ selectDropdownItemAt(labelDropdownIndex);
2479
+ return;
2480
+ }
2481
+ }
2482
+ if (e.key === "Backspace" && searchText.length === 0) {
2483
+ // Pop last label when backspacing on empty input.
2484
+ if (selectedLabels.length > 0) {
2485
+ const last: SearchLabelOption | undefined =
2486
+ selectedLabels[selectedLabels.length - 1];
2487
+ if (last) {
2488
+ removeLabel(last.id);
2489
+ }
2490
+ }
2491
+ return;
2492
+ }
2493
+ if (e.key === "Escape") {
2494
+ if (showLabelDropdown) {
2495
+ // Just cancel the @ mention parse — clear @ prefix.
2496
+ const m: MentionParseResult =
2497
+ parseLabelMention(searchText);
2498
+ if (m.hasMention) {
2499
+ setSearchText(
2500
+ searchText
2501
+ .substring(0, m.atIndex)
2502
+ .replace(/\s+$/, ""),
2503
+ );
2504
+ }
2505
+ return;
2506
+ }
2507
+ if (searchText) {
2508
+ setSearchText("");
2509
+ } else if (selectedLabels.length > 0) {
2510
+ setSelectedLabels([]);
2511
+ } else {
2512
+ collapseSearch();
2513
+ searchInputRef.current?.blur();
2514
+ }
2515
+ }
2516
+ }}
2517
+ placeholder={
2518
+ selectedLabels.length === 0 ? placeholder : "Refine search…"
2519
+ }
2520
+ spellCheck={false}
2521
+ autoComplete="off"
2522
+ tabIndex={expanded ? 0 : -1}
2523
+ className="min-w-[6rem] flex-1 bg-transparent py-1 text-sm text-gray-900 placeholder-gray-400 outline-none"
2524
+ />
2525
+ </div>
2526
+ {isSearching && (
2527
+ <div className="flex-none text-gray-400" title="Searching…">
2528
+ <Icon
2529
+ icon={IconProp.Spinner}
2530
+ className="h-4 w-4 animate-spin"
2531
+ />
2532
+ </div>
2533
+ )}
2534
+ {showMatchPill && totalItemsCount >= 0 && (
2535
+ <span
2536
+ className="flex-none whitespace-nowrap rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700"
2537
+ title={`${totalItemsCount} ${totalItemsCount === 1 ? "result" : "results"}`}
2538
+ >
2539
+ {totalItemsCount} {totalItemsCount === 1 ? "match" : "matches"}
2540
+ </span>
2541
+ )}
2542
+ {searchText.length > 0 || selectedLabels.length > 0 ? (
2543
+ <button
2544
+ type="button"
2545
+ onMouseDown={(e: React.MouseEvent<HTMLButtonElement>) => {
2546
+ e.preventDefault();
2547
+ }}
2548
+ onClick={() => {
2549
+ collapseSearch();
2550
+ }}
2551
+ title="Clear search (Esc Esc)"
2552
+ aria-label="Clear search"
2553
+ className="flex-none rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
2554
+ >
2555
+ <Icon icon={IconProp.Close} className="h-3.5 w-3.5" />
2556
+ </button>
2557
+ ) : (
2558
+ <kbd
2559
+ 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"
2560
+ title="Press / to focus search"
2561
+ >
2562
+ /
2563
+ </kbd>
2564
+ )}
2565
+ </div>
2566
+
2567
+ {/* Label suggestion dropdown */}
2568
+ {showLabelDropdown && (
2569
+ <div
2570
+ 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"
2571
+ onMouseDown={(e: React.MouseEvent<HTMLDivElement>) => {
2572
+ // Prevent input blur on dropdown clicks.
2573
+ e.preventDefault();
2574
+ }}
2575
+ >
2576
+ <div className="flex items-center justify-between border-b border-gray-100 px-3 py-2">
2577
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">
2578
+ {isLabelsLoading ? "Loading labels…" : "Filter by label"}
2579
+ </span>
2580
+ <span className="text-[10px] text-gray-400">
2581
+ <kbd className="font-mono">↑</kbd>
2582
+ <kbd className="ml-0.5 font-mono">↓</kbd>
2583
+ <span className="ml-1">to navigate</span>
2584
+ <span className="mx-1.5">·</span>
2585
+ <kbd className="font-mono">↵</kbd>
2586
+ <span className="ml-1">to select</span>
2587
+ </span>
2588
+ </div>
2589
+ <div className="max-h-64 overflow-y-auto py-1">
2590
+ {!isLabelsLoading && dropdownLabels.length === 0 && (
2591
+ <div className="px-3 py-3 text-sm text-gray-500">
2592
+ {availableLabels.length === 0
2593
+ ? "No labels available for this resource."
2594
+ : `No labels matching "${mention.prefix}"`}
2595
+ </div>
2596
+ )}
2597
+ {dropdownLabels.map((label: SearchLabelOption, idx: number) => {
2598
+ const isActive: boolean = idx === labelDropdownIndex;
2599
+ return (
2600
+ <button
2601
+ key={label.id}
2602
+ type="button"
2603
+ onMouseEnter={() => {
2604
+ setLabelDropdownIndex(idx);
2605
+ }}
2606
+ onMouseDown={(e: React.MouseEvent<HTMLButtonElement>) => {
2607
+ e.preventDefault();
2608
+ }}
2609
+ onClick={() => {
2610
+ addLabel(label);
2611
+ searchInputRef.current?.focus();
2612
+ }}
2613
+ className={`flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors ${
2614
+ isActive
2615
+ ? "bg-gray-100 text-gray-900"
2616
+ : "text-gray-700 hover:bg-gray-50"
2617
+ }`}
2618
+ >
2619
+ <span
2620
+ className="h-2.5 w-2.5 flex-none rounded-full ring-1 ring-inset ring-black/5"
2621
+ style={{ backgroundColor: label.color }}
2622
+ aria-hidden="true"
2623
+ />
2624
+ <span className="flex-1 truncate">{label.name}</span>
2625
+ {isActive && (
2626
+ <span className="text-[10px] font-medium uppercase tracking-wide text-gray-500">
2627
+
2628
+ </span>
2629
+ )}
2630
+ </button>
2631
+ );
2632
+ })}
2633
+ </div>
2634
+ </div>
2635
+ )}
2636
+ </div>
2637
+ </div>
2638
+ );
2639
+ };
2640
+
2641
+ type GetHeaderButtonsFunction = () => Array<CardButtonSchema | ReactElement>;
2642
+
2643
+ /*
2644
+ * Returns the buttons array passed to Card. When search support is enabled
2645
+ * we add a standalone search-trigger icon to the end of the row. We do
2646
+ * NOT collapse the other buttons here — the expanded search bar lives in
2647
+ * the title slot (see getExpandedSearchTitle), so existing buttons like
2648
+ * Refresh / Filter / Create remain accessible while searching.
2649
+ */
2650
+ /*
2651
+ * The "primary" button is the one with NORMAL style and the Add icon — the
2652
+ * Create button that BaseModelTable injects automatically. We surface it
2653
+ * alongside the search bar and hide everything else behind a kebab menu.
2654
+ * If the page has no such button we fall back to the first NORMAL-styled
2655
+ * one, or the first button outright.
2656
+ */
2657
+ type FindMainButtonResult = {
2658
+ main: CardButtonSchema | null;
2659
+ rest: Array<CardButtonSchema | ReactElement>;
2660
+ };
2661
+
2662
+ const splitButtonsForHeader: (
2663
+ buttons: Array<CardButtonSchema | ReactElement>,
2664
+ ) => FindMainButtonResult = (
2665
+ buttons: Array<CardButtonSchema | ReactElement>,
2666
+ ): FindMainButtonResult => {
2667
+ let mainIndex: number = -1;
2668
+ for (let i: number = 0; i < buttons.length; i++) {
2669
+ const b: CardButtonSchema | ReactElement = buttons[i]!;
2670
+ if (React.isValidElement(b)) {
2671
+ continue;
2672
+ }
2673
+ const c: CardButtonSchema = b as CardButtonSchema;
2674
+ if (c.buttonStyle === ButtonStyleType.NORMAL && c.icon === IconProp.Add) {
2675
+ mainIndex = i;
2676
+ break;
2677
+ }
2678
+ }
2679
+ if (mainIndex < 0) {
2680
+ for (let i: number = 0; i < buttons.length; i++) {
2681
+ const b: CardButtonSchema | ReactElement = buttons[i]!;
2682
+ if (React.isValidElement(b)) {
2683
+ continue;
2684
+ }
2685
+ if ((b as CardButtonSchema).buttonStyle === ButtonStyleType.NORMAL) {
2686
+ mainIndex = i;
2687
+ break;
2688
+ }
2689
+ }
2690
+ }
2691
+ if (mainIndex < 0) {
2692
+ return { main: null, rest: buttons };
2693
+ }
2694
+ const main: CardButtonSchema = buttons[mainIndex] as CardButtonSchema;
2695
+ const rest: Array<CardButtonSchema | ReactElement> = [
2696
+ ...buttons.slice(0, mainIndex),
2697
+ ...buttons.slice(mainIndex + 1),
2698
+ ];
2699
+ return { main, rest };
2700
+ };
2701
+
2702
+ const renderMainButton: (b: CardButtonSchema) => ReactElement = (
2703
+ b: CardButtonSchema,
2704
+ ): ReactElement => {
2705
+ return (
2706
+ <Button
2707
+ key="model-table-main-action"
2708
+ title={b.title}
2709
+ buttonStyle={b.buttonStyle}
2710
+ buttonSize={b.buttonSize}
2711
+ className={b.className}
2712
+ onClick={() => {
2713
+ b.onClick?.();
2714
+ }}
2715
+ disabled={b.disabled}
2716
+ icon={b.icon}
2717
+ shortcutKey={b.shortcutKey}
2718
+ dataTestId="card-button"
2719
+ isLoading={b.isLoading}
2720
+ />
2721
+ );
2722
+ };
2723
+
2724
+ /*
2725
+ * Icon-only card buttons (Refresh, Filter, …) have empty titles, so derive
2726
+ * a label from the icon for the More menu.
2727
+ */
2728
+ // Fallback labels for icon-only buttons (Refresh, Filter, ...) in the More menu.
2729
+ const labelForIconButton: (icon: IconProp | undefined) => string = (
2730
+ icon: IconProp | undefined,
2731
+ ): string => {
2732
+ switch (icon) {
2733
+ case IconProp.Refresh:
2734
+ return "Refresh";
2735
+ case IconProp.Filter:
2736
+ return "Filter";
2737
+ case IconProp.Add:
2738
+ return "Add";
2739
+ case IconProp.Help:
2740
+ return "Help";
2741
+ case IconProp.Book:
2742
+ return "Documentation";
2743
+ case IconProp.Play:
2744
+ return "Watch Demo";
2745
+ case IconProp.Search:
2746
+ return "Search";
2747
+ default:
2748
+ return "Action";
2749
+ }
2750
+ };
2751
+
2752
+ const renderMoreMenu: (
2753
+ items: Array<CardButtonSchema | ReactElement>,
2754
+ ) => ReactElement | null = (
2755
+ items: Array<CardButtonSchema | ReactElement>,
2756
+ ): ReactElement | null => {
2757
+ if (items.length === 0) {
2758
+ return null;
2759
+ }
2760
+ const children: Array<ReactElement> = items.map(
2761
+ (item: CardButtonSchema | ReactElement, idx: number) => {
2762
+ if (React.isValidElement(item)) {
2763
+ return (
2764
+ <div key={`more-${idx}`} className="px-2 py-1">
2765
+ {item}
2766
+ </div>
2767
+ );
2768
+ }
2769
+ const b: CardButtonSchema = item as CardButtonSchema;
2770
+ const label: string = b.title || labelForIconButton(b.icon);
2771
+ return (
2772
+ <MoreMenuItem
2773
+ key={`more-${idx}`}
2774
+ text={label}
2775
+ icon={b.icon}
2776
+ onClick={() => {
2777
+ if (!b.disabled) {
2778
+ b.onClick?.();
2779
+ }
2780
+ }}
2781
+ className={b.disabled ? "opacity-40 pointer-events-none" : ""}
2782
+ />
2783
+ );
2784
+ },
2785
+ );
2786
+
2787
+ return (
2788
+ <MoreMenu
2789
+ key="model-table-more-menu"
2790
+ menuIcon={IconProp.EllipsisHorizontal}
2791
+ text=""
2792
+ >
2793
+ {children}
2794
+ </MoreMenu>
2795
+ );
2796
+ };
2797
+
2798
+ /*
2799
+ * Builds the right-hand side of the card header. All three slots — search
2800
+ * trigger/bar, main button, more menu — stay mounted at all times. State
2801
+ * transitions are purely CSS so the inputs keep their focus, the dropdown
2802
+ * keeps its open state, and there's no mount/unmount flicker.
2803
+ *
2804
+ * Layout when collapsed: [🔍 trigger] [main] [⋯]
2805
+ * Layout when expanded: [🔍 ━━━ wide search bar ━━━]
2806
+ * The main button + more menu fade and collapse-to-zero-width when the
2807
+ * search expands, freeing horizontal space for the bar.
2808
+ */
2809
+ const getHeaderButtonsWithSearch: GetHeaderButtonsFunction = (): Array<
2810
+ CardButtonSchema | ReactElement
2811
+ > => {
2812
+ const hasSearch: boolean = Boolean(
2813
+ props.searchableFields && props.searchableFields.length > 0,
2814
+ );
2815
+
2816
+ if (cardButtons.length === 0 && !hasSearch) {
2817
+ return cardButtons;
2818
+ }
2819
+
2820
+ if (!hasSearch) {
2821
+ // Without search, just split into [main] [⋯]; no special wrapping.
2822
+ const { main, rest }: FindMainButtonResult =
2823
+ splitButtonsForHeader(cardButtons);
2824
+ const composed: Array<ReactElement> = [];
2825
+ if (main) {
2826
+ composed.push(renderMainButton(main));
2827
+ }
2828
+ const moreMenu: ReactElement | null = renderMoreMenu(rest);
2829
+ if (moreMenu) {
2830
+ composed.push(moreMenu);
2831
+ }
2832
+ return composed;
2833
+ }
2834
+
2835
+ const trimmedActive: string = debouncedSearchText.trim();
2836
+ const isExpanded: boolean =
2837
+ isSearchExpanded || trimmedActive.length > 0 || selectedLabels.length > 0;
2838
+
2839
+ const { main, rest }: FindMainButtonResult =
2840
+ splitButtonsForHeader(cardButtons);
2841
+ const moreMenu: ReactElement | null = renderMoreMenu(rest);
2842
+
2843
+ const wrapped: ReactElement = (
2844
+ <div
2845
+ key="model-table-header-actions"
2846
+ className="flex items-center gap-1.5"
2847
+ >
2848
+ {/*
2849
+ * Search slot — a compact pill showing "Search … /" by default,
2850
+ * grown into the full search bar when expanded. Always advertising
2851
+ * itself (rather than starting as a tiny icon) reads as a more
2852
+ * polished search affordance, in line with Stripe / Linear / GitHub.
2853
+ */}
2854
+ <div
2855
+ className={`relative shrink-0 transition-[width] duration-300 ease-out ${
2856
+ isExpanded ? "w-[22rem] sm:w-[26rem] lg:w-[32rem]" : "w-44 sm:w-56"
2857
+ }`}
2858
+ >
2859
+ {/* Trigger (collapsed state) */}
2860
+ <button
2861
+ type="button"
2862
+ onClick={() => {
2863
+ setIsSearchExpanded(true);
2864
+ requestAnimationFrame(() => {
2865
+ searchInputRef.current?.focus();
2866
+ });
2867
+ }}
2868
+ title="Search (/)"
2869
+ aria-label="Open search"
2870
+ tabIndex={isExpanded ? -1 : 0}
2871
+ 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 ${
2872
+ isExpanded
2873
+ ? "pointer-events-none border-gray-200 opacity-0"
2874
+ : "border-gray-200 opacity-100 hover:border-gray-300 hover:bg-gray-50"
2875
+ }`}
2876
+ >
2877
+ <Icon
2878
+ icon={IconProp.Search}
2879
+ className="h-4 w-4 flex-none text-gray-400"
2880
+ />
2881
+ <span className="flex-1 truncate text-left text-gray-400">
2882
+ Search…
2883
+ </span>
2884
+ <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">
2885
+ /
2886
+ </kbd>
2887
+ </button>
2888
+
2889
+ {/* Expanded search bar */}
2890
+ <div
2891
+ className={`transition-opacity duration-200 ease-out ${
2892
+ isExpanded
2893
+ ? "opacity-100 delay-150"
2894
+ : "pointer-events-none opacity-0"
2895
+ }`}
2896
+ >
2897
+ {getSearchControl()}
2898
+ </div>
2899
+ </div>
2900
+
2901
+ {/*
2902
+ * Buttons that collapse when the bar expands. We avoid
2903
+ * `overflow-hidden` here — the MoreMenu dropdown is an absolutely
2904
+ * positioned child of one of these buttons and would otherwise get
2905
+ * clipped by an `overflow-hidden` ancestor, leaving the user with
2906
+ * an apparent "no menu appears" bug when they click ⋯. The wrapper
2907
+ * is hidden via opacity + pointer-events instead, and its width
2908
+ * collapses by setting both `max-width` and `gap` to 0 so the
2909
+ * search slot to the left can grow into the freed space.
2910
+ */}
2911
+ <div
2912
+ className={`flex items-center transition-all duration-300 ease-out ${
2913
+ isExpanded
2914
+ ? "max-w-0 -ml-1.5 gap-0 opacity-0 pointer-events-none"
2915
+ : "max-w-[600px] gap-1.5 opacity-100"
2916
+ }`}
2917
+ aria-hidden={isExpanded}
2918
+ >
2919
+ {main && renderMainButton(main)}
2920
+ {moreMenu}
2921
+ </div>
2922
+ </div>
2923
+ );
2924
+
2925
+ return [wrapped];
2926
+ };
2927
+
2928
+ /*
2929
+ * Title slot stays unchanged — the search bar lives in the buttons row.
2930
+ * The wrapping function is kept for symmetry / future styling but is a
2931
+ * pass-through today.
2932
+ */
2933
+ type GetCardHeaderTitleFunction = (
2934
+ originalTitle: ReactElement,
2935
+ ) => ReactElement;
2936
+
2937
+ const getCardHeaderTitle: GetCardHeaderTitleFunction = (
2938
+ originalTitle: ReactElement,
2939
+ ): ReactElement => {
2940
+ return originalTitle;
2941
+ };
2942
+
1976
2943
  const getCardComponent: GetReactElementFunction = (): ReactElement => {
2944
+ const headerButtons: Array<CardButtonSchema | ReactElement> =
2945
+ getHeaderButtonsWithSearch();
2946
+
1977
2947
  if (showAs === ShowAs.Table || showAs === ShowAs.List) {
1978
2948
  return (
1979
2949
  <div>
1980
2950
  {props.cardProps && (
1981
2951
  <Card
1982
2952
  {...props.cardProps}
1983
- buttons={cardButtons}
2953
+ buttons={headerButtons}
1984
2954
  bodyClassName={
1985
2955
  showAs === ShowAs.List
1986
2956
  ? "-ml-6 -mr-6 bg-gray-50 border-top"
1987
2957
  : ""
1988
2958
  }
1989
- title={getCardTitle(props.cardProps.title || "")}
2959
+ title={getCardHeaderTitle(
2960
+ getCardTitle(props.cardProps.title || ""),
2961
+ )}
1990
2962
  >
1991
2963
  {tableColumns.length === 0 && props.columns.length > 0 ? (
1992
2964
  <ErrorMessage
@@ -2011,6 +2983,18 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
2011
2983
  </Card>
2012
2984
  )}
2013
2985
 
2986
+ {/*
2987
+ * For card-less tables we expose the search beside the table header
2988
+ * via a thin right-aligned row.
2989
+ */}
2990
+ {!props.cardProps &&
2991
+ (showAs === ShowAs.Table || showAs === ShowAs.List) &&
2992
+ props.searchableFields &&
2993
+ props.searchableFields.length > 0 ? (
2994
+ <div className="mb-3 flex justify-end">{getSearchControl()}</div>
2995
+ ) : (
2996
+ <></>
2997
+ )}
2014
2998
  {!props.cardProps && showAs === ShowAs.Table ? getTable() : <></>}
2015
2999
  {!props.cardProps && showAs === ShowAs.List ? getList() : <></>}
2016
3000
  </div>
@@ -2022,7 +3006,7 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
2022
3006
  {props.cardProps && (
2023
3007
  <Card
2024
3008
  {...props.cardProps}
2025
- buttons={cardButtons}
3009
+ buttons={headerButtons}
2026
3010
  title={getCardTitle(props.cardProps.title || "")}
2027
3011
  >
2028
3012
  {getOrderedStatesList()}