@oneuptime/common 14.0.0 → 14.0.1
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/Index.ts +4 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveFeed.ts +2 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.ts +522 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.ts +38 -4
- package/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.ts +603 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.ts +248 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.ts +19 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Services/DatabaseService.ts +10 -0
- package/Server/Services/Index.ts +4 -0
- package/Server/Services/MonitorService.ts +3 -2
- package/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.ts +385 -0
- package/Server/Services/ServiceLevelObjectiveLabelRuleService.ts +51 -0
- package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +92 -16
- package/Server/Services/ServiceLevelObjectiveMonitorRuleService.ts +36 -1
- package/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.ts +521 -0
- package/Server/Services/ServiceLevelObjectiveOwnerRuleService.ts +51 -0
- package/Server/Services/ServiceLevelObjectiveService.ts +28 -0
- package/Server/Types/Workflow/Components/Telegram/SendMessageToChat.ts +27 -1
- package/Server/Utils/AI/Toolbox/SloTools.ts +1 -0
- package/Server/Utils/Monitor/AffectedResourceList.ts +31 -73
- package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +1 -1
- package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +1 -1
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +181 -136
- package/Server/Utils/Monitor/RootCauseList.ts +139 -0
- package/Server/Utils/Rules/RuleRun/RuleRunRegistry.ts +28 -0
- package/Server/Utils/Slo/SloRulePatternValidator.ts +42 -0
- package/Tests/App/Dashboard/FeedFilterSortIntegration.test.tsx +1632 -0
- package/Tests/App/Dashboard/FeedOptionsLocales.test.ts +250 -0
- package/Tests/App/Dashboard/FeedOrdering.test.ts +279 -9
- package/Tests/App/Dashboard/InvestigationFeedRefresh.test.tsx +60 -1
- package/Tests/App/Dashboard/InvestigationPanel.test.tsx +594 -3
- package/Tests/App/Dashboard/ProductOrientation.test.tsx +646 -0
- package/Tests/App/Dashboard/ResourceFeed.test.tsx +612 -2
- package/Tests/App/Dashboard/ResourceFeedPageWiring.test.ts +30 -0
- package/Tests/App/Dashboard/ResourceFeedPagesFilterSort.test.tsx +1202 -0
- package/Tests/App/Dashboard/ScheduledMaintenanceFeedRefresh.test.tsx +52 -1
- package/Tests/App/Dashboard/SloFeed.test.tsx +106 -1
- package/Tests/App/Dashboard/SloMonitorRuleCriteria.test.tsx +566 -0
- package/Tests/App/Dashboard/SloNavigationWiring.test.tsx +6 -4
- package/Tests/App/Dashboard/SloNoticeBanner.test.tsx +186 -0
- package/Tests/App/Dashboard/SloOverviewKpiStrip.test.tsx +388 -44
- package/Tests/App/Dashboard/SloOverviewLayout.test.tsx +613 -0
- package/Tests/Models/DatabaseModels/ResourceFeedModels.test.ts +0 -8
- package/Tests/Models/DatabaseModels/SloLabelAndOwnerRuleModels.test.ts +404 -0
- package/Tests/Models/DatabaseModels/SloOverhaulModels.test.ts +17 -1
- package/Tests/Models/DatabaseModels/StaticRuleCriteriaInheritance.test.ts +4 -2
- package/Tests/Server/Infrastructure/Postgres/AddConfigurableRuleCriteriaMigration.test.ts +3 -0
- package/Tests/Server/Infrastructure/Postgres/AddSloLabelAndOwnerRulesMigration.test.ts +407 -0
- package/Tests/Server/Infrastructure/Postgres/AddSloMonitorRuleMonitorTypeMigration.test.ts +171 -0
- package/Tests/Server/Infrastructure/Postgres/SloProductOverhaulMigration.test.ts +88 -4
- package/Tests/Server/Services/DatabaseServiceRuleCriteriaValidation.test.ts +104 -0
- package/Tests/Server/Services/RuleRun/LabelRuleEnginesGroupBRun.test.ts +48 -1
- package/Tests/Server/Services/RuleRun/OwnerRuleEnginesGroupBRun.test.ts +30 -0
- package/Tests/Server/Services/ServiceLevelObjectiveLabelAndOwnerRuleEngines.test.ts +1170 -0
- package/Tests/Server/Services/ServiceLevelObjectiveLabelAndOwnerRuleServices.test.ts +172 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +323 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleService.test.ts +115 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleTriggers.test.ts +21 -2
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorTypePostgres.test.ts +480 -0
- package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +162 -0
- package/Tests/Server/Services/StaticRuleCriteriaRuntimeCoverage.test.ts +45 -6
- package/Tests/Server/Types/Database/FeedEventTypeFilterQuery.test.ts +745 -0
- package/Tests/Server/Types/Markdown.test.ts +2 -1
- package/Tests/Server/Types/Workflow/Components/ApiComponentSsrf.test.ts +49 -0
- package/Tests/Server/Types/Workflow/Components/TelegramSendMessageToChat.test.ts +331 -0
- package/Tests/Server/Utils/AI/SloTools.test.ts +10 -0
- package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +1 -1
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +405 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorBreachingSamples.test.ts +1101 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorMetricUnits.test.ts +106 -80
- package/Tests/Server/Utils/Monitor/RootCauseList.test.ts +252 -0
- package/Tests/Server/Utils/Rules/RuleRun/RuleRunPermission.test.ts +154 -0
- package/Tests/Server/Utils/SSRFProtection.test.ts +111 -0
- package/Tests/Server/Utils/Slo/SloRulePatternValidator.test.ts +67 -0
- package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +213 -0
- package/Tests/Types/API/Route.test.ts +56 -0
- package/Tests/Types/API/URLSchemeLikePath.test.ts +304 -0
- package/Tests/UI/Components/FeedOptions.test.ts +1404 -0
- package/Tests/UI/Components/FeedOptionsButton.test.tsx +2603 -0
- package/Tests/UI/Components/Icon.test.tsx +26 -0
- package/Tests/UI/Components/RuleCriteria/RuleCriteriaModelTable.test.tsx +3 -3
- package/Tests/UI/Components/useFeedItemsViewKey.test.tsx +1017 -0
- package/Tests/UI/Components/useFeedOptions.test.tsx +1107 -0
- package/Tests/Utils/LabelRuleImportExport.test.ts +102 -0
- package/Tests/Utils/Slo/SloDuration.test.ts +115 -24
- package/Tests/Utils/Slo/SloHealth.test.ts +93 -10
- package/Tests/Utils/Slo/SloMonitorRuleCriteria.test.ts +109 -0
- package/Tests/Utils/Slo/SloOverviewText.test.ts +64 -0
- package/Types/API/Route.ts +19 -2
- package/Types/API/URL.ts +26 -2
- package/Types/Monitor/MetricMonitor/MetricCriteriaContext.ts +6 -5
- package/Types/Monitor/MetricMonitor/MetricMonitorResponse.ts +9 -0
- package/Types/Permission.ts +90 -0
- package/Types/Rules/RuleCriteriaFieldRegistry.ts +11 -0
- package/Types/Rules/RuleRun.ts +4 -0
- package/UI/Components/Feed/FeedOptions.ts +427 -0
- package/UI/Components/Feed/FeedOptionsButton.tsx +692 -0
- package/UI/Components/Feed/useFeedItems.ts +28 -9
- package/UI/Components/Feed/useFeedOptions.ts +225 -0
- package/UI/Components/Icon/Icon.tsx +1 -1
- package/Utils/LabelRuleImportExport.ts +27 -9
- package/Utils/Monitor/MetricValueFormatter.ts +2 -2
- package/Utils/Rules/MonitorTypeRuleCriteria.ts +31 -0
- package/Utils/Slo/SloDuration.ts +22 -6
- package/Utils/Slo/SloHealth.ts +8 -7
- package/Utils/Slo/SloMonitorRuleCriteria.ts +33 -1
- package/Utils/Slo/SloOverviewText.ts +33 -0
- package/Utils/Slo/SloProjection.ts +4 -3
- package/build/dist/Models/DatabaseModels/Index.js +4 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.js +530 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.js +39 -4
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.js +612 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.js +93 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.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/Services/DatabaseService.js +7 -0
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +4 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +3 -2
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.js +295 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleService.js +56 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +59 -14
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleService.js +22 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.js +400 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleService.js +56 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js +21 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Telegram/SendMessageToChat.js +20 -1
- package/build/dist/Server/Types/Workflow/Components/Telegram/SendMessageToChat.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/AffectedResourceList.js +22 -51
- package/build/dist/Server/Utils/Monitor/AffectedResourceList.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +139 -109
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/RootCauseList.js +101 -0
- package/build/dist/Server/Utils/Monitor/RootCauseList.js.map +1 -0
- package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js +28 -0
- package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js.map +1 -1
- package/build/dist/Server/Utils/Slo/SloRulePatternValidator.js +28 -0
- package/build/dist/Server/Utils/Slo/SloRulePatternValidator.js.map +1 -0
- package/build/dist/Types/API/Route.js +18 -2
- package/build/dist/Types/API/Route.js.map +1 -1
- package/build/dist/Types/API/URL.js +23 -2
- package/build/dist/Types/API/URL.js.map +1 -1
- package/build/dist/Types/Permission.js +84 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js +11 -0
- package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js.map +1 -1
- package/build/dist/Types/Rules/RuleRun.js +4 -0
- package/build/dist/Types/Rules/RuleRun.js.map +1 -1
- package/build/dist/UI/Components/Feed/FeedOptions.js +235 -0
- package/build/dist/UI/Components/Feed/FeedOptions.js.map +1 -0
- package/build/dist/UI/Components/Feed/FeedOptionsButton.js +346 -0
- package/build/dist/UI/Components/Feed/FeedOptionsButton.js.map +1 -0
- package/build/dist/UI/Components/Feed/useFeedItems.js +17 -7
- package/build/dist/UI/Components/Feed/useFeedItems.js.map +1 -1
- package/build/dist/UI/Components/Feed/useFeedOptions.js +130 -0
- package/build/dist/UI/Components/Feed/useFeedOptions.js.map +1 -0
- package/build/dist/UI/Components/Icon/Icon.js +1 -1
- package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
- package/build/dist/Utils/LabelRuleImportExport.js +22 -6
- package/build/dist/Utils/LabelRuleImportExport.js.map +1 -1
- package/build/dist/Utils/Monitor/MetricValueFormatter.js +2 -2
- package/build/dist/Utils/Rules/MonitorTypeRuleCriteria.js +18 -0
- package/build/dist/Utils/Rules/MonitorTypeRuleCriteria.js.map +1 -0
- package/build/dist/Utils/Slo/SloDuration.js +13 -5
- package/build/dist/Utils/Slo/SloDuration.js.map +1 -1
- package/build/dist/Utils/Slo/SloHealth.js +8 -7
- package/build/dist/Utils/Slo/SloHealth.js.map +1 -1
- package/build/dist/Utils/Slo/SloMonitorRuleCriteria.js +16 -1
- package/build/dist/Utils/Slo/SloMonitorRuleCriteria.js.map +1 -1
- package/build/dist/Utils/Slo/SloOverviewText.js +16 -0
- package/build/dist/Utils/Slo/SloOverviewText.js.map +1 -1
- package/build/dist/Utils/Slo/SloProjection.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
FunctionComponent,
|
|
3
|
+
ReactElement,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from "react";
|
|
12
|
+
import SortOrder from "../../../Types/BaseDatabase/SortOrder";
|
|
13
|
+
import IconProp from "../../../Types/Icon/IconProp";
|
|
14
|
+
import useTranslateValue from "../../Utils/Translation";
|
|
15
|
+
import Icon from "../Icon/Icon";
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_FEED_SORT_ORDER,
|
|
18
|
+
FEED_OPTIONS_TEXT,
|
|
19
|
+
FEED_SORT_ORDER_OPTIONS,
|
|
20
|
+
FeedEventTypeOption,
|
|
21
|
+
FeedOptions,
|
|
22
|
+
FeedSortOrderOption,
|
|
23
|
+
getFeedOptionsSummary,
|
|
24
|
+
isDefaultFeedOptions,
|
|
25
|
+
translateFeedOptionsText,
|
|
26
|
+
} from "./FeedOptions";
|
|
27
|
+
|
|
28
|
+
export interface ComponentProps {
|
|
29
|
+
value: FeedOptions;
|
|
30
|
+
onChange: (options: FeedOptions) => void;
|
|
31
|
+
eventTypeOptions: Array<FeedEventTypeOption>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A short checklist is scanned by eye; a longer one gets a search box.
|
|
35
|
+
export const FEED_EVENT_TYPE_SEARCH_THRESHOLD: number = 8;
|
|
36
|
+
|
|
37
|
+
export const FEED_OPTIONS_PANEL_WIDTH_PX: number = 288;
|
|
38
|
+
|
|
39
|
+
// The panel never comes closer than this to either edge of the viewport.
|
|
40
|
+
export const FEED_OPTIONS_VIEWPORT_GUTTER_PX: number = 16;
|
|
41
|
+
|
|
42
|
+
export interface FeedOptionsPanelPlacement {
|
|
43
|
+
// Offset from the trigger's left edge, in px.
|
|
44
|
+
left: number;
|
|
45
|
+
width: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type GetFeedOptionsPanelPlacement = (data: {
|
|
49
|
+
triggerLeft: number;
|
|
50
|
+
triggerRight: number;
|
|
51
|
+
viewportWidth: number;
|
|
52
|
+
}) => FeedOptionsPanelPlacement;
|
|
53
|
+
|
|
54
|
+
/*
|
|
55
|
+
* Where the panel goes. It lines its right edge up with the trigger's, like
|
|
56
|
+
* every other header menu, and slides back inside the viewport when that
|
|
57
|
+
* would cross an edge. That happens on phones and tablets: the card header
|
|
58
|
+
* stacks there and puts this button, the first of the header's controls, at
|
|
59
|
+
* the left of the row, so a right-aligned panel would open off the screen.
|
|
60
|
+
*/
|
|
61
|
+
export const getFeedOptionsPanelPlacement: GetFeedOptionsPanelPlacement =
|
|
62
|
+
(data: {
|
|
63
|
+
triggerLeft: number;
|
|
64
|
+
triggerRight: number;
|
|
65
|
+
viewportWidth: number;
|
|
66
|
+
}): FeedOptionsPanelPlacement => {
|
|
67
|
+
const width: number = Math.max(
|
|
68
|
+
Math.min(
|
|
69
|
+
FEED_OPTIONS_PANEL_WIDTH_PX,
|
|
70
|
+
data.viewportWidth - 2 * FEED_OPTIONS_VIEWPORT_GUTTER_PX,
|
|
71
|
+
),
|
|
72
|
+
0,
|
|
73
|
+
);
|
|
74
|
+
const lowestLeft: number = FEED_OPTIONS_VIEWPORT_GUTTER_PX;
|
|
75
|
+
const highestLeft: number = Math.max(
|
|
76
|
+
data.viewportWidth - FEED_OPTIONS_VIEWPORT_GUTTER_PX - width,
|
|
77
|
+
lowestLeft,
|
|
78
|
+
);
|
|
79
|
+
const left: number = Math.min(
|
|
80
|
+
Math.max(data.triggerRight - width, lowestLeft),
|
|
81
|
+
highestLeft,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
left: left - data.triggerLeft,
|
|
86
|
+
width,
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/*
|
|
91
|
+
* The one control in a feed's header for everything about what the feed
|
|
92
|
+
* shows: the time order and the event type filter. It opens a small panel
|
|
93
|
+
* rather than a menu, because the reader ticks several boxes in one visit and
|
|
94
|
+
* a menu closes on every pick. Changes apply as they are made - there is no
|
|
95
|
+
* Apply button to forget - and the trigger is tinted, with a count of the
|
|
96
|
+
* chosen event types, whenever the feed is not showing its default view.
|
|
97
|
+
*/
|
|
98
|
+
const FeedOptionsButton: FunctionComponent<ComponentProps> = (
|
|
99
|
+
props: ComponentProps,
|
|
100
|
+
): ReactElement => {
|
|
101
|
+
const { translateString } = useTranslateValue();
|
|
102
|
+
const uniqueId: string = useId();
|
|
103
|
+
const buttonId: string = `feed-options-button-${uniqueId}`;
|
|
104
|
+
const summaryId: string = `feed-options-summary-${uniqueId}`;
|
|
105
|
+
const panelId: string = `feed-options-panel-${uniqueId}`;
|
|
106
|
+
const sortHeadingId: string = `feed-options-sort-${uniqueId}`;
|
|
107
|
+
const eventTypesHeadingId: string = `feed-options-event-types-${uniqueId}`;
|
|
108
|
+
|
|
109
|
+
const [isOpen, setIsOpen] = useState<boolean>(false);
|
|
110
|
+
const [searchText, setSearchText] = useState<string>("");
|
|
111
|
+
const [placement, setPlacement] = useState<FeedOptionsPanelPlacement | null>(
|
|
112
|
+
null,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const containerRef: React.MutableRefObject<HTMLDivElement | null> =
|
|
116
|
+
useRef<HTMLDivElement | null>(null);
|
|
117
|
+
const triggerRef: React.MutableRefObject<HTMLButtonElement | null> =
|
|
118
|
+
useRef<HTMLButtonElement | null>(null);
|
|
119
|
+
const sortRadioRefs: React.MutableRefObject<Array<HTMLButtonElement | null>> =
|
|
120
|
+
useRef<Array<HTMLButtonElement | null>>([]);
|
|
121
|
+
const eventTypeListRef: React.MutableRefObject<HTMLUListElement | null> =
|
|
122
|
+
useRef<HTMLUListElement | null>(null);
|
|
123
|
+
|
|
124
|
+
const selectedEventTypes: Set<string> = useMemo(() => {
|
|
125
|
+
return new Set<string>(props.value.eventTypes);
|
|
126
|
+
}, [props.value.eventTypes]);
|
|
127
|
+
|
|
128
|
+
const selectedCount: number = props.value.eventTypes.length;
|
|
129
|
+
const isCustomized: boolean = !isDefaultFeedOptions(props.value);
|
|
130
|
+
const isOldestFirst: boolean =
|
|
131
|
+
props.value.sortOrder !== DEFAULT_FEED_SORT_ORDER;
|
|
132
|
+
const translate: (
|
|
133
|
+
text: string,
|
|
134
|
+
values?: Record<string, string | number>,
|
|
135
|
+
) => string = (
|
|
136
|
+
text: string,
|
|
137
|
+
values?: Record<string, string | number>,
|
|
138
|
+
): string => {
|
|
139
|
+
return translateFeedOptionsText({
|
|
140
|
+
text,
|
|
141
|
+
values,
|
|
142
|
+
translate: translateString,
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const summary: string = getFeedOptionsSummary({
|
|
147
|
+
options: props.value,
|
|
148
|
+
eventTypeCount: props.eventTypeOptions.length,
|
|
149
|
+
translate: translateString,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const showSearch: boolean =
|
|
153
|
+
props.eventTypeOptions.length > FEED_EVENT_TYPE_SEARCH_THRESHOLD;
|
|
154
|
+
|
|
155
|
+
/*
|
|
156
|
+
* Labels are shown - and searched and ordered - in the reader's language.
|
|
157
|
+
* A label with no translation yet stays in English.
|
|
158
|
+
*/
|
|
159
|
+
const translatedEventTypeOptions: Array<FeedEventTypeOption> =
|
|
160
|
+
props.eventTypeOptions
|
|
161
|
+
.map((option: FeedEventTypeOption): FeedEventTypeOption => {
|
|
162
|
+
return {
|
|
163
|
+
...option,
|
|
164
|
+
label: translate(option.label),
|
|
165
|
+
};
|
|
166
|
+
})
|
|
167
|
+
.sort((a: FeedEventTypeOption, b: FeedEventTypeOption): number => {
|
|
168
|
+
return a.label.localeCompare(b.label);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const searchTerm: string = searchText.trim().toLowerCase();
|
|
172
|
+
const visibleEventTypeOptions: Array<FeedEventTypeOption> = searchTerm
|
|
173
|
+
? translatedEventTypeOptions.filter((option: FeedEventTypeOption) => {
|
|
174
|
+
return option.label.toLowerCase().includes(searchTerm);
|
|
175
|
+
})
|
|
176
|
+
: translatedEventTypeOptions;
|
|
177
|
+
|
|
178
|
+
const close: (shouldReturnFocus: boolean) => void = (
|
|
179
|
+
shouldReturnFocus: boolean,
|
|
180
|
+
): void => {
|
|
181
|
+
setIsOpen(false);
|
|
182
|
+
setSearchText("");
|
|
183
|
+
|
|
184
|
+
if (shouldReturnFocus) {
|
|
185
|
+
triggerRef.current?.focus();
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/*
|
|
190
|
+
* Dismiss on a press outside the trigger and panel. The listener sits on
|
|
191
|
+
* the container that holds both, so pressing the trigger to close the panel
|
|
192
|
+
* is a toggle, not a dismissal followed by a re-open.
|
|
193
|
+
*/
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (!isOpen) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const onPointerDown: (event: MouseEvent) => void = (
|
|
200
|
+
event: MouseEvent,
|
|
201
|
+
): void => {
|
|
202
|
+
const target: EventTarget | null = event.target;
|
|
203
|
+
|
|
204
|
+
if (
|
|
205
|
+
containerRef.current &&
|
|
206
|
+
target instanceof Node &&
|
|
207
|
+
!containerRef.current.contains(target)
|
|
208
|
+
) {
|
|
209
|
+
setIsOpen(false);
|
|
210
|
+
setSearchText("");
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
document.addEventListener("mousedown", onPointerDown);
|
|
215
|
+
|
|
216
|
+
return () => {
|
|
217
|
+
document.removeEventListener("mousedown", onPointerDown);
|
|
218
|
+
};
|
|
219
|
+
}, [isOpen]);
|
|
220
|
+
|
|
221
|
+
const placePanel: () => void = useCallback((): void => {
|
|
222
|
+
const container: HTMLDivElement | null = containerRef.current;
|
|
223
|
+
|
|
224
|
+
if (!container) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const rect: DOMRect = container.getBoundingClientRect();
|
|
229
|
+
|
|
230
|
+
setPlacement(
|
|
231
|
+
getFeedOptionsPanelPlacement({
|
|
232
|
+
triggerLeft: rect.left,
|
|
233
|
+
triggerRight: rect.right,
|
|
234
|
+
viewportWidth:
|
|
235
|
+
document.documentElement.clientWidth || window.innerWidth,
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
}, []);
|
|
239
|
+
|
|
240
|
+
// Placed before paint, so the panel never flashes off-screen first.
|
|
241
|
+
useLayoutEffect(() => {
|
|
242
|
+
if (!isOpen) {
|
|
243
|
+
setPlacement(null);
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
placePanel();
|
|
248
|
+
window.addEventListener("resize", placePanel);
|
|
249
|
+
|
|
250
|
+
return () => {
|
|
251
|
+
window.removeEventListener("resize", placePanel);
|
|
252
|
+
};
|
|
253
|
+
}, [isOpen, placePanel]);
|
|
254
|
+
|
|
255
|
+
// Opening moves focus onto the chosen sort order, the panel's first control.
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
if (!isOpen) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const selectedIndex: number = FEED_SORT_ORDER_OPTIONS.findIndex(
|
|
262
|
+
(option: FeedSortOrderOption) => {
|
|
263
|
+
return option.value === props.value.sortOrder;
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
sortRadioRefs.current[Math.max(selectedIndex, 0)]?.focus();
|
|
268
|
+
// Only on open: re-focusing on every sort change would fight the keyboard.
|
|
269
|
+
}, [isOpen]);
|
|
270
|
+
|
|
271
|
+
const setSortOrder: (sortOrder: SortOrder) => void = (
|
|
272
|
+
sortOrder: SortOrder,
|
|
273
|
+
): void => {
|
|
274
|
+
if (sortOrder === props.value.sortOrder) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
props.onChange({
|
|
279
|
+
...props.value,
|
|
280
|
+
sortOrder,
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const toggleEventType: (eventType: string) => void = (
|
|
285
|
+
eventType: string,
|
|
286
|
+
): void => {
|
|
287
|
+
const nextSelected: Set<string> = new Set<string>(selectedEventTypes);
|
|
288
|
+
|
|
289
|
+
if (nextSelected.has(eventType)) {
|
|
290
|
+
nextSelected.delete(eventType);
|
|
291
|
+
} else {
|
|
292
|
+
nextSelected.add(eventType);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Keep the checklist's order, not the order the boxes were ticked in.
|
|
296
|
+
props.onChange({
|
|
297
|
+
...props.value,
|
|
298
|
+
eventTypes: props.eventTypeOptions
|
|
299
|
+
.map((option: FeedEventTypeOption) => {
|
|
300
|
+
return option.value;
|
|
301
|
+
})
|
|
302
|
+
.filter((value: string) => {
|
|
303
|
+
return nextSelected.has(value);
|
|
304
|
+
}),
|
|
305
|
+
});
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/*
|
|
309
|
+
* "Show all" and "Reset to default" hide themselves once they have done
|
|
310
|
+
* their job. Focus on a button that unmounts falls to the page body, out of
|
|
311
|
+
* the panel, where Escape no longer closes it - so both hand focus to the
|
|
312
|
+
* control the reader is most likely to reach for next.
|
|
313
|
+
*/
|
|
314
|
+
const focusSortOrder: (sortOrder: SortOrder) => void = (
|
|
315
|
+
sortOrder: SortOrder,
|
|
316
|
+
): void => {
|
|
317
|
+
const index: number = FEED_SORT_ORDER_OPTIONS.findIndex(
|
|
318
|
+
(option: FeedSortOrderOption) => {
|
|
319
|
+
return option.value === sortOrder;
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
sortRadioRefs.current[Math.max(index, 0)]?.focus();
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const clearEventTypes: () => void = (): void => {
|
|
327
|
+
props.onChange({
|
|
328
|
+
...props.value,
|
|
329
|
+
eventTypes: [],
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const firstCheckbox: HTMLInputElement | null =
|
|
333
|
+
eventTypeListRef.current?.querySelector<HTMLInputElement>(
|
|
334
|
+
'input[type="checkbox"]',
|
|
335
|
+
) || null;
|
|
336
|
+
|
|
337
|
+
if (firstCheckbox) {
|
|
338
|
+
firstCheckbox.focus();
|
|
339
|
+
} else {
|
|
340
|
+
focusSortOrder(props.value.sortOrder);
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const resetOptions: () => void = (): void => {
|
|
345
|
+
props.onChange({
|
|
346
|
+
sortOrder: DEFAULT_FEED_SORT_ORDER,
|
|
347
|
+
eventTypes: [],
|
|
348
|
+
});
|
|
349
|
+
focusSortOrder(DEFAULT_FEED_SORT_ORDER);
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const onContainerKeyDown: (event: React.KeyboardEvent) => void = (
|
|
353
|
+
event: React.KeyboardEvent,
|
|
354
|
+
): void => {
|
|
355
|
+
if (isOpen && event.key === "Escape") {
|
|
356
|
+
event.preventDefault();
|
|
357
|
+
event.stopPropagation();
|
|
358
|
+
close(true);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/*
|
|
363
|
+
* Tabbing out of the panel closes it. Only focus that lands on something
|
|
364
|
+
* outside counts: a press on the panel's own padding moves focus to the
|
|
365
|
+
* panel itself (it is focusable for exactly this reason), which keeps
|
|
366
|
+
* Escape working.
|
|
367
|
+
*/
|
|
368
|
+
const onContainerBlur: (event: React.FocusEvent) => void = (
|
|
369
|
+
event: React.FocusEvent,
|
|
370
|
+
): void => {
|
|
371
|
+
const nextFocus: EventTarget | null = event.relatedTarget;
|
|
372
|
+
|
|
373
|
+
if (
|
|
374
|
+
isOpen &&
|
|
375
|
+
nextFocus instanceof Node &&
|
|
376
|
+
containerRef.current &&
|
|
377
|
+
!containerRef.current.contains(nextFocus)
|
|
378
|
+
) {
|
|
379
|
+
setIsOpen(false);
|
|
380
|
+
setSearchText("");
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// Arrow keys move between the two sort orders, as in any radio group.
|
|
385
|
+
const onSortKeyDown: (event: React.KeyboardEvent, index: number) => void = (
|
|
386
|
+
event: React.KeyboardEvent,
|
|
387
|
+
index: number,
|
|
388
|
+
): void => {
|
|
389
|
+
let nextIndex: number | null = null;
|
|
390
|
+
|
|
391
|
+
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
|
392
|
+
nextIndex = (index + 1) % FEED_SORT_ORDER_OPTIONS.length;
|
|
393
|
+
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
|
394
|
+
nextIndex =
|
|
395
|
+
(index - 1 + FEED_SORT_ORDER_OPTIONS.length) %
|
|
396
|
+
FEED_SORT_ORDER_OPTIONS.length;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (nextIndex === null) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
event.preventDefault();
|
|
404
|
+
const nextOption: FeedSortOrderOption | undefined =
|
|
405
|
+
FEED_SORT_ORDER_OPTIONS[nextIndex];
|
|
406
|
+
|
|
407
|
+
if (nextOption) {
|
|
408
|
+
setSortOrder(nextOption.value);
|
|
409
|
+
sortRadioRefs.current[nextIndex]?.focus();
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/*
|
|
414
|
+
* The visible label is the button's name, and the state behind it - order
|
|
415
|
+
* and filter - its description, so a screen reader says each once. The
|
|
416
|
+
* label is only drawn from xl up: beside the feed's other header controls
|
|
417
|
+
* it would squeeze the card title into a sliver on tablets. Oldest first
|
|
418
|
+
* swaps the funnel for the sort glyph, so a reversed feed does not rely on
|
|
419
|
+
* the tint alone to say so.
|
|
420
|
+
*/
|
|
421
|
+
const getTrigger: () => ReactElement = (): ReactElement => {
|
|
422
|
+
return (
|
|
423
|
+
<button
|
|
424
|
+
ref={triggerRef}
|
|
425
|
+
id={buttonId}
|
|
426
|
+
type="button"
|
|
427
|
+
data-testid="feed-options-button"
|
|
428
|
+
className={`inline-flex min-h-[2.375rem] items-center gap-2 rounded-lg border px-3.5 py-2 text-sm font-medium shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-all duration-150 cursor-pointer select-none ${
|
|
429
|
+
isCustomized
|
|
430
|
+
? "border-indigo-300 bg-indigo-50 text-indigo-700 hover:bg-indigo-100 hover:border-indigo-400"
|
|
431
|
+
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50 hover:border-gray-400 hover:text-gray-900"
|
|
432
|
+
}`}
|
|
433
|
+
title={summary}
|
|
434
|
+
aria-describedby={summaryId}
|
|
435
|
+
aria-haspopup="dialog"
|
|
436
|
+
aria-expanded={isOpen}
|
|
437
|
+
aria-controls={isOpen ? panelId : undefined}
|
|
438
|
+
onClick={() => {
|
|
439
|
+
if (isOpen) {
|
|
440
|
+
close(false);
|
|
441
|
+
} else {
|
|
442
|
+
setIsOpen(true);
|
|
443
|
+
}
|
|
444
|
+
}}
|
|
445
|
+
>
|
|
446
|
+
<Icon
|
|
447
|
+
icon={isOldestFirst ? IconProp.BarsArrowUp : IconProp.Filter}
|
|
448
|
+
className={`h-4 w-4 ${isCustomized ? "text-indigo-500" : "text-gray-500"}`}
|
|
449
|
+
/>
|
|
450
|
+
<span
|
|
451
|
+
className="sr-only xl:not-sr-only"
|
|
452
|
+
data-testid="feed-options-label"
|
|
453
|
+
>
|
|
454
|
+
{translate(FEED_OPTIONS_TEXT.triggerLabel)}
|
|
455
|
+
</span>
|
|
456
|
+
{selectedCount > 0 && (
|
|
457
|
+
<span
|
|
458
|
+
data-testid="feed-options-count"
|
|
459
|
+
className="inline-flex min-w-[1.25rem] justify-center rounded-full bg-indigo-600 px-1.5 text-xs font-semibold leading-5 text-white"
|
|
460
|
+
aria-hidden="true"
|
|
461
|
+
>
|
|
462
|
+
{selectedCount}
|
|
463
|
+
</span>
|
|
464
|
+
)}
|
|
465
|
+
<Icon
|
|
466
|
+
icon={IconProp.ChevronDown}
|
|
467
|
+
className={`h-3.5 w-3.5 ml-0.5 ${isCustomized ? "text-indigo-400" : "text-gray-400"}`}
|
|
468
|
+
/>
|
|
469
|
+
</button>
|
|
470
|
+
);
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const getSortSection: () => ReactElement = (): ReactElement => {
|
|
474
|
+
return (
|
|
475
|
+
<div className="border-b border-gray-100 p-3">
|
|
476
|
+
<div
|
|
477
|
+
id={sortHeadingId}
|
|
478
|
+
className="text-xs font-semibold uppercase tracking-wide text-gray-500"
|
|
479
|
+
>
|
|
480
|
+
{translate(FEED_OPTIONS_TEXT.sortHeading)}
|
|
481
|
+
</div>
|
|
482
|
+
<div
|
|
483
|
+
role="radiogroup"
|
|
484
|
+
aria-labelledby={sortHeadingId}
|
|
485
|
+
className="mt-2 grid grid-cols-2 gap-1 rounded-lg bg-gray-100 p-1"
|
|
486
|
+
>
|
|
487
|
+
{FEED_SORT_ORDER_OPTIONS.map(
|
|
488
|
+
(option: FeedSortOrderOption, index: number) => {
|
|
489
|
+
const isSelected: boolean =
|
|
490
|
+
option.value === props.value.sortOrder;
|
|
491
|
+
|
|
492
|
+
return (
|
|
493
|
+
<button
|
|
494
|
+
key={option.value}
|
|
495
|
+
ref={(element: HTMLButtonElement | null) => {
|
|
496
|
+
sortRadioRefs.current[index] = element;
|
|
497
|
+
}}
|
|
498
|
+
type="button"
|
|
499
|
+
role="radio"
|
|
500
|
+
aria-checked={isSelected}
|
|
501
|
+
tabIndex={isSelected ? 0 : -1}
|
|
502
|
+
data-testid={`feed-options-sort-${option.value}`}
|
|
503
|
+
className={`inline-flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
|
|
504
|
+
isSelected
|
|
505
|
+
? "bg-white font-semibold text-gray-900 shadow-sm ring-1 ring-indigo-400"
|
|
506
|
+
: "font-normal text-gray-600 hover:text-gray-900"
|
|
507
|
+
}`}
|
|
508
|
+
onClick={() => {
|
|
509
|
+
setSortOrder(option.value);
|
|
510
|
+
}}
|
|
511
|
+
onKeyDown={(event: React.KeyboardEvent) => {
|
|
512
|
+
onSortKeyDown(event, index);
|
|
513
|
+
}}
|
|
514
|
+
>
|
|
515
|
+
<Icon
|
|
516
|
+
icon={option.icon}
|
|
517
|
+
className={`h-4 w-4 ${isSelected ? "text-indigo-500" : "text-gray-400"}`}
|
|
518
|
+
/>
|
|
519
|
+
{translate(option.label)}
|
|
520
|
+
</button>
|
|
521
|
+
);
|
|
522
|
+
},
|
|
523
|
+
)}
|
|
524
|
+
</div>
|
|
525
|
+
</div>
|
|
526
|
+
);
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const getEventTypeSection: () => ReactElement = (): ReactElement => {
|
|
530
|
+
return (
|
|
531
|
+
<div className="p-3">
|
|
532
|
+
<div className="flex items-center justify-between">
|
|
533
|
+
<div
|
|
534
|
+
id={eventTypesHeadingId}
|
|
535
|
+
className="text-xs font-semibold uppercase tracking-wide text-gray-500"
|
|
536
|
+
>
|
|
537
|
+
{translate(FEED_OPTIONS_TEXT.eventTypesHeading)}
|
|
538
|
+
</div>
|
|
539
|
+
{selectedCount > 0 && (
|
|
540
|
+
<button
|
|
541
|
+
type="button"
|
|
542
|
+
data-testid="feed-options-clear-event-types"
|
|
543
|
+
className="-my-1 rounded px-1.5 py-1 text-xs font-medium text-indigo-600 hover:text-indigo-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
|
544
|
+
onClick={clearEventTypes}
|
|
545
|
+
>
|
|
546
|
+
{translate(FEED_OPTIONS_TEXT.showAll)}
|
|
547
|
+
</button>
|
|
548
|
+
)}
|
|
549
|
+
</div>
|
|
550
|
+
<p
|
|
551
|
+
className="mt-1 text-xs text-gray-500"
|
|
552
|
+
data-testid="feed-options-event-type-summary"
|
|
553
|
+
aria-live="polite"
|
|
554
|
+
>
|
|
555
|
+
{selectedCount === 0
|
|
556
|
+
? translate(FEED_OPTIONS_TEXT.showingEveryEventType)
|
|
557
|
+
: translate(FEED_OPTIONS_TEXT.showingSomeEventTypes, {
|
|
558
|
+
selected: selectedCount,
|
|
559
|
+
total: props.eventTypeOptions.length,
|
|
560
|
+
})}
|
|
561
|
+
</p>
|
|
562
|
+
{showSearch && (
|
|
563
|
+
<div className="relative mt-2">
|
|
564
|
+
<Icon
|
|
565
|
+
icon={IconProp.Search}
|
|
566
|
+
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
|
|
567
|
+
/>
|
|
568
|
+
<input
|
|
569
|
+
type="text"
|
|
570
|
+
value={searchText}
|
|
571
|
+
aria-label={translate(FEED_OPTIONS_TEXT.searchPlaceholder)}
|
|
572
|
+
placeholder={translate(FEED_OPTIONS_TEXT.searchPlaceholder)}
|
|
573
|
+
data-testid="feed-options-search"
|
|
574
|
+
className="block w-full rounded-md border border-gray-300 bg-white py-1.5 pl-8 pr-2 text-sm text-gray-900 placeholder-gray-500 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
|
575
|
+
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
576
|
+
setSearchText(event.target.value);
|
|
577
|
+
}}
|
|
578
|
+
/>
|
|
579
|
+
<span
|
|
580
|
+
className="sr-only"
|
|
581
|
+
aria-live="polite"
|
|
582
|
+
data-testid="feed-options-search-results"
|
|
583
|
+
>
|
|
584
|
+
{searchTerm
|
|
585
|
+
? translate(FEED_OPTIONS_TEXT.searchResults, {
|
|
586
|
+
count: visibleEventTypeOptions.length,
|
|
587
|
+
})
|
|
588
|
+
: ""}
|
|
589
|
+
</span>
|
|
590
|
+
</div>
|
|
591
|
+
)}
|
|
592
|
+
<div role="group" aria-labelledby={eventTypesHeadingId}>
|
|
593
|
+
<ul
|
|
594
|
+
ref={eventTypeListRef}
|
|
595
|
+
role="list"
|
|
596
|
+
className="-mx-1 mt-2 max-h-64 overflow-y-auto"
|
|
597
|
+
data-testid="feed-options-event-types"
|
|
598
|
+
>
|
|
599
|
+
{visibleEventTypeOptions.map((option: FeedEventTypeOption) => {
|
|
600
|
+
const isChecked: boolean = selectedEventTypes.has(option.value);
|
|
601
|
+
|
|
602
|
+
return (
|
|
603
|
+
<li key={option.value}>
|
|
604
|
+
<label className="flex cursor-pointer items-center gap-2.5 rounded-md px-1.5 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
|
605
|
+
<input
|
|
606
|
+
type="checkbox"
|
|
607
|
+
checked={isChecked}
|
|
608
|
+
data-testid={`feed-options-event-type-${option.value}`}
|
|
609
|
+
className="h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300 accent-indigo-600 text-indigo-600 focus:ring-indigo-500"
|
|
610
|
+
onChange={() => {
|
|
611
|
+
toggleEventType(option.value);
|
|
612
|
+
}}
|
|
613
|
+
/>
|
|
614
|
+
{option.icon && (
|
|
615
|
+
<Icon
|
|
616
|
+
icon={option.icon}
|
|
617
|
+
className="h-4 w-4 shrink-0 text-gray-400"
|
|
618
|
+
/>
|
|
619
|
+
)}
|
|
620
|
+
<span className="truncate">{option.label}</span>
|
|
621
|
+
</label>
|
|
622
|
+
</li>
|
|
623
|
+
);
|
|
624
|
+
})}
|
|
625
|
+
{visibleEventTypeOptions.length === 0 && (
|
|
626
|
+
<li className="px-1.5 py-2 text-sm text-gray-500">
|
|
627
|
+
{props.eventTypeOptions.length === 0
|
|
628
|
+
? translate(FEED_OPTIONS_TEXT.noEventTypes)
|
|
629
|
+
: translate(FEED_OPTIONS_TEXT.noSearchMatches, {
|
|
630
|
+
search: searchText.trim(),
|
|
631
|
+
})}
|
|
632
|
+
</li>
|
|
633
|
+
)}
|
|
634
|
+
</ul>
|
|
635
|
+
</div>
|
|
636
|
+
</div>
|
|
637
|
+
);
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
return (
|
|
641
|
+
<div
|
|
642
|
+
ref={containerRef}
|
|
643
|
+
className="relative inline-block text-left"
|
|
644
|
+
onKeyDown={onContainerKeyDown}
|
|
645
|
+
onBlur={onContainerBlur}
|
|
646
|
+
>
|
|
647
|
+
{getTrigger()}
|
|
648
|
+
{/* Outside the button, or its text would join the button's name. */}
|
|
649
|
+
<span
|
|
650
|
+
id={summaryId}
|
|
651
|
+
className="sr-only"
|
|
652
|
+
data-testid="feed-options-summary"
|
|
653
|
+
>
|
|
654
|
+
{summary}
|
|
655
|
+
</span>
|
|
656
|
+
{isOpen && (
|
|
657
|
+
<div
|
|
658
|
+
id={panelId}
|
|
659
|
+
role="dialog"
|
|
660
|
+
tabIndex={-1}
|
|
661
|
+
aria-label={translate(FEED_OPTIONS_TEXT.panelLabel)}
|
|
662
|
+
data-testid="feed-options-panel"
|
|
663
|
+
style={
|
|
664
|
+
placement
|
|
665
|
+
? { left: `${placement.left}px`, width: `${placement.width}px` }
|
|
666
|
+
: undefined
|
|
667
|
+
}
|
|
668
|
+
className={`absolute z-50 mt-2 origin-top-right rounded-lg bg-white shadow-xl ring-1 ring-gray-200 focus:outline-none ${
|
|
669
|
+
placement ? "" : "right-0 w-72"
|
|
670
|
+
}`}
|
|
671
|
+
>
|
|
672
|
+
{getSortSection()}
|
|
673
|
+
{getEventTypeSection()}
|
|
674
|
+
{isCustomized && (
|
|
675
|
+
<div className="flex justify-end border-t border-gray-100 px-3 py-2">
|
|
676
|
+
<button
|
|
677
|
+
type="button"
|
|
678
|
+
data-testid="feed-options-reset"
|
|
679
|
+
className="-my-1 rounded px-1.5 py-1 text-xs font-medium text-gray-600 hover:text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
|
680
|
+
onClick={resetOptions}
|
|
681
|
+
>
|
|
682
|
+
{translate(FEED_OPTIONS_TEXT.reset)}
|
|
683
|
+
</button>
|
|
684
|
+
</div>
|
|
685
|
+
)}
|
|
686
|
+
</div>
|
|
687
|
+
)}
|
|
688
|
+
</div>
|
|
689
|
+
);
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
export default FeedOptionsButton;
|