+ + + +
- +
`; diff --git a/src/app/css/_deviceEvents.scss b/src/app/devices/deviceEvents/components/deviceEvents.scss similarity index 53% rename from src/app/css/_deviceEvents.scss rename to src/app/devices/deviceEvents/components/deviceEvents.scss index 1a779704b..b2e828be4 100644 --- a/src/app/css/_deviceEvents.scss +++ b/src/app/devices/deviceEvents/components/deviceEvents.scss @@ -3,7 +3,7 @@ * Licensed under the MIT License **********************************************************/ -@import 'themes'; +@import '../../../css/themes'; .device-events { .device-events-container { @@ -20,6 +20,10 @@ border-top: 1px solid themed('contrastBorder'); } padding: 0 25px; + width: 100%; + pre { + white-space: pre-wrap; + } } } @@ -64,7 +68,54 @@ } .scrollable-telemetry { - height: calc(100vh - 400px); + height: calc(100vh - 390px); overflow-y: auto; + overflow-x: hidden; + } + + .scrollable-telemetry-custom { + height: calc(100vh - 482px) !important; + } + + .scrollable-pnp-telemetry-custom { + height: calc(100vh - 525px) !important; + } + + .scrollable-pnp-telemetry { + height: calc(100vh - 433px); + overflow-y: auto; + overflow-x: hidden; + .column-value-text { + .value-validation-error { + @include themify($themes) { + color: themed('errorText'); + } + padding-top: 10px; + font-size: 14px; + } + } + } + + .pnp-modeled-list { + @include themify($themes) { + background-color: themed('backgroundColor'); + } + .list-header { + font-weight: bold; + border-bottom: 1px solid; + @include themify($themes) { + border-bottom-color: themed('borderColor'); + } + padding-bottom: 5px; + } + } + + .item-summary { + overflow: auto; + width: 100%; + border-bottom: 1px solid; + @include themify($themes) { + border-bottom-color: themed('borderColor'); + } } } \ No newline at end of file diff --git a/src/app/devices/deviceEvents/components/deviceEvents.spec.tsx b/src/app/devices/deviceEvents/components/deviceEvents.spec.tsx index 71de85287..65d54c93c 100644 --- a/src/app/devices/deviceEvents/components/deviceEvents.spec.tsx +++ b/src/app/devices/deviceEvents/components/deviceEvents.spec.tsx @@ -6,100 +6,289 @@ import 'jest'; import * as React from 'react'; import { act } from 'react-dom/test-utils'; import { shallow, mount } from 'enzyme'; -const InfiniteScroll = require('react-infinite-scroller'); // tslint:disable-line: no-var-requires import { TextField } from 'office-ui-fabric-react/lib/components/TextField'; import { Toggle } from 'office-ui-fabric-react/lib/components/Toggle'; import { CommandBar } from 'office-ui-fabric-react/lib/components/CommandBar'; +import { Shimmer } from 'office-ui-fabric-react/lib/components/Shimmer'; import { DeviceEvents } from './deviceEvents'; import { appConfig, HostMode } from '../../../../appConfig/appConfig'; -import { SynchronizationStatus } from '../../../api/models/synchronizationStatus'; import { DEFAULT_CONSUMER_GROUP } from '../../../constants/apiConstants'; -import { MILLISECONDS_IN_MINUTE } from '../../../constants/shared'; +import { startEventsMonitoringAction } from '../actions'; +import * as AsyncSagaReducer from '../../../shared/hooks/useAsyncSagaReducer'; +import { SynchronizationStatus } from '../../../api/models/synchronizationStatus'; +import * as pnpStateContext from '../../../shared/contexts/pnpStateContext'; +import { pnpStateInitial, PnpStateInterface } from '../../pnp/state'; +import { testModelDefinition } from '../../pnp/components/deviceEvents/testData'; +import { REPOSITORY_LOCATION_TYPE } from '../../../constants/repositoryLocationTypes'; +import { ErrorBoundary } from '../../shared/components/errorBoundary'; +import { ResourceKeys } from '../../../../localization/resourceKeys'; const pathname = `#/devices/detail/events/?id=device1`; +const currentTime = new Date(); jest.mock('react-router-dom', () => ({ - useLocation: () => ({ search: `?id=device1`, pathname }), + useHistory: () => ({ push: jest.fn() }), + useLocation: () => ({ search: `?deviceId=device1`, pathname, push: jest.fn() }) })); -describe('components/devices/deviceEvents', () => { - it('matches snapshot in electron', () => { - appConfig.hostMode = HostMode.Electron; - expect(shallow()).toMatchSnapshot(); - }); - - it('matches snapshot in hosted environment', () => { - appConfig.hostMode = HostMode.Browser; - expect(shallow()).toMatchSnapshot(); - }); - - it('changes state accordingly when command bar buttons are clicked', () => { - const wrapper = mount(); - const commandBar = wrapper.find(CommandBar).first(); - // click the start button - act(() => commandBar.props().items[0].onClick(null)); - wrapper.update(); - expect(wrapper.find(InfiniteScroll).first().props().hasMore).toBeTruthy(); - - // click the start button again which has been toggled to stop button - act(() => wrapper.find(CommandBar).first().props().items[0].onClick(null)); - wrapper.update(); - expect(wrapper.find(InfiniteScroll).first().props().hasMore).toBeFalsy(); - - // clear events button should be disabled - expect(commandBar.props().items[1].disabled).toBeTruthy(); - - // click the show system property button - expect(commandBar.props().items[2].iconProps.iconName).toEqual('Checkbox'); - act(() => commandBar.props().items[2].onClick(null)); // tslint:disable-line:no-magic-numbers - wrapper.update(); - const updatedCommandBar = wrapper.find(CommandBar).first(); - expect(updatedCommandBar.props().items[2].iconProps.iconName).toEqual('CheckboxComposite'); - }); - - it('changes state accordingly when consumer group value is changed', () => { - const wrapper = mount(); - const textField = wrapper.find(TextField).first(); - act(() => textField.instance().props.onChange({ target: null}, 'testGroup')); - wrapper.update(); - expect(wrapper.find(TextField).first().props().value).toEqual('testGroup'); - }); - - it('changes state accordingly when custom event hub boolean value is changed', () => { - const wrapper = mount(); - expect(wrapper.find('.custom-event-hub-text-field').length).toEqual(0); - const toggle = wrapper.find(Toggle).at(0); - act(() => toggle.instance().props.onChange({ target: null}, false)); - wrapper.update(); - expect(wrapper.find('.custom-event-hub-text-field').length).toEqual(6); - }); - - it('renders events', () => { +describe('deviceEvents', () => { + describe('deviceEvents in non-pnp context', () => { const events = [{ body: { humid: 123 }, enqueuedTime: '2019-10-14T21:44:58.397Z', properties: { - 'iothub-message-schema': 'humid' + 'iothub-message-schema': 'humid' } }]; + beforeEach(() => { + jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([{ + payload: events, + synchronizationStatus: SynchronizationStatus.fetched + }, jest.fn()]); + const realUseState = React.useState; + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(DEFAULT_CONSUMER_GROUP)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(currentTime)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(true)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false)); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('matches snapshot in electron', () => { + appConfig.hostMode = HostMode.Electron; + expect(shallow()).toMatchSnapshot(); + }); + + it('matches snapshot in hosted environment', () => { + appConfig.hostMode = HostMode.Browser; + expect(shallow()).toMatchSnapshot(); + }); + + it('changes state accordingly when command bar buttons are clicked', () => { + const wrapper = mount(); + const commandBar = wrapper.find(CommandBar).first(); + // tslint:disable-next-line: no-magic-numbers + expect(commandBar.props().items.length).toEqual(3); + + // click the start button + const startEventsMonitoringSpy = jest.spyOn(startEventsMonitoringAction, 'started'); + act(() => commandBar.props().items[0].onClick(null)); + wrapper.update(); + expect(startEventsMonitoringSpy.mock.calls[0][0]).toEqual({ + consumerGroup: DEFAULT_CONSUMER_GROUP, + deviceId: 'device1', + startTime: currentTime + }); + + // click the show system property button + expect(commandBar.props().items[1].iconProps.iconName).toEqual('Checkbox'); + act(() => commandBar.props().items[1].onClick(null)); // tslint:disable-line:no-magic-numbers + wrapper.update(); + const updatedCommandBar = wrapper.find(CommandBar).first(); + expect(updatedCommandBar.props().items[1].iconProps.iconName).toEqual('CheckboxComposite'); + }); + + it('changes state accordingly when consumer group value is changed', () => { + const wrapper = mount(); + const textField = wrapper.find(TextField).first(); + act(() => textField.instance().props.onChange({ target: null}, 'testGroup')); + wrapper.update(); + expect(wrapper.find(TextField).first().props().value).toEqual('testGroup'); + }); + + it('changes state accordingly when custom event hub boolean value is changed', () => { + const wrapper = mount(); + expect(wrapper.find('.custom-event-hub-text-field').length).toEqual(0); + const toggle = wrapper.find(Toggle).at(0); + act(() => toggle.instance().props.onChange({ target: null}, false)); + wrapper.update(); + // tslint:disable-next-line: no-magic-numbers + expect(wrapper.find('.custom-event-hub-text-field').length).toEqual(6); + }); + + it('renders events', () => { + jest.spyOn(pnpStateContext, 'usePnpStateContext').mockReturnValue({ pnpState: pnpStateInitial(), dispatch: jest.fn()}); + const wrapper = mount(); + const rawTelemetry = wrapper.find('article'); + expect(rawTelemetry).toHaveLength(1); + }); + }); + + describe('deviceEvents in pnp context', () => { + const getModelDefinitionMock = jest.fn(); + + const mockFetchedState = () => { + const pnpState: PnpStateInterface = { + ...pnpStateInitial(), + modelDefinitionWithSource: { + payload: { + isModelValid: true, + modelDefinition: testModelDefinition, + source: REPOSITORY_LOCATION_TYPE.Public, + }, + synchronizationStatus: SynchronizationStatus.fetched + } + }; + jest.spyOn(pnpStateContext, 'usePnpStateContext').mockReturnValue({pnpState, dispatch: jest.fn(), getModelDefinition: getModelDefinitionMock}); + }; + + beforeEach(() => { + const realUseState = React.useState; + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(DEFAULT_CONSUMER_GROUP)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(currentTime)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(true)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(true)); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('renders Shimmer while loading', () => { + const pnpState: PnpStateInterface = { + ...pnpStateInitial(), + modelDefinitionWithSource: { + synchronizationStatus: SynchronizationStatus.working + } + }; + jest.spyOn(pnpStateContext, 'usePnpStateContext').mockReturnValueOnce({pnpState, dispatch: jest.fn(), getModelDefinition: jest.fn()}); + const wrapper = mount(); + expect(wrapper.find(Shimmer)).toBeDefined(); + }); + + it('matches snapshot while interface cannot be found', () => { + const pnpState: PnpStateInterface = { + ...pnpStateInitial(), + modelDefinitionWithSource: { + payload: null, + synchronizationStatus: SynchronizationStatus.fetched + } + }; + jest.spyOn(pnpStateContext, 'usePnpStateContext').mockReturnValueOnce({pnpState, dispatch: jest.fn(), getModelDefinition: jest.fn()}); + expect(shallow()).toMatchSnapshot(); + }); + + it('matches snapshot while interface definition is retrieved in electron', () => { + appConfig.hostMode = HostMode.Electron; + mockFetchedState(); + expect(shallow()).toMatchSnapshot(); + }); + + it('matches snapshot while interface definition is retrieved in hosted environment', () => { + appConfig.hostMode = HostMode.Browser; + mockFetchedState(); + expect(shallow()).toMatchSnapshot(); + }); + + it('renders events which body\'s value type is wrong with expected columns', () => { + const events = [{ + body: { + humid: '123' // intentionally set a value which type is double + }, + enqueuedTime: '2019-10-14T21:44:58.397Z', + systemProperties: { + 'iothub-message-schema': 'humid' + } + }]; + jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([{ + payload: events, + synchronizationStatus: SynchronizationStatus.fetched + }, jest.fn()]); + mockFetchedState(); + + const wrapper = mount(); + const errorBoundary = wrapper.find(ErrorBoundary); + expect(errorBoundary.children().at(1).props().children.props.children).toEqual('humid (Temperature)'); + expect(errorBoundary.children().at(2).props().children.props.children).toEqual('double'); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children[0]).toEqual(JSON.stringify(events[0].body, undefined, 2)); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children[1].props['aria-label']).toEqual(ResourceKeys.deviceEvents.columns.validation.value.label); // tslint:disable-line:no-magic-numbers + }); + + it('renders events which body\'s key name is wrong with expected columns', () => { + const events = [{ + body: { + 'non-matching-key': 0 + }, + enqueuedTime: '2019-10-14T21:44:58.397Z', + systemProperties: { + 'iothub-message-schema': 'humid' + } + }]; + jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([{ + payload: events, + synchronizationStatus: SynchronizationStatus.fetched + }, jest.fn()]); + mockFetchedState(); + + const wrapper = mount(); + const errorBoundary = wrapper.find(ErrorBoundary); + expect(errorBoundary.children().at(1).props().children.props.children).toEqual('humid (Temperature)'); + expect(errorBoundary.children().at(2).props().children.props.children).toEqual('double'); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children[0]).toEqual(JSON.stringify(events[0].body, undefined, 2)); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children[1].props.className).toEqual('value-validation-error'); // tslint:disable-line:no-magic-numbers + }); + + it('renders events when body is exploded and schema is not provided in system properties', () => { + const events = [{ + body: { + 'humid': 0, + 'humid-foo': 'test' + }, + enqueuedTime: '2019-10-14T21:44:58.397Z', + systemProperties: {} + }]; + jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([{ + payload: events, + synchronizationStatus: SynchronizationStatus.fetched + }, jest.fn()]); + mockFetchedState(); + + const wrapper = shallow(); + let errorBoundary = wrapper.find(ErrorBoundary).first(); + expect(errorBoundary.children().at(1).props().children.props.children).toEqual('humid (Temperature)'); + expect(errorBoundary.children().at(2).props().children.props.children).toEqual('double'); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children[0]).toEqual(JSON.stringify({humid: 0}, undefined, 2)); // tslint:disable-line:no-magic-numbers + + errorBoundary = wrapper.find(ErrorBoundary).at(1); + expect(errorBoundary.children().at(1).props().children.props.children).toEqual('--'); + expect(errorBoundary.children().at(2).props().children.props.children).toEqual('--'); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children[0]).toEqual(JSON.stringify({'humid-foo': 'test'}, undefined, 2)); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children[1].props.className).toEqual('value-validation-error'); // tslint:disable-line:no-magic-numbers + }); + + it('renders events which body\'s key name is not populated', () => { + const events = [{ + body: { + 'non-matching-key': 0 + }, + enqueuedTime: '2019-10-14T21:44:58.397Z' + }]; + jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([{ + payload: events, + synchronizationStatus: SynchronizationStatus.fetched + }, jest.fn()]); + mockFetchedState(); - const realUseState = React.useState; - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(DEFAULT_CONSUMER_GROUP)); - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(events)); - - const wrapper = mount(); - const enqueueTime = wrapper.find('h5'); - // tslint:disable-next-line:no-any - expect((enqueueTime.props().children as any).join('')).toBeDefined(); - - // click the clear events button - expect(wrapper.find(InfiniteScroll).first().props().role).toEqual('feed'); - const commandBar = wrapper.find(CommandBar).first(); - act(() => commandBar.props().items[1].onClick(null)); - wrapper.update(); - expect(wrapper.find(InfiniteScroll).first().props().role).toEqual('main'); + const wrapper = shallow(); + const errorBoundary = wrapper.find(ErrorBoundary); + expect(errorBoundary.children().at(1).props().children.props.children).toEqual('--'); + expect(errorBoundary.children().at(2).props().children.props.children).toEqual('--'); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children).toEqual(JSON.stringify(events[0].body, undefined, 2)); // tslint:disable-line:no-magic-numbers + }); }); }); diff --git a/src/app/devices/deviceEvents/components/deviceEvents.tsx b/src/app/devices/deviceEvents/components/deviceEvents.tsx index 00b3a88a2..62fd739a1 100644 --- a/src/app/devices/deviceEvents/components/deviceEvents.tsx +++ b/src/app/devices/deviceEvents/components/deviceEvents.tsx @@ -6,76 +6,133 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { CommandBar, ICommandBarItemProps } from 'office-ui-fabric-react/lib/components/CommandBar'; import { Spinner } from 'office-ui-fabric-react/lib/components/Spinner'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useHistory } from 'react-router-dom'; import { TextField, ITextFieldProps } from 'office-ui-fabric-react/lib/components/TextField'; import { Announced } from 'office-ui-fabric-react/lib/components/Announced'; import { Toggle } from 'office-ui-fabric-react/lib/components/Toggle'; +import { Label } from 'office-ui-fabric-react/lib/components/Label'; import { ResourceKeys } from '../../../../localization/resourceKeys'; -import { monitorEvents, stopMonitoringEvents } from '../../../api/services/devicesService'; -import { Message } from '../../../api/models/messages'; +import { Message, MESSAGE_SYSTEM_PROPERTIES, MESSAGE_PROPERTIES } from '../../../api/models/messages'; import { parseDateTimeString } from '../../../api/dataTransforms/transformHelper'; -import { CLEAR, CHECKED_CHECKBOX, EMPTY_CHECKBOX, START, STOP } from '../../../constants/iconNames'; -import { getDeviceIdFromQueryString } from '../../../shared/utils/queryStringHelper'; +import { CLEAR, CHECKED_CHECKBOX, EMPTY_CHECKBOX, START, STOP, NAVIGATE_BACK, REFRESH, REMOVE } from '../../../constants/iconNames'; +import { getDeviceIdFromQueryString, getComponentNameFromQueryString, getInterfaceIdFromQueryString } from '../../../shared/utils/queryStringHelper'; import { SynchronizationStatus } from '../../../api/models/synchronizationStatus'; import { MonitorEventsParameters } from '../../../api/parameters/deviceParameters'; -import { NotificationType } from '../../../api/models/notification'; import { LabelWithTooltip } from '../../../shared/components/labelWithTooltip'; import { DEFAULT_CONSUMER_GROUP } from '../../../constants/apiConstants'; import { MILLISECONDS_IN_MINUTE } from '../../../constants/shared'; import { appConfig, HostMode } from '../../../../appConfig/appConfig'; import { HeaderView } from '../../../shared/components/headerView'; import { isValidEventHubConnectionString } from '../../../shared/utils/hubConnectionStringHelper'; -import { raiseNotificationToast } from '../../../notifications/components/notificationToast'; -import '../../../css/_deviceEvents.scss'; +import { useAsyncSagaReducer } from '../../../shared/hooks/useAsyncSagaReducer'; +import { deviceEventsReducer } from '../reducers'; +import { EventMonitoringSaga } from '../saga'; +import { deviceEventsStateInitial } from '../state'; +import { startEventsMonitoringAction, stopEventsMonitoringAction, clearMonitoringEventsAction } from './../actions'; +import { DEFAULT_COMPONENT_FOR_DIGITAL_TWIN } from '../../../constants/devices'; +import { usePnpStateContext } from '../../../shared/contexts/pnpStateContext'; +import { getDeviceTelemetry, TelemetrySchema } from '../../pnp/components/deviceEvents/dataHelper'; +import { ROUTE_PARAMS } from '../../../constants/routes'; +import { MultiLineShimmer } from '../../../shared/components/multiLineShimmer'; +import { ErrorBoundary } from '../../shared/components/errorBoundary'; +import { SemanticUnit } from '../../../shared/units/components/semanticUnit'; +import { getSchemaValidationErrors } from '../../../shared/utils/jsonSchemaAdaptor'; +import { ParsedJsonSchema } from '../../../api/models/interfaceJsonParserOutput'; +import { TelemetryContent } from '../../../api/models/modelDefinition'; +import { getLocalizedData } from '../../../api/dataTransforms/modelDefinitionTransform'; +import './deviceEvents.scss'; const JSON_SPACES = 2; -const LOADING_LOCK = 50; - -export interface ConfigurationSettings { - consumerGroup: string; - useBuiltInEventHub: boolean; - customEventHubName?: string; - customEventHubConnectionString?: string; -} +const LOADING_LOCK = 2000; export const DeviceEvents: React.FC = () => { - let timerID: any; // tslint:disable-line:no-any const { t } = useTranslation(); - const { search } = useLocation(); + const { search, pathname } = useLocation(); + const history = useHistory(); const deviceId = getDeviceIdFromQueryString(search); + const [ localState, dispatch ] = useAsyncSagaReducer(deviceEventsReducer, EventMonitoringSaga, deviceEventsStateInitial(), 'deviceEventsState'); + const synchronizationStatus = localState.synchronizationStatus; + const events = localState.payload; + + // event hub settings const [ consumerGroup, setConsumerGroup] = React.useState(DEFAULT_CONSUMER_GROUP); const [ customEventHubConnectionString, setCustomEventHubConnectionString] = React.useState(undefined); const [ customEventHubName, setCustomEventHubName] = React.useState(undefined); - const [ events, SetEvents] = React.useState([]); const [ startTime, SetStartTime] = React.useState(new Date(new Date().getTime() - MILLISECONDS_IN_MINUTE)); const [ useBuiltInEventHub, setUseBuiltInEventHub] = React.useState(true); - const [ hasMore, setHasMore ] = React.useState(false); - const [ loading, setLoading ] = React.useState(false); + const [ showSystemProperties, setShowSystemProperties ] = React.useState(false); + + // event message state const [ loadingAnnounced, setLoadingAnnounced ] = React.useState(undefined); const [ monitoringData, setMonitoringData ] = React.useState(false); - const [ synchronizationStatus, setSynchronizationStatus ] = React.useState(SynchronizationStatus.initialized); - const [ showSystemProperties, setShowSystemProperties ] = React.useState(false); + + // pnp events specific + const TELEMETRY_SCHEMA_PROP = MESSAGE_PROPERTIES.IOTHUB_MESSAGE_SCHEMA; + const componentName = getComponentNameFromQueryString(search); // if component name exist, we are in pnp context + const interfaceId = getInterfaceIdFromQueryString(search); + const { pnpState, getModelDefinition } = usePnpStateContext(); + const modelDefinitionWithSource = pnpState.modelDefinitionWithSource.payload; + const modelDefinition = modelDefinitionWithSource && modelDefinitionWithSource.modelDefinition; + const isLoading = pnpState.modelDefinitionWithSource.synchronizationStatus === SynchronizationStatus.working; + const telemetrySchema = React.useMemo(() => getDeviceTelemetry(modelDefinition), [modelDefinition]); + const [ showPnpModeledEvents, setShowPnpModeledEvents ] = React.useState(false); + React.useEffect(() => { return () => { stopMonitoring(); }; }, []); + // tslint:disable-next-line: cyclomatic-complexity + React.useEffect(() => { + if (synchronizationStatus === SynchronizationStatus.fetched) { + if (appConfig.hostMode === HostMode.Electron) { + if (monitoringData) { + SetStartTime(new Date()); + setTimeout(() => { + fetchData(); + }, LOADING_LOCK); + } + else { + stopMonitoring(); + } + } + else { + stopMonitoring(); + } + } + if (synchronizationStatus === SynchronizationStatus.upserted) { + SetStartTime(new Date()); + setMonitoringData(false); + } + if (monitoringData && synchronizationStatus === SynchronizationStatus.failed) { + stopMonitoring(); + } + }, [synchronizationStatus]); + const createCommandBarItems = (): ICommandBarItemProps[] => { - return [ - createStartMonitoringCommandItem(), - createClearCommandItem(), - createSystemPropertiesCommandItem() - ]; + if (componentName) { + return [createStartMonitoringCommandItem(), + createPnpModeledEventsCommandItem(), + createSystemPropertiesCommandItem(), + createRefreshCommandItem(), + createClearCommandItem() + ]; + } + else { + return [createStartMonitoringCommandItem(), + createSystemPropertiesCommandItem(), + createClearCommandItem() + ]; + } }; const createClearCommandItem = (): ICommandBarItemProps => { return { ariaLabel: t(ResourceKeys.deviceEvents.command.clearEvents), - disabled: events.length === 0 || synchronizationStatus === SynchronizationStatus.updating, iconProps: { - iconName: CLEAR + iconName: REMOVE }, key: CLEAR, name: t(ResourceKeys.deviceEvents.command.clearEvents), @@ -86,7 +143,7 @@ export const DeviceEvents: React.FC = () => { const createSystemPropertiesCommandItem = (): ICommandBarItemProps => { return { ariaLabel: t(ResourceKeys.deviceEvents.command.showSystemProperties), - disabled: synchronizationStatus === SynchronizationStatus.updating, + disabled: synchronizationStatus === SynchronizationStatus.updating || showPnpModeledEvents, iconProps: { iconName: showSystemProperties ? CHECKED_CHECKBOX : EMPTY_CHECKBOX }, @@ -96,6 +153,18 @@ export const DeviceEvents: React.FC = () => { }; }; + const createPnpModeledEventsCommandItem = (): ICommandBarItemProps => { + return { + ariaLabel: 'Show modeled events', + iconProps: { + iconName: showPnpModeledEvents ? CHECKED_CHECKBOX : EMPTY_CHECKBOX + }, + key: EMPTY_CHECKBOX, + name: 'Show modeled events', + onClick: onShowPnpModeledEvents + }; + }; + // tslint:disable-next-line: cyclomatic-complexity const createStartMonitoringCommandItem = (): ICommandBarItemProps => { if (appConfig.hostMode === HostMode.Electron) { @@ -215,144 +284,367 @@ export const DeviceEvents: React.FC = () => { ); }; - const stopMonitoring = async () => { - clearTimeout(timerID); - return stopMonitoringEvents(); + const stopMonitoring = () => { + dispatch(stopEventsMonitoringAction.started()); }; const onToggleStart = () => { if (monitoringData) { - stopMonitoring().then(() => { - setHasMore(false); - setMonitoringData(false); - setSynchronizationStatus(SynchronizationStatus.fetched); - }); - setHasMore(false); - setSynchronizationStatus(SynchronizationStatus.updating); - } else { - setHasMore(true); - setLoading(false); + setMonitoringData(false); setLoadingAnnounced(undefined); + } else { + fetchData(); setMonitoringData(true); + setLoadingAnnounced(); } }; - const renderInfiniteScroll = () => { - const InfiniteScroll = require('react-infinite-scroller'); // https://github.com/CassetteRocks/react-infinite-scroller/issues/110 - return ( - - {renderEvents()} - - ); + const filterMessage = (message: Message) => { + if (!message || !message.systemProperties) { + return false; + } + if (componentName === DEFAULT_COMPONENT_FOR_DIGITAL_TWIN) { + // for default component, we only expect ${IOTHUB_INTERFACE_ID} to be in the system property not ${IOTHUB_COMPONENT_NAME} + return message.systemProperties[MESSAGE_SYSTEM_PROPERTIES.IOTHUB_INTERFACE_ID] === interfaceId && + !message.systemProperties[MESSAGE_SYSTEM_PROPERTIES.IOTHUB_COMPONENT_NAME]; + } + return message.systemProperties[MESSAGE_SYSTEM_PROPERTIES.IOTHUB_COMPONENT_NAME] === componentName; }; - const renderEvents = () => { + const renderRawEvents = () => { + const filteredEvents = componentName ? events.filter(result => filterMessage(result)) : events; return ( -
+ <> { - events && events.map((event: Message, index) => { + filteredEvents && filteredEvents.map((event: Message, index) => { + const modifiedEvents = showSystemProperties ? event : { + body: event.body, + enqueuedTime: event.enqueuedTime, + properties: event.properties + }; return (
- {
{parseDateTimeString(event.enqueuedTime)}:
} -
{JSON.stringify(event, undefined, JSON_SPACES)}
+ {
{parseDateTimeString(modifiedEvents.enqueuedTime)}:
} +
{JSON.stringify(modifiedEvents, undefined, JSON_SPACES)}
); }) } -
+ ); }; - const renderLoader = (): JSX.Element => { + //#region pnp specific render + const renderPnpModeledEvents = () => { + return ( + <> + { + events && events.length > 0 && + <> +
+
+ {t(ResourceKeys.deviceEvents.columns.timestamp)} + {t(ResourceKeys.deviceEvents.columns.displayName)} + {t(ResourceKeys.deviceEvents.columns.schema)} + {t(ResourceKeys.deviceEvents.columns.unit)} + {t(ResourceKeys.deviceEvents.columns.value)} +
+
+
+ { + events.map((event: Message, index) => { + return !event.systemProperties ? renderEventsWithNoSystemProperties(event, index) : + event.systemProperties[TELEMETRY_SCHEMA_PROP] ? + renderEventsWithSchemaProperty(event, index) : + renderEventsWithNoSchemaProperty(event, index); + }) + } +
+ + } + + ); + }; + + const renderEventsWithNoSystemProperties = (event: Message, index: number, ) => { + return ( +
+
+ + {renderTimestamp(event.enqueuedTime)} + {renderEventName()} + {renderEventSchema()} + {renderEventUnit()} + {renderMessageBodyWithNoSchema(event.body)} + +
+
+ ); + }; + + const renderEventsWithSchemaProperty = (event: Message, index: number) => { + const { telemetryModelDefinition, parsedSchema } = getModelDefinitionAndSchema(event.systemProperties[TELEMETRY_SCHEMA_PROP]); + + return ( +
+
+ + {renderTimestamp(event.enqueuedTime)} + {renderEventName(telemetryModelDefinition)} + {renderEventSchema(telemetryModelDefinition)} + {renderEventUnit(telemetryModelDefinition)} + {renderMessageBodyWithSchema(event.body, parsedSchema, event.systemProperties[TELEMETRY_SCHEMA_PROP])} + +
+
+ ); + }; + + const renderEventsWithNoSchemaProperty = (event: Message, index: number) => { + const telemetryKeys = Object.keys(event.body); + if (telemetryKeys && telemetryKeys.length !== 0) { + return telemetryKeys.map((key, keyIndex) => { + const { telemetryModelDefinition, parsedSchema } = getModelDefinitionAndSchema(key); + const partialEventBody: any = {}; // tslint:disable-line:no-any + partialEventBody[key] = event.body[key]; + const isNotItemLast = keyIndex !== telemetryKeys.length - 1; + + return ( +
+
+ + {renderTimestamp(keyIndex === 0 ? event.enqueuedTime : null)} + {renderEventName(telemetryModelDefinition)} + {renderEventSchema(telemetryModelDefinition)} + {renderEventUnit(telemetryModelDefinition)} + {renderMessageBodyWithSchema(partialEventBody, parsedSchema, key)} + +
+
+ ); + }); + } return ( -
- -

{t(ResourceKeys.deviceEvents.infiniteScroll.loading)}

+
+
+ + {renderTimestamp(event.enqueuedTime)} + {renderEventName()} + {renderEventSchema()} + {renderEventUnit()} + {renderMessageBodyWithSchema(event.body, null, null)} + +
+
+ ); + }; + + const renderTimestamp = (enqueuedTime: string) => { + return( +
+
); }; - const fetchData = () => { - if (!loading && monitoringData) { - setLoading(true); - setLoadingAnnounced(); - timerID = setTimeout( - () => { - let parameters: MonitorEventsParameters = { - consumerGroup, - deviceId, - fetchSystemProperties: showSystemProperties, - startTime - }; + const renderEventName = (telemetryModelDefinition?: TelemetryContent) => { + const displayName = telemetryModelDefinition ? getLocalizedData(telemetryModelDefinition.displayName) : ''; + return( +
+ +
+ ); + }; - if (!useBuiltInEventHub && customEventHubConnectionString && customEventHubName) { - parameters = { - ...parameters, - customEventHubConnectionString, - customEventHubName - }; + const renderEventSchema = (telemetryModelDefinition?: TelemetryContent) => { + return( +
+ +
+ ); + }; + + const renderEventUnit = (telemetryModelDefinition?: TelemetryContent) => { + return( +
+ +
+ ); + }; + + // tslint:disable-next-line: cyclomatic-complexity + const renderMessageBodyWithSchema = (eventBody: any, schema: ParsedJsonSchema, key: string) => { // tslint:disable-line:no-any + if (key && !schema) { // DTDL doesn't contain corresponding key + const labelContent = t(ResourceKeys.deviceEvents.columns.validation.key.isNotSpecified, { key }); + return( +
+ + {JSON.stringify(eventBody, undefined, JSON_SPACES)} + + +
+ ); + } + + if (eventBody && Object.keys(eventBody) && Object.keys(eventBody)[0] !== key) { // key in event body doesn't match property name + const labelContent = Object.keys(eventBody)[0] ? t(ResourceKeys.deviceEvents.columns.validation.key.doesNotMatch, { + expectedKey: key, + receivedKey: Object.keys(eventBody)[0] + }) : t(ResourceKeys.deviceEvents.columns.validation.value.bodyIsEmpty); + return( +
+ + {JSON.stringify(eventBody, undefined, JSON_SPACES)} + + +
+ ); + } + + return renderMessageBodyWithValueValidation(eventBody, schema, key); + }; + + const renderMessageBodyWithValueValidation = (eventBody: any, schema: ParsedJsonSchema, key: string) => { // tslint:disable-line:no-any + const errors = getSchemaValidationErrors(eventBody[key], schema, true); + + return( +
+ +
+ ); + }; - monitorEvents(parameters) - .then(results => { - const messages = results ? results.reverse().map((message: Message) => message) : []; - SetEvents([...messages, ...events]); - SetStartTime(new Date()); - setLoading(false); - stopMonitoringIfNecessary(); - }) - .catch (error => { - raiseNotificationToast({ - text: { - translationKey: ResourceKeys.deviceEvents.error, - translationOptions: { - error - } - }, - type: NotificationType.error - }); - stopMonitoringIfNecessary(); - }); - }, - LOADING_LOCK); + const renderMessageBodyWithNoSchema = (eventBody: any) => { // tslint:disable-line:no-any + return( +
+ +
+ ); + }; + + const getModelDefinitionAndSchema = (key: string): TelemetrySchema => { + const matchingSchema = telemetrySchema.filter(schema => schema.telemetryModelDefinition.name === key); + const telemetryModelDefinition = matchingSchema && matchingSchema.length !== 0 && matchingSchema[0].telemetryModelDefinition; + const parsedSchema = matchingSchema && matchingSchema.length !== 0 && matchingSchema[0].parsedSchema; + return { + parsedSchema, + telemetryModelDefinition + }; + }; + + const createRefreshCommandItem = (): ICommandBarItemProps => { + return { + ariaLabel: t(ResourceKeys.deviceEvents.command.refresh), + disabled: synchronizationStatus === SynchronizationStatus.updating, + iconProps: {iconName: REFRESH}, + key: REFRESH, + name: t(ResourceKeys.deviceEvents.command.refresh), + onClick: getModelDefinition + }; + }; + + const createNavigateBackCommandItem = (): ICommandBarItemProps => { + return { + ariaLabel: t(ResourceKeys.deviceEvents.command.close), + iconProps: {iconName: NAVIGATE_BACK}, + key: NAVIGATE_BACK, + name: t(ResourceKeys.deviceEvents.command.close), + onClick: handleClose + }; + }; + + const handleClose = () => { + const path = pathname.replace(/\/ioTPlugAndPlayDetail\/events\/.*/, ``); + history.push(`${path}/?${ROUTE_PARAMS.DEVICE_ID}=${encodeURIComponent(deviceId)}`); + }; + //#endregion + + const renderLoader = (): JSX.Element => { + return ( + <> + {monitoringData && ( +
+ +

{t(ResourceKeys.deviceEvents.infiniteScroll.loading)}

+
+ )} + + ); + }; + + const fetchData = () => { + let parameters: MonitorEventsParameters = { + consumerGroup, + deviceId, + startTime + }; + + if (!useBuiltInEventHub) { + parameters = { + ...parameters, + customEventHubConnectionString, + customEventHubName + }; } + + dispatch(startEventsMonitoringAction.started(parameters)); }; const onClearData = () => { - SetEvents([]); + dispatch(clearMonitoringEventsAction()); }; const onShowSystemProperties = () => { setShowSystemProperties(!showSystemProperties); }; - const stopMonitoringIfNecessary = () => { - if (appConfig.hostMode === HostMode.Electron) { - return; - } - else { - stopMonitoring().then(() => { - setHasMore(false); - setMonitoringData(false); - setSynchronizationStatus(SynchronizationStatus.fetched); - }); - } + const onShowPnpModeledEvents = () => { + setShowPnpModeledEvents(!showPnpModeledEvents); }; + if (isLoading) { + return ; + } + + const className = componentName ? + 'scrollable-pnp-telemetry' + (!useBuiltInEventHub ? ' scrollable-pnp-telemetry-custom' : '') : + 'scrollable-telemetry' + (!useBuiltInEventHub ? ' scrollable-telemetry-custom' : ''); return (
{ /> {renderConsumerGroup()} {renderCustomEventHub()} - {renderInfiniteScroll()} +
+ {renderLoader()} +
+ {showPnpModeledEvents ? renderPnpModeledEvents() : renderRawEvents()} +
+
{loadingAnnounced}
); diff --git a/src/app/devices/deviceEvents/reducer.spec.ts b/src/app/devices/deviceEvents/reducer.spec.ts new file mode 100644 index 000000000..eab91ba17 --- /dev/null +++ b/src/app/devices/deviceEvents/reducer.spec.ts @@ -0,0 +1,64 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import 'jest'; +import { START_EVENTS_MONITORING, STOP_EVENTS_MONITORING } from '../../constants/actionTypes'; +import { startEventsMonitoringAction, stopEventsMonitoringAction } from './actions'; +import { deviceEventsReducer } from './reducers'; +import { deviceEventsStateInitial } from './state'; +import { SynchronizationStatus } from '../../api/models/synchronizationStatus'; +import { DEFAULT_CONSUMER_GROUP } from './../../constants/apiConstants'; + +describe('deviceEventsReducer', () => { + const deviceId = 'testDeviceId'; + const params = {consumerGroup: DEFAULT_CONSUMER_GROUP, deviceId, startTime: new Date()}; + const events = [{ + body: { + humid: '123' // intentionally set a value which type is double + }, + enqueuedTime: '2019-10-14T21:44:58.397Z', + systemProperties: { + 'iothub-message-schema': 'humid' + } + }]; + it (`handles ${START_EVENTS_MONITORING}/ACTION_START action`, () => { + const action = startEventsMonitoringAction.started(params); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).synchronizationStatus).toEqual(SynchronizationStatus.working); + }); + + it (`handles ${START_EVENTS_MONITORING}/ACTION_DONE action`, () => { + const action = startEventsMonitoringAction.done({params, result: events}); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).payload).toEqual(events); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).synchronizationStatus).toEqual(SynchronizationStatus.fetched); + }); + + it (`handles ${START_EVENTS_MONITORING}/ACTION_FAILED action`, () => { + const action = startEventsMonitoringAction.failed({error: -1, params}); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).synchronizationStatus).toEqual(SynchronizationStatus.failed); + }); + + let initialState = deviceEventsStateInitial(); + initialState = initialState.merge({ + payload: events, + synchronizationStatus: SynchronizationStatus.fetched + }); + + it (`handles ${STOP_EVENTS_MONITORING}/ACTION_START action`, () => { + const action = stopEventsMonitoringAction.started(); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).synchronizationStatus).toEqual(SynchronizationStatus.updating); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).payload).toEqual([]); + }); + + it (`handles ${STOP_EVENTS_MONITORING}/ACTION_DONE action`, () => { + const action = stopEventsMonitoringAction.done({}); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).synchronizationStatus).toEqual(SynchronizationStatus.upserted); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).payload).toEqual([]); + }); + + it (`handles ${STOP_EVENTS_MONITORING}/ACTION_FAILED action`, () => { + const action = stopEventsMonitoringAction.failed({error: -1}); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).synchronizationStatus).toEqual(SynchronizationStatus.failed); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).payload).toEqual([]); + }); +}); diff --git a/src/app/devices/deviceEvents/reducers.ts b/src/app/devices/deviceEvents/reducers.ts new file mode 100644 index 000000000..03d0cd191 --- /dev/null +++ b/src/app/devices/deviceEvents/reducers.ts @@ -0,0 +1,58 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import { reducerWithInitialState } from 'typescript-fsa-reducers'; +import { DeviceEventsStateInterface, deviceEventsStateInitial, DeviceEventsStateType } from './state'; +import { + startEventsMonitoringAction, + stopEventsMonitoringAction, + clearMonitoringEventsAction +} from './actions'; +import { SynchronizationStatus } from '../../api/models/synchronizationStatus'; +import { MonitorEventsParameters } from '../../api/parameters/deviceParameters'; +import { Message } from '../../api/models/messages'; + +export const deviceEventsReducer = reducerWithInitialState(deviceEventsStateInitial()) + .case(startEventsMonitoringAction.started, (state: DeviceEventsStateType) => { + return state.merge({ + synchronizationStatus: SynchronizationStatus.working + }); + }) + .case(startEventsMonitoringAction.done, (state: DeviceEventsStateType, payload: {params: MonitorEventsParameters, result: Message[]}) => { + const messages = payload.result ? payload.result.reverse().map((message: Message) => message) : []; + let filteredMessages = messages; + if (state.payload.length > 0 && messages.length > 0) { + // filter overlaped messages returned from event hub + filteredMessages = messages.filter(message => message.enqueuedTime > state.payload[0].enqueuedTime); + } + return state.merge({ + payload: [...filteredMessages, ...state.payload], + synchronizationStatus: SynchronizationStatus.fetched + }); + }) + .case(startEventsMonitoringAction.failed, (state: DeviceEventsStateType) => { + return state.merge({ + synchronizationStatus: SynchronizationStatus.failed + }); + }) + .case(stopEventsMonitoringAction.started, (state: DeviceEventsStateType) => { + return state.merge({ + synchronizationStatus: SynchronizationStatus.updating + }); + }) + .case(stopEventsMonitoringAction.done, (state: DeviceEventsStateType) => { + return state.merge({ + synchronizationStatus: SynchronizationStatus.upserted + }); + }) + .case(stopEventsMonitoringAction.failed, (state: DeviceEventsStateType) => { + return state.merge({ + synchronizationStatus: SynchronizationStatus.failed + }); + }) + .case(clearMonitoringEventsAction, (state: DeviceEventsStateType) => { + return state.merge({ + payload: [] + }); + }); diff --git a/src/app/devices/deviceEvents/saga.spec.ts b/src/app/devices/deviceEvents/saga.spec.ts new file mode 100644 index 000000000..02e8a7fcd --- /dev/null +++ b/src/app/devices/deviceEvents/saga.spec.ts @@ -0,0 +1,130 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import 'jest'; +import { put, call } from 'redux-saga/effects'; +// tslint:disable-next-line: no-implicit-dependencies +import { cloneableGenerator } from '@redux-saga/testing-utils'; +import { startEventsMonitoringSagaWorker, stopEventsMonitoringSagaWorker } from './saga'; +import { startEventsMonitoringAction, stopEventsMonitoringAction } from './actions'; +import * as DevicesService from '../../api/services/devicesService'; +import { ResourceKeys } from '../../../localization/resourceKeys'; +import { NotificationType } from '../../api/models/notification'; +import { raiseNotificationToast } from '../../notifications/components/notificationToast'; +import { DEFAULT_CONSUMER_GROUP } from '../../constants/apiConstants'; + +describe('deviceMonitoringSaga', () => { + let startEventsMonitoringSagaGenerator; + let stopEventsMonitoringSagaGenerator; + + const mockMonitorEventsFn = jest.spyOn(DevicesService, 'monitorEvents').mockImplementationOnce(parameters => { + return null; + }); + const mockStopMonitorEventsFn = jest.spyOn(DevicesService, 'stopMonitoringEvents').mockImplementationOnce(() => { + return null; + }); + const deviceId = 'test_id'; + const params = {consumerGroup: DEFAULT_CONSUMER_GROUP, deviceId, startTime: new Date()}; + + beforeEach(() => { + startEventsMonitoringSagaGenerator = cloneableGenerator(startEventsMonitoringSagaWorker)(startEventsMonitoringAction.started(params)); + stopEventsMonitoringSagaGenerator = cloneableGenerator(stopEventsMonitoringSagaWorker)(); + }); + + it('start device events monitoring', () => { + // call for device id + expect(startEventsMonitoringSagaGenerator.next()).toEqual({ + done: false, + value: call(mockMonitorEventsFn, params) + }); + + // add to store + expect(startEventsMonitoringSagaGenerator.next([])).toEqual({ + done: false, + value: put(startEventsMonitoringAction.done({ + params, + result: [] + })) + }); + + // success + const success = startEventsMonitoringSagaGenerator.clone(); + expect(success.next()).toEqual({ + done: true + }); + + // failure + const failed = startEventsMonitoringSagaGenerator.clone(); + const error = { code: -1 }; + + expect(failed.throw(error)).toEqual({ + done: false, + value: call(raiseNotificationToast, { + text: { + translationKey: ResourceKeys.notifications.startEventMonitoringOnError, + translationOptions: { + error + }, + }, + type: NotificationType.error + }) + }); + + expect(failed.next([])).toEqual({ + done: false, + value: put(startEventsMonitoringAction.failed({ + error, + params + })) + }); + + expect(failed.next().done).toEqual(true); + }); + + it('stop device events monitoring', () => { + // call for device id + expect(stopEventsMonitoringSagaGenerator.next()).toEqual({ + done: false, + value: call(mockStopMonitorEventsFn) + }); + + // add to store + expect(stopEventsMonitoringSagaGenerator.next([])).toEqual({ + done: false, + value: put(stopEventsMonitoringAction.done({})) + }); + + // success + const success = stopEventsMonitoringSagaGenerator.clone(); + expect(success.next()).toEqual({ + done: true + }); + + // failure + const failed = stopEventsMonitoringSagaGenerator.clone(); + const error = { code: -1 }; + + expect(failed.throw(error)).toEqual({ + done: false, + value: call(raiseNotificationToast, { + text: { + translationKey: ResourceKeys.notifications.stopEventMonitoringOnError, + translationOptions: { + error + }, + }, + type: NotificationType.error + }) + }); + + expect(failed.next([])).toEqual({ + done: false, + value: put(stopEventsMonitoringAction.failed({ + error + })) + }); + + expect(failed.next().done).toEqual(true); + }); +}); diff --git a/src/app/devices/deviceEvents/saga.ts b/src/app/devices/deviceEvents/saga.ts new file mode 100644 index 000000000..b8b685d51 --- /dev/null +++ b/src/app/devices/deviceEvents/saga.ts @@ -0,0 +1,55 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import { call, put, all, takeLatest, takeEvery } from 'redux-saga/effects'; +import { Action } from 'typescript-fsa'; +import { monitorEvents, stopMonitoringEvents } from '../../api/services/devicesService'; +import { NotificationType } from '../../api/models/notification'; +import { ResourceKeys } from '../../../localization/resourceKeys'; +import { startEventsMonitoringAction, stopEventsMonitoringAction } from './actions'; +import { raiseNotificationToast } from '../../notifications/components/notificationToast'; +import { MonitorEventsParameters } from '../../api/parameters/deviceParameters'; + +export function* startEventsMonitoringSagaWorker(action: Action) { + try { + const messages = yield call(monitorEvents, action.payload); + yield put(startEventsMonitoringAction.done({params: action.payload, result: messages})); + } catch (error) { + yield call(raiseNotificationToast, { + text: { + translationKey: ResourceKeys.notifications.startEventMonitoringOnError, + translationOptions: { + error, + }, + }, + type: NotificationType.error + }); + yield put(startEventsMonitoringAction.failed({params: action.payload, error})); + } +} + +export function* stopEventsMonitoringSagaWorker() { + try { + yield call(stopMonitoringEvents); + yield put(stopEventsMonitoringAction.done({})); + } catch (error) { + yield call(raiseNotificationToast, { + text: { + translationKey: ResourceKeys.notifications.stopEventMonitoringOnError, + translationOptions: { + error, + }, + }, + type: NotificationType.error + }); + yield put(stopEventsMonitoringAction.failed({error})); + } +} + +export function* EventMonitoringSaga() { + yield all([ + takeEvery(startEventsMonitoringAction.started.type, startEventsMonitoringSagaWorker), + takeLatest(stopEventsMonitoringAction.started.type, stopEventsMonitoringSagaWorker), + ]); +} diff --git a/src/app/devices/deviceEvents/state.ts b/src/app/devices/deviceEvents/state.ts new file mode 100644 index 000000000..d6ec024e9 --- /dev/null +++ b/src/app/devices/deviceEvents/state.ts @@ -0,0 +1,18 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import { Record } from 'immutable'; +import { IM } from '../../shared/types/types'; +import { SynchronizationWrapper } from '../../api/models/synchronizationWrapper'; +import { Message } from '../../api/models/messages'; +import { SynchronizationStatus } from '../../api/models/synchronizationStatus'; + +export interface DeviceEventsStateInterface extends SynchronizationWrapper{} + +export const deviceEventsStateInitial = Record({ + payload: [], + synchronizationStatus: SynchronizationStatus.initialized +}); + +export type DeviceEventsStateType = IM; diff --git a/src/app/devices/module/moduleIdentityTwin/components/moduleIdentityTwin.tsx b/src/app/devices/module/moduleIdentityTwin/components/moduleIdentityTwin.tsx index 062655562..d3c361d12 100644 --- a/src/app/devices/module/moduleIdentityTwin/components/moduleIdentityTwin.tsx +++ b/src/app/devices/module/moduleIdentityTwin/components/moduleIdentityTwin.tsx @@ -6,8 +6,6 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { Route, useLocation, useHistory } from 'react-router-dom'; import { CommandBar } from 'office-ui-fabric-react/lib/components/CommandBar'; -import { SpinnerSize, Spinner } from 'office-ui-fabric-react/lib/components/Spinner'; -import { useThemeContext } from '../../../../shared/contexts/themeContext'; import { ResourceKeys } from '../../../../../localization/resourceKeys'; import { getDeviceIdFromQueryString, getModuleIdentityIdFromQueryString } from '../../../../shared/utils/queryStringHelper'; import { REFRESH, NAVIGATE_BACK } from '../../../../constants/iconNames'; @@ -26,7 +24,6 @@ import '../../../../css/_moduleIdentityDetail.scss'; export const ModuleIdentityTwin: React.FC = () => { const { t } = useTranslation(); - const { editorTheme } = useThemeContext(); const { search, pathname } = useLocation(); const history = useHistory(); const moduleId = getModuleIdentityIdFromQueryString(search); diff --git a/src/app/devices/module/shared/components/__snapshots__/moduleIdentityDetailHeader.spec.tsx.snap b/src/app/devices/module/shared/components/__snapshots__/moduleIdentityDetailHeader.spec.tsx.snap new file mode 100644 index 000000000..444fe50c5 --- /dev/null +++ b/src/app/devices/module/shared/components/__snapshots__/moduleIdentityDetailHeader.spec.tsx.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ModuleIdentityDetailHeader matches snapshot 1`] = ` + + + + + + +`; diff --git a/src/app/devices/module/shared/components/moduleIdentityDetailHeader.scss b/src/app/devices/module/shared/components/moduleIdentityDetailHeader.scss new file mode 100644 index 000000000..d1aa54ff2 --- /dev/null +++ b/src/app/devices/module/shared/components/moduleIdentityDetailHeader.scss @@ -0,0 +1,9 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ + @import '../../../../css/themes'; + + .module-pivot { + padding-left: 20px; + } \ No newline at end of file diff --git a/src/app/devices/module/shared/components/moduleIdentityDetailHeader.spec.tsx b/src/app/devices/module/shared/components/moduleIdentityDetailHeader.spec.tsx new file mode 100644 index 000000000..c8671f62e --- /dev/null +++ b/src/app/devices/module/shared/components/moduleIdentityDetailHeader.spec.tsx @@ -0,0 +1,22 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import 'jest'; +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { ModuleIdentityDetailHeader } from './moduleIdentityDetailHeader'; + +const search = '?id=device1'; +const pathname = `/#/devices/deviceDetail/moduleIdentity/moduleDetail/${search}`; +jest.mock('react-router-dom', () => ({ + useHistory: () => ({ push: jest.fn() }), + useLocation: () => ({ search, pathname }), + useRouteMatch: () => ({ url: pathname }) +})); + +describe('ModuleIdentityDetailHeader', () => { + it('matches snapshot', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/src/app/devices/module/shared/components/moduleIdentityDetailHeader.tsx b/src/app/devices/module/shared/components/moduleIdentityDetailHeader.tsx index 798361094..119f39f28 100644 --- a/src/app/devices/module/shared/components/moduleIdentityDetailHeader.tsx +++ b/src/app/devices/module/shared/components/moduleIdentityDetailHeader.tsx @@ -4,39 +4,47 @@ **********************************************************/ import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useHistory } from 'react-router-dom'; import { Stack } from 'office-ui-fabric-react/lib/components/Stack'; -import { ActionButton } from 'office-ui-fabric-react/lib/components/Button'; +import { Pivot, PivotItem } from 'office-ui-fabric-react/lib/components/Pivot'; import { ROUTE_PARTS, ROUTE_PARAMS } from '../../../../constants/routes'; import { ResourceKeys } from '../../../../../localization/resourceKeys'; import { getDeviceIdFromQueryString, getModuleIdentityIdFromQueryString } from '../../../../shared/utils/queryStringHelper'; -import '../../../../css/_pivotHeader.scss'; +import './moduleIdentityDetailHeader.scss'; export const ModuleIdentityDetailHeader: React.FC = () => { const { t } = useTranslation(); const { search, pathname } = useLocation(); + const history = useHistory(); const NAV_LINK_ITEMS = [ROUTE_PARTS.MODULE_DETAIL, ROUTE_PARTS.MODULE_TWIN]; const deviceId = getDeviceIdFromQueryString(search); const moduleId = getModuleIdentityIdFromQueryString(search); + const [selectedKey, setSelectedKey] = React.useState((NAV_LINK_ITEMS.find(item => pathname.indexOf(item) > 0) || ROUTE_PARTS.MODULE_DETAIL).toString()); + const path = pathname.replace(/\/moduleIdentity\/.*/, `/${ROUTE_PARTS.MODULE_IDENTITY}`); const pivotItems = NAV_LINK_ITEMS.map(nav => { const text = t((ResourceKeys.deviceContent.navBar as any)[nav]); // tslint:disable-line:no-any - const path = pathname.replace(/\/moduleIdentity\/.*/, `/${ROUTE_PARTS.MODULE_IDENTITY}`); - const url = `#${path}/${nav}/?${ROUTE_PARAMS.DEVICE_ID}=${encodeURIComponent(deviceId)}&${ROUTE_PARAMS.MODULE_ID}=${encodeURIComponent(moduleId)}`; - const isCurrentPivot = pathname.indexOf(nav) > 0; - return ( - - - {text} - - - ); + return (); }); + const handleLinkClick = (item: PivotItem) => { + setSelectedKey(item.props.itemKey); + const url = `${path}/${item.props.itemKey}/?${ROUTE_PARAMS.DEVICE_ID}=${encodeURIComponent(deviceId)}&${ROUTE_PARAMS.MODULE_ID}=${encodeURIComponent(moduleId)}`; + history.push(url); + }; + const pivot = ( + + {pivotItems} + + ); return ( - - {pivotItems} + + {pivot} ); }; diff --git a/src/app/devices/pnp/components/__snapshots__/digitalTwinDetail.spec.tsx.snap b/src/app/devices/pnp/components/__snapshots__/digitalTwinDetail.spec.tsx.snap index 009e7cfd6..b5ede6d24 100644 --- a/src/app/devices/pnp/components/__snapshots__/digitalTwinDetail.spec.tsx.snap +++ b/src/app/devices/pnp/components/__snapshots__/digitalTwinDetail.spec.tsx.snap @@ -3,59 +3,40 @@ exports[`DigitalTwinDetail matches snapshot 1`] = ` - - - deviceContent.navBar.interfaces - - - - - deviceContent.navBar.properties - - - - - deviceContent.navBar.settings - - - - - deviceContent.navBar.commands - - - - - deviceContent.navBar.events - - + + + + + + - - - deviceEvents.noEvent - -
-`; - -exports[`components/devices/deviceEventsPerInterface matches snapshot while interface definition is retrieved in electron 1`] = ` -
- - - - -
- -

- deviceEvents.infiniteScroll.loading -

-
-
- } - pageStart={0} - ref={null} - threshold={250} - useCapture={false} - useWindow={true} - > -
-
-
- -