From 7ea89b5fea8a7e8f6fafddd5099e0bfd56cab565 Mon Sep 17 00:00:00 2001 From: henry-tp Date: Tue, 8 Sep 2026 15:21:35 -0700 Subject: [PATCH] WEB-4454 add in filtering --- .../ConnectionIssues/ConnectionIssues.js | 36 +++++---- .../ConnectionIssues/EmptyNodeContent.js | 62 +++++++++++++++ .../ConnectionIssues/PaginationController.js | 2 +- .../ConnectionIssues/connectionIssuesApi.js | 44 ++++++++--- .../connectionIssuesFiltersSlice.js | 36 +++++++++ .../filters/AppliedFiltersList.js | 77 +++++++++++++++++++ .../ConnectionIssues/filters/FilterBySites.js | 19 +++++ .../ConnectionIssues/filters/FilterByTags.js | 19 +++++ .../useConnectionIssuesPatients.js | 20 +++++ .../usePruneInvalidFilters.js | 43 +++++++++++ app/redux/actions/async.js | 8 +- app/redux/actions/sync.js | 3 +- app/redux/reducers/index.js | 2 + app/redux/store/configureStore.dev.js | 4 +- app/redux/store/configureStore.prod.js | 4 +- app/redux/store/localStorage.js | 4 + 16 files changed, 353 insertions(+), 30 deletions(-) create mode 100644 app/pages/clinicworkspace/ConnectionIssues/EmptyNodeContent.js create mode 100644 app/pages/clinicworkspace/ConnectionIssues/connectionIssuesFiltersSlice.js create mode 100644 app/pages/clinicworkspace/ConnectionIssues/filters/AppliedFiltersList.js create mode 100644 app/pages/clinicworkspace/ConnectionIssues/filters/FilterBySites.js create mode 100644 app/pages/clinicworkspace/ConnectionIssues/filters/FilterByTags.js create mode 100644 app/pages/clinicworkspace/ConnectionIssues/useConnectionIssuesPatients.js create mode 100644 app/pages/clinicworkspace/ConnectionIssues/usePruneInvalidFilters.js diff --git a/app/pages/clinicworkspace/ConnectionIssues/ConnectionIssues.js b/app/pages/clinicworkspace/ConnectionIssues/ConnectionIssues.js index 0c0efcf03d..89aff21d77 100644 --- a/app/pages/clinicworkspace/ConnectionIssues/ConnectionIssues.js +++ b/app/pages/clinicworkspace/ConnectionIssues/ConnectionIssues.js @@ -1,38 +1,38 @@ -import React, { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { Trans } from 'react-i18next'; +import React, { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { Trans, useTranslation } from 'react-i18next'; import { colors as vizColors } from '@tidepool/viz'; import Table from '../../../components/elements/Table'; import { Flex, Text } from 'theme-ui'; +import AppliedFiltersList from './filters/AppliedFiltersList'; import FilterByCategory from './filters/FilterByCategory'; +import FilterByTags from './filters/FilterByTags'; +import FilterBySites from './filters/FilterBySites'; import PaginationController from './PaginationController'; +import EmptyContentNode from './EmptyNodeContent'; import { resetConnectionIssuesState } from './connectionIssuesSlice'; -import { useGetConnectionIssuesPatientsQuery } from './connectionIssuesApi'; import useTableColumns from './useTableColumns'; +import useConnectionIssuesPatients from './useConnectionIssuesPatients'; -const LIMIT = 12; +const tableContainerProps = { sx: { containerType: 'inline-size' } }; const ConnectionIssues = () => { + const { t } = useTranslation(); const dispatch = useDispatch(); - const selectedClinicId = useSelector(state => state.blip.selectedClinicId); - const category = useSelector(state => state.blip.connectionIssues.category); - const offset = useSelector(state => state.blip.connectionIssues.offset); + const { data } = useConnectionIssuesPatients(); const columns = useTableColumns(); - const { data } = useGetConnectionIssuesPatientsQuery( - { clinicId: selectedClinicId, offset, category, limit: LIMIT }, - { skip: !selectedClinicId } - ); - // reset state on dismount useEffect(() => { return () => dispatch(resetConnectionIssuesState()); }, []); + const emptyContentNode = useMemo(() => , []); + if (!data) return null; const patients = data?.data || []; @@ -49,17 +49,25 @@ const ConnectionIssues = () => { + + {t('Filter By')} + + + + + diff --git a/app/pages/clinicworkspace/ConnectionIssues/EmptyNodeContent.js b/app/pages/clinicworkspace/ConnectionIssues/EmptyNodeContent.js new file mode 100644 index 0000000000..3f652cc15c --- /dev/null +++ b/app/pages/clinicworkspace/ConnectionIssues/EmptyNodeContent.js @@ -0,0 +1,62 @@ +import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import colorPalette from '../../../themes/colorPalette'; +import { Flex, Text } from 'theme-ui'; +import { resetConnectionIssuesFilters } from './connectionIssuesFiltersSlice'; +import { setOffset } from './connectionIssuesSlice'; +import ClearFilterButtons, { PATIENT_QUERY_STATE } from '../components/ClearFilterButtons'; +import noop from 'lodash/noop'; +import without from 'lodash/without'; + +const EmptyContentNode = () => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const { patientTags, clinicSites } = useSelector(state => state.blip.connectionIssuesFilters); + + const activeFiltersCount = without([ + patientTags?.length, + clinicSites?.length, + ], null, 0, undefined).length; + + const hasActiveFilters = activeFiltersCount > 0; + + const handleResetFilters = () => { + dispatch(resetConnectionIssuesFilters()); + dispatch(setOffset(0)); + }; + + const emptyContentCopy = hasActiveFilters + ? t('There are no patients with the current filter(s)') + : t('There are no results to show'); + + const patientQueryState = hasActiveFilters ? PATIENT_QUERY_STATE.FILTER_ONLY : PATIENT_QUERY_STATE.NONE; + + return ( + + + {emptyContentCopy} + + + { hasActiveFilters && + + } + + ); +}; + +export default EmptyContentNode; diff --git a/app/pages/clinicworkspace/ConnectionIssues/PaginationController.js b/app/pages/clinicworkspace/ConnectionIssues/PaginationController.js index 18255d8169..0fff8b8fff 100644 --- a/app/pages/clinicworkspace/ConnectionIssues/PaginationController.js +++ b/app/pages/clinicworkspace/ConnectionIssues/PaginationController.js @@ -6,7 +6,7 @@ import { Grid, Flex } from 'theme-ui'; import { colors as vizColors } from '@tidepool/viz'; import { setOffset } from './connectionIssuesSlice'; -import { LIMIT } from './connectionIssuesApi'; +import { LIMIT } from './useConnectionIssuesPatients'; const PaginationController = ({ total }) => { const dispatch = useDispatch(); diff --git a/app/pages/clinicworkspace/ConnectionIssues/connectionIssuesApi.js b/app/pages/clinicworkspace/ConnectionIssues/connectionIssuesApi.js index 1b76ea9aad..2e3965ef8d 100644 --- a/app/pages/clinicworkspace/ConnectionIssues/connectionIssuesApi.js +++ b/app/pages/clinicworkspace/ConnectionIssues/connectionIssuesApi.js @@ -25,25 +25,49 @@ const getConnectionIssuesParam = (category) => { } }; -export const LIMIT = 12; +export const buildGetConnectionIssuesPatientsParams = (offset, limit, category, tags = [], sites = []) => { + const deviceIssues = getConnectionIssuesParam(category); + const omitHiddenDevicesIssues = category !== CATEGORY.HIDDEN; + + const formattedTags = tags?.length > 0 ? tags.join(',') : undefined; + const formattedSites = sites?.length > 0 ? sites.join(',') : undefined; + + return { + offset, + limit, + deviceIssues, + omitHiddenDevicesIssues, + tags: formattedTags, + sites: formattedSites, + }; +}; + +export const tagTypes = { + CONNECTION_ISSUES_PATIENTS: 'CONNECTION_ISSUES_PATIENTS', +}; + +const { CONNECTION_ISSUES_PATIENTS } = tagTypes; + +RTKQueryApi.enhanceEndpoints({ + addTagTypes: [CONNECTION_ISSUES_PATIENTS], +}); const connectionIssuesApi = RTKQueryApi.injectEndpoints({ endpoints: (builder) => ({ getConnectionIssuesPatients: builder.query({ - query: ({ clinicId, offset, category, limit }) => { - const deviceIssues = getConnectionIssuesParam(category); - const omitHiddenDevicesIssues = category !== CATEGORY.HIDDEN; + query: ({ clinicId, offset, category, limit, tags, sites }) => { + const params = buildGetConnectionIssuesPatientsParams(offset, limit, category, tags, sites); return { url: `/clinics/${clinicId}/patients`, - params: { - offset, - limit, - deviceIssues, - omitHiddenDevicesIssues, - }, + params, }; }, + transformResponse: (response, _meta, arg) => ({ + ...response, + category: arg.category, + }), + providesTags: [CONNECTION_ISSUES_PATIENTS], }), }), }); diff --git a/app/pages/clinicworkspace/ConnectionIssues/connectionIssuesFiltersSlice.js b/app/pages/clinicworkspace/ConnectionIssues/connectionIssuesFiltersSlice.js new file mode 100644 index 0000000000..a59cce52ba --- /dev/null +++ b/app/pages/clinicworkspace/ConnectionIssues/connectionIssuesFiltersSlice.js @@ -0,0 +1,36 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { SELECT_CLINIC_SUCCESS } from '../../../redux/constants/actionTypes'; + +const getInitialState = () => ({ + patientTags: [], + clinicSites: [], +}); + +const connectionIssuesFiltersSlice = createSlice({ + name: 'connectionIssuesFilters', + initialState: getInitialState(), + reducers: { + setConnectionIssuesFilters: (_state, action) => action.payload || getInitialState(), + setPatientTagsFilter: (state, action) => { + state.patientTags = action.payload; + }, + setClinicSitesFilter: (state, action) => { + state.clinicSites = action.payload; + }, + resetConnectionIssuesFilters: () => getInitialState(), + }, + extraReducers: (builder) => { + builder.addCase(SELECT_CLINIC_SUCCESS, (_state, action) => { + return action.payload?.connectionIssuesFilters || getInitialState(); + }); + }, +}); + +export const { + setConnectionIssuesFilters, + setClinicSitesFilter, + setPatientTagsFilter, + resetConnectionIssuesFilters, +} = connectionIssuesFiltersSlice.actions; + +export default connectionIssuesFiltersSlice.reducer; diff --git a/app/pages/clinicworkspace/ConnectionIssues/filters/AppliedFiltersList.js b/app/pages/clinicworkspace/ConnectionIssues/filters/AppliedFiltersList.js new file mode 100644 index 0000000000..854359bf02 --- /dev/null +++ b/app/pages/clinicworkspace/ConnectionIssues/filters/AppliedFiltersList.js @@ -0,0 +1,77 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; +import without from 'lodash/without'; +import noop from 'lodash/noop'; + +import ActiveFiltersTray from '../../components/filters/ActiveFiltersTray'; +import ClearFilterButtons, { PATIENT_QUERY_STATE } from '../../components/ClearFilterButtons'; +import { Box } from 'theme-ui'; +import { setClinicSitesFilter, setPatientTagsFilter } from '../connectionIssuesFiltersSlice'; +import { setOffset } from '../connectionIssuesSlice'; + +export const getPatientQueryState = (patientTags, clinicSites) => { + const hasFiltersActive = clinicSites?.length > 0 || patientTags?.length > 0; + + if (hasFiltersActive) return PATIENT_QUERY_STATE.FILTER_ONLY; + + return PATIENT_QUERY_STATE.NONE; +}; + +const AppliedFiltersList = ({ patientCount = 0 }) => { + const dispatch = useDispatch(); + + const { clinicSites, patientTags } = useSelector(state => state.blip.connectionIssuesFilters); + + const activeFilters = { clinicSites, patientTags }; + + const handleResetFilters = () => { + dispatch(setPatientTagsFilter([])); + dispatch(setClinicSitesFilter([])); + dispatch(setOffset(0)); + }; + + const handleRemoveFilter = (filterKey, value) => { + switch (filterKey) { + case 'patientTags': { + const updatedTags = without(patientTags, value); + dispatch(setPatientTagsFilter(updatedTags)); + dispatch(setOffset(0)); + break; + } + + case 'clinicSites': { + const updatedSites = without(clinicSites, value); + dispatch(setClinicSitesFilter(updatedSites)); + dispatch(setOffset(0)); + break; + } + } + }; + + const patientQueryState = getPatientQueryState(patientTags, clinicSites); + + return ( + + + + } + /> + ); +}; + +AppliedFiltersList.propTypes = { + patientCount: PropTypes.number, +}; + +export default AppliedFiltersList; diff --git a/app/pages/clinicworkspace/ConnectionIssues/filters/FilterBySites.js b/app/pages/clinicworkspace/ConnectionIssues/filters/FilterBySites.js new file mode 100644 index 0000000000..4506bc192e --- /dev/null +++ b/app/pages/clinicworkspace/ConnectionIssues/filters/FilterBySites.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { setOffset } from '../connectionIssuesSlice'; +import { setClinicSitesFilter } from '../connectionIssuesFiltersSlice'; +import SiteFilterDropdown from '../../components/filters/SiteFilterDropdown'; + +const FilterBySites = () => { + const dispatch = useDispatch(); + const { clinicSites } = useSelector(state => state.blip.connectionIssuesFilters); + + const handleChange = (clinicSites) => { + dispatch(setClinicSitesFilter(clinicSites)); + dispatch(setOffset(0)); + }; + + return ; +}; + +export default FilterBySites; diff --git a/app/pages/clinicworkspace/ConnectionIssues/filters/FilterByTags.js b/app/pages/clinicworkspace/ConnectionIssues/filters/FilterByTags.js new file mode 100644 index 0000000000..849e576e6c --- /dev/null +++ b/app/pages/clinicworkspace/ConnectionIssues/filters/FilterByTags.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { setOffset } from '../connectionIssuesSlice'; +import { setPatientTagsFilter } from '../connectionIssuesFiltersSlice'; +import TagFilterDropdown from '../../components/filters/TagFilterDropdown'; + +const FilterByTags = () => { + const dispatch = useDispatch(); + const { patientTags } = useSelector(state => state.blip.connectionIssuesFilters); + + const handleChange = (tags) => { + dispatch(setPatientTagsFilter(tags)); + dispatch(setOffset(0)); + }; + + return ; +}; + +export default FilterByTags; diff --git a/app/pages/clinicworkspace/ConnectionIssues/useConnectionIssuesPatients.js b/app/pages/clinicworkspace/ConnectionIssues/useConnectionIssuesPatients.js new file mode 100644 index 0000000000..3e3a74edb3 --- /dev/null +++ b/app/pages/clinicworkspace/ConnectionIssues/useConnectionIssuesPatients.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { useGetConnectionIssuesPatientsQuery } from './connectionIssuesApi'; + +export const LIMIT = 12; + +const useConnectionIssuesPatients = () => { + const selectedClinicId = useSelector(state => state.blip.selectedClinicId); + const category = useSelector(state => state.blip.connectionIssues.category); + const offset = useSelector(state => state.blip.connectionIssues.offset); + const patientTags = useSelector(state => state.blip.connectionIssuesFilters.patientTags); + const clinicSites = useSelector(state => state.blip.connectionIssuesFilters.clinicSites); + + return useGetConnectionIssuesPatientsQuery( + { clinicId: selectedClinicId, offset, category, limit: LIMIT, tags: patientTags, sites: clinicSites }, + { skip: !selectedClinicId } + ); +}; + +export default useConnectionIssuesPatients; diff --git a/app/pages/clinicworkspace/ConnectionIssues/usePruneInvalidFilters.js b/app/pages/clinicworkspace/ConnectionIssues/usePruneInvalidFilters.js new file mode 100644 index 0000000000..a1dc839d40 --- /dev/null +++ b/app/pages/clinicworkspace/ConnectionIssues/usePruneInvalidFilters.js @@ -0,0 +1,43 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { setPatientTagsFilter, setClinicSitesFilter } from './connectionIssuesFiltersSlice'; +import { SPECIAL_FILTER_STATES } from '../useClinicPatientsFilters'; +import keyBy from 'lodash/keyBy'; +import isEqual from 'lodash/isEqual'; + +const usePruneInvalidFilters = () => { + const dispatch = useDispatch(); + const selectedClinicId = useSelector(state => state.blip.selectedClinicId); + const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); + const { patientTags, clinicSites } = useSelector(state => state.blip.connectionIssuesFilters); + + const clinicId = clinic?.id; + + useEffect(() => { + if (!patientTags?.length || !clinicId) return; + + if (isEqual(patientTags, SPECIAL_FILTER_STATES.ZERO_TAGS)) return; + + const availableTags = keyBy(clinic.patientTags || [], 'id'); + const prunedTags = patientTags.filter(tagId => !!availableTags[tagId]); + + if (prunedTags.length < patientTags.length) { + dispatch(setPatientTagsFilter(prunedTags)); + } + }, [clinicId]); + + useEffect(() => { + if (!clinicSites?.length || !clinicId) return; + + if (isEqual(clinicSites, SPECIAL_FILTER_STATES.ZERO_SITES)) return; + + const availableSites = keyBy(clinic.sites || [], 'id'); + const prunedSites = clinicSites.filter(siteId => !!availableSites[siteId]); + + if (prunedSites.length < clinicSites.length) { + dispatch(setClinicSitesFilter(prunedSites)); + } + }, [clinicId]); +}; + +export default usePruneInvalidFilters; diff --git a/app/redux/actions/async.js b/app/redux/actions/async.js index 30de11200c..0015819e04 100644 --- a/app/redux/actions/async.js +++ b/app/redux/actions/async.js @@ -18,7 +18,7 @@ import utils from '../../core/utils'; import { clinicUIDetails } from '../../core/clinicUtils.js'; import { getDismissedAltRangeBannerKey, isRangeWithNonStandardTarget } from '../../providers/AppBanner/appBannerHelpers.js'; import { getGlycemicRangesPreset } from '../../core/glycemicRangesUtils.js'; -import { getTideDashboardFiltersKey, loadLocalState } from '../store/localStorage'; +import { getTideDashboardFiltersKey, getConnectionIssuesFiltersKey, loadLocalState } from '../store/localStorage'; // Exported as a mutable reference to allow location to be swapped in tests export const _win = { location: window.location }; @@ -3158,7 +3158,11 @@ export function selectClinic(api, clinicId) { const tideDashboardFiltersKey = getTideDashboardFiltersKey(loggedInUserId, clinicId); const tideDashboardFilters = loggedInUserId && clinicId ? loadLocalState(tideDashboardFiltersKey) : undefined; - dispatch(sync.selectClinicSuccess(clinicId, tideDashboardFilters)); + + const connectionIssuesFiltersKey = getConnectionIssuesFiltersKey(loggedInUserId, clinicId); + const connectionIssuesFilters = loggedInUserId && clinicId ? loadLocalState(connectionIssuesFiltersKey) : undefined; + + dispatch(sync.selectClinicSuccess(clinicId, tideDashboardFilters, connectionIssuesFilters)); const clinic = clinics[clinicId]; diff --git a/app/redux/actions/sync.js b/app/redux/actions/sync.js index 9577a9134f..4b00660144 100644 --- a/app/redux/actions/sync.js +++ b/app/redux/actions/sync.js @@ -2114,12 +2114,13 @@ export function getClinicsForClinicianFailure(error, apiError) { }; } -export function selectClinicSuccess(clinicId, tideDashboardFilters) { +export function selectClinicSuccess(clinicId, tideDashboardFilters, connectionIssuesFilters) { return { type: ActionTypes.SELECT_CLINIC_SUCCESS, payload: { clinicId, tideDashboardFilters, + connectionIssuesFilters, }, }; } diff --git a/app/redux/reducers/index.js b/app/redux/reducers/index.js index fc0b4bd26d..95f0d63109 100644 --- a/app/redux/reducers/index.js +++ b/app/redux/reducers/index.js @@ -23,6 +23,7 @@ import patientListFilters from './patientListFilters'; import tideDashboard from '../../pages/clinicworkspace/TideDashboardV2/tideDashboardSlice'; import tideDashboardFilters from '../../pages/clinicworkspace/TideDashboardV2/tideDashboardFiltersSlice'; import connectionIssues from '../../pages/clinicworkspace/ConnectionIssues/connectionIssuesSlice'; +import connectionIssuesFilters from '../../pages/clinicworkspace/ConnectionIssues/connectionIssuesFiltersSlice'; import { bgPrefs, @@ -115,4 +116,5 @@ export default combineReducers({ tideDashboard, tideDashboardFilters, connectionIssues, + connectionIssuesFilters, }); diff --git a/app/redux/store/configureStore.dev.js b/app/redux/store/configureStore.dev.js index d537c9fd72..f3620e6382 100644 --- a/app/redux/store/configureStore.dev.js +++ b/app/redux/store/configureStore.dev.js @@ -31,7 +31,7 @@ import { stringify, parse } from 'qs'; import blipState from '../reducers/initialState'; import reducers from '../reducers'; -import { getTideDashboardFiltersKey, loadLocalState, saveLocalState } from './localStorage'; +import { getTideDashboardFiltersKey, getConnectionIssuesFiltersKey, loadLocalState, saveLocalState } from './localStorage'; import createErrorLogger from '../utils/logErrorMiddleware'; import trackingMiddleware from '../utils/trackingMiddleware'; @@ -117,8 +117,10 @@ function _createStore(api) { if (loggedInUserId && selectedClinicId) { const tideDashboardFiltersKey = getTideDashboardFiltersKey(loggedInUserId, selectedClinicId); + const connectionIssuesFiltersKey = getConnectionIssuesFiltersKey(loggedInUserId, selectedClinicId); saveLocalState(store.getState().blip?.tideDashboardFilters, tideDashboardFiltersKey); + saveLocalState(store.getState().blip?.connectionIssuesFilters, connectionIssuesFiltersKey); } }, 1000)); diff --git a/app/redux/store/configureStore.prod.js b/app/redux/store/configureStore.prod.js index 57b5eb44f5..ca2068bf29 100644 --- a/app/redux/store/configureStore.prod.js +++ b/app/redux/store/configureStore.prod.js @@ -26,7 +26,7 @@ import throttle from 'lodash/throttle'; import blipState from '../reducers/initialState'; import reducers from '../reducers'; -import { getTideDashboardFiltersKey, loadLocalState, saveLocalState } from './localStorage'; +import { getTideDashboardFiltersKey, getConnectionIssuesFiltersKey, loadLocalState, saveLocalState } from './localStorage'; import createErrorLogger from '../utils/logErrorMiddleware'; import trackingMiddleware from '../utils/trackingMiddleware'; @@ -73,8 +73,10 @@ function _createStore(api) { if (loggedInUserId && selectedClinicId) { const tideDashboardFiltersKey = getTideDashboardFiltersKey(loggedInUserId, selectedClinicId); + const connectionIssuesFiltersKey = getConnectionIssuesFiltersKey(loggedInUserId, selectedClinicId); saveLocalState(store.getState().blip?.tideDashboardFilters, tideDashboardFiltersKey); + saveLocalState(store.getState().blip?.connectionIssuesFilters, connectionIssuesFiltersKey); } }, 1000)); diff --git a/app/redux/store/localStorage.js b/app/redux/store/localStorage.js index ecc790139c..e09e9325fc 100644 --- a/app/redux/store/localStorage.js +++ b/app/redux/store/localStorage.js @@ -23,3 +23,7 @@ export const saveLocalState = (state, key = 'blipState') => { export const getTideDashboardFiltersKey = (userId, clinicId) => { return `tideDashboardFilters/${userId}/${clinicId}`; }; + +export const getConnectionIssuesFiltersKey = (userId, clinicId) => { + return `connectionIssuesFilters/${userId}/${clinicId}`; +};