@oneuptime/common 10.0.19 → 10.0.21

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 (92) hide show
  1. package/Server/API/GitHubAPI.ts +104 -12
  2. package/Server/API/TelemetryAPI.ts +208 -0
  3. package/Server/API/UserCallAPI.ts +29 -0
  4. package/Server/API/UserEmailAPI.ts +29 -0
  5. package/Server/API/UserSmsAPI.ts +29 -0
  6. package/Server/API/UserWhatsAppAPI.ts +29 -0
  7. package/Server/Services/LogAggregationService.ts +251 -0
  8. package/Server/Utils/VM/VMRunner.ts +45 -0
  9. package/Types/Log/LogQueryParser.ts +252 -0
  10. package/Types/Log/LogQueryToFilter.ts +131 -0
  11. package/UI/Components/CopyTextButton/CopyTextButton.tsx +3 -3
  12. package/UI/Components/LogsViewer/LogsViewer.tsx +166 -93
  13. package/UI/Components/LogsViewer/components/ActiveFilterChips.tsx +58 -0
  14. package/UI/Components/LogsViewer/components/FacetSection.tsx +119 -0
  15. package/UI/Components/LogsViewer/components/FacetValueRow.tsx +102 -0
  16. package/UI/Components/LogsViewer/components/HistogramTooltip.tsx +122 -0
  17. package/UI/Components/LogsViewer/components/LiveLogsToggle.tsx +4 -4
  18. package/UI/Components/LogsViewer/components/LogDetailsPanel.tsx +22 -26
  19. package/UI/Components/LogsViewer/components/LogSearchBar.tsx +360 -0
  20. package/UI/Components/LogsViewer/components/LogSearchHelp.tsx +128 -0
  21. package/UI/Components/LogsViewer/components/LogSearchSuggestions.tsx +64 -0
  22. package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +199 -0
  23. package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +172 -0
  24. package/UI/Components/LogsViewer/components/LogsFilterCard.tsx +27 -57
  25. package/UI/Components/LogsViewer/components/LogsHistogram.tsx +268 -0
  26. package/UI/Components/LogsViewer/components/LogsPagination.tsx +12 -10
  27. package/UI/Components/LogsViewer/components/LogsTable.tsx +33 -32
  28. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +16 -18
  29. package/UI/Components/LogsViewer/components/severityColors.ts +31 -0
  30. package/UI/Components/LogsViewer/components/severityTheme.ts +25 -25
  31. package/UI/Components/LogsViewer/types.ts +20 -0
  32. package/build/dist/Server/API/GitHubAPI.js +40 -9
  33. package/build/dist/Server/API/GitHubAPI.js.map +1 -1
  34. package/build/dist/Server/API/TelemetryAPI.js +136 -0
  35. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  36. package/build/dist/Server/API/UserCallAPI.js +17 -0
  37. package/build/dist/Server/API/UserCallAPI.js.map +1 -1
  38. package/build/dist/Server/API/UserEmailAPI.js +17 -0
  39. package/build/dist/Server/API/UserEmailAPI.js.map +1 -1
  40. package/build/dist/Server/API/UserSmsAPI.js +17 -0
  41. package/build/dist/Server/API/UserSmsAPI.js.map +1 -1
  42. package/build/dist/Server/API/UserWhatsAppAPI.js +17 -0
  43. package/build/dist/Server/API/UserWhatsAppAPI.js.map +1 -1
  44. package/build/dist/Server/Services/LogAggregationService.js +163 -0
  45. package/build/dist/Server/Services/LogAggregationService.js.map +1 -0
  46. package/build/dist/Server/Utils/VM/VMRunner.js +31 -0
  47. package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
  48. package/build/dist/Types/Log/LogQueryParser.js +200 -0
  49. package/build/dist/Types/Log/LogQueryParser.js.map +1 -0
  50. package/build/dist/Types/Log/LogQueryToFilter.js +96 -0
  51. package/build/dist/Types/Log/LogQueryToFilter.js.map +1 -0
  52. package/build/dist/UI/Components/CopyTextButton/CopyTextButton.js +3 -3
  53. package/build/dist/UI/Components/CopyTextButton/CopyTextButton.js.map +1 -1
  54. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +64 -42
  55. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  56. package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js +24 -0
  57. package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js.map +1 -0
  58. package/build/dist/UI/Components/LogsViewer/components/FacetSection.js +46 -0
  59. package/build/dist/UI/Components/LogsViewer/components/FacetSection.js.map +1 -0
  60. package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js +35 -0
  61. package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js.map +1 -0
  62. package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js +64 -0
  63. package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js.map +1 -0
  64. package/build/dist/UI/Components/LogsViewer/components/LiveLogsToggle.js +4 -4
  65. package/build/dist/UI/Components/LogsViewer/components/LiveLogsToggle.js.map +1 -1
  66. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js +19 -21
  67. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js.map +1 -1
  68. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js +230 -0
  69. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js.map +1 -0
  70. package/build/dist/UI/Components/LogsViewer/components/LogSearchHelp.js +84 -0
  71. package/build/dist/UI/Components/LogsViewer/components/LogSearchHelp.js.map +1 -0
  72. package/build/dist/UI/Components/LogsViewer/components/LogSearchSuggestions.js +27 -0
  73. package/build/dist/UI/Components/LogsViewer/components/LogSearchSuggestions.js.map +1 -0
  74. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +100 -0
  75. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -0
  76. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +104 -0
  77. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -0
  78. package/build/dist/UI/Components/LogsViewer/components/LogsFilterCard.js +14 -35
  79. package/build/dist/UI/Components/LogsViewer/components/LogsFilterCard.js.map +1 -1
  80. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +127 -0
  81. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -0
  82. package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js +9 -9
  83. package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js.map +1 -1
  84. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js +31 -30
  85. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js.map +1 -1
  86. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +7 -8
  87. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  88. package/build/dist/UI/Components/LogsViewer/components/severityColors.js +22 -0
  89. package/build/dist/UI/Components/LogsViewer/components/severityColors.js.map +1 -0
  90. package/build/dist/UI/Components/LogsViewer/components/severityTheme.js +25 -25
  91. package/build/dist/UI/Components/LogsViewer/components/severityTheme.js.map +1 -1
  92. package/package.json +1 -1
@@ -54,14 +54,14 @@ const CopyTextButton: FunctionComponent<ComponentProps> = (
54
54
 
55
55
  const variantClasses: Record<typeof variant, string> = {
56
56
  ghost:
57
- "bg-transparent border border-slate-600 text-slate-300 hover:bg-slate-700/40",
58
- soft: "bg-slate-700 text-white border border-slate-600 hover:bg-slate-600",
57
+ "bg-transparent border border-gray-200 text-gray-400 hover:bg-gray-50 hover:text-gray-600",
58
+ soft: "bg-gray-100 text-gray-600 border border-gray-200 hover:bg-gray-200",
59
59
  solid:
60
60
  "bg-indigo-600 text-white border border-indigo-600 hover:bg-indigo-500",
61
61
  } as const;
62
62
 
63
63
  const copiedClasses: string =
64
- "bg-emerald-600/20 border border-emerald-500 text-emerald-300";
64
+ "bg-emerald-50 border border-emerald-200 text-emerald-600";
65
65
 
66
66
  return (
67
67
  <button
@@ -34,7 +34,17 @@ import LogsTable, {
34
34
  } from "./components/LogsTable";
35
35
  import LogsPagination from "./components/LogsPagination";
36
36
  import LogDetailsPanel from "./components/LogDetailsPanel";
37
- import { LiveLogsOptions } from "./types";
37
+ import LogsHistogram from "./components/LogsHistogram";
38
+ import LogsFacetSidebar from "./components/LogsFacetSidebar";
39
+ import ActiveFilterChips from "./components/ActiveFilterChips";
40
+ import {
41
+ LiveLogsOptions,
42
+ HistogramBucket,
43
+ FacetData,
44
+ ActiveFilter,
45
+ } from "./types";
46
+ import { queryStringToFilter } from "../../../Types/Log/LogQueryToFilter";
47
+ import RangeStartAndEndDateTime from "../../../Types/Time/RangeStartAndEndDateTime";
38
48
 
39
49
  export interface ComponentProps {
40
50
  logs: Array<Log>;
@@ -54,10 +64,26 @@ export interface ComponentProps {
54
64
  sortOrder?: SortOrder | undefined;
55
65
  onSortChange?: (field: LogsTableSortField, order: SortOrder) => void;
56
66
  liveOptions?: LiveLogsOptions | undefined;
67
+ histogramBuckets?: Array<HistogramBucket>;
68
+ histogramLoading?: boolean;
69
+ onHistogramTimeRangeSelect?: (startTime: Date, endTime: Date) => void;
70
+ facetData?: FacetData;
71
+ facetLoading?: boolean;
72
+ onFacetInclude?: (facetKey: string, value: string) => void;
73
+ onFacetExclude?: (facetKey: string, value: string) => void;
74
+ showFacetSidebar?: boolean;
75
+ activeFilters?: Array<ActiveFilter> | undefined;
76
+ onRemoveFilter?: ((facetKey: string, value: string) => void) | undefined;
77
+ onClearAllFilters?: (() => void) | undefined;
78
+ valueSuggestions?: Record<string, Array<string>> | undefined;
79
+ onFieldValueSelect?: ((fieldKey: string, value: string) => void) | undefined;
80
+ timeRange?: RangeStartAndEndDateTime | undefined;
81
+ onTimeRangeChange?: ((value: RangeStartAndEndDateTime) => void) | undefined;
57
82
  }
58
83
 
59
84
  export type LogsSortField = LogsTableSortField;
60
85
  export type { LiveLogsOptions } from "./types";
86
+ export type { HistogramBucket, FacetData, ActiveFilter } from "./types";
61
87
 
62
88
  const DEFAULT_PAGE_SIZE: number = 100;
63
89
  const PAGE_SIZE_OPTIONS: Array<number> = [100, 250, 500, 1000];
@@ -88,13 +114,11 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
88
114
  props: ComponentProps,
89
115
  ): ReactElement => {
90
116
  const [filterData, setFilterData] = useState<Query<Log>>(props.filterData);
117
+ const [searchQuery, setSearchQuery] = useState<string>("");
91
118
 
92
119
  const [logAttributes, setLogAttributes] = useState<Array<string>>([]);
93
120
  const [attributesLoaded, setAttributesLoaded] = useState<boolean>(false);
94
121
  const [attributesLoading, setAttributesLoading] = useState<boolean>(false);
95
- const [attributesError, setAttributesError] = useState<string>("");
96
- const [areAdvancedFiltersVisible, setAreAdvancedFiltersVisible] =
97
- useState<boolean>(false);
98
122
 
99
123
  const [isPageLoading, setIsPageLoading] = useState<boolean>(true);
100
124
  const [pageError, setPageError] = useState<string>("");
@@ -278,7 +302,6 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
278
302
  useCallback(async (): Promise<void> => {
279
303
  try {
280
304
  setAttributesLoading(true);
281
- setAttributesError("");
282
305
 
283
306
  const attributeResponse: HTTPResponse<JSONObject> | HTTPErrorResponse =
284
307
  await API.post({
@@ -300,12 +323,9 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
300
323
  ] || []) as Array<string>;
301
324
  setLogAttributes(attributes);
302
325
  setAttributesLoaded(true);
303
- } catch (err) {
326
+ } catch {
304
327
  setLogAttributes([]);
305
328
  setAttributesLoaded(false);
306
- setAttributesError(
307
- `We couldn't load log attributes. Filters may be limited. ${API.getFriendlyErrorMessage(err as Error)}`,
308
- );
309
329
  } finally {
310
330
  setAttributesLoading(false);
311
331
  }
@@ -315,6 +335,13 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
315
335
  void loadServices();
316
336
  }, [loadServices]);
317
337
 
338
+ // Load attributes eagerly for search bar suggestions
339
+ useEffect(() => {
340
+ if (!attributesLoaded && !attributesLoading) {
341
+ void loadAttributes();
342
+ }
343
+ }, [attributesLoaded, attributesLoading, loadAttributes]);
344
+
318
345
  const resetPage: () => void = (): void => {
319
346
  if (props.onPageChange) {
320
347
  props.onPageChange(1);
@@ -325,10 +352,18 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
325
352
  }
326
353
  };
327
354
 
328
- const handleApplyFilters: () => void = (): void => {
355
+ const handleSearchSubmit: () => void = (): void => {
356
+ const queryFilter: Record<string, unknown> = queryStringToFilter(
357
+ searchQuery,
358
+ ) as Record<string, unknown>;
359
+ const mergedFilter: Query<Log> = {
360
+ ...filterData,
361
+ ...queryFilter,
362
+ } as Query<Log>;
363
+
329
364
  resetPage();
330
365
  setSelectedLogId(null);
331
- props.onFilterChanged(filterData);
366
+ props.onFilterChanged(mergedFilter);
332
367
  };
333
368
 
334
369
  const handlePageChange: (page: number) => void = (page: number): void => {
@@ -341,8 +376,6 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
341
376
  }
342
377
 
343
378
  setSelectedLogId(null);
344
-
345
- setSelectedLogId(null);
346
379
  };
347
380
 
348
381
  const handlePageSizeChange: (size: number) => void = (size: number): void => {
@@ -377,6 +410,28 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
377
410
  setSelectedLogId(null);
378
411
  };
379
412
 
413
+ /*
414
+ * Enrich active filters with resolved display values (e.g. service names)
415
+ * Must be before early returns to maintain consistent hook call order.
416
+ */
417
+ const enrichedActiveFilters: Array<ActiveFilter> = useMemo(() => {
418
+ if (!props.activeFilters) {
419
+ return [];
420
+ }
421
+
422
+ return props.activeFilters.map((filter: ActiveFilter): ActiveFilter => {
423
+ if (filter.facetKey === "serviceId" && serviceMap[filter.value]) {
424
+ const service: Service | undefined = serviceMap[filter.value];
425
+ return {
426
+ ...filter,
427
+ displayValue: service?.name || filter.value,
428
+ };
429
+ }
430
+
431
+ return filter;
432
+ });
433
+ }, [props.activeFilters, serviceMap]);
434
+
380
435
  if (isPageLoading) {
381
436
  return <PageLoader isVisible={true} />;
382
437
  }
@@ -390,99 +445,117 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
390
445
  currentPage,
391
446
  totalPages,
392
447
  ...(props.liveOptions ? { liveOptions: props.liveOptions } : {}),
448
+ ...(props.timeRange && props.onTimeRangeChange
449
+ ? {
450
+ timeRange: props.timeRange,
451
+ onTimeRangeChange: props.onTimeRangeChange,
452
+ }
453
+ : {}),
393
454
  };
394
455
 
456
+ const showSidebar: boolean =
457
+ props.showFacetSidebar !== false && Boolean(props.facetData);
458
+
395
459
  return (
396
- <div className="space-y-6">
460
+ <div className="space-y-2">
397
461
  {props.showFilters && (
398
- <div className="mb-6">
462
+ <div>
399
463
  <LogsFilterCard
400
- filterData={filterData}
401
- onFilterChanged={(updated: Query<Log>) => {
402
- setFilterData(updated);
403
- }}
404
- onAdvancedFiltersToggle={(show: boolean) => {
405
- setAreAdvancedFiltersVisible(show);
406
-
407
- if (show && !attributesLoaded && !attributesLoading) {
408
- void loadAttributes();
409
- }
410
- }}
411
- isFilterLoading={areAdvancedFiltersVisible && attributesLoading}
412
- filterError={
413
- areAdvancedFiltersVisible && attributesError
414
- ? attributesError
415
- : undefined
416
- }
417
- onFilterRefreshClick={
418
- areAdvancedFiltersVisible && attributesError
419
- ? () => {
420
- void loadAttributes();
421
- }
422
- : undefined
423
- }
424
464
  logAttributes={logAttributes}
425
- toolbar={
426
- <LogsViewerToolbar
427
- {...toolbarProps}
428
- showApplyButton={true}
429
- onApplyFilters={handleApplyFilters}
430
- />
431
- }
465
+ searchQuery={searchQuery}
466
+ onSearchQueryChange={setSearchQuery}
467
+ onSearchSubmit={handleSearchSubmit}
468
+ valueSuggestions={props.valueSuggestions}
469
+ onFieldValueSelect={props.onFieldValueSelect}
470
+ toolbar={<LogsViewerToolbar {...toolbarProps} />}
432
471
  />
433
472
  </div>
434
473
  )}
435
474
 
436
- <div className="overflow-hidden rounded-2xl border border-slate-800/70 bg-slate-950/60 shadow-xl">
437
- {!props.showFilters && (
438
- <div className="border-b border-slate-800/70 bg-slate-950/70 px-4 py-3">
439
- <LogsViewerToolbar {...toolbarProps} />
440
- </div>
441
- )}
442
-
443
- <LogsTable
444
- logs={displayedLogs}
445
- serviceMap={serviceMap}
446
- isLoading={props.isLoading}
447
- emptyMessage={props.noLogsMessage}
448
- onRowClick={(_log: Log, rowId: string) => {
449
- setSelectedLogId((currentSelected: string | null) => {
450
- if (currentSelected === rowId) {
451
- return null;
452
- }
453
-
454
- return rowId;
455
- });
456
- }}
457
- selectedLogId={selectedLogId}
458
- sortField={sortField}
459
- sortOrder={sortOrder}
460
- onSortChange={handleSortChange}
461
- renderExpandedContent={(log: Log) => {
462
- return (
463
- <LogDetailsPanel
464
- log={log}
465
- serviceMap={serviceMap}
466
- onClose={() => {
467
- setSelectedLogId(null);
468
- }}
469
- getTraceRoute={props.getTraceRoute}
470
- getSpanRoute={props.getSpanRoute}
471
- variant="embedded"
472
- />
473
- );
474
- }}
475
+ {/* Active filter chips */}
476
+ {enrichedActiveFilters.length > 0 && props.onRemoveFilter && (
477
+ <ActiveFilterChips
478
+ filters={enrichedActiveFilters}
479
+ onRemove={props.onRemoveFilter}
480
+ onClearAll={props.onClearAllFilters || (() => {})}
475
481
  />
482
+ )}
476
483
 
477
- <LogsPagination
478
- currentPage={currentPage}
479
- totalItems={totalItems}
480
- pageSize={pageSize}
481
- pageSizeOptions={PAGE_SIZE_OPTIONS}
482
- onPageChange={handlePageChange}
483
- onPageSizeChange={handlePageSizeChange}
484
- isDisabled={props.isLoading || totalItems === 0}
484
+ {/* Histogram */}
485
+ {props.histogramBuckets && (
486
+ <LogsHistogram
487
+ buckets={props.histogramBuckets}
488
+ isLoading={props.histogramLoading || false}
489
+ onTimeRangeSelect={props.onHistogramTimeRangeSelect}
485
490
  />
491
+ )}
492
+
493
+ {/* Main content: sidebar + table */}
494
+ <div className="flex gap-3">
495
+ {showSidebar && props.facetData && (
496
+ <LogsFacetSidebar
497
+ facetData={props.facetData}
498
+ isLoading={props.facetLoading || false}
499
+ serviceMap={serviceMap}
500
+ onIncludeFilter={props.onFacetInclude || (() => {})}
501
+ onExcludeFilter={props.onFacetExclude || (() => {})}
502
+ activeFilters={props.activeFilters}
503
+ />
504
+ )}
505
+
506
+ <div className="min-w-0 flex-1">
507
+ <div className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
508
+ {!props.showFilters && (
509
+ <div className="border-b border-gray-100 bg-gray-50/50 px-4 py-3">
510
+ <LogsViewerToolbar {...toolbarProps} />
511
+ </div>
512
+ )}
513
+
514
+ <LogsTable
515
+ logs={displayedLogs}
516
+ serviceMap={serviceMap}
517
+ isLoading={props.isLoading}
518
+ emptyMessage={props.noLogsMessage}
519
+ onRowClick={(_log: Log, rowId: string) => {
520
+ setSelectedLogId((currentSelected: string | null) => {
521
+ if (currentSelected === rowId) {
522
+ return null;
523
+ }
524
+
525
+ return rowId;
526
+ });
527
+ }}
528
+ selectedLogId={selectedLogId}
529
+ sortField={sortField}
530
+ sortOrder={sortOrder}
531
+ onSortChange={handleSortChange}
532
+ renderExpandedContent={(log: Log) => {
533
+ return (
534
+ <LogDetailsPanel
535
+ log={log}
536
+ serviceMap={serviceMap}
537
+ onClose={() => {
538
+ setSelectedLogId(null);
539
+ }}
540
+ getTraceRoute={props.getTraceRoute}
541
+ getSpanRoute={props.getSpanRoute}
542
+ variant="embedded"
543
+ />
544
+ );
545
+ }}
546
+ />
547
+
548
+ <LogsPagination
549
+ currentPage={currentPage}
550
+ totalItems={totalItems}
551
+ pageSize={pageSize}
552
+ pageSizeOptions={PAGE_SIZE_OPTIONS}
553
+ onPageChange={handlePageChange}
554
+ onPageSizeChange={handlePageSizeChange}
555
+ isDisabled={props.isLoading || totalItems === 0}
556
+ />
557
+ </div>
558
+ </div>
486
559
  </div>
487
560
  </div>
488
561
  );
@@ -0,0 +1,58 @@
1
+ import React, { FunctionComponent, ReactElement } from "react";
2
+ import { ActiveFilter } from "../types";
3
+ import Icon from "../../Icon/Icon";
4
+ import IconProp from "../../../../Types/Icon/IconProp";
5
+
6
+ export interface ActiveFilterChipsProps {
7
+ filters: Array<ActiveFilter>;
8
+ onRemove: (facetKey: string, value: string) => void;
9
+ onClearAll: () => void;
10
+ }
11
+
12
+ const ActiveFilterChips: FunctionComponent<ActiveFilterChipsProps> = (
13
+ props: ActiveFilterChipsProps,
14
+ ): ReactElement | null => {
15
+ if (props.filters.length === 0) {
16
+ return null;
17
+ }
18
+
19
+ return (
20
+ <div className="flex flex-wrap items-center gap-1.5 px-0.5">
21
+ {props.filters.map((filter: ActiveFilter) => {
22
+ const chipKey: string = `${filter.facetKey}:${filter.value}`;
23
+ return (
24
+ <span
25
+ key={chipKey}
26
+ className="inline-flex items-center gap-1 rounded-md border border-indigo-200 bg-indigo-50 py-0.5 pl-2 pr-1 text-xs text-indigo-700"
27
+ >
28
+ <span className="font-medium text-indigo-500">
29
+ {filter.displayKey}:
30
+ </span>
31
+ <span>{filter.displayValue}</span>
32
+ <button
33
+ type="button"
34
+ className="ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded text-indigo-400 transition-colors hover:bg-indigo-100 hover:text-indigo-600"
35
+ onClick={() => {
36
+ props.onRemove(filter.facetKey, filter.value);
37
+ }}
38
+ title={`Remove ${filter.displayKey}: ${filter.displayValue}`}
39
+ >
40
+ <Icon icon={IconProp.Close} className="h-2.5 w-2.5" />
41
+ </button>
42
+ </span>
43
+ );
44
+ })}
45
+ {props.filters.length > 1 && (
46
+ <button
47
+ type="button"
48
+ className="rounded px-1.5 py-0.5 text-[11px] font-medium text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
49
+ onClick={props.onClearAll}
50
+ >
51
+ Clear all
52
+ </button>
53
+ )}
54
+ </div>
55
+ );
56
+ };
57
+
58
+ export default ActiveFilterChips;
@@ -0,0 +1,119 @@
1
+ import React, { FunctionComponent, ReactElement, useState } from "react";
2
+ import { FacetValue } from "../types";
3
+ import FacetValueRow from "./FacetValueRow";
4
+ import Icon from "../../Icon/Icon";
5
+ import IconProp from "../../../../Types/Icon/IconProp";
6
+
7
+ export interface FacetSectionProps {
8
+ title: string;
9
+ values: Array<FacetValue>;
10
+ initialVisibleCount?: number;
11
+ onIncludeValue: (key: string, value: string) => void;
12
+ onExcludeValue: (key: string, value: string) => void;
13
+ facetKey: string;
14
+ valueDisplayMap?: Record<string, string> | undefined;
15
+ valueColorMap?: Record<string, string> | undefined;
16
+ activeValues?: Set<string> | undefined;
17
+ }
18
+
19
+ const DEFAULT_VISIBLE_COUNT: number = 5;
20
+
21
+ const FacetSection: FunctionComponent<FacetSectionProps> = (
22
+ props: FacetSectionProps,
23
+ ): ReactElement => {
24
+ const [isExpanded, setIsExpanded] = useState<boolean>(true);
25
+ const [showAll, setShowAll] = useState<boolean>(false);
26
+
27
+ const visibleCount: number =
28
+ props.initialVisibleCount ?? DEFAULT_VISIBLE_COUNT;
29
+
30
+ const displayedValues: Array<FacetValue> = showAll
31
+ ? props.values
32
+ : props.values.slice(0, visibleCount);
33
+
34
+ const hasMore: boolean = props.values.length > visibleCount;
35
+
36
+ const maxCount: number =
37
+ props.values.length > 0
38
+ ? Math.max(
39
+ ...props.values.map((v: FacetValue) => {
40
+ return v.count;
41
+ }),
42
+ )
43
+ : 0;
44
+
45
+ const activeCount: number = props.activeValues ? props.activeValues.size : 0;
46
+
47
+ return (
48
+ <div className="border-b border-gray-100 py-2">
49
+ <button
50
+ type="button"
51
+ className="flex w-full items-center justify-between px-2 py-1 text-left"
52
+ onClick={() => {
53
+ setIsExpanded(!isExpanded);
54
+ }}
55
+ >
56
+ <div className="flex items-center gap-1.5">
57
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-gray-500">
58
+ {props.title}
59
+ </span>
60
+ {activeCount > 0 && (
61
+ <span className="inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-indigo-100 px-1 text-[10px] font-semibold text-indigo-600">
62
+ {activeCount}
63
+ </span>
64
+ )}
65
+ </div>
66
+ <Icon
67
+ icon={isExpanded ? IconProp.ChevronDown : IconProp.ChevronRight}
68
+ className="h-3 w-3 text-gray-400"
69
+ />
70
+ </button>
71
+
72
+ {isExpanded && (
73
+ <div className="mt-1 px-1">
74
+ {displayedValues.map((facet: FacetValue) => {
75
+ return (
76
+ <FacetValueRow
77
+ key={facet.value}
78
+ value={facet.value}
79
+ displayValue={props.valueDisplayMap?.[facet.value]}
80
+ count={facet.count}
81
+ maxCount={maxCount}
82
+ color={props.valueColorMap?.[facet.value]}
83
+ isActive={props.activeValues?.has(facet.value) || false}
84
+ onInclude={(value: string) => {
85
+ props.onIncludeValue(props.facetKey, value);
86
+ }}
87
+ onExclude={(value: string) => {
88
+ props.onExcludeValue(props.facetKey, value);
89
+ }}
90
+ />
91
+ );
92
+ })}
93
+
94
+ {props.values.length === 0 && (
95
+ <p className="px-1 py-2 text-[11px] text-gray-400">
96
+ No values found
97
+ </p>
98
+ )}
99
+
100
+ {hasMore && (
101
+ <button
102
+ type="button"
103
+ className="mt-1 px-1 text-[11px] font-medium text-indigo-500 hover:text-indigo-600"
104
+ onClick={() => {
105
+ setShowAll(!showAll);
106
+ }}
107
+ >
108
+ {showAll
109
+ ? "Show less"
110
+ : `+${props.values.length - visibleCount} more`}
111
+ </button>
112
+ )}
113
+ </div>
114
+ )}
115
+ </div>
116
+ );
117
+ };
118
+
119
+ export default FacetSection;
@@ -0,0 +1,102 @@
1
+ import React, { FunctionComponent, ReactElement } from "react";
2
+ import Icon from "../../Icon/Icon";
3
+ import IconProp from "../../../../Types/Icon/IconProp";
4
+
5
+ export interface FacetValueRowProps {
6
+ value: string;
7
+ displayValue?: string | undefined;
8
+ count: number;
9
+ maxCount: number;
10
+ color?: string | undefined;
11
+ isActive?: boolean | undefined;
12
+ onInclude: (value: string) => void;
13
+ onExclude: (value: string) => void;
14
+ }
15
+
16
+ const FacetValueRow: FunctionComponent<FacetValueRowProps> = (
17
+ props: FacetValueRowProps,
18
+ ): ReactElement => {
19
+ const barWidth: number =
20
+ props.maxCount > 0
21
+ ? Math.max(4, Math.round((props.count / props.maxCount) * 100))
22
+ : 0;
23
+
24
+ const displayLabel: string = props.displayValue || props.value || "(empty)";
25
+ const isActive: boolean = props.isActive || false;
26
+
27
+ return (
28
+ <div className="group flex items-center gap-2 py-0.5">
29
+ <button
30
+ type="button"
31
+ className={`flex min-w-0 flex-1 items-center gap-2 rounded px-1.5 py-0.5 text-left transition-colors ${
32
+ isActive ? "bg-indigo-50 ring-1 ring-indigo-200" : "hover:bg-gray-50"
33
+ }`}
34
+ onClick={() => {
35
+ props.onInclude(props.value);
36
+ }}
37
+ title={
38
+ isActive
39
+ ? `Remove filter: ${displayLabel}`
40
+ : `Filter to ${displayLabel}`
41
+ }
42
+ >
43
+ {isActive ? (
44
+ <span className="flex h-3.5 w-3.5 flex-none items-center justify-center rounded bg-indigo-500">
45
+ <Icon icon={IconProp.Check} className="h-2.5 w-2.5 text-white" />
46
+ </span>
47
+ ) : props.color ? (
48
+ <span
49
+ className="h-2.5 w-2.5 flex-none rounded-full"
50
+ style={{ backgroundColor: props.color }}
51
+ />
52
+ ) : (
53
+ <span className="h-2.5 w-2.5 flex-none rounded-full bg-gray-300" />
54
+ )}
55
+ <span
56
+ className={`min-w-0 truncate text-[12px] ${
57
+ isActive ? "font-medium text-indigo-700" : "text-gray-700"
58
+ }`}
59
+ >
60
+ {displayLabel}
61
+ </span>
62
+ </button>
63
+
64
+ <div className="flex items-center gap-1.5">
65
+ <div className="w-12">
66
+ <div className="h-1.5 w-full rounded-full bg-gray-100">
67
+ <div
68
+ className={`h-1.5 rounded-full transition-all ${isActive ? "opacity-100" : "opacity-70"}`}
69
+ style={{
70
+ width: `${barWidth}%`,
71
+ backgroundColor: isActive
72
+ ? "#6366f1"
73
+ : props.color || "#9ca3af",
74
+ }}
75
+ />
76
+ </div>
77
+ </div>
78
+ <span
79
+ className={`min-w-[2rem] text-right font-mono text-[10px] tabular-nums ${
80
+ isActive ? "font-medium text-indigo-600" : "text-gray-400"
81
+ }`}
82
+ >
83
+ {props.count.toLocaleString()}
84
+ </span>
85
+ </div>
86
+
87
+ <button
88
+ type="button"
89
+ className="hidden h-5 w-5 items-center justify-center rounded text-[10px] text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:flex"
90
+ onClick={(e: React.MouseEvent) => {
91
+ e.stopPropagation();
92
+ props.onExclude(props.value);
93
+ }}
94
+ title={`Exclude ${displayLabel}`}
95
+ >
96
+ -
97
+ </button>
98
+ </div>
99
+ );
100
+ };
101
+
102
+ export default FacetValueRow;