@oneuptime/common 10.0.85 → 10.0.88

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 (90) hide show
  1. package/Models/DatabaseModels/EnterpriseLicense.ts +54 -0
  2. package/Models/DatabaseModels/GlobalConfig.ts +51 -0
  3. package/Server/API/EnterpriseLicenseAPI.ts +83 -0
  4. package/Server/API/GlobalConfigAPI.ts +59 -0
  5. package/Server/API/TelemetryAPI.ts +24 -0
  6. package/Server/EnvironmentConfig.ts +10 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1777629313843-MigrationName.ts +59 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  9. package/Server/Infrastructure/Queue.ts +4 -4
  10. package/Server/Services/TelemetryAttributeService.ts +37 -3
  11. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +174 -7
  12. package/Tests/Types/Date.test.ts +46 -0
  13. package/Types/Date.ts +9 -4
  14. package/UI/Components/AutocompleteTextInput/AutocompleteTextInput.tsx +60 -21
  15. package/UI/Components/Dictionary/Dictionary.tsx +188 -26
  16. package/UI/Components/Dictionary/DictionaryFilterOperator.ts +357 -0
  17. package/UI/Components/Dictionary/DictionaryOfStrings.tsx +12 -7
  18. package/UI/Components/EditionLabel/EditionLabel.tsx +224 -10
  19. package/UI/Components/Filters/FilterViewer.tsx +81 -16
  20. package/UI/Components/Filters/FiltersForm.tsx +18 -3
  21. package/UI/Components/Filters/JSONFilter.tsx +11 -2
  22. package/UI/Components/Filters/Types/Filter.ts +3 -0
  23. package/UI/Components/Forms/Fields/FormField.tsx +6 -1
  24. package/UI/Components/Forms/Types/Field.ts +5 -0
  25. package/UI/Components/LogsViewer/LogsViewer.tsx +73 -4
  26. package/UI/Components/LogsViewer/components/LogSearchBar.tsx +77 -31
  27. package/UI/Components/LogsViewer/components/LogSearchSuggestions.tsx +44 -1
  28. package/UI/Components/LogsViewer/components/LogsFilterCard.tsx +7 -5
  29. package/UI/Components/TelemetryViewer/TelemetryViewer.tsx +6 -0
  30. package/UI/Components/TelemetryViewer/components/TelemetrySearchBar.tsx +84 -25
  31. package/UI/Components/TelemetryViewer/components/TelemetrySearchSuggestions.tsx +44 -1
  32. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js +57 -0
  33. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js.map +1 -1
  34. package/build/dist/Models/DatabaseModels/GlobalConfig.js +54 -0
  35. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  36. package/build/dist/Server/API/EnterpriseLicenseAPI.js +64 -1
  37. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  38. package/build/dist/Server/API/GlobalConfigAPI.js +47 -0
  39. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  40. package/build/dist/Server/API/TelemetryAPI.js +9 -0
  41. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  42. package/build/dist/Server/EnvironmentConfig.js +3 -0
  43. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  44. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1777629313843-MigrationName.js +26 -0
  45. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1777629313843-MigrationName.js.map +1 -0
  46. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  47. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  48. package/build/dist/Server/Infrastructure/Queue.js +3 -3
  49. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  50. package/build/dist/Server/Services/TelemetryAttributeService.js +36 -7
  51. package/build/dist/Server/Services/TelemetryAttributeService.js.map +1 -1
  52. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +135 -5
  53. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  54. package/build/dist/Tests/Types/Date.test.js +40 -0
  55. package/build/dist/Tests/Types/Date.test.js.map +1 -1
  56. package/build/dist/Types/Date.js +7 -2
  57. package/build/dist/Types/Date.js.map +1 -1
  58. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js +21 -10
  59. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js.map +1 -1
  60. package/build/dist/UI/Components/Dictionary/Dictionary.js +109 -16
  61. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  62. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js +263 -0
  63. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js.map +1 -0
  64. package/build/dist/UI/Components/Dictionary/DictionaryOfStrings.js +10 -6
  65. package/build/dist/UI/Components/Dictionary/DictionaryOfStrings.js.map +1 -1
  66. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +124 -6
  67. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  68. package/build/dist/UI/Components/Filters/FilterViewer.js +50 -12
  69. package/build/dist/UI/Components/Filters/FilterViewer.js.map +1 -1
  70. package/build/dist/UI/Components/Filters/FiltersForm.js +5 -4
  71. package/build/dist/UI/Components/Filters/FiltersForm.js.map +1 -1
  72. package/build/dist/UI/Components/Filters/JSONFilter.js +1 -1
  73. package/build/dist/UI/Components/Filters/JSONFilter.js.map +1 -1
  74. package/build/dist/UI/Components/Forms/Fields/FormField.js +1 -1
  75. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  76. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +54 -5
  77. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  78. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js +59 -29
  79. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js.map +1 -1
  80. package/build/dist/UI/Components/LogsViewer/components/LogSearchSuggestions.js +10 -2
  81. package/build/dist/UI/Components/LogsViewer/components/LogSearchSuggestions.js.map +1 -1
  82. package/build/dist/UI/Components/LogsViewer/components/LogsFilterCard.js +2 -5
  83. package/build/dist/UI/Components/LogsViewer/components/LogsFilterCard.js.map +1 -1
  84. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js +1 -1
  85. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js.map +1 -1
  86. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchBar.js +59 -22
  87. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchBar.js.map +1 -1
  88. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchSuggestions.js +10 -2
  89. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchSuggestions.js.map +1 -1
  90. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import DictionaryForm, { ValueType } from "./Dictionary";
2
+ import { DictionaryEntryValue } from "./DictionaryFilterOperator";
2
3
  import Dictionary from "../../../Types/Dictionary";
3
4
  import React, { FunctionComponent, ReactElement } from "react";
4
5
 
@@ -18,15 +19,19 @@ const DictionaryOfStrings: FunctionComponent<ComponentProps> = (
18
19
  <DictionaryForm
19
20
  {...props}
20
21
  valueTypes={[ValueType.Text]}
21
- onChange={(value: Dictionary<string | number | boolean>) => {
22
- const stringDict: Dictionary<string> = value as Dictionary<string>;
22
+ onChange={(value: Dictionary<DictionaryEntryValue>) => {
23
+ /*
24
+ * Operators are not enabled here, so values come back as bare
25
+ * strings/numbers/booleans only.
26
+ */
27
+ const stringDict: Dictionary<string> = {};
23
28
 
24
- // convert all values to strings
25
-
26
- for (const key in stringDict) {
27
- if (stringDict[key]) {
28
- stringDict[key] = stringDict[key]?.toString() || "";
29
+ for (const key of Object.keys(value)) {
30
+ const entry: DictionaryEntryValue | undefined = value[key];
31
+ if (entry === undefined || entry === null) {
32
+ continue;
29
33
  }
34
+ stringDict[key] = entry.toString();
30
35
  }
31
36
 
32
37
  if (props.onChange) {
@@ -98,6 +98,23 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
98
98
  );
99
99
  }
100
100
 
101
+ if (typeof payload["userLimit"] === "number") {
102
+ configModel.enterpriseLicenseUserLimit = payload[
103
+ "userLimit"
104
+ ] as number;
105
+ }
106
+
107
+ if (typeof payload["currentUserCount"] === "number") {
108
+ configModel.enterpriseLicenseCurrentUserCount = payload[
109
+ "currentUserCount"
110
+ ] as number;
111
+ }
112
+
113
+ if (payload["userCountUpdatedAt"]) {
114
+ configModel.enterpriseLicenseUserCountUpdatedAt =
115
+ OneUptimeDate.fromString(payload["userCountUpdatedAt"] as string);
116
+ }
117
+
101
118
  setGlobalConfig(configModel);
102
119
 
103
120
  if (!licenseInputEditedRef.current) {
@@ -153,6 +170,65 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
153
170
  return expiresAt.toLocaleString();
154
171
  }, [globalConfig?.enterpriseLicenseExpiresAt]);
155
172
 
173
+ const userLimit: number | null = useMemo(() => {
174
+ return typeof globalConfig?.enterpriseLicenseUserLimit === "number"
175
+ ? globalConfig.enterpriseLicenseUserLimit
176
+ : null;
177
+ }, [globalConfig?.enterpriseLicenseUserLimit]);
178
+
179
+ const currentUserCount: number | null = useMemo(() => {
180
+ return typeof globalConfig?.enterpriseLicenseCurrentUserCount === "number"
181
+ ? globalConfig.enterpriseLicenseCurrentUserCount
182
+ : null;
183
+ }, [globalConfig?.enterpriseLicenseCurrentUserCount]);
184
+
185
+ const userCountUpdatedAtText: string | null = useMemo(() => {
186
+ if (!globalConfig?.enterpriseLicenseUserCountUpdatedAt) {
187
+ return null;
188
+ }
189
+
190
+ const reportedAt: Date = OneUptimeDate.fromString(
191
+ globalConfig.enterpriseLicenseUserCountUpdatedAt,
192
+ );
193
+
194
+ if (Number.isNaN(reportedAt.getTime())) {
195
+ return null;
196
+ }
197
+
198
+ return reportedAt.toLocaleString();
199
+ }, [globalConfig?.enterpriseLicenseUserCountUpdatedAt]);
200
+
201
+ const isUserLimitBreached: boolean = useMemo(() => {
202
+ if (!licenseValid) {
203
+ return false;
204
+ }
205
+
206
+ if (typeof userLimit !== "number" || userLimit <= 0) {
207
+ return false;
208
+ }
209
+
210
+ if (typeof currentUserCount !== "number") {
211
+ return false;
212
+ }
213
+
214
+ return currentUserCount > userLimit;
215
+ }, [licenseValid, userLimit, currentUserCount]);
216
+
217
+ const userUsagePercent: number | null = useMemo(() => {
218
+ if (typeof userLimit !== "number" || userLimit <= 0) {
219
+ return null;
220
+ }
221
+
222
+ if (typeof currentUserCount !== "number") {
223
+ return null;
224
+ }
225
+
226
+ return Math.min(
227
+ 100,
228
+ Math.max(0, Math.round((currentUserCount / userLimit) * 100)),
229
+ );
230
+ }, [userLimit, currentUserCount]);
231
+
156
232
  const editionName: string = useMemo(() => {
157
233
  if (!IS_ENTERPRISE_EDITION) {
158
234
  return "Community Edition";
@@ -176,8 +252,16 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
176
252
  return "bg-yellow-400";
177
253
  }
178
254
 
179
- return licenseValid ? "bg-emerald-500" : "bg-red-500";
180
- }, [isConfigLoading, licenseValid]);
255
+ if (!licenseValid) {
256
+ return "bg-red-500";
257
+ }
258
+
259
+ if (isUserLimitBreached) {
260
+ return "bg-red-500";
261
+ }
262
+
263
+ return "bg-emerald-500";
264
+ }, [isConfigLoading, licenseValid, isUserLimitBreached]);
181
265
 
182
266
  const ctaLabel: string = useMemo(() => {
183
267
  if (!IS_ENTERPRISE_EDITION) {
@@ -188,8 +272,16 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
188
272
  return "Checking";
189
273
  }
190
274
 
191
- return licenseValid ? "View details" : "Validate license";
192
- }, [isConfigLoading, licenseValid]);
275
+ if (!licenseValid) {
276
+ return "Validate license";
277
+ }
278
+
279
+ if (isUserLimitBreached) {
280
+ return "User limit exceeded";
281
+ }
282
+
283
+ return "View details";
284
+ }, [isConfigLoading, licenseValid, isUserLimitBreached]);
193
285
 
194
286
  const communityFeatures: Array<string> = useMemo(() => {
195
287
  return [
@@ -330,23 +422,40 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
330
422
  : undefined
331
423
  : false;
332
424
 
425
+ const showAlertedPill: boolean =
426
+ IS_ENTERPRISE_EDITION &&
427
+ !isConfigLoading &&
428
+ (isUserLimitBreached ||
429
+ (!licenseValid && Boolean(globalConfig?.enterpriseLicenseKey)));
430
+
431
+ const pillClassName: string = showAlertedPill
432
+ ? "group inline-flex items-center gap-2 rounded-full border border-red-200 bg-red-50 px-3 py-1 text-xs font-medium text-red-700 shadow-sm transition hover:border-red-300 hover:bg-red-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
433
+ : "group inline-flex items-center gap-2 rounded-full border border-indigo-100 bg-white px-3 py-1 text-xs font-medium text-indigo-700 shadow-sm transition hover:border-indigo-300 hover:bg-indigo-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
434
+
435
+ const pillCtaTextClassName: string = showAlertedPill
436
+ ? "text-[11px] text-red-500 group-hover:text-red-600"
437
+ : "text-[11px] text-indigo-500 group-hover:text-indigo-600";
438
+
333
439
  return (
334
440
  <>
335
441
  <button
336
442
  type="button"
337
443
  onClick={openDialog}
338
- className={`group inline-flex items-center gap-2 rounded-full border border-indigo-100 bg-white px-3 py-1 text-xs font-medium text-indigo-700 shadow-sm transition hover:border-indigo-300 hover:bg-indigo-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
339
- props.className ? props.className : ""
340
- }`}
444
+ className={`${pillClassName} ${props.className ? props.className : ""}`}
341
445
  aria-label={`${editionName} details`}
342
446
  >
447
+ {showAlertedPill && (
448
+ <Icon
449
+ icon={IconProp.Alert}
450
+ size={SizeProp.Small}
451
+ className="text-red-600"
452
+ />
453
+ )}
343
454
  <span
344
455
  className={`h-2 w-2 rounded-full transition group-hover:scale-110 ${indicatorColor}`}
345
456
  ></span>
346
457
  <span className="tracking-wide">{editionName}</span>
347
- <span className="text-[11px] text-indigo-500 group-hover:text-indigo-600">
348
- {ctaLabel}
349
- </span>
458
+ <span className={pillCtaTextClassName}>{ctaLabel}</span>
350
459
  </button>
351
460
 
352
461
  {isDialogOpen && (
@@ -397,6 +506,111 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
397
506
  </div>
398
507
  )}
399
508
 
509
+ {!configError && !isConfigLoading && licenseValid && (
510
+ <div
511
+ className={`rounded-lg border p-4 shadow-sm ${
512
+ isUserLimitBreached
513
+ ? "border-red-200 bg-red-50"
514
+ : "border-gray-200 bg-white"
515
+ }`}
516
+ >
517
+ <div className="flex items-start gap-3">
518
+ <div
519
+ className={`flex h-9 w-9 items-center justify-center rounded-full ${
520
+ isUserLimitBreached
521
+ ? "bg-red-100 text-red-600"
522
+ : "bg-indigo-100 text-indigo-600"
523
+ }`}
524
+ >
525
+ <Icon
526
+ icon={
527
+ isUserLimitBreached ? IconProp.Alert : IconProp.User
528
+ }
529
+ size={SizeProp.Regular}
530
+ />
531
+ </div>
532
+ <div className="flex-1">
533
+ <div className="flex items-center justify-between">
534
+ <h3
535
+ className={`text-sm font-semibold ${
536
+ isUserLimitBreached
537
+ ? "text-red-900"
538
+ : "text-gray-900"
539
+ }`}
540
+ >
541
+ User Usage
542
+ </h3>
543
+ {isUserLimitBreached && (
544
+ <span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">
545
+ Limit exceeded
546
+ </span>
547
+ )}
548
+ </div>
549
+
550
+ <div className="mt-2 flex items-baseline gap-1">
551
+ <span
552
+ className={`text-2xl font-semibold ${
553
+ isUserLimitBreached
554
+ ? "text-red-700"
555
+ : "text-gray-900"
556
+ }`}
557
+ >
558
+ {typeof currentUserCount === "number"
559
+ ? currentUserCount.toLocaleString()
560
+ : "—"}
561
+ </span>
562
+ <span className="text-sm text-gray-500">
563
+ {" / "}
564
+ {typeof userLimit === "number" && userLimit > 0
565
+ ? `${userLimit.toLocaleString()} users`
566
+ : "unlimited"}
567
+ </span>
568
+ </div>
569
+
570
+ {typeof userUsagePercent === "number" && (
571
+ <div className="mt-3">
572
+ <div className="h-2 w-full overflow-hidden rounded-full bg-gray-200">
573
+ <div
574
+ className={`h-full rounded-full transition-all ${
575
+ isUserLimitBreached
576
+ ? "bg-red-500"
577
+ : userUsagePercent >= 80
578
+ ? "bg-amber-500"
579
+ : "bg-emerald-500"
580
+ }`}
581
+ style={{ width: `${userUsagePercent}%` }}
582
+ />
583
+ </div>
584
+ <p className="mt-1 text-xs text-gray-500">
585
+ {userUsagePercent}% of licensed seats in use
586
+ </p>
587
+ </div>
588
+ )}
589
+
590
+ {isUserLimitBreached && (
591
+ <p className="mt-3 text-xs text-red-700">
592
+ Your installation has more users than your license
593
+ permits. Please contact{" "}
594
+ <a
595
+ href="mailto:sales@oneuptime.com"
596
+ className="font-medium text-red-800 underline hover:text-red-900"
597
+ >
598
+ sales@oneuptime.com
599
+ </a>{" "}
600
+ to expand your license.
601
+ </p>
602
+ )}
603
+
604
+ <p className="mt-3 text-xs text-gray-500">
605
+ {userCountUpdatedAtText
606
+ ? `Last reported to OneUptime on ${userCountUpdatedAtText}.`
607
+ : "User count has not been reported to OneUptime yet. The first report will be sent within 24 hours."}
608
+ </p>
609
+ </div>
610
+ </div>
611
+ </div>
612
+ )}
613
+
400
614
  {!configError &&
401
615
  !isConfigLoading &&
402
616
  !licenseValid &&
@@ -1,3 +1,9 @@
1
+ import {
2
+ DictionaryFilterOperator,
3
+ DictionaryFilterOperatorOption,
4
+ detectOperatorFromValue,
5
+ getOperatorOption,
6
+ } from "../Dictionary/DictionaryFilterOperator";
1
7
  import Icon, { SizeProp } from "../Icon/Icon";
2
8
  import Includes from "../../../Types/BaseDatabase/Includes";
3
9
  import IncludesAll from "../../../Types/BaseDatabase/IncludesAll";
@@ -81,20 +87,50 @@ const FilterComponent: FilterComponentFunction = <T extends GenericObject>(
81
87
  const formatJson: FormatJsonFunction = (
82
88
  json: Dictionary<string | number | boolean>,
83
89
  ): ReactElement => {
90
+ /*
91
+ * Ignore entries with empty key or empty value, and translate
92
+ * operator wrappers into their display symbol so chips like
93
+ * `host.name != prod-1` or `path contains api` render correctly.
94
+ */
95
+ const isMeaningfulEntry: (key: string, value: unknown) => boolean = (
96
+ key: string,
97
+ value: unknown,
98
+ ): boolean => {
99
+ if (key.trim() === "" || value === undefined || value === null) {
100
+ return false;
101
+ }
102
+ const detected: { operator: DictionaryFilterOperator; rawValue: string } =
103
+ detectOperatorFromValue(value);
104
+ const option: DictionaryFilterOperatorOption = getOperatorOption(
105
+ detected.operator,
106
+ );
107
+ if (option.hidesValueInput) {
108
+ return true;
109
+ }
110
+ return detected.rawValue.trim() !== "";
111
+ };
112
+
113
+ const visibleKeys: Array<string> = Object.keys(json).filter(
114
+ (key: string) => {
115
+ return isMeaningfulEntry(key, json[key]);
116
+ },
117
+ );
118
+
84
119
  return (
85
120
  <div className="flex space-x-2 -mt-1">
86
- {Object.keys(json).map((key: string, i: number) => {
87
- let jsonText: string | number | boolean = json[key] as
88
- | string
89
- | number
90
- | boolean;
91
-
92
- if (typeof jsonText === "boolean" && jsonText === true) {
93
- jsonText = "True";
94
- }
121
+ {visibleKeys.map((key: string, i: number) => {
122
+ const rawValue: unknown = json[key];
123
+ const detected: {
124
+ operator: DictionaryFilterOperator;
125
+ rawValue: string;
126
+ } = detectOperatorFromValue(rawValue);
127
+ const option: DictionaryFilterOperatorOption = getOperatorOption(
128
+ detected.operator,
129
+ );
95
130
 
96
- if (typeof jsonText === "boolean" && jsonText === false) {
97
- jsonText = "False";
131
+ let jsonText: string = detected.rawValue;
132
+ if (typeof rawValue === "boolean") {
133
+ jsonText = rawValue ? "True" : "False";
98
134
  }
99
135
 
100
136
  return (
@@ -102,8 +138,14 @@ const FilterComponent: FilterComponentFunction = <T extends GenericObject>(
102
138
  key={i}
103
139
  className="rounded-full h-7 bg-gray-100 text-gray-500 border-2 border-gray-200 p-1 pr-2 pl-2 text-xs"
104
140
  >
105
- <span className="font-medium">{key}</span> ={" "}
106
- <span className="font-medium">{jsonText}</span>
141
+ <span className="font-medium">{key}</span>{" "}
142
+ <span>{option.symbol}</span>
143
+ {!option.hidesValueInput && (
144
+ <>
145
+ {" "}
146
+ <span className="font-medium">{jsonText}</span>
147
+ </>
148
+ )}
107
149
  </div>
108
150
  );
109
151
  })}
@@ -334,13 +376,36 @@ const FilterComponent: FilterComponentFunction = <T extends GenericObject>(
334
376
  key
335
377
  ] as Dictionary<string | number | boolean>;
336
378
 
337
- // if json is empty, return null
379
+ /*
380
+ * Count only meaningful entries — empty key/value pairs aren't
381
+ * real filters, and value-less operators (IsEmpty/IsNotEmpty) are
382
+ * valid even with no value.
383
+ */
384
+ const nonEmptyEntryCount: number = Object.keys(json).filter(
385
+ (entryKey: string) => {
386
+ const value: unknown = json[entryKey];
387
+ if (entryKey.trim() === "" || value === undefined || value === null) {
388
+ return false;
389
+ }
390
+ const detected: {
391
+ operator: DictionaryFilterOperator;
392
+ rawValue: string;
393
+ } = detectOperatorFromValue(value);
394
+ const option: DictionaryFilterOperatorOption = getOperatorOption(
395
+ detected.operator,
396
+ );
397
+ if (option.hidesValueInput) {
398
+ return true;
399
+ }
400
+ return detected.rawValue.trim() !== "";
401
+ },
402
+ ).length;
338
403
 
339
- if (Object.keys(json).length === 0) {
404
+ if (nonEmptyEntryCount === 0) {
340
405
  return null;
341
406
  }
342
407
 
343
- const isPlural: boolean = Object.keys(json).length > 1;
408
+ const isPlural: boolean = nonEmptyEntryCount > 1;
344
409
 
345
410
  return (
346
411
  <span className="inline-flex items-center space-x-1">
@@ -111,13 +111,21 @@ const FiltersForm: FiltersFormFunction = <T extends GenericObject>(
111
111
  props.filterData[filter.key] !== undefined &&
112
112
  props.filterData[filter.key] !== null;
113
113
 
114
+ const isMultiRowFilter: boolean = filter.type === FieldType.JSON;
115
+
114
116
  return (
115
117
  <div
116
118
  key={i}
117
- className="grid grid-cols-[140px_1fr_auto] items-center gap-3"
119
+ className={`grid grid-cols-[140px_1fr_auto] gap-3 ${
120
+ isMultiRowFilter ? "items-start" : "items-center"
121
+ }`}
118
122
  >
119
123
  {/* Label column */}
120
- <div className="flex items-center min-w-0">
124
+ <div
125
+ className={`flex items-center min-w-0 ${
126
+ isMultiRowFilter ? "pt-7" : ""
127
+ }`}
128
+ >
121
129
  <label className="text-sm font-medium text-gray-700 truncate">
122
130
  {filter.title}
123
131
  </label>
@@ -169,11 +177,18 @@ const FiltersForm: FiltersFormFunction = <T extends GenericObject>(
169
177
  jsonKeys={filter.jsonKeys}
170
178
  jsonValueSuggestions={filter.jsonValueSuggestions}
171
179
  onJsonKeySelected={filter.onJsonKeySelected}
180
+ isLoadingJsonKeys={filter.isLoadingJsonKeys}
181
+ loadingJsonValueKeys={filter.loadingJsonValueKeys}
182
+ enableOperators={filter.jsonEnableOperators}
172
183
  />
173
184
  </div>
174
185
 
175
186
  {/* Clear column */}
176
- <div className="flex items-center">
187
+ <div
188
+ className={`flex items-center ${
189
+ isMultiRowFilter ? "pt-7" : ""
190
+ }`}
191
+ >
177
192
  {hasValue && filter.key ? (
178
193
  <button
179
194
  type="button"
@@ -1,5 +1,6 @@
1
1
  import { FindWhereProperty } from "../../../Types/BaseDatabase/Query";
2
2
  import DictionaryForm, { ValueType } from "../Dictionary/Dictionary";
3
+ import { DictionaryEntryValue } from "../Dictionary/DictionaryFilterOperator";
3
4
  import FieldType from "../Types/FieldType";
4
5
  import Filter from "./Types/Filter";
5
6
  import FilterData from "./Types/FilterData";
@@ -14,6 +15,9 @@ export interface ComponentProps<T extends GenericObject> {
14
15
  jsonKeys?: Array<string> | undefined;
15
16
  jsonValueSuggestions?: Record<string, Array<string>> | undefined;
16
17
  onJsonKeySelected?: ((key: string) => void) | undefined;
18
+ isLoadingJsonKeys?: boolean | undefined;
19
+ loadingJsonValueKeys?: Array<string> | undefined;
20
+ enableOperators?: boolean | undefined;
17
21
  }
18
22
 
19
23
  type JSONFilterFunction = <T extends GenericObject>(
@@ -32,12 +36,17 @@ const JSONFilter: JSONFilterFunction = <T extends GenericObject>(
32
36
  keys={props.jsonKeys}
33
37
  valueSuggestions={props.jsonValueSuggestions}
34
38
  onKeySelected={props.onJsonKeySelected}
39
+ isLoadingKeys={props.isLoadingJsonKeys}
40
+ loadingValueKeys={props.loadingJsonValueKeys}
41
+ enableOperators={props.enableOperators}
35
42
  addButtonSuffix={filter.title}
36
43
  keyPlaceholder={"Key"}
37
44
  valuePlaceholder={"Value"}
38
45
  valueTypes={[ValueType.Text]}
39
- initialValue={(filterData[filter.key] as Dictionary<string>) || {}}
40
- onChange={(value: Dictionary<string | number | boolean>) => {
46
+ initialValue={
47
+ (filterData[filter.key] as Dictionary<DictionaryEntryValue>) || {}
48
+ }
49
+ onChange={(value: Dictionary<DictionaryEntryValue>) => {
41
50
  // if no keys in the dictionary, remove the filter
42
51
 
43
52
  if (Object.keys(value).length > 0) {
@@ -10,5 +10,8 @@ export default interface Filter<T extends GenericObject> {
10
10
  jsonKeys?: Array<string> | undefined;
11
11
  jsonValueSuggestions?: Record<string, Array<string>> | undefined;
12
12
  onJsonKeySelected?: ((key: string) => void) | undefined;
13
+ isLoadingJsonKeys?: boolean | undefined;
14
+ loadingJsonValueKeys?: Array<string> | undefined;
15
+ jsonEnableOperators?: boolean | undefined;
13
16
  isAdvancedFilter?: boolean | undefined;
14
17
  }
@@ -457,6 +457,11 @@ const FormField: <T extends GenericObject>(
457
457
  {props.field.fieldType === FormFieldSchemaType.Dictionary && (
458
458
  <DictionaryForm
459
459
  keys={props.field.jsonKeysForDictionary}
460
+ valueSuggestions={props.field.dictionaryValueSuggestions}
461
+ onKeySelected={props.field.onDictionaryKeySelected}
462
+ isLoadingKeys={props.field.isLoadingDictionaryKeys}
463
+ loadingValueKeys={props.field.loadingDictionaryValueKeys}
464
+ enableOperators={props.field.dictionaryEnableOperators}
460
465
  addButtonSuffix={props.field.title}
461
466
  keyPlaceholder={"Key"}
462
467
  valuePlaceholder={"Value"}
@@ -467,7 +472,7 @@ const FormField: <T extends GenericObject>(
467
472
  ? (props.currentValues as any)[props.fieldName]
468
473
  : props.field.defaultValue || {}
469
474
  }
470
- onChange={(value: Dictionary<string | number | boolean>) => {
475
+ onChange={(value: Dictionary<any>) => {
471
476
  onChange(value);
472
477
  props.setFieldValue(props.fieldName, value);
473
478
  }}
@@ -122,6 +122,11 @@ export default interface Field<TEntity> {
122
122
 
123
123
  //
124
124
  jsonKeysForDictionary?: Array<string> | undefined;
125
+ isLoadingDictionaryKeys?: boolean | undefined;
126
+ dictionaryValueSuggestions?: Record<string, Array<string>> | undefined;
127
+ loadingDictionaryValueKeys?: Array<string> | undefined;
128
+ onDictionaryKeySelected?: ((key: string) => void) | undefined;
129
+ dictionaryEnableOperators?: boolean | undefined;
125
130
 
126
131
  hideOptionalLabel?: boolean | undefined;
127
132