From 9e47582b573fe38a59afec048d2fbf48901d3d6f Mon Sep 17 00:00:00 2001 From: Ludwig Date: Thu, 22 May 2025 16:14:27 -0600 Subject: [PATCH] ERA-11552: Fix v2 event types filtering and refactor a bit the involved files --- public/locales/en-US/filters.json | 4 +- public/locales/es/filters.json | 4 +- public/locales/fr/filters.json | 4 +- public/locales/ne-NP/filters.json | 4 +- public/locales/pt/filters.json | 4 +- public/locales/sw/filters.json | 4 +- src/EventFilter/Filters/index.js | 371 +++++++++++++++++++---------- src/ReportTypeMultiSelect/index.js | 151 ++++++------ src/i18n.js | 12 +- 9 files changed, 347 insertions(+), 211 deletions(-) diff --git a/public/locales/en-US/filters.json b/public/locales/en-US/filters.json index 7b7ff217b..dc6e8ed69 100644 --- a/public/locales/en-US/filters.json +++ b/public/locales/en-US/filters.json @@ -30,8 +30,8 @@ "noneSelected": "None selected", "someSelected": "{{reportTypesCheckedCount}} of {{eventTypeIDsLength}} selected" }, - "restAllButton": "Reset All", - "restButton": "Reset", + "resetAllButton": "Reset All", + "resetButton": "Reset", "stateLabel": "State", "stateSelector": { "active": "Active", diff --git a/public/locales/es/filters.json b/public/locales/es/filters.json index b0d529969..019054cb8 100644 --- a/public/locales/es/filters.json +++ b/public/locales/es/filters.json @@ -30,8 +30,8 @@ "noneSelected": "Ninguno seleccionado", "someSelected": "{{reportTypesCheckedCount}} de {{eventTypeIDsLength}} seleccionados" }, - "restAllButton": "Restablecer todos", - "restButton": "Restablecer", + "resetAllButton": "Restablecer todos", + "resetButton": "Restablecer", "stateLabel": "Estado", "stateSelector": { "active": "Activo", diff --git a/public/locales/fr/filters.json b/public/locales/fr/filters.json index c2e16707d..83e95baab 100644 --- a/public/locales/fr/filters.json +++ b/public/locales/fr/filters.json @@ -30,8 +30,8 @@ "noneSelected": "Aucune sélection", "someSelected": "{{reportTypesCheckedCount}} sur {{eventTypeIDsLength}} sélectionnés " }, - "restAllButton": "Tout réinitialiser", - "restButton": "Réinitialiser", + "resetAllButton": "Tout réinitialiser", + "resetButton": "Réinitialiser", "stateLabel": "Statut", "stateSelector": { "active": "Actif", diff --git a/public/locales/ne-NP/filters.json b/public/locales/ne-NP/filters.json index b7d83cc1f..06f8fa4d2 100644 --- a/public/locales/ne-NP/filters.json +++ b/public/locales/ne-NP/filters.json @@ -30,8 +30,8 @@ "noneSelected": "कुनैपनि छानिएन", "someSelected": "{{eventTypeIDsLength}} को {{reportTypesCheckedCount}} वटा छानियो" }, - "restAllButton": "सबै रिसेट गर्नुहोस्", - "restButton": "रिसेट गर्नुहोस्", + "resetAllButton": "सबै रिसेट गर्नुहोस्", + "resetButton": "रिसेट गर्नुहोस्", "stateLabel": "अवस्था", "stateSelector": { "active": "सकृय", diff --git a/public/locales/pt/filters.json b/public/locales/pt/filters.json index f7edfed0f..742fde6f8 100644 --- a/public/locales/pt/filters.json +++ b/public/locales/pt/filters.json @@ -30,8 +30,8 @@ "noneSelected": "Nenhum selecionado", "someSelected": "{{reportTypesCheckedCount}} de {{eventTypeIDsLength}} selecionado" }, - "restAllButton": "Redefinir tudo", - "restButton": "Redefinir", + "resetAllButton": "Redefinir tudo", + "resetButton": "Redefinir", "stateLabel": "Status ou Situação", "stateSelector": { "active": "Ativo", diff --git a/public/locales/sw/filters.json b/public/locales/sw/filters.json index 4fb029ab6..4197a5970 100644 --- a/public/locales/sw/filters.json +++ b/public/locales/sw/filters.json @@ -30,8 +30,8 @@ "noneSelected": "Hakuna iliyochaguliwa", "someSelected": "{{reportTypesCheckedCount}} kati ya {{eventTypeIDsLength}} zimechaguliwa" }, - "restAllButton": "Rudisha Zote", - "restButton": "Rudisha", + "resetAllButton": "Rudisha Zote", + "resetButton": "Rudisha", "stateLabel": "Hali", "stateSelector": { "active": "Tendaji", diff --git a/src/EventFilter/Filters/index.js b/src/EventFilter/Filters/index.js index 6cdc69d57..6a9e37c25 100644 --- a/src/EventFilter/Filters/index.js +++ b/src/EventFilter/Filters/index.js @@ -1,183 +1,260 @@ import React, { memo, useCallback, useMemo } from 'react'; -import Popover from 'react-bootstrap/Popover'; import Button from 'react-bootstrap/Button'; -import intersection from 'lodash-es/intersection'; import isEqual from 'react-fast-compare'; +import Popover from 'react-bootstrap/Popover'; import uniq from 'lodash-es/uniq'; import { useTranslation } from 'react-i18next'; import { ReactComponent as UserIcon } from '../../common/images/icons/user-profile.svg'; -import ReportedBySelect from '../../ReportedBySelect'; -import PriorityPicker from '../../PriorityPicker'; -import CheckMark from '../../Checkmark'; -import ReportTypeMultiSelect from '../../ReportTypeMultiSelect'; + import { EVENT_STATE_CHOICES } from '../../constants'; import { INITIAL_FILTER_STATE } from '../../ducks/event-filter'; +import CheckMark from '../../Checkmark'; +import PriorityPicker from '../../PriorityPicker'; +import ReportedBySelect from '../../ReportedBySelect'; +import ReportTypeMultiSelect from '../../ReportTypeMultiSelect'; + import * as styles from '../styles.module.scss'; -const StateSelector = ({ onStateSelect, state, t }) => ( - ; +}; const Filters = ({ - priority, - reportTypeFilterText, - isFilterModified, - isReportedByFilterModified, + currentFilterReportTypes, + eventFilterTracker, + eventTypes, isEventTypeFilterEmpty, + isFilterModified, isPriorityFilterModified, + isReportedByFilterModified, isStateFilterModified, - currentFilterReportTypes, - reporters, + onResetPopoverFilters, + priority, reportedByFilter, - eventFilterTracker, - updateEventFilter, - eventTypes, + reporters, + reportTypeFilterText, + setReportTypeFilterText, state, - onResetPopoverFilters, - setReportTypeFilterText + updateEventFilter, }) => { - const eventTypeIDs = useMemo(() => eventTypes.map(type => type.id), [eventTypes]); - const reportTypesCheckedCount = intersection(eventTypeIDs, currentFilterReportTypes).length; - const someReportTypesChecked = !isEventTypeFilterEmpty && !!reportTypesCheckedCount; - const noReportTypesChecked = !isEventTypeFilterEmpty && !someReportTypesChecked; const { t } = useTranslation('filters', { keyPrefix: 'filters' }); - const selectedReporters = useMemo(() => - reportedByFilter && !!reportedByFilter.length - ? reportedByFilter.map(id => reporters.find(r => r.id === id)).filter(item => !!item) - : [], - [reporters, reportedByFilter]); + const eventTypeIds = useMemo(() => eventTypes.map((eventType) => eventType.id), [eventTypes]); + + const selectedEventTypesCount = useMemo( + () => eventTypeIds.filter((eventTypeId) => currentFilterReportTypes.includes(eventTypeId)).length, + [currentFilterReportTypes, eventTypeIds] + ); + + const someReportTypesChecked = !isEventTypeFilterEmpty && !!selectedEventTypesCount; + const noReportTypesChecked = !isEventTypeFilterEmpty && !someReportTypesChecked; + + let appliedFilterLabel = t('reportTypesSelectionLabels.noneSelected'); + if (isEventTypeFilterEmpty){ + appliedFilterLabel = t('reportTypesSelectionLabels.allSelected'); + } else if (someReportTypesChecked) { + appliedFilterLabel = t('reportTypesSelectionLabels.someSelected', { + eventTypeIDsLength: eventTypeIds.length, + selectedEventTypesCount, + }); + } + + const selectedReporters = useMemo(() => reportedByFilter?.length > 0 + ? reportedByFilter + .map((reportedById) => reporters.find((reporter) => reporter.id === reportedById)) + .filter((item) => !!item) + : [], [reportedByFilter, reporters]); + + const onAllEventTypesClick = useCallback((event) => { + event.stopPropagation(); - const onToggleAllReportTypes = useCallback((e) => { - e.stopPropagation(); if (isEventTypeFilterEmpty) { eventFilterTracker.track('Uncheck All Event Types Filter'); + updateEventFilter({ filter: { event_type: [null] } }); } else { eventFilterTracker.track('Check All Event Types Filter'); + updateEventFilter({ filter: { event_type: [] } }); } }, [eventFilterTracker, isEventTypeFilterEmpty, updateEventFilter]); - const onResetReportTypes = useCallback((_e) => { + const onEventTypesReset = useCallback(() => { eventFilterTracker.track('Reset Event Types Filter'); + setReportTypeFilterText(''); updateEventFilter({ filter: { event_type: [] } }); }, [eventFilterTracker, setReportTypeFilterText, updateEventFilter]); - const onReportCategoryToggle = useCallback(({ value }) => { - const toToggle = eventTypes.filter(({ category: { value: v } }) => v === value).map(({ id }) => id); - const allShown = isEventTypeFilterEmpty ? true : (intersection(currentFilterReportTypes, toToggle).length === toToggle.length); + const onCategoryToggle = useCallback((category) => { + const idsOfEventTypesContainedByToggledCategory = eventTypes + .filter((eventType) => { + const eventTypeCategoryValue = eventType.version === 1 + ? eventType.category.value + : eventType.category; + + return eventTypeCategoryValue === category.value; + }).map((eventType) => eventType.id); + + const areAllEventTypesInCurrentFilter = idsOfEventTypesContainedByToggledCategory.every( + (eventTypeId) => currentFilterReportTypes.includes(eventTypeId) + ); + + let newFilteredEventTypes; + if (isEventTypeFilterEmpty) { + // If no event types are filtered we just filter out all the event types + // contained by the toggled category and keep the rest. + newFilteredEventTypes = eventTypeIds + .filter((eventTypeId) => !idsOfEventTypesContainedByToggledCategory.includes(eventTypeId)); - if (allShown) { eventFilterTracker.track('Uncheck Event Type Category Filter'); - updateEventFilter({ filter: { event_type: (isEventTypeFilterEmpty ? eventTypeIDs : currentFilterReportTypes).filter(id => !toToggle.includes(id)) } }); - } else { + } else if (areAllEventTypesInCurrentFilter) { + // If there are event types filtered and all the event types contained by + // the toggled category are in the current filter, we just filter them + // out from the current filtered event types and keep the rest. + newFilteredEventTypes = currentFilterReportTypes + .filter((eventTypeId) => !idsOfEventTypesContainedByToggledCategory.includes(eventTypeId)); + eventFilterTracker.track('Uncheck Event Type Category Filter'); - const updatedValue = uniq([...currentFilterReportTypes, ...toToggle]); - updateEventFilter({ filter: { event_type: updatedValue.length === eventTypeIDs.length ? [] : updatedValue } }); + } else { + // If there are event types filtered and not all the event types + // contained by the toggled category are in the current filter we add + // them all to the currently filtered event types. + const uniqEventTypeIdsFiltered = uniq([ + ...currentFilterReportTypes, + ...idsOfEventTypesContainedByToggledCategory + ]); + newFilteredEventTypes = uniqEventTypeIdsFiltered.length === eventTypeIds.length ? [] : uniqEventTypeIdsFiltered; + + eventFilterTracker.track('Check Event Type Category Filter'); } - }, [eventTypes, isEventTypeFilterEmpty, currentFilterReportTypes, eventFilterTracker, updateEventFilter, eventTypeIDs]); + + updateEventFilter({ filter: { event_type: newFilteredEventTypes } }); + }, [ + eventTypeIds, + eventTypes, + isEventTypeFilterEmpty, + currentFilterReportTypes, + eventFilterTracker, + updateEventFilter, + ]); const onReportedByChange = useCallback((values) => { - const hasValue = values && !!values.length; + const hasValue = !!values?.length; + updateEventFilter({ filter: { - reported_by: hasValue ? uniq(values.map(({ id }) => id)) : [], + reported_by: hasValue ? uniq(values.map((reportedBy) => reportedBy.id)) : [], } }); - eventFilterTracker.track(`${hasValue ? 'Set' : 'Clear'} 'Reported By' Filter`, hasValue ? `${values.length} reporters` : null); + + eventFilterTracker.track( + `${hasValue ? 'Set' : 'Clear'} 'Reported By' Filter`, + hasValue ? `${values.length} reporters` : null + ); }, [eventFilterTracker, updateEventFilter]); - const onPriorityChange = useCallback((value) => { - const newVal = priority.includes(value) - ? priority.filter(item => item !== value) - : [...priority, value]; + const onPriorityPickerSelect = useCallback((selectedPriority) => { + const newPriorityFilter = priority.includes(selectedPriority) + ? priority.filter((filteredPriority) => filteredPriority !== selectedPriority) + : [...priority, selectedPriority]; + updateEventFilter({ filter: { - priority: newVal, + priority: newPriorityFilter, }, }); - eventFilterTracker.track('Set Priority Filter', newVal.toString()); + + eventFilterTracker.track('Set Priority Filter', newPriorityFilter.toString()); }, [eventFilterTracker, priority, updateEventFilter]); - const onReportTypeToggle = useCallback(({ id }) => { - const visible = isEventTypeFilterEmpty ? true : currentFilterReportTypes.includes(id); - if (visible) { + const onEventTypeToggle = useCallback((eventType) => { + const isIncludedInFilter = isEventTypeFilterEmpty ? true : currentFilterReportTypes.includes(eventType.id); + if (isIncludedInFilter) { + updateEventFilter({ + filter: { + event_type: (isEventTypeFilterEmpty ? eventTypeIds : currentFilterReportTypes) + .filter((eventTypeId) => eventTypeId !== eventType.id) + }, + }); + eventFilterTracker.track('Uncheck Event Type Filter'); - updateEventFilter({ filter: { event_type: (isEventTypeFilterEmpty ? eventTypeIDs : currentFilterReportTypes).filter(item => item !== id) } }); } else { + const eventTypeIdsFiltered = [...currentFilterReportTypes, eventType.id]; + updateEventFilter({ + filter: { + event_type: eventTypeIdsFiltered.length === eventTypeIds.length ? [] : eventTypeIdsFiltered, + }, + }); + eventFilterTracker.track('Check Event Type Filter'); - const updatedValue = [...currentFilterReportTypes, id]; - updateEventFilter({ filter: { event_type: updatedValue.length === eventTypeIDs.length ? [] : updatedValue } }); } - }, [isEventTypeFilterEmpty, currentFilterReportTypes, eventFilterTracker, updateEventFilter, eventTypeIDs]); + }, [currentFilterReportTypes, eventFilterTracker, eventTypeIds, isEventTypeFilterEmpty, updateEventFilter]); - const onFilteredReportsSelect = useCallback((types) => { - updateEventFilter({ filter: { event_type: types.map(({ id }) => id) } }); - }, [updateEventFilter]); + const onStateReset = useCallback((event) => { + event.stopPropagation(); - const onResetStateFilter = useCallback((e) => { - e.stopPropagation(); updateEventFilter({ state: INITIAL_FILTER_STATE.state }); + eventFilterTracker.track('Click Reset State Filter'); }, [eventFilterTracker, updateEventFilter]); - const onResetPriorityFilter = useCallback((e) => { - e.stopPropagation(); + const onPriorityReset = useCallback((event) => { + event.stopPropagation(); + updateEventFilter({ filter: { priority: INITIAL_FILTER_STATE.filter.priority } }); + eventFilterTracker.track('Click Reset Priority Filter'); }, [eventFilterTracker, updateEventFilter]); - const onResetReportedByFilter = useCallback((e) => { - e.stopPropagation(); + const onReportedByReset = useCallback((event) => { + event.stopPropagation(); + updateEventFilter({ filter: { reported_by: INITIAL_FILTER_STATE.filter.reported_by } }); + eventFilterTracker.track('Click Reset Reported By Filter'); }, [eventFilterTracker, updateEventFilter]); - const onStateSelect = useCallback(({ value }) => { - if (!isEqual(state, value)){ - updateEventFilter({ state: value }); - eventFilterTracker.track(`Select '${value}' State Filter`); - } - }, [eventFilterTracker, state, updateEventFilter]); + const onStateSelect = useCallback((option) => { + if (state !== option.value) { + updateEventFilter({ state: option.value }); - const appliedFilterLabel = useMemo(() => { - if (isEventTypeFilterEmpty){ - return t('reportTypesSelectionLabels.allSelected'); - } - if (someReportTypesChecked) { - return t('reportTypesSelectionLabels.someSelected', { - reportTypesCheckedCount, - eventTypeIDsLength: eventTypeIDs.length - }); + eventFilterTracker.track(`Select '${option.value}' State Filter`); } - - return t('reportTypesSelectionLabels.noneSelected'); - }, [eventTypeIDs.length, isEventTypeFilterEmpty, reportTypesCheckedCount, someReportTypesChecked, t]); + }, [eventFilterTracker, state, updateEventFilter]); return <>
{t('title')} - + +
@@ -186,50 +263,100 @@ const Filters = ({ - - + + + +
- + + - + className={styles.priorityPicker} + isMulti + onSelect={onPriorityPickerSelect} + selected={priority} + /> +
- - + + + +
- + + {t('reportTypesAllLabel')}
+ {t('reportTypesLabel')} - - {appliedFilterLabel} - - + + {appliedFilterLabel} + +
- + updateEventFilter({ + filter: { + event_type: eventTypes.map((eventType) => eventType.id), + }, + })} + onTypeToggle={onEventTypeToggle} + selectedReportTypeIDs={currentFilterReportTypes} + />
; diff --git a/src/ReportTypeMultiSelect/index.js b/src/ReportTypeMultiSelect/index.js index d639e4a01..ffd50905f 100644 --- a/src/ReportTypeMultiSelect/index.js +++ b/src/ReportTypeMultiSelect/index.js @@ -1,6 +1,5 @@ -import React, { memo, Fragment, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import Button from 'react-bootstrap/Button'; -import intersection from 'lodash/intersection'; import { useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; @@ -13,113 +12,123 @@ import SearchBar from '../SearchBar'; import * as styles from './styles.module.scss'; -const filterProps = ['display', 'value', 'category.display']; const eventFilterTracker = trackEventFactory(EVENT_FILTER_CATEGORY); -const filterEventTypes = (eventTypes, filterText) => - eventTypes.filter(item => - filterProps.some((prop) => { - if (prop.includes('.')) { - const nestedFilterProp = prop.split('.').reduce((accumulator, prop) => { - if (typeof accumulator === 'object') { - return accumulator[prop]; - } - return accumulator; - }, item); - return nestedFilterProp?.toString().toLowerCase().includes(filterText.toString().toLowerCase()); +const EVENT_TYPE_TEXT_FILTER_FIELDS = ['display', 'value', 'category.display']; + +const filterEventTypes = (eventTypes, filterText) => { + const filterTextInLowerCase = filterText.toString().toLowerCase(); + + return eventTypes.filter((eventType) => + EVENT_TYPE_TEXT_FILTER_FIELDS.some((field) => { + let fieldValue = eventType?.[field]; + if (field.includes('.')) { + // If the field has a "." we traverse the event type object to the + // nested field. + fieldValue = field + .split('.') + .reduce((accumulator, field) => accumulator?.[field], eventType); } - return item?.[prop].toString().toLowerCase().includes(filterText.toString().toLowerCase()); + + return fieldValue?.toString().toLowerCase().includes(filterTextInLowerCase); }) ); +}; + +const ListItem = ({ display, onTypeToggle, selectedReportTypeIDs, types }) => <> +
{display}
+ + !selectedReportTypeIDs.length|| selectedReportTypeIDs.includes(eventType.id)} + items={types} + onCheckClick={onTypeToggle} + /> +; const ReportTypeMultiSelect = ({ filter, + onCategoryToggle, onFilterChange, onFilteredItemsSelect, - onCategoryToggle, onTypeToggle, selectedReportTypeIDs, }) => { const { t } = useTranslation('filters', { keyPrefix: 'reportTypeMultiSelect' }); - const eventTypes = useSelector((state) => state.data.eventTypes); const eventCategories = useSelector((state) => state.data.eventCategories); + const eventTypes = useSelector((state) => state.data.eventTypes); - const noEventTypeSetInFilter = !selectedReportTypeIDs.length; + const filteredEventTypes = useMemo( + () => filter.length > 0 ? filterEventTypes(eventTypes, filter) : eventTypes, + [eventTypes, filter] + ); - const onSearchValueChange = ({ target: { value } }) => { - onFilterChange(value); - }; + const eventTypesMappedByCategory = mapEventTypesToCategories(filteredEventTypes, eventCategories); - const onFilterClear = () => { + let setToMatchesButtonText = t('noResultsLabel'); + if (filteredEventTypes.length > 0) { + setToMatchesButtonText = filteredEventTypes.length > 1 + ? t('someResultsLabel', { resultCount: filteredEventTypes.length }) + : t('singleResultLabel'); + } + + const onSearchBarClear = () => { onFilterChange(''); + eventFilterTracker.track('Clear Report Type Text Filter'); }; - const filteredEventTypes = filter.length ? filterEventTypes(eventTypes, filter) : eventTypes; - - const itemsGroupedByCategory = mapEventTypesToCategories(filteredEventTypes, eventCategories); - - const categoryFullyChecked = (category) => { - if (noEventTypeSetInFilter) return true; + const onSetToMatchesButtonClick = () => { + onFilteredItemsSelect(filteredEventTypes); - const categoryTypeIDs = category.types.map(t => t.id); - return intersection(categoryTypeIDs, selectedReportTypeIDs).length === categoryTypeIDs.length; + eventFilterTracker.track('Set Selected Report Types From Searchbar'); }; - const categoryPartiallyChecked = (category) => { - const categoryTypeIDs = category.types.map(t => t.id); - return !categoryFullyChecked(category) && !!intersection(categoryTypeIDs, selectedReportTypeIDs).length; - }; + const areAllCategoryEventTypesSelected = (category) => { + if (!selectedReportTypeIDs.length) { + return true; + } - const selectFilteredItems = () => { - onFilteredItemsSelect(filteredEventTypes); - eventFilterTracker.track('Set Selected Report Types From Searchbar'); - }; + const categoryEventTypeIds = category.types.map((eventType) => eventType.id); - const reportTypeChecked = (type) => noEventTypeSetInFilter ? true : selectedReportTypeIDs.includes(type.id); - - const ListItem = (props) => { // eslint-disable-line react/display-name - const { display, types } = props; - return -
{display}
- -
; + return categoryEventTypeIds.every((eventTypeId) => selectedReportTypeIDs.includes(eventTypeId)); }; - const MemoizedListItem = memo(ListItem); + const areCategoryEventTypesPartiallySelected = (category) => { + const categoryEventTypeIds = category.types.map((eventType) => eventType.id); - const matchesButtonText = useMemo(() => { - if (filteredEventTypes.length){ - return filteredEventTypes.length > 1 - ? t('someResultsLabel', { resultCount: filteredEventTypes.length }) - : t('singleResultLabel'); - } - return t('noResultsLabel'); - }, [filteredEventTypes, t]); + return categoryEventTypeIds.some((eventTypeId) => selectedReportTypeIDs.includes(eventTypeId)) + && !areAllCategoryEventTypesSelected(category); + }; return
- - {!!filter.length - && - } + onFilterChange(event.target.value)} + onClear={onSearchBarClear} + placeholder={t('placeholder')} + value={filter} + /> + + {filter.length > 0 && }
+
; }; diff --git a/src/i18n.js b/src/i18n.js index 58a96b37d..2a3a8d5d3 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -50,12 +50,12 @@ i18n backendOptions: [{ expirationTime: 24 * 60 * 60 * 1000 * 7, versions: { - es: 'v1.12', - 'en-US': 'v1.12', - fr: 'v1.12', - 'ne-NP': 'v1.12', - pt: 'v1.12', - sw: 'v1.12' + es: 'v1.13', + 'en-US': 'v1.13', + fr: 'v1.13', + 'ne-NP': 'v1.13', + pt: 'v1.13', + sw: 'v1.13' } }] }