@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.
- package/Models/DatabaseModels/Service.ts +26 -0
- package/Server/API/GlobalConfigAPI.ts +13 -16
- package/Server/Infrastructure/Postgres/SchemaMigrations/1778582583897-MigrationName.ts +15 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/OpenTelemetryIngestService.ts +15 -0
- package/Server/Services/ServiceService.ts +37 -0
- package/Server/Types/Database/QueryHelper.ts +38 -0
- package/Server/Types/Database/QueryUtil.ts +77 -0
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +52 -0
- package/Types/BaseDatabase/MultiSearch.ts +53 -0
- package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
- package/Types/Dashboard/DashboardComponents/DashboardChartComponent.ts +2 -0
- package/Types/JSON.ts +3 -0
- package/Types/SerializableObjectDictionary.ts +2 -0
- package/UI/Components/Header/ProjectPicker/ProjectPicker.tsx +11 -6
- package/UI/Components/LogsViewer/components/ColumnSelector.tsx +58 -4
- package/UI/Components/ModelTable/BaseModelTable.tsx +1026 -10
- package/UI/Components/ModelTable/TableView.tsx +58 -32
- package/UI/Utils/GlobalConfig.ts +55 -0
- package/Utils/Dashboard/Components/DashboardChartComponent.ts +11 -0
- package/build/dist/Models/DatabaseModels/Service.js +28 -0
- package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
- package/build/dist/Server/API/GlobalConfigAPI.js +10 -16
- package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778582583897-MigrationName.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778582583897-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/OpenTelemetryIngestService.js +11 -0
- package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
- package/build/dist/Server/Services/ServiceService.js +34 -0
- package/build/dist/Server/Services/ServiceService.js.map +1 -1
- package/build/dist/Server/Types/Database/QueryHelper.js +33 -0
- package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
- package/build/dist/Server/Types/Database/QueryUtil.js +64 -0
- package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +44 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Types/BaseDatabase/MultiSearch.js +44 -0
- package/build/dist/Types/BaseDatabase/MultiSearch.js.map +1 -0
- package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
- package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
- package/build/dist/Types/JSON.js +1 -0
- package/build/dist/Types/JSON.js.map +1 -1
- package/build/dist/Types/SerializableObjectDictionary.js +2 -0
- package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
- package/build/dist/UI/Components/Header/ProjectPicker/ProjectPicker.js +2 -2
- package/build/dist/UI/Components/Header/ProjectPicker/ProjectPicker.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/ColumnSelector.js +33 -3
- package/build/dist/UI/Components/LogsViewer/components/ColumnSelector.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +618 -12
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/TableView.js +25 -18
- package/build/dist/UI/Components/ModelTable/TableView.js.map +1 -1
- package/build/dist/UI/Utils/GlobalConfig.js +38 -0
- package/build/dist/UI/Utils/GlobalConfig.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/DashboardChartComponent.js +9 -0
- package/build/dist/Utils/Dashboard/Components/DashboardChartComponent.js.map +1 -1
- 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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
3041
|
+
buttons={headerButtons}
|
|
2026
3042
|
title={getCardTitle(props.cardProps.title || "")}
|
|
2027
3043
|
>
|
|
2028
3044
|
{getOrderedStatesList()}
|