@oneuptime/common 12.0.11 → 12.0.13
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/DatabaseBaseModel/DatabaseBaseModel.ts +32 -5
- package/Models/DatabaseModels/NetworkDevice.ts +64 -0
- package/Models/DatabaseModels/StatusPage.ts +59 -0
- package/Models/DatabaseModels/StatusPageOidc.ts +2 -0
- package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +19 -2
- package/Server/API/AIChatAPI.ts +55 -56
- package/Server/API/CommonAPI.ts +89 -0
- package/Server/API/MicrosoftTeamsAPI.ts +20 -9
- package/Server/API/SlackAPI.ts +13 -10
- package/Server/API/StatusPageAPI.ts +2197 -2128
- package/Server/API/TelemetryAPI.ts +72 -3
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.ts +30 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.ts +47 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Middleware/UserAuthorization.ts +11 -2
- package/Server/Services/ApiKeyPermissionService.ts +116 -2
- package/Server/Services/DatabaseService.ts +17 -6
- package/Server/Services/LogAggregationService.ts +34 -0
- package/Server/Services/LogService.ts +21 -0
- package/Server/Services/NetworkSiteService.ts +12 -0
- package/Server/Services/SpanService.ts +21 -0
- package/Server/Services/TraceAggregationService.ts +15 -0
- package/Server/Services/WorkspaceNotificationRuleService.ts +172 -220
- package/Server/Types/AnalyticsDatabase/ModelPermission.ts +9 -0
- package/Server/Types/Database/Permissions/AccessControlPermission.ts +47 -16
- package/Server/Types/Workflow/Components/Conditions/IfElse.ts +9 -0
- package/Server/Types/Workflow/Components/JavaScript.ts +9 -0
- package/Server/Utils/APIKey/AccessPermission.ts +16 -40
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +144 -0
- package/Server/Utils/LogRedaction.ts +576 -0
- package/Server/Utils/Logger.ts +123 -46
- package/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.ts +94 -23
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +12 -0
- package/Server/Utils/Monitor/MonitorResource.ts +29 -9
- package/Server/Utils/Monitor/NetworkInventoryUtil.ts +28 -7
- package/Server/Utils/SessionReplay/SessionReplayGateCacheStore.ts +56 -10
- package/Server/Utils/StartServer.ts +30 -9
- package/Server/Utils/StatusPageSearchEngineIndexing.ts +33 -0
- package/Server/Utils/Telemetry/ResourceEntityFilter.ts +441 -0
- package/Server/Utils/VM/VMRunner.ts +693 -874
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +488 -46
- package/Tests/App/Dashboard/MonitorTypePicker.test.tsx +300 -0
- package/Tests/App/StatusPage/StatusPageIndexPageRobotsMeta.test.ts +130 -0
- package/Tests/Models/DatabaseModels/DatabaseBaseModelToJSONObjectCache.test.ts +180 -0
- package/Tests/Models/StatusPageEnableSearchEngineIndexing.test.ts +157 -0
- package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +23 -1
- package/Tests/Server/API/AIChatRouteAuthorization.test.ts +785 -0
- package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +21 -8
- package/Tests/Server/API/CommonAPIAuthGuard.test.ts +290 -0
- package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +7 -1
- package/Tests/Server/API/StatusPageOverviewCache.test.ts +474 -0
- package/Tests/Server/API/StatusPageSeoSearchEngineIndexing.test.ts +189 -0
- package/Tests/Server/Infrastructure/InMemoryTTLCache.test.ts +158 -0
- package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +9 -60
- package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +24 -15
- package/Tests/Server/Middleware/UserAuthorization.test.ts +79 -0
- package/Tests/Server/Services/AddNetworkDeviceReachabilityColumnsMigration.test.ts +332 -0
- package/Tests/Server/Services/ApiKeyPermissionService.test.ts +347 -0
- package/Tests/Server/Services/DatabaseServiceUpdateDebugLogging.test.ts +282 -0
- package/Tests/Server/Services/NetworkSiteService.test.ts +56 -2
- package/Tests/Server/Services/TelemetryResourceFacetFilters.test.ts +267 -0
- package/Tests/Server/Services/WorkspaceNotificationRuleTestRuleChannels.test.ts +702 -0
- package/Tests/Server/Types/Database/Permissions/AccessControlPermission.test.ts +165 -0
- package/Tests/Server/Types/Workflow/Components/CustomCodeScriptError.test.ts +121 -0
- package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +48 -36
- package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +213 -0
- package/Tests/Server/Utils/LogRedaction.test.ts +415 -0
- package/Tests/Server/Utils/LoggerCredentialLeak.test.ts +245 -0
- package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +374 -4
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorJavascriptExpression.test.ts +92 -0
- package/Tests/Server/Utils/Monitor/MonitorResourceIngestedStepSelection.test.ts +455 -0
- package/Tests/Server/Utils/Monitor/NetworkDeviceReachabilityRoundTrip.test.ts +369 -0
- package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +86 -8
- package/Tests/Server/Utils/PrivacyFilterUtil.test.ts +64 -0
- package/Tests/Server/Utils/SessionReplay/SessionReplayGateCacheStore.test.ts +207 -0
- package/Tests/Server/Utils/StartServerBodyVerify.test.ts +335 -0
- package/Tests/Server/Utils/StatusPageSearchEngineIndexing.test.ts +60 -0
- package/Tests/Server/Utils/Telemetry/ResourceEntityFilter.test.ts +435 -0
- package/Tests/Server/Utils/VM/VMRunnerIsolation.test.ts +385 -0
- package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +3 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelSend.test.ts +607 -62
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsInstalledTeamIdSpace.test.ts +778 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsTeamInstalls.test.ts +44 -10
- package/Tests/Types/API/HostnameFromAuthority.test.ts +164 -0
- package/Tests/Types/Database/AccessControl/ColumnAccessControlCache.test.ts +344 -0
- package/Tests/Types/Database/DatabasePropertyFindOperator.test.ts +518 -0
- package/Tests/Types/Database/TableColumnMetadataCache.test.ts +286 -0
- package/Tests/Types/Monitor/MonitorStep.test.ts +100 -0
- package/Tests/Types/Monitor/MonitorTypeKeywords.test.ts +360 -0
- package/Tests/Types/StatusPage/SearchEngineIndexing.test.ts +86 -0
- package/Tests/Types/Telemetry/ResourceEntityFacet.test.ts +254 -0
- package/Tests/UI/Components/CardSelect.test.tsx +1021 -0
- package/Tests/UI/Components/MasterPage.test.tsx +36 -1
- package/Tests/Utils/Monitor/MonitorMetricType.test.ts +66 -1
- package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +115 -6
- package/Tests/Utils/NetworkDevice/DeviceReachabilityUtil.test.ts +610 -0
- package/Tests/Utils/NetworkSite/SiteStatusRollupUtil.test.ts +114 -45
- package/Types/API/Hostname.ts +79 -0
- package/Types/Database/AccessControl/ColumnAccessControl.ts +33 -11
- package/Types/Database/AccessControl/ColumnBillingAccessControl.ts +33 -11
- package/Types/Database/DatabaseProperty.ts +38 -0
- package/Types/Database/TableColumn.ts +34 -7
- package/Types/Events/Recurring.ts +16 -1
- package/Types/Monitor/CustomCodeMonitor/CustomCodeMonitorResponse.ts +14 -2
- package/Types/Monitor/MonitorStep.ts +13 -1
- package/Types/Monitor/MonitorType.ts +345 -40
- package/Types/Monitor/SSLMonitor/SslMonitorResponse.ts +43 -0
- package/Types/StatusPage/SearchEngineIndexing.ts +61 -0
- package/Types/Telemetry/ResourceEntityFacet.ts +211 -0
- package/UI/Components/CardSelect/CardSelect.tsx +714 -105
- package/UI/Components/EmptyState/EmptyState.tsx +8 -1
- package/UI/Components/Footer/Footer.tsx +32 -6
- package/UI/Components/Forms/Fields/FormField.tsx +3 -0
- package/UI/Components/Forms/Types/Field.ts +8 -0
- package/UI/Components/Loader/PageLoader.tsx +29 -8
- package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +26 -4
- package/UI/Components/Markdown.tsx/LazyMarkdownViewer.tsx +17 -1
- package/UI/Components/MasterPage/MasterPage.tsx +24 -11
- package/Utils/Monitor/MonitorMetricType.ts +16 -1
- package/Utils/Monitor/NetworkTopologyUtil.ts +60 -8
- package/Utils/NetworkDevice/DeviceReachabilityUtil.ts +262 -0
- package/Utils/NetworkSite/SiteStatusRollupUtil.ts +41 -24
- package/Utils/Telemetry/CrossSignalScope.ts +75 -4
- package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js +20 -4
- package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +66 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPage.js +60 -0
- package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPageOidc.js +2 -0
- package/build/dist/Models/DatabaseModels/StatusPageOidc.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
- package/build/dist/Server/API/AIChatAPI.js +48 -35
- package/build/dist/Server/API/AIChatAPI.js.map +1 -1
- package/build/dist/Server/API/CommonAPI.js +59 -0
- package/build/dist/Server/API/CommonAPI.js.map +1 -1
- package/build/dist/Server/API/MicrosoftTeamsAPI.js +20 -6
- package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
- package/build/dist/Server/API/SlackAPI.js +13 -10
- package/build/dist/Server/API/SlackAPI.js.map +1 -1
- package/build/dist/Server/API/StatusPageAPI.js +745 -673
- package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
- package/build/dist/Server/API/TelemetryAPI.js +34 -3
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js +23 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js +34 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/UserAuthorization.js +6 -2
- package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
- package/build/dist/Server/Services/ApiKeyPermissionService.js +90 -1
- package/build/dist/Server/Services/ApiKeyPermissionService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +15 -6
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/LogAggregationService.js +2 -0
- package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
- package/build/dist/Server/Services/LogService.js +16 -0
- package/build/dist/Server/Services/LogService.js.map +1 -1
- package/build/dist/Server/Services/NetworkSiteService.js +12 -0
- package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
- package/build/dist/Server/Services/SpanService.js +16 -0
- package/build/dist/Server/Services/SpanService.js.map +1 -1
- package/build/dist/Server/Services/TraceAggregationService.js +2 -0
- package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
- package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +137 -164
- package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js +9 -0
- package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js +21 -13
- package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js +8 -0
- package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/JavaScript.js +8 -0
- package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
- package/build/dist/Server/Utils/APIKey/AccessPermission.js +9 -32
- package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +87 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/LogRedaction.js +449 -0
- package/build/dist/Server/Utils/LogRedaction.js.map +1 -0
- package/build/dist/Server/Utils/Logger.js +98 -45
- package/build/dist/Server/Utils/Logger.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js +65 -13
- package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +12 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +20 -3
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +27 -7
- package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js +29 -10
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +10 -7
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js +24 -0
- package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js +311 -0
- package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js.map +1 -0
- package/build/dist/Server/Utils/VM/VMRunner.js +402 -525
- package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +366 -37
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/API/Hostname.js +63 -0
- package/build/dist/Types/API/Hostname.js.map +1 -1
- package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js +20 -6
- package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js.map +1 -1
- package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js +20 -6
- package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js.map +1 -1
- package/build/dist/Types/Database/DatabaseProperty.js +38 -0
- package/build/dist/Types/Database/DatabaseProperty.js.map +1 -1
- package/build/dist/Types/Database/TableColumn.js +24 -6
- package/build/dist/Types/Database/TableColumn.js.map +1 -1
- package/build/dist/Types/Events/Recurring.js +13 -1
- package/build/dist/Types/Events/Recurring.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorStep.js +9 -1
- package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorType.js +330 -31
- package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
- package/build/dist/Types/StatusPage/SearchEngineIndexing.js +55 -0
- package/build/dist/Types/StatusPage/SearchEngineIndexing.js.map +1 -0
- package/build/dist/Types/Telemetry/ResourceEntityFacet.js +156 -0
- package/build/dist/Types/Telemetry/ResourceEntityFacet.js.map +1 -0
- package/build/dist/UI/Components/CardSelect/CardSelect.js +360 -40
- package/build/dist/UI/Components/CardSelect/CardSelect.js.map +1 -1
- package/build/dist/UI/Components/EmptyState/EmptyState.js +1 -1
- package/build/dist/UI/Components/EmptyState/EmptyState.js.map +1 -1
- package/build/dist/UI/Components/Footer/Footer.js +7 -4
- package/build/dist/UI/Components/Footer/Footer.js.map +1 -1
- package/build/dist/UI/Components/Forms/Fields/FormField.js +1 -1
- package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
- package/build/dist/UI/Components/Loader/PageLoader.js +15 -5
- package/build/dist/UI/Components/Loader/PageLoader.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +16 -3
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
- package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js +13 -1
- package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js.map +1 -1
- package/build/dist/UI/Components/MasterPage/MasterPage.js +10 -2
- package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
- package/build/dist/Utils/Monitor/MonitorMetricType.js +13 -1
- package/build/dist/Utils/Monitor/MonitorMetricType.js.map +1 -1
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +34 -7
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
- package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js +177 -0
- package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js.map +1 -0
- package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js +23 -27
- package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js.map +1 -1
- package/build/dist/Utils/Telemetry/CrossSignalScope.js +44 -4
- package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import IconProp from "../../../Types/Icon/IconProp";
|
|
2
2
|
import Icon, { SizeProp } from "../Icon/Icon";
|
|
3
|
-
import React, {
|
|
3
|
+
import React, {
|
|
4
|
+
FunctionComponent,
|
|
5
|
+
ReactElement,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
4
10
|
|
|
5
11
|
export interface CardSelectOption {
|
|
6
12
|
value: string;
|
|
7
13
|
title: string;
|
|
8
14
|
description: string;
|
|
9
15
|
icon: IconProp;
|
|
16
|
+
/*
|
|
17
|
+
* Words that should match this card in search but are not on it - product
|
|
18
|
+
* names, protocols, synonyms. Optional: a catalog small enough to read in
|
|
19
|
+
* one screen has no use for them.
|
|
20
|
+
*/
|
|
21
|
+
keywords?: Array<string> | undefined;
|
|
10
22
|
}
|
|
11
23
|
|
|
12
24
|
export interface CardSelectOptionGroup {
|
|
@@ -33,6 +45,19 @@ export interface ComponentProps {
|
|
|
33
45
|
ariaLabelledby?: string | undefined;
|
|
34
46
|
// Force single-column (1 item per row). Default: responsive 1/2/3 grid.
|
|
35
47
|
singleColumn?: boolean | undefined;
|
|
48
|
+
/*
|
|
49
|
+
* Opt in to the search box. Off by default so a picker with a handful of
|
|
50
|
+
* cards is not given a search box it does not need, and so existing callers
|
|
51
|
+
* keep the exact markup they had.
|
|
52
|
+
*/
|
|
53
|
+
searchable?: boolean | undefined;
|
|
54
|
+
searchPlaceholder?: string | undefined;
|
|
55
|
+
/*
|
|
56
|
+
* Opt in to collapsing groups behind their headers. Off by default. Only
|
|
57
|
+
* has an effect on grouped options: the first group and the group holding
|
|
58
|
+
* the current selection start open, the rest start closed behind a count.
|
|
59
|
+
*/
|
|
60
|
+
collapsibleGroups?: boolean | undefined;
|
|
36
61
|
}
|
|
37
62
|
|
|
38
63
|
interface RenderGroup {
|
|
@@ -40,16 +65,189 @@ interface RenderGroup {
|
|
|
40
65
|
options: Array<CardSelectOption>;
|
|
41
66
|
}
|
|
42
67
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
68
|
+
/**
|
|
69
|
+
* The words of a search, in lower case. Empty when nothing was typed.
|
|
70
|
+
*/
|
|
71
|
+
export type CardSelectSearchTokensFunction = (search: string) => Array<string>;
|
|
72
|
+
|
|
73
|
+
export const getCardSelectSearchTokens: CardSelectSearchTokensFunction = (
|
|
74
|
+
search: string,
|
|
75
|
+
): Array<string> => {
|
|
76
|
+
return search
|
|
77
|
+
.trim()
|
|
78
|
+
.toLowerCase()
|
|
79
|
+
.split(/\s+/)
|
|
80
|
+
.filter((token: string) => {
|
|
81
|
+
return token.length > 0;
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/*
|
|
86
|
+
* The text a search runs against: everything visible on the card, the heading
|
|
87
|
+
* it sits under, and its hidden keywords.
|
|
88
|
+
*/
|
|
89
|
+
type CardSelectHaystackFunction = (
|
|
90
|
+
option: CardSelectOption,
|
|
91
|
+
groupLabel: string | null,
|
|
92
|
+
) => string;
|
|
93
|
+
|
|
94
|
+
const getHaystack: CardSelectHaystackFunction = (
|
|
95
|
+
option: CardSelectOption,
|
|
96
|
+
groupLabel: string | null,
|
|
97
|
+
): string => {
|
|
98
|
+
return `${option.title} ${option.description} ${groupLabel || ""} ${(
|
|
99
|
+
option.keywords || []
|
|
100
|
+
).join(" ")}`.toLowerCase();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Does this card match every word typed?
|
|
105
|
+
*
|
|
106
|
+
* Every word, anywhere across title, description, group heading and keywords -
|
|
107
|
+
* not the whole search string as one substring. Matching the words separately
|
|
108
|
+
* is what lets "expired cert" and "cert expired" both find SSL Certificate.
|
|
109
|
+
* The same rule the workflow component palette settled on, for the same
|
|
110
|
+
* reason: in a catalog this size, browsing is not a fallback.
|
|
111
|
+
*/
|
|
112
|
+
export type CardSelectMatchesSearchFunction = (
|
|
113
|
+
option: CardSelectOption,
|
|
114
|
+
tokens: Array<string>,
|
|
115
|
+
groupLabel?: string | null | undefined,
|
|
116
|
+
) => boolean;
|
|
117
|
+
|
|
118
|
+
export const cardSelectOptionMatchesSearch: CardSelectMatchesSearchFunction = (
|
|
119
|
+
option: CardSelectOption,
|
|
120
|
+
tokens: Array<string>,
|
|
121
|
+
groupLabel?: string | null | undefined,
|
|
122
|
+
): boolean => {
|
|
123
|
+
if (tokens.length === 0) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const haystack: string = getHaystack(option, groupLabel || null);
|
|
128
|
+
|
|
129
|
+
return tokens.every((token: string) => {
|
|
130
|
+
return haystack.includes(token);
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
type CardSelectScoreForTokenFunction = (
|
|
135
|
+
option: CardSelectOption,
|
|
136
|
+
groupLabel: string | null,
|
|
137
|
+
searchTerm: string,
|
|
138
|
+
) => number;
|
|
139
|
+
|
|
140
|
+
const getSearchScoreForToken: CardSelectScoreForTokenFunction = (
|
|
141
|
+
option: CardSelectOption,
|
|
142
|
+
groupLabel: string | null,
|
|
143
|
+
searchTerm: string,
|
|
144
|
+
): number => {
|
|
145
|
+
const title: string = option.title.toLowerCase();
|
|
146
|
+
const description: string = option.description.toLowerCase();
|
|
147
|
+
const label: string = (groupLabel || "").toLowerCase();
|
|
148
|
+
const keywords: Array<string> = (option.keywords || []).map(
|
|
149
|
+
(keyword: string) => {
|
|
150
|
+
return keyword.toLowerCase();
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
let score: number = 0;
|
|
155
|
+
|
|
156
|
+
if (title === searchTerm) {
|
|
157
|
+
score += 200;
|
|
158
|
+
} else if (title.startsWith(searchTerm)) {
|
|
159
|
+
score += 140;
|
|
160
|
+
} else if (title.includes(searchTerm)) {
|
|
161
|
+
score += 100;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/*
|
|
165
|
+
* An exact keyword hit is the strongest signal there is after the title
|
|
166
|
+
* itself: someone who types "k8s" or "postgres" has named the thing, they
|
|
167
|
+
* just have not named it the way the card does.
|
|
168
|
+
*/
|
|
169
|
+
if (
|
|
170
|
+
keywords.some((keyword: string) => {
|
|
171
|
+
return keyword === searchTerm;
|
|
172
|
+
})
|
|
173
|
+
) {
|
|
174
|
+
score += 120;
|
|
175
|
+
} else if (
|
|
176
|
+
keywords.some((keyword: string) => {
|
|
177
|
+
return keyword.startsWith(searchTerm);
|
|
178
|
+
})
|
|
179
|
+
) {
|
|
180
|
+
score += 80;
|
|
181
|
+
} else if (
|
|
182
|
+
keywords.some((keyword: string) => {
|
|
183
|
+
return keyword.includes(searchTerm);
|
|
184
|
+
})
|
|
185
|
+
) {
|
|
186
|
+
score += 50;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (label.startsWith(searchTerm)) {
|
|
190
|
+
score += 75;
|
|
191
|
+
} else if (label.includes(searchTerm)) {
|
|
192
|
+
score += 55;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (description.includes(searchTerm)) {
|
|
196
|
+
score += 35;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (
|
|
200
|
+
title.split(" ").some((word: string) => {
|
|
201
|
+
return word.trim().startsWith(searchTerm);
|
|
202
|
+
})
|
|
203
|
+
) {
|
|
204
|
+
score += 15;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return score;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Ranking, summed over the words typed.
|
|
212
|
+
*/
|
|
213
|
+
export type CardSelectSearchScoreFunction = (
|
|
214
|
+
option: CardSelectOption,
|
|
215
|
+
tokens: Array<string>,
|
|
216
|
+
groupLabel?: string | null | undefined,
|
|
217
|
+
) => number;
|
|
218
|
+
|
|
219
|
+
export const getCardSelectOptionSearchScore: CardSelectSearchScoreFunction = (
|
|
220
|
+
option: CardSelectOption,
|
|
221
|
+
tokens: Array<string>,
|
|
222
|
+
groupLabel?: string | null | undefined,
|
|
223
|
+
): number => {
|
|
224
|
+
return tokens.reduce((total: number, token: string) => {
|
|
225
|
+
return total + getSearchScoreForToken(option, groupLabel || null, token);
|
|
226
|
+
}, 0);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
interface ScoredOption {
|
|
230
|
+
option: CardSelectOption;
|
|
231
|
+
groupLabel: string | null;
|
|
232
|
+
score: number;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
type NormalizeGroupsFunction = (
|
|
236
|
+
options: Array<CardSelectOption | CardSelectOptionGroup>,
|
|
237
|
+
) => Array<RenderGroup>;
|
|
238
|
+
|
|
239
|
+
/*
|
|
240
|
+
* Flat options and groups can be interleaved by the caller, so a run of flat
|
|
241
|
+
* options becomes its own unlabelled group wherever it appears.
|
|
242
|
+
*/
|
|
243
|
+
export const normalizeCardSelectGroups: NormalizeGroupsFunction = (
|
|
244
|
+
options: Array<CardSelectOption | CardSelectOptionGroup>,
|
|
245
|
+
): Array<RenderGroup> => {
|
|
47
246
|
const groups: Array<RenderGroup> = [];
|
|
48
247
|
let ungroupedOptions: Array<CardSelectOption> = [];
|
|
49
248
|
|
|
50
|
-
for (const option of
|
|
249
|
+
for (const option of options) {
|
|
51
250
|
if (isCardSelectOptionGroup(option)) {
|
|
52
|
-
// Flush any accumulated ungrouped options first
|
|
53
251
|
if (ungroupedOptions.length > 0) {
|
|
54
252
|
groups.push({ label: null, options: ungroupedOptions });
|
|
55
253
|
ungroupedOptions = [];
|
|
@@ -64,117 +262,528 @@ const CardSelect: FunctionComponent<ComponentProps> = (
|
|
|
64
262
|
groups.push({ label: null, options: ungroupedOptions });
|
|
65
263
|
}
|
|
66
264
|
|
|
67
|
-
|
|
265
|
+
return groups;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const CardSelect: FunctionComponent<ComponentProps> = (
|
|
269
|
+
props: ComponentProps,
|
|
270
|
+
): ReactElement => {
|
|
271
|
+
const [search, setSearch] = useState<string>("");
|
|
272
|
+
const searchInputRef: React.RefObject<HTMLInputElement> =
|
|
273
|
+
useRef<HTMLInputElement>(null);
|
|
274
|
+
const containerRef: React.RefObject<HTMLDivElement> =
|
|
275
|
+
useRef<HTMLDivElement>(null);
|
|
276
|
+
|
|
277
|
+
/*
|
|
278
|
+
* Groups the user has toggled by hand, by label. Anything absent falls back
|
|
279
|
+
* to the default below, so a group does not spring shut again the moment
|
|
280
|
+
* the selection moves elsewhere.
|
|
281
|
+
*/
|
|
282
|
+
const [toggledGroups, setToggledGroups] = useState<Record<string, boolean>>(
|
|
283
|
+
{},
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const groups: Array<RenderGroup> = useMemo(() => {
|
|
287
|
+
return normalizeCardSelectGroups(props.options);
|
|
288
|
+
}, [props.options]);
|
|
289
|
+
|
|
290
|
+
const tokens: Array<string> = useMemo(() => {
|
|
291
|
+
return getCardSelectSearchTokens(search);
|
|
292
|
+
}, [search]);
|
|
293
|
+
|
|
294
|
+
const isSearching: boolean = tokens.length > 0;
|
|
295
|
+
|
|
296
|
+
const totalOptionCount: number = useMemo(() => {
|
|
297
|
+
return groups.reduce((total: number, group: RenderGroup) => {
|
|
298
|
+
return total + group.options.length;
|
|
299
|
+
}, 0);
|
|
300
|
+
}, [groups]);
|
|
301
|
+
|
|
302
|
+
/*
|
|
303
|
+
* A search flattens the groups. Ranked results in nine separate headed
|
|
304
|
+
* sections would bury the best match under whichever heading happens to
|
|
305
|
+
* come first, which is the problem the search is there to solve.
|
|
306
|
+
*/
|
|
307
|
+
const { searchResults, isShowingClosestMatches } = useMemo((): {
|
|
308
|
+
searchResults: Array<ScoredOption>;
|
|
309
|
+
isShowingClosestMatches: boolean;
|
|
310
|
+
} => {
|
|
311
|
+
if (!isSearching) {
|
|
312
|
+
return { searchResults: [], isShowingClosestMatches: false };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
type CollectFunction = (requireEveryWord: boolean) => Array<ScoredOption>;
|
|
316
|
+
|
|
317
|
+
const collect: CollectFunction = (
|
|
318
|
+
requireEveryWord: boolean,
|
|
319
|
+
): Array<ScoredOption> => {
|
|
320
|
+
const scored: Array<ScoredOption> = [];
|
|
321
|
+
|
|
322
|
+
for (const group of groups) {
|
|
323
|
+
for (const option of group.options) {
|
|
324
|
+
const matches: boolean = requireEveryWord
|
|
325
|
+
? cardSelectOptionMatchesSearch(option, tokens, group.label)
|
|
326
|
+
: tokens.some((token: string) => {
|
|
327
|
+
return cardSelectOptionMatchesSearch(
|
|
328
|
+
option,
|
|
329
|
+
[token],
|
|
330
|
+
group.label,
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (!matches) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
scored.push({
|
|
339
|
+
option: option,
|
|
340
|
+
groupLabel: group.label,
|
|
341
|
+
score: getCardSelectOptionSearchScore(option, tokens, group.label),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return scored.sort((a: ScoredOption, b: ScoredOption) => {
|
|
347
|
+
const scoreDifference: number = b.score - a.score;
|
|
348
|
+
|
|
349
|
+
if (scoreDifference !== 0) {
|
|
350
|
+
return scoreDifference;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return a.option.title.localeCompare(b.option.title);
|
|
354
|
+
});
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const everyWord: Array<ScoredOption> = collect(true);
|
|
358
|
+
|
|
359
|
+
if (everyWord.length > 0) {
|
|
360
|
+
return { searchResults: everyWord, isShowingClosestMatches: false };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/*
|
|
364
|
+
* Nothing contains every word. Rather than a dead end, fall back to the
|
|
365
|
+
* cards matching any of them, ranked the same way - someone who typed
|
|
366
|
+
* "postgres uptime" gets SQL Query rather than an empty panel. With a
|
|
367
|
+
* single word this is the same query, so it correctly stays empty.
|
|
368
|
+
*/
|
|
369
|
+
const anyWord: Array<ScoredOption> = collect(false);
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
searchResults: anyWord,
|
|
373
|
+
isShowingClosestMatches: anyWord.length > 0,
|
|
374
|
+
};
|
|
375
|
+
}, [groups, tokens, isSearching]);
|
|
376
|
+
|
|
377
|
+
type IsGroupExpandedFunction = (
|
|
378
|
+
group: RenderGroup,
|
|
379
|
+
groupIndex: number,
|
|
380
|
+
) => boolean;
|
|
381
|
+
|
|
382
|
+
const isGroupExpanded: IsGroupExpandedFunction = (
|
|
383
|
+
group: RenderGroup,
|
|
384
|
+
groupIndex: number,
|
|
385
|
+
): boolean => {
|
|
386
|
+
// Unlabelled groups have no header to collapse behind.
|
|
387
|
+
if (!props.collapsibleGroups || !group.label) {
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const toggled: boolean | undefined = toggledGroups[group.label];
|
|
392
|
+
|
|
393
|
+
if (toggled !== undefined) {
|
|
394
|
+
return toggled;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// The group holding the current selection, so re-entering a form shows it.
|
|
398
|
+
if (
|
|
399
|
+
props.value &&
|
|
400
|
+
group.options.some((option: CardSelectOption) => {
|
|
401
|
+
return option.value === props.value;
|
|
402
|
+
})
|
|
403
|
+
) {
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/*
|
|
408
|
+
* The first group is the common case in every catalog that bothers to
|
|
409
|
+
* order itself, so it is the one worth opening for a user who has not
|
|
410
|
+
* told us anything yet.
|
|
411
|
+
*/
|
|
412
|
+
return groupIndex === 0;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const gridClassName: string = props.singleColumn
|
|
416
|
+
? "grid grid-cols-1 gap-4"
|
|
417
|
+
: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3";
|
|
418
|
+
|
|
419
|
+
/*
|
|
420
|
+
* The cards on screen, in the order they are rendered. A search reorders
|
|
421
|
+
* them and a folded group removes them, so this is recomputed rather than
|
|
422
|
+
* taken from props.
|
|
423
|
+
*/
|
|
424
|
+
const visibleOptionValues: Array<string> = useMemo(() => {
|
|
425
|
+
if (isSearching) {
|
|
426
|
+
return searchResults.map((result: ScoredOption) => {
|
|
427
|
+
return result.option.value;
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const values: Array<string> = [];
|
|
432
|
+
|
|
433
|
+
groups.forEach((group: RenderGroup, groupIndex: number) => {
|
|
434
|
+
if (!isGroupExpanded(group, groupIndex)) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
for (const option of group.options) {
|
|
439
|
+
values.push(option.value);
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
return values;
|
|
444
|
+
/*
|
|
445
|
+
* isGroupExpanded is read here rather than listed: it is rebuilt every
|
|
446
|
+
* render, and everything it actually depends on - the groups, the manual
|
|
447
|
+
* toggles and the current value - is in the list.
|
|
448
|
+
*/
|
|
449
|
+
}, [groups, isSearching, searchResults, toggledGroups, props.value]);
|
|
450
|
+
|
|
451
|
+
/*
|
|
452
|
+
* One tab stop for the whole group, which is what a radiogroup is supposed
|
|
453
|
+
* to be. Every card used to get its own increasing tabIndex - with the 0
|
|
454
|
+
* that FormField passes, that produced 1, 2, 3 ... 28: positive values,
|
|
455
|
+
* which jump ahead of every other control on the page in tab order. Arrow
|
|
456
|
+
* keys move between the cards once the group has focus.
|
|
457
|
+
*/
|
|
458
|
+
const activeOptionValue: string | undefined =
|
|
459
|
+
props.value && visibleOptionValues.includes(props.value)
|
|
460
|
+
? props.value
|
|
461
|
+
: visibleOptionValues[0];
|
|
462
|
+
|
|
463
|
+
type FocusCardFunction = (value: string) => void;
|
|
464
|
+
|
|
465
|
+
const focusCard: FocusCardFunction = (value: string): void => {
|
|
466
|
+
const card: HTMLElement | null =
|
|
467
|
+
containerRef.current?.querySelector(
|
|
468
|
+
`[data-card-select-value="${CSS.escape(value)}"]`,
|
|
469
|
+
) || null;
|
|
470
|
+
|
|
471
|
+
card?.focus();
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
type MoveFocusFunction = (fromValue: string, offset: number) => void;
|
|
475
|
+
|
|
476
|
+
const moveFocus: MoveFocusFunction = (
|
|
477
|
+
fromValue: string,
|
|
478
|
+
offset: number,
|
|
479
|
+
): void => {
|
|
480
|
+
const currentIndex: number = visibleOptionValues.indexOf(fromValue);
|
|
481
|
+
|
|
482
|
+
if (currentIndex < 0) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/*
|
|
487
|
+
* Clamped rather than wrapping: running off the end of a 29 card grid and
|
|
488
|
+
* landing back at the top reads as a glitch, not as navigation.
|
|
489
|
+
*/
|
|
490
|
+
const nextIndex: number = Math.min(
|
|
491
|
+
Math.max(currentIndex + offset, 0),
|
|
492
|
+
visibleOptionValues.length - 1,
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const nextValue: string | undefined = visibleOptionValues[nextIndex];
|
|
496
|
+
|
|
497
|
+
if (!nextValue || nextValue === fromValue) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
focusCard(nextValue);
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
type RenderCardFunction = (option: CardSelectOption) => ReactElement;
|
|
505
|
+
|
|
506
|
+
const renderCard: RenderCardFunction = (
|
|
507
|
+
option: CardSelectOption,
|
|
508
|
+
): ReactElement => {
|
|
509
|
+
const isSelected: boolean = props.value === option.value;
|
|
510
|
+
|
|
511
|
+
return (
|
|
512
|
+
<div
|
|
513
|
+
key={option.value}
|
|
514
|
+
tabIndex={option.value === activeOptionValue ? props.tabIndex || 0 : -1}
|
|
515
|
+
data-card-select-value={option.value}
|
|
516
|
+
onClick={() => {
|
|
517
|
+
props.onChange(option.value);
|
|
518
|
+
}}
|
|
519
|
+
onKeyDown={(e: React.KeyboardEvent) => {
|
|
520
|
+
/*
|
|
521
|
+
* Manual activation: arrows move focus, Enter or Space chooses.
|
|
522
|
+
* Selecting on focus would fire onChange for every card arrowed
|
|
523
|
+
* past, and on this form that resets the criteria below.
|
|
524
|
+
*/
|
|
525
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
526
|
+
e.preventDefault();
|
|
527
|
+
props.onChange(option.value);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
|
532
|
+
e.preventDefault();
|
|
533
|
+
moveFocus(option.value, 1);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
|
538
|
+
e.preventDefault();
|
|
539
|
+
moveFocus(option.value, -1);
|
|
540
|
+
}
|
|
541
|
+
}}
|
|
542
|
+
className={`relative flex cursor-pointer rounded-lg border p-4 shadow-sm transition-all duration-200 hover:border-indigo-400 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 ${
|
|
543
|
+
isSelected
|
|
544
|
+
? "border-indigo-500 bg-indigo-50/50"
|
|
545
|
+
: "border-gray-200 bg-white"
|
|
546
|
+
}`}
|
|
547
|
+
role="radio"
|
|
548
|
+
aria-checked={isSelected}
|
|
549
|
+
data-testid={`card-select-option-${option.value}`}
|
|
550
|
+
>
|
|
551
|
+
<div className="flex w-full items-start">
|
|
552
|
+
<div
|
|
553
|
+
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg ${
|
|
554
|
+
isSelected ? "bg-indigo-100" : "bg-gray-100"
|
|
555
|
+
}`}
|
|
556
|
+
>
|
|
557
|
+
<Icon
|
|
558
|
+
icon={option.icon}
|
|
559
|
+
size={SizeProp.Large}
|
|
560
|
+
className={`h-5 w-5 ${
|
|
561
|
+
isSelected ? "text-indigo-600" : "text-gray-600"
|
|
562
|
+
}`}
|
|
563
|
+
/>
|
|
564
|
+
</div>
|
|
565
|
+
<div className="ml-4 flex-1">
|
|
566
|
+
<span
|
|
567
|
+
className={`block text-sm font-semibold ${
|
|
568
|
+
isSelected ? "text-gray-900" : "text-gray-900"
|
|
569
|
+
}`}
|
|
570
|
+
>
|
|
571
|
+
{option.title}
|
|
572
|
+
</span>
|
|
573
|
+
<span
|
|
574
|
+
className={`mt-1 block text-sm ${
|
|
575
|
+
isSelected ? "text-gray-600" : "text-gray-500"
|
|
576
|
+
}`}
|
|
577
|
+
>
|
|
578
|
+
{option.description}
|
|
579
|
+
</span>
|
|
580
|
+
</div>
|
|
581
|
+
{isSelected && (
|
|
582
|
+
<div className="flex-shrink-0 ml-2">
|
|
583
|
+
<Icon
|
|
584
|
+
icon={IconProp.CheckCircle}
|
|
585
|
+
size={SizeProp.Large}
|
|
586
|
+
className="h-5 w-5 text-indigo-500"
|
|
587
|
+
/>
|
|
588
|
+
</div>
|
|
589
|
+
)}
|
|
590
|
+
</div>
|
|
591
|
+
</div>
|
|
592
|
+
);
|
|
593
|
+
};
|
|
68
594
|
|
|
69
595
|
return (
|
|
70
596
|
<div data-testid={props.dataTestId}>
|
|
597
|
+
{props.searchable && (
|
|
598
|
+
<div className="mb-5">
|
|
599
|
+
<div className="relative flex items-center gap-3 rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm transition-all duration-200 focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
|
|
600
|
+
<Icon
|
|
601
|
+
icon={IconProp.Search}
|
|
602
|
+
className="h-4 w-4 flex-shrink-0 text-gray-400"
|
|
603
|
+
/>
|
|
604
|
+
<input
|
|
605
|
+
ref={searchInputRef}
|
|
606
|
+
type="text"
|
|
607
|
+
value={search}
|
|
608
|
+
placeholder={props.searchPlaceholder || "Search"}
|
|
609
|
+
autoComplete="off"
|
|
610
|
+
aria-label={props.searchPlaceholder || "Search"}
|
|
611
|
+
data-testid="card-select-search"
|
|
612
|
+
className="block w-full border-0 bg-transparent p-0 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-0"
|
|
613
|
+
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
614
|
+
setSearch(event.target.value);
|
|
615
|
+
}}
|
|
616
|
+
onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
617
|
+
if (event.key === "Escape" && search.length > 0) {
|
|
618
|
+
/*
|
|
619
|
+
* Stops the keypress reaching a modal or side over that
|
|
620
|
+
* would take a clear-the-search Escape as close-the-form.
|
|
621
|
+
*/
|
|
622
|
+
event.stopPropagation();
|
|
623
|
+
event.preventDefault();
|
|
624
|
+
setSearch("");
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/*
|
|
629
|
+
* Type a couple of letters, press Enter, done - the whole
|
|
630
|
+
* point of the search box for someone who already knows what
|
|
631
|
+
* they want. Left alone when there is nothing to choose.
|
|
632
|
+
*/
|
|
633
|
+
if (event.key === "Enter" && searchResults[0]) {
|
|
634
|
+
event.preventDefault();
|
|
635
|
+
props.onChange(searchResults[0].option.value);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// The only way into the grid without reaching for the mouse.
|
|
640
|
+
if (event.key === "ArrowDown" && activeOptionValue) {
|
|
641
|
+
event.preventDefault();
|
|
642
|
+
focusCard(activeOptionValue);
|
|
643
|
+
}
|
|
644
|
+
}}
|
|
645
|
+
/>
|
|
646
|
+
{search.length > 0 && (
|
|
647
|
+
<button
|
|
648
|
+
type="button"
|
|
649
|
+
aria-label="Clear search"
|
|
650
|
+
data-testid="card-select-search-clear"
|
|
651
|
+
className="flex flex-shrink-0 items-center text-gray-400 hover:text-gray-600"
|
|
652
|
+
onClick={() => {
|
|
653
|
+
setSearch("");
|
|
654
|
+
searchInputRef.current?.focus();
|
|
655
|
+
}}
|
|
656
|
+
>
|
|
657
|
+
<Icon icon={IconProp.Close} className="h-4 w-4" />
|
|
658
|
+
</button>
|
|
659
|
+
)}
|
|
660
|
+
</div>
|
|
661
|
+
<p
|
|
662
|
+
className="mt-2 text-xs text-gray-500"
|
|
663
|
+
data-testid="card-select-search-summary"
|
|
664
|
+
role="status"
|
|
665
|
+
>
|
|
666
|
+
{isSearching
|
|
667
|
+
? isShowingClosestMatches
|
|
668
|
+
? `Showing ${searchResults.length} closest of ${totalOptionCount}`
|
|
669
|
+
: `Showing ${searchResults.length} of ${totalOptionCount}`
|
|
670
|
+
: `${totalOptionCount} to choose from. Search by name, category, or what you want to watch.`}
|
|
671
|
+
</p>
|
|
672
|
+
</div>
|
|
673
|
+
)}
|
|
674
|
+
|
|
71
675
|
<div
|
|
676
|
+
ref={containerRef}
|
|
72
677
|
role="radiogroup"
|
|
73
678
|
aria-label="Select an option"
|
|
74
679
|
aria-labelledby={props.ariaLabelledby}
|
|
75
680
|
>
|
|
76
|
-
{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
681
|
+
{isSearching && isShowingClosestMatches && (
|
|
682
|
+
<p
|
|
683
|
+
className="mb-4 text-sm text-gray-500"
|
|
684
|
+
data-testid="card-select-closest-matches"
|
|
685
|
+
>
|
|
686
|
+
Nothing matches every word you typed. Closest matches:
|
|
687
|
+
</p>
|
|
688
|
+
)}
|
|
689
|
+
|
|
690
|
+
{isSearching && (
|
|
691
|
+
<div className={gridClassName}>
|
|
692
|
+
{searchResults.map((result: ScoredOption) => {
|
|
693
|
+
return renderCard(result.option);
|
|
694
|
+
})}
|
|
695
|
+
</div>
|
|
696
|
+
)}
|
|
697
|
+
|
|
698
|
+
{isSearching && searchResults.length === 0 && (
|
|
699
|
+
<div
|
|
700
|
+
className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-6 text-center"
|
|
701
|
+
data-testid="card-select-no-results"
|
|
702
|
+
>
|
|
703
|
+
<p className="text-sm font-medium text-gray-900">
|
|
704
|
+
Nothing matches every word you typed.
|
|
705
|
+
</p>
|
|
706
|
+
<p className="mt-1 text-sm text-gray-500">
|
|
707
|
+
Try fewer words, or a protocol or product name.
|
|
708
|
+
</p>
|
|
709
|
+
<button
|
|
710
|
+
type="button"
|
|
711
|
+
className="mt-3 text-sm font-medium text-indigo-600 hover:text-indigo-500"
|
|
712
|
+
onClick={() => {
|
|
713
|
+
setSearch("");
|
|
714
|
+
searchInputRef.current?.focus();
|
|
715
|
+
}}
|
|
716
|
+
>
|
|
717
|
+
Clear search
|
|
718
|
+
</button>
|
|
719
|
+
</div>
|
|
720
|
+
)}
|
|
721
|
+
|
|
722
|
+
{!isSearching &&
|
|
723
|
+
groups.map((group: RenderGroup, groupIndex: number) => {
|
|
724
|
+
const expanded: boolean = isGroupExpanded(group, groupIndex);
|
|
725
|
+
const canCollapse: boolean = Boolean(
|
|
726
|
+
props.collapsibleGroups && group.label,
|
|
727
|
+
);
|
|
104
728
|
|
|
105
|
-
|
|
729
|
+
return (
|
|
730
|
+
<div key={groupIndex} className={groupIndex > 0 ? "mt-8" : ""}>
|
|
731
|
+
{group.label && !canCollapse && (
|
|
732
|
+
<div className="relative mb-4">
|
|
106
733
|
<div
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
props.tabIndex
|
|
110
|
-
? props.tabIndex + currentIndex
|
|
111
|
-
: currentIndex
|
|
112
|
-
}
|
|
113
|
-
onClick={() => {
|
|
114
|
-
props.onChange(option.value);
|
|
115
|
-
}}
|
|
116
|
-
onKeyDown={(e: React.KeyboardEvent) => {
|
|
117
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
118
|
-
e.preventDefault();
|
|
119
|
-
props.onChange(option.value);
|
|
120
|
-
}
|
|
121
|
-
}}
|
|
122
|
-
className={`relative flex cursor-pointer rounded-lg border p-4 shadow-sm focus:outline-none transition-all duration-200 hover:border-indigo-400 hover:shadow-md ${
|
|
123
|
-
isSelected
|
|
124
|
-
? "border-indigo-500 bg-indigo-50/50"
|
|
125
|
-
: "border-gray-200 bg-white"
|
|
126
|
-
}`}
|
|
127
|
-
role="radio"
|
|
128
|
-
aria-checked={isSelected}
|
|
129
|
-
data-testid={`card-select-option-${option.value}`}
|
|
734
|
+
className="absolute inset-0 flex items-center"
|
|
735
|
+
aria-hidden="true"
|
|
130
736
|
>
|
|
131
|
-
<div className="
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
<Icon
|
|
138
|
-
icon={option.icon}
|
|
139
|
-
size={SizeProp.Large}
|
|
140
|
-
className={`h-5 w-5 ${
|
|
141
|
-
isSelected ? "text-indigo-600" : "text-gray-600"
|
|
142
|
-
}`}
|
|
143
|
-
/>
|
|
144
|
-
</div>
|
|
145
|
-
<div className="ml-4 flex-1">
|
|
146
|
-
<span
|
|
147
|
-
className={`block text-sm font-semibold ${
|
|
148
|
-
isSelected ? "text-gray-900" : "text-gray-900"
|
|
149
|
-
}`}
|
|
150
|
-
>
|
|
151
|
-
{option.title}
|
|
152
|
-
</span>
|
|
153
|
-
<span
|
|
154
|
-
className={`mt-1 block text-sm ${
|
|
155
|
-
isSelected ? "text-gray-600" : "text-gray-500"
|
|
156
|
-
}`}
|
|
157
|
-
>
|
|
158
|
-
{option.description}
|
|
159
|
-
</span>
|
|
160
|
-
</div>
|
|
161
|
-
{isSelected && (
|
|
162
|
-
<div className="flex-shrink-0 ml-2">
|
|
163
|
-
<Icon
|
|
164
|
-
icon={IconProp.CheckCircle}
|
|
165
|
-
size={SizeProp.Large}
|
|
166
|
-
className="h-5 w-5 text-indigo-500"
|
|
167
|
-
/>
|
|
168
|
-
</div>
|
|
169
|
-
)}
|
|
170
|
-
</div>
|
|
737
|
+
<div className="w-full border-t border-gray-200"></div>
|
|
738
|
+
</div>
|
|
739
|
+
<div className="relative flex justify-start">
|
|
740
|
+
<span className="bg-white pr-3 text-xs font-semibold uppercase tracking-wider text-gray-400">
|
|
741
|
+
{group.label}
|
|
742
|
+
</span>
|
|
171
743
|
</div>
|
|
172
|
-
|
|
173
|
-
|
|
744
|
+
</div>
|
|
745
|
+
)}
|
|
746
|
+
|
|
747
|
+
{group.label && canCollapse && (
|
|
748
|
+
<button
|
|
749
|
+
type="button"
|
|
750
|
+
aria-expanded={expanded}
|
|
751
|
+
data-testid={`card-select-group-${group.label}`}
|
|
752
|
+
className="mb-4 flex w-full items-center gap-2 border-b border-gray-200 pb-2 text-left"
|
|
753
|
+
onClick={() => {
|
|
754
|
+
setToggledGroups((previous: Record<string, boolean>) => {
|
|
755
|
+
return {
|
|
756
|
+
...previous,
|
|
757
|
+
[group.label as string]: !expanded,
|
|
758
|
+
};
|
|
759
|
+
});
|
|
760
|
+
}}
|
|
761
|
+
>
|
|
762
|
+
<Icon
|
|
763
|
+
icon={
|
|
764
|
+
expanded ? IconProp.ChevronDown : IconProp.ChevronRight
|
|
765
|
+
}
|
|
766
|
+
className="h-4 w-4 flex-shrink-0 text-gray-400"
|
|
767
|
+
/>
|
|
768
|
+
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
|
769
|
+
{group.label}
|
|
770
|
+
</span>
|
|
771
|
+
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">
|
|
772
|
+
{group.options.length}
|
|
773
|
+
</span>
|
|
774
|
+
</button>
|
|
775
|
+
)}
|
|
776
|
+
|
|
777
|
+
{expanded && (
|
|
778
|
+
<div className={gridClassName}>
|
|
779
|
+
{group.options.map((option: CardSelectOption) => {
|
|
780
|
+
return renderCard(option);
|
|
781
|
+
})}
|
|
782
|
+
</div>
|
|
783
|
+
)}
|
|
174
784
|
</div>
|
|
175
|
-
|
|
176
|
-
)
|
|
177
|
-
})}
|
|
785
|
+
);
|
|
786
|
+
})}
|
|
178
787
|
</div>
|
|
179
788
|
{props.error && (
|
|
180
789
|
<p className="mt-2 text-sm text-red-600" role="alert">
|