From 2d92cc0b82f4473ddcd7410d3bf6d40c334e76d4 Mon Sep 17 00:00:00 2001 From: Emmanuel Ormancey <emmanuel.ormancey@cern.ch> Date: Fri, 19 Feb 2021 16:40:43 +0100 Subject: [PATCH] id to uuid, import cleanup and folder reorg for devices and prefs 2 --- .../components/AddChannel/AddChannel.js | 291 +++++++++++++++ .../components/AddChannel/AddChannel.scss | 3 + .../CreateChannelPage.js | 234 ++++++++++++ src/devices/actions/devices.js | 74 ++++ src/devices/components/AddDevice.js | 223 ++++++++++++ src/devices/components/AddDevice.scss | 13 + src/devices/components/DevicesList.jsx | 247 +++++++++++++ src/devices/components/SafariRegistration.js | 104 ++++++ .../components/ServiceWorkerRegistration.js | 141 ++++++++ src/devices/pages/DevicesGlobal.js | 56 +++ src/devices/reducers/devices.js | 95 +++++ src/preferences/actions/preferences.js | 71 ++++ src/preferences/components/AddPreference.js | 337 ++++++++++++++++++ .../components/AddPreferences.scss | 23 ++ .../components/NoPreferencesWarningBanner.jsx | 18 + .../components/PreferencesList.jsx | 176 +++++++++ src/preferences/reducers/preferences.js | 71 ++++ 17 files changed, 2177 insertions(+) create mode 100644 src/channels/components/AddChannel/AddChannel.js create mode 100644 src/channels/components/AddChannel/AddChannel.scss create mode 100644 src/channels/pages/CreateChannelPage.NOT.USED/CreateChannelPage.js create mode 100644 src/devices/actions/devices.js create mode 100644 src/devices/components/AddDevice.js create mode 100644 src/devices/components/AddDevice.scss create mode 100644 src/devices/components/DevicesList.jsx create mode 100644 src/devices/components/SafariRegistration.js create mode 100644 src/devices/components/ServiceWorkerRegistration.js create mode 100644 src/devices/pages/DevicesGlobal.js create mode 100644 src/devices/reducers/devices.js create mode 100644 src/preferences/actions/preferences.js create mode 100644 src/preferences/components/AddPreference.js create mode 100644 src/preferences/components/AddPreferences.scss create mode 100644 src/preferences/components/NoPreferencesWarningBanner.jsx create mode 100644 src/preferences/components/PreferencesList.jsx create mode 100644 src/preferences/reducers/preferences.js diff --git a/src/channels/components/AddChannel/AddChannel.js b/src/channels/components/AddChannel/AddChannel.js new file mode 100644 index 00000000..5cdf79b8 --- /dev/null +++ b/src/channels/components/AddChannel/AddChannel.js @@ -0,0 +1,291 @@ +import React, {useState} from 'react'; + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {Form, Radio, Popup, Label, Modal, Button, Segment} from 'semantic-ui-react'; + +import * as showSnackBarActionCreator from 'common/actions/Snackbar'; +import * as createChannelActionCreator from 'channels/actions/CreateChannel'; +import * as paginationActionCreators from 'channels/actions/PaginationActions'; +import './AddChannel.scss'; + +const AddChannel = ({ + createChannel, + showSnackbar, + getChannelsQuery, + setGetChannelsQuery, + loading, +}) => { + const [channel, setChannel] = useState({ + slug: '', + name: '', + description: '', + adminGroup: { + groupIdentifier: '', + }, + visibility: 'RESTRICTED', + subscriptionPolicy: 'SELF_SUBSCRIPTION', + archive: false, + }); + const [modalOpen, setModalOpen] = useState(false); + + const resetFormValues = () => { + setChannel({ + slug: '', + name: '', + description: '', + adminGroup: { + groupIdentifier: '', + }, + visibility: 'RESTRICTED', + subscriptionPolicy: 'SELF_SUBSCRIPTION', + archive: false, + }); + }; + + const updateField = (e, d) => { + // Auto gen the Slug if name changed + if (d.name === 'name') { + setChannel({ + ...channel, + [d.name]: d.value || d.checked, + slug: d.value.toLowerCase().replace(/[^0-9a-z-_]/g, '-'), + }); + } else { + setChannel({ + ...channel, + [d.name]: d.value || d.checked, + }); + } + }; + + function handleClose() { + resetFormValues(); + setModalOpen(false); + } + + async function handleSubmit() { + const response = await createChannel(channel); + if (response.error) { + showSnackbar(`Error: ${response.payload.response.message}`, 'error'); + } else { + showSnackbar('The channel has been created successfully', 'success'); + handleClose(); + setGetChannelsQuery({ + ...getChannelsQuery, + ownerFilter: true, + subscribedFilter: false, + skip: 0, + }); + } + } + + return ( + <Modal + trigger={<Button primary onClick={() => setModalOpen(true)} content="Add channel" />} + open={modalOpen} + onClose={handleClose} + > + <Modal.Header>Add channel</Modal.Header> + <Segment basic> + <Form onSubmit={handleSubmit}> + <Form.Input + required + label="Name" + name="name" + placeholder="Name" + value={channel.name} + onChange={updateField} + /> + <Form.Input + required + label="Slug (identifier for mail to channel)" + name="slug" + placeholder="Slug" + value={channel.slug} + onChange={updateField} + /> + <Form.TextArea + label="Description" + placeholder="Description" + name="description" + value={channel.description} + onChange={updateField} + /> + <Form.Input + label={ + <div> + <span style={{fontSize: 13, fontWeight: 'bold'}}>Admin group </span> + <a + style={{fontSize: 11}} + href="https://groups-portal.web.cern.ch/" + target="_blank" + rel="noopener noreferrer" + > + (search groups at GRAPPA's portal) + </a> + </div> + } + name="adminGroup" + placeholder="Admin group" + value={channel.adminGroup.groupIdentifier} + onChange={(e, d) => { + setChannel({ + ...channel, + adminGroup: { + ...channel.adminGroup, + groupIdentifier: d.value, + }, + }); + }} + /> + <Label color="blue" ribbon> + Specify who can send notifications via e-mail to this channel + </Label> + <Form.Input + label="Incoming e-mail address" + type="email" + name="incomingEmail" + placeholder="Incoming e-mail address" + value={channel.incomingEmail} + onChange={updateField} + /> + <Form.Group inline> + <label>Visibility</label> + <Popup + content="All users can see the channel and its notifications" + trigger={ + <Form.Field + control={Radio} + label="Public" + value="PUBLIC" + checked={channel.visibility === 'PUBLIC'} + onChange={() => setChannel({...channel, visibility: 'PUBLIC'})} + /> + } + /> + <Popup + content="Only authenticated users can see the channel and its notifications" + trigger={ + <Form.Field + control={Radio} + label="Internal" + value="INTERNAL" + checked={channel.visibility === 'INTERNAL'} + onChange={() => setChannel({...channel, visibility: 'INTERNAL'})} + /> + } + /> + <Popup + content="Only channel members can see the channel and its notifications" + trigger={ + <Form.Field + control={Radio} + label="Restricted" + value="RESTRICTED" + checked={channel.visibility === 'RESTRICTED'} + onChange={() => setChannel({...channel, visibility: 'RESTRICTED'})} + /> + } + /> + </Form.Group> + {channel.visibility !== 'PRIVATE' && ( + <Form.Group inline> + <label>Subscription Policy</label> + <Popup + content="All authenticated users can subscribe to the channel" + trigger={ + <Form.Field + control={Radio} + label="Self subscription" + value="SELF_SUBSCRIPTION" + checked={channel.subscriptionPolicy === 'SELF_SUBSCRIPTION'} + onChange={() => + setChannel({ + ...channel, + subscriptionPolicy: 'SELF_SUBSCRIPTION', + }) + } + /> + } + /> + + <Popup + content="All authenticated users can subscribe to the channel" + trigger={ + <Form.Field + control={Radio} + label="Self subscription with approval" + value="SELF_SUBSCRIPTION_APPROVAL" + checked={channel.subscriptionPolicy === 'SELF_SUBSCRIPTION_APPROVAL'} + onChange={() => + setChannel({ + ...channel, + subscriptionPolicy: 'SELF_SUBSCRIPTION_APPROVAL', + }) + } + /> + } + /> + </Form.Group> + )} + + <Form.Group inline> + <label>Archive</label> + <Popup + content={`Channel content will be automatically archived in ${process.env.REACT_APP_ARCHIVE_URL}`} + trigger={ + <Form.Field + control={Radio} + label="Enabled" + value="true" + checked={channel.archive === true} + onChange={() => setChannel({...channel, archive: true})} + /> + } + /> + <Popup + content="Channel content will not be archived." + trigger={ + <Form.Field + control={Radio} + label="Disabled" + value="false" + checked={channel.archive === false} + onChange={() => setChannel({...channel, archive: false})} + /> + } + /> + <span> + (archive content to{' '} + <a href={process.env.REACT_APP_ARCHIVE_URL} target="_blank" rel="noopener noreferrer"> + {process.env.REACT_APP_ARCHIVE_URL} + </a> + ) + </span> + </Form.Group> + <Form.Button primary disabled={loading} loading={loading}> + Submit + </Form.Button> + </Form> + </Segment> + </Modal> + ); +}; + +const mapStateToProps = state => { + return { + getChannelsQuery: state.channels.channelsList.getChannelsQuery, + loading: state.channels.newChannel.loading, + }; +}; + +const mapDispatchToProps = dispatch => { + return { + ...bindActionCreators(createChannelActionCreator, dispatch), + ...bindActionCreators(showSnackBarActionCreator, dispatch), + ...bindActionCreators(paginationActionCreators, dispatch), + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(AddChannel); diff --git a/src/channels/components/AddChannel/AddChannel.scss b/src/channels/components/AddChannel/AddChannel.scss new file mode 100644 index 00000000..3c299cdd --- /dev/null +++ b/src/channels/components/AddChannel/AddChannel.scss @@ -0,0 +1,3 @@ +.content { + overflow: visible; +} diff --git a/src/channels/pages/CreateChannelPage.NOT.USED/CreateChannelPage.js b/src/channels/pages/CreateChannelPage.NOT.USED/CreateChannelPage.js new file mode 100644 index 00000000..ee962098 --- /dev/null +++ b/src/channels/pages/CreateChannelPage.NOT.USED/CreateChannelPage.js @@ -0,0 +1,234 @@ +import React, {useState} from 'react'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {Form, Radio, Popup, Segment} from 'semantic-ui-react'; + +import * as showSnackBarActionCreator from 'common/actions/Snackbar'; +import * as createChannelActionCreator from 'channels/actions/CreateChannel'; + +import './CreateChannelPage.css'; + +const CreateChannelPage = ({createChannel, showSnackbar, history}) => { + const [channel, setChannel] = useState({ + slug: '', + name: '', + description: '', + adminGroup: { + groupIdentifier: '', + }, + visibility: 'RESTRICTED', + subscriptionPolicy: 'SELF_SUBSCRIPTION', + archive: true, + }); + + const updateField = (e, d) => { + setChannel({ + ...channel, + [d.name]: d.value || d.checked, + }); + }; + + return ( + <Segment + style={{ + width: '933px', + marginTop: '100px', + marginLeft: 'auto', + marginRight: 'auto', + }} + > + <Form + style={{margin: 100}} + onSubmit={() => + createChannel(channel).then(({payload, error}) => { + if (error) { + showSnackbar(`Error: ${payload.response.message}`, 'error'); + } else { + showSnackbar('The channel has been created successfully', 'success'); + history.push('/main/channels'); + } + }) + } + > + <Form.Input + label="Slug" + name="slug" + placeholder="SLUG" + value={channel.slug} + onChange={updateField} + /> + <Form.Input + label="Name" + name="name" + placeholder="Name" + value={channel.name} + onChange={updateField} + /> + <Form.TextArea + label="Description" + placeholder="Description" + name="description" + value={channel.description} + onChange={updateField} + /> + <Form.Input + label="Admin group" + name="adminGroup" + placeholder="Admin group" + value={channel.adminGroup.groupIdentifier} + onChange={(e, d) => { + setChannel({ + ...channel, + adminGroup: { + ...channel.adminGroup, + groupIdentifier: d.value, + }, + }); + }} + /> + <Popup + content="Who can send notifications via e-mail to this channel?" + trigger={ + <Form.Input + label="Incoming e-mail address" + type="email" + name="incomingEmail" + placeholder="Incoming e-mail address" + value={channel.incomingEmail} + onChange={updateField} + /> + } + /> + <Form.Group inline> + <label>Visibility</label> + <Popup + content="Authenticated and not authenticated users can see the channel and its notifications" + trigger={ + <Form.Field + control={Radio} + label="Public" + value="PUBLIC" + checked={channel.visibility === 'PUBLIC'} + onChange={() => setChannel({...channel, visibility: 'PUBLIC'})} + /> + } + /> + <Popup + content="Only authenticated users can see the channel and its notifications" + trigger={ + <Form.Field + control={Radio} + label="Internal" + value="INTERNAL" + checked={channel.visibility === 'INTERNAL'} + onChange={() => setChannel({...channel, visibility: 'INTERNAL'})} + /> + } + /> + <Popup + content="Only channel members can see the channel and its notifictions" + trigger={ + <Form.Field + control={Radio} + label="Restricted" + value="RESTRICTED" + checked={channel.visibility === 'RESTRICTED'} + onChange={() => setChannel({...channel, visibility: 'RESTRICTED'})} + /> + } + /> + </Form.Group> + {channel.visibility !== 'PRIVATE' && ( + <Form.Group inline> + <label>Subscription Policy</label> + <Popup + content="All authenticated users can subscribe to the channel" + trigger={ + <Form.Field + control={Radio} + label="Self subscription" + value="SELF_SUBSCRIPTION" + checked={channel.subscriptionPolicy === 'SELF_SUBSCRIPTION'} + onChange={() => + setChannel({ + ...channel, + subscriptionPolicy: 'SELF_SUBSCRIPTION', + }) + } + /> + } + /> + + <Popup + content="All authenticated users can subscribe to the channel" + trigger={ + <Form.Field + control={Radio} + label="Self subscription with approval" + value="SELF_SUBSCRIPTION_APPROVAL" + checked={channel.subscriptionPolicy === 'SELF_SUBSCRIPTION_APPROVAL'} + onChange={() => + setChannel({ + ...channel, + subscriptionPolicy: 'SELF_SUBSCRIPTION_APPROVAL', + }) + } + /> + } + /> + </Form.Group> + )} + + <Form.Group inline> + <label>Archive</label> + <Popup + content={`Channel content will be automatically archived in ${process.env.REACT_APP_ARCHIVE_URL}`} + trigger={ + <Form.Field + control={Radio} + label="Enabled" + value="true" + checked={channel.archive === true} + onChange={() => setChannel({...channel, archive: true})} + /> + } + /> + <Popup + content="Channel content will not be archived." + trigger={ + <Form.Field + control={Radio} + label="Disabled" + value="false" + checked={channel.archive === false} + onChange={() => setChannel({...channel, archive: false})} + /> + } + /> + <span> + (archive content to{' '} + <a href={process.env.REACT_APP_ARCHIVE_URL} target="_blank" rel="noopener noreferrer"> + {process.env.REACT_APP_ARCHIVE_URL} + </a> + ) + </span> + </Form.Group> + + <Form.Button>Submit</Form.Button> + </Form> + </Segment> + ); +}; + +const mapStateToProps = state => { + return {}; +}; + +const mapDispatchToProps = dispatch => { + return { + ...bindActionCreators(createChannelActionCreator, dispatch), + ...bindActionCreators(showSnackBarActionCreator, dispatch), + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(CreateChannelPage); diff --git a/src/devices/actions/devices.js b/src/devices/actions/devices.js new file mode 100644 index 00000000..41cc5e43 --- /dev/null +++ b/src/devices/actions/devices.js @@ -0,0 +1,74 @@ +import {RSAA} from 'redux-api-middleware'; +import {withAuth} from 'auth/utils/authUtils'; + +export const GET_DEVICES = 'GET_DEVICES'; +export const GET_DEVICES_SUCCESS = 'GET_DEVICES_SUCCESS'; +export const GET_DEVICES_FAILURE = 'GET_DEVICES_FAILURE'; + +export const CREATE_DEVICE = 'CREATE_DEVICE'; +export const CREATE_DEVICE_SUCCESS = 'CREATE_DEVICE_SUCCESS'; +export const CREATE_DEVICE_FAILURE = 'CREATE_DEVICE_FAILURE'; + +export const DELETE_DEVICE = 'DELETE_DEVICE'; +export const DELETE_DEVICE_SUCCESS = 'DELETE_DEVICE_SUCCESS'; +export const DELETE_DEVICE_FAILURE = 'DELETE_DEVICE_FAILURE'; + +export const TEST_DEVICE = 'TEST_DEVICE'; +export const TEST_DEVICE_SUCCESS = 'TEST_DEVICE_SUCCESS'; +export const TEST_DEVICE_FAILURE = 'TEST_DEVICE_FAILURE'; + +export const UPDATE_DEVICE = 'UPDATE_DEVICE'; +export const UPDATE_DEVICE_SUCCESS = 'UPDATE_DEVICE_SUCCESS'; +export const UPDATE_DEVICE_FAILURE = 'UPDATE_DEVICE_FAILURE'; + +export const getDevices = () => ({ + [RSAA]: { + endpoint: `${process.env.REACT_APP_BASE_URL}/devices/`, + method: 'GET', + credentials: 'include', + headers: withAuth({'Content-Type': 'application/json'}), + types: [GET_DEVICES, GET_DEVICES_SUCCESS, GET_DEVICES_FAILURE], + }, +}); + +export const createDevice = device => ({ + [RSAA]: { + endpoint: `${process.env.REACT_APP_BASE_URL}/devices`, + method: 'POST', + credentials: 'include', + headers: withAuth({'Content-Type': 'application/json'}), + body: JSON.stringify({device}), + types: [CREATE_DEVICE, CREATE_DEVICE_SUCCESS, CREATE_DEVICE_FAILURE], + }, +}); + +export const deleteDeviceById = deviceId => ({ + [RSAA]: { + endpoint: `${process.env.REACT_APP_BASE_URL}/devices/${deviceId}`, + method: 'DELETE', + credentials: 'include', + headers: withAuth({'Content-Type': 'application/json'}), + types: [DELETE_DEVICE, DELETE_DEVICE_SUCCESS, DELETE_DEVICE_FAILURE], + }, +}); + +export const tryBrowserPushNotification = deviceId => ({ + [RSAA]: { + endpoint: `${process.env.REACT_APP_BASE_URL}/devices/${deviceId}`, + method: 'POST', + credentials: 'include', + headers: withAuth({'Content-Type': 'application/json'}), + types: [TEST_DEVICE, TEST_DEVICE_SUCCESS, TEST_DEVICE_FAILURE], + }, +}); + +export const updateDeviceById = (deviceId, name, info) => ({ + [RSAA]: { + endpoint: `${process.env.REACT_APP_BASE_URL}/devices/${deviceId}`, + method: 'PUT', + credentials: 'include', + headers: withAuth({'Content-Type': 'application/json'}), + body: JSON.stringify({name, info}), + types: [UPDATE_DEVICE, UPDATE_DEVICE_SUCCESS, UPDATE_DEVICE_FAILURE], + }, +}); diff --git a/src/devices/components/AddDevice.js b/src/devices/components/AddDevice.js new file mode 100644 index 00000000..23f25aac --- /dev/null +++ b/src/devices/components/AddDevice.js @@ -0,0 +1,223 @@ +import React, {useState} from 'react'; +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; +import {Form, Modal, Button, Label, Radio, Message} from 'semantic-ui-react'; +import {v4 as uuidv4} from 'uuid'; + +import * as deviceActions from 'devices/actions/devices'; +import * as showSnackBarActionCreators from 'common/actions/Snackbar'; +import ServiceWorkerRegistration from 'devices/components/ServiceWorkerRegistration'; +import SafariRegistration from 'devices/components/SafariRegistration'; +import {getClientInformation, isSafari} from 'utils/user-agent'; +import './AddDevice.scss'; + +const clientInformation = getClientInformation(); + +// Current supported list +const deviceTypesEnum = ['BROWSER', 'APP', 'MAIL']; +const deviceSubTypesEnum = [ + 'SAFARI', + 'OTHER', + 'IOS', + 'ANDROID', + 'WINDOWS', + 'LINUX', + 'MAC', + 'PRIMARY', +]; + +const AddDevice = ({createDevice, showSnackbar}) => { + const [deviceName, setDeviceName] = useState(clientInformation); + const [deviceInfo, setDeviceInfo] = useState(navigator.userAgent); + const [deviceType, setDeviceType] = useState('BROWSER'); + const [deviceSubType, setDeviceSubType] = useState(isSafari() ? 'SAFARI' : 'OTHER'); + const [deviceUuid, setdeviceUuid] = useState(uuidv4()); + const [deviceToken, setDeviceToken] = useState(''); + + const [modalOpen, setModalOpen] = useState(false); + const [submitButtonDisabled, setSubmitButtonDisabled] = useState(false); + const [advancedAdd, setAdvancedAdd] = useState(false); + const [workerAlreadyRegistered, setWorkerAlreadyRegistered] = useState(false); + + const resetFormValues = () => { + setDeviceName(clientInformation); // initialize with platform name, let user edit for easy remembering + setDeviceInfo(navigator.userAgent); + setDeviceType('BROWSER'); + setDeviceSubType(isSafari() ? 'SAFARI' : 'OTHER'); + setdeviceUuid(uuidv4()); + setDeviceToken(''); + }; + + const saveSubscriptionBlob = subscriptionBlob => { + setDeviceToken(JSON.stringify(subscriptionBlob)); + }; + + const handleTypeChange = (e, d) => setDeviceType(d.value); + const handleSubTypeChange = (e, d) => setDeviceSubType(d.value); + + return ( + <Modal + trigger={<Button onClick={() => setModalOpen(true)}>Add device</Button>} + open={modalOpen} + onClose={() => setModalOpen(false)} + > + <Modal.Header>Add device</Modal.Header> + <Modal.Content> + <Modal.Description> + <Form> + <Form.Group grouped className="add-device"> + <Label color="teal" ribbon> + Specify a device or computer name for easy identification in preferences targets + </Label> + <Form.Field> + <Form.Input + label="Device name" + placeholder="Device name" + value={deviceName} + onChange={(e, d) => setDeviceName(d.value)} + /> + </Form.Field> + </Form.Group> + {!advancedAdd && ( + <Form.Group grouped> + <Button size="mini" onClick={() => setAdvancedAdd(true)} icon="settings" /> Advanced + options, use at your own risk + </Form.Group> + )} + {advancedAdd && ( + <Form.Group grouped className="add-device-advanced"> + <Label color="teal" ribbon> + Advanced options, use at your own risk + </Label> + + <Form.Group grouped> + <label>Device Type</label> + <Form.Group inline> + {deviceTypesEnum.map(oneType => ( + <Form.Field key={oneType}> + <Radio + label={oneType.charAt(0) + oneType.substring(1).toLowerCase()} + control="input" + name="radioDeviceTypes" + value={oneType} + checked={deviceType === oneType} + onChange={handleTypeChange} + /> + </Form.Field> + ))} + </Form.Group> + + <label>Device Subtype</label> + <Form.Group inline> + {deviceSubTypesEnum.map(oneSubType => ( + <Form.Field key={oneSubType}> + <Radio + label={oneSubType.charAt(0) + oneSubType.substring(1).toLowerCase()} + control="input" + name="radioDeviceSubTypes" + value={oneSubType} + checked={deviceSubType === oneSubType} + onChange={handleSubTypeChange} + /> + </Form.Field> + ))} + </Form.Group> + </Form.Group> + + <Form.Field> + <Form.Input + label="Information" + placeholder="Information" + value={deviceInfo} + onChange={(e, d) => setDeviceInfo(d.value)} + /> + </Form.Field> + + <Form.Field> + <Form.Input + label="Device Token" + placeholder="Device Token" + value={deviceToken} + onChange={(e, d) => setDeviceToken(d.value)} + /> + </Form.Field> + </Form.Group> + )} + + {deviceType === 'BROWSER' && ( + <Form.Group grouped className="add-device"> + <Label color="teal" ribbon> + Toggle to activate push notifications, click authorize if browser asks permission + </Label> + {deviceSubType !== 'SAFARI' && ( + <ServiceWorkerRegistration + onSubscription={saveSubscriptionBlob} + onLoadStatus={setWorkerAlreadyRegistered} + /> + )} + {deviceSubType === 'SAFARI' && ( + <SafariRegistration + onSubscription={saveSubscriptionBlob} + onLoadStatus={setWorkerAlreadyRegistered} + deviceUuid={deviceUuid} + /> + )} + </Form.Group> + )} + + {deviceType === 'BROWSER' && deviceSubType !== 'SAFARI' && workerAlreadyRegistered && ( + <Message negative> + This browser seems to be already registered. If you want to proceed anyway, make + sure you delete the other device entry if it still exists, and toggle the above + subscription box off and on. + </Message> + )} + + <Form.Button + disabled={submitButtonDisabled} + onClick={() => { + if (deviceToken) { + setSubmitButtonDisabled(true); + createDevice({ + name: deviceName, + info: deviceInfo, + type: deviceType, + subType: deviceSubType, + uuid: deviceUuid, + token: deviceToken, + }).then(({error}) => { + setSubmitButtonDisabled(false); + if (error) { + showSnackbar('An error occurred while adding your device', 'error'); + } else { + showSnackbar('The device has been added successfully', 'success'); + resetFormValues(); + setModalOpen(false); + } + }); + } else { + showSnackbar( + 'Notification subscription data missing, please try to toggle subscription again.', + 'error' + ); + } + }} + > + Submit + </Form.Button> + </Form> + </Modal.Description> + </Modal.Content> + </Modal> + ); +}; + +export default connect(null, dispatch => + bindActionCreators( + { + createDevice: deviceActions.createDevice, + ...showSnackBarActionCreators, + }, + dispatch + ) +)(AddDevice); diff --git a/src/devices/components/AddDevice.scss b/src/devices/components/AddDevice.scss new file mode 100644 index 00000000..f66854ba --- /dev/null +++ b/src/devices/components/AddDevice.scss @@ -0,0 +1,13 @@ +@import '../../common/colors/colors.scss'; + +.ui.form { + .add-device { + padding-left: 15px; + padding-right: 15px; + } + + .add-device-advanced { + background-color: $background-lightgrey; + padding: 15px; + } +} diff --git a/src/devices/components/DevicesList.jsx b/src/devices/components/DevicesList.jsx new file mode 100644 index 00000000..5f565b9a --- /dev/null +++ b/src/devices/components/DevicesList.jsx @@ -0,0 +1,247 @@ +import React, {useState} from 'react'; +import PropTypes from 'prop-types'; +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; +import {Button, Table, Form, Popup} from 'semantic-ui-react'; + +import * as snackBarActions from 'common/actions/Snackbar'; +import * as deviceActions from 'devices/actions/devices'; +import AddDevice from 'devices/components/AddDevice'; +import DeviceTypeIcon from 'utils/device-type-icon'; + +const baseColumns = [ + {id: 'name', label: 'Name'}, + {id: 'info', label: 'Information'}, + {id: 'type', label: 'Device type'}, +]; + +function DevicesList({ + devices, + allowAdd, + allowDelete, + deleteDeviceById, + tryBrowserPushNotification, + updateDeviceById, + showSnackbar, + loadingDelete, + loadingUpdate, +}) { + const [editDeviceId, setEditDeviceId] = useState(''); + const [editDeviceName, setEditDeviceName] = useState(''); + const [editDeviceInfo, setEditDeviceInfo] = useState(''); + const [deleteId, setDeleteId] = useState(null); + + function resetUpdateValues() { + setEditDeviceId(''); + setEditDeviceName(''); + setEditDeviceInfo(''); + } + async function handleDelete(device) { + setDeleteId(device.id); + const response = await deleteDeviceById(device.id); + if (response.error) { + setDeleteId(null); + showSnackbar('An error occurred while deleting the device', 'error'); + } else { + setDeleteId(null); + showSnackbar('Device removed successfully', 'success'); + } + } + + async function handleUpdate(device) { + const response = await updateDeviceById(device.id, editDeviceName, editDeviceInfo); + if (response.error) { + showSnackbar('An error occurred while updating the device', 'error'); + resetUpdateValues(); + } else { + showSnackbar('Device updated successfully', 'success'); + resetUpdateValues(); + } + } + + return ( + <> + <Form> + <Table striped> + <Table.Header> + <Table.Row> + {baseColumns.map(column => ( + <Table.HeaderCell key={column.id}>{column.label}</Table.HeaderCell> + ))} + <Table.HeaderCell /> + </Table.Row> + </Table.Header> + <Table.Body> + {devices.map(device => { + return ( + <Table.Row key={device.id}> + <Table.Cell> + {device.id === editDeviceId ? ( + <Form.Input + placeholder="device name" + value={editDeviceName} + onChange={(e, d) => setEditDeviceName(d.value)} + /> + ) : ( + <>{device.name}</> + )} + </Table.Cell> + <Table.Cell> + {device.id === editDeviceId ? ( + <Form.TextArea + placeholder="device info" + value={editDeviceInfo} + onChange={(e, d) => setEditDeviceInfo(d.value)} + /> + ) : ( + <>{device.info}</> + )} + </Table.Cell> + <Table.Cell> + <DeviceTypeIcon type={device.type} /> + </Table.Cell> + <Table.Cell singleLine textAlign="right"> + {device.id === editDeviceId && ( + <> + <Popup + trigger={ + <Button + disabled={device.id === editDeviceId && loadingUpdate} + loading={device.id === editDeviceId && loadingUpdate} + size="mini" + color="green" + icon="check" + onClick={() => handleUpdate(device)} + /> + } + position="top center" + content="Save" + /> + <Popup + trigger={ + <Button + size="mini" + icon="cancel" + onClick={() => { + setEditDeviceId(''); + setEditDeviceName(''); + setEditDeviceInfo(''); + }} + /> + } + position="top center" + content="Cancel" + /> + </> + )} + + {device.id !== editDeviceId && device.type !== 'MAIL' && ( + <Popup + trigger={ + <Button + icon="pencil" + onClick={() => { + if (device.id === editDeviceId) { + setEditDeviceId(''); + setEditDeviceName(''); + setEditDeviceInfo(''); + } else { + setEditDeviceId(device.id); + setEditDeviceName(device.name); + setEditDeviceInfo(device.info); + } + }} + size="mini" + /> + } + position="top center" + content="Edit" + /> + )} + + {device.id !== editDeviceId && + allowDelete && + !(device.type === 'MAIL' && device.info === 'Default') && ( + <Popup + trigger={ + <Button + inverted + color="red" + icon="trash" + disabled={deleteId === device.id && loadingDelete} + loading={deleteId === device.id && loadingDelete} + onClick={() => handleDelete(device)} + size="mini" + /> + } + position="top center" + content="Delete" + /> + )} + + {device.id !== editDeviceId && + (device.type === 'BROWSER' || device.type === 'WINDOWS') && ( + <Popup + trigger={ + <Button + inverted + content="Try me" + onClick={() => tryBrowserPushNotification(device.id)} + color="green" + size="mini" + /> + } + position="top center" + content="Send a test notification" + /> + )} + </Table.Cell> + </Table.Row> + ); + })} + </Table.Body> + </Table> + {allowAdd && <AddDevice />} + </Form> + </> + ); +} + +DevicesList.propTypes = { + devices: PropTypes.arrayOf(PropTypes.object).isRequired, + allowAdd: PropTypes.bool, + allowDelete: PropTypes.bool, + deleteDeviceById: PropTypes.func.isRequired, + tryBrowserPushNotification: PropTypes.func.isRequired, + updateDeviceById: PropTypes.func.isRequired, + showSnackbar: PropTypes.func.isRequired, + loadingDelete: PropTypes.bool, + loadingUpdate: PropTypes.bool, +}; + +DevicesList.defaultProps = { + allowAdd: true, + allowDelete: false, + loadingDelete: false, + loadingUpdate: false, +}; + +const mapStateToProps = state => { + return { + loadingDelete: state.devices.loadingDelete, + loadingUpdate: state.devices.loadingUpdate, + }; +}; + +const mapDispatchToProps = dispatch => + bindActionCreators( + { + deleteDeviceById: deviceActions.deleteDeviceById, + tryBrowserPushNotification: deviceActions.tryBrowserPushNotification, + updateDeviceById: deviceActions.updateDeviceById, + showSnackbar: snackBarActions.showSnackbar, + }, + dispatch + ); + +export default connect(mapStateToProps, mapDispatchToProps)(DevicesList); diff --git a/src/devices/components/SafariRegistration.js b/src/devices/components/SafariRegistration.js new file mode 100644 index 00000000..6de7b8e6 --- /dev/null +++ b/src/devices/components/SafariRegistration.js @@ -0,0 +1,104 @@ +import React, {useState, useEffect} from 'react'; +import {Form, Message, Checkbox, Popup} from 'semantic-ui-react'; + +function SafariRegistration({onSubscription, onLoadStatus, deviceUuid}) { + const [status, setStatus] = useState([]); + const [errormsg, setErrormsg] = useState([]); + const [isEnabled, setIsEnabled] = useState(false); + + useEffect(() => { + // Ensure that the user can receive Safari Push Notifications. + if ('safari' in window && 'pushNotification' in window.safari) { + var permissionData = window.safari.pushNotification.permission( + process.env.REACT_APP_APPLE_WEBSITEPUSHID + ); + if (permissionData.permission === 'granted') { + setIsEnabled(true); + onLoadStatus(true); + onSubscription(permissionData.deviceToken); + setStatus(prevState => [...prevState, 'Registration is already active.']); + } else if (permissionData.permission === 'denied') { + onLoadStatus(false); + setErrormsg(prevState => [ + ...prevState, + 'Permission for notifications on this site is denied. Check your preferences.', + ]); + } else onLoadStatus(false); + } else { + setErrormsg(prevState => [...prevState, 'Not Safari or no push support in this version !']); + onLoadStatus(false); + } + }, [onLoadStatus, onSubscription]); + + function checkRemotePermission(permissionData) { + if (permissionData.permission === 'default') { + window.safari.pushNotification.requestPermission( + process.env.REACT_APP_APPLE_WEBSERVICEURL, // The web service URL. + process.env.REACT_APP_APPLE_WEBSITEPUSHID, // The Website Push ID. + // TODO: Encrypt uuid + {anonid: deviceUuid}, // Data that you choose to send to your server to help you identify the user. + checkRemotePermission // The callback function. + ); + } else if (permissionData.permission === 'denied') { + // The user said no. + setErrormsg(prevState => [ + ...prevState, + 'Permission for notifications on this site is denied. Check your preferences.', + ]); + } else if (permissionData.permission === 'granted') { + // The web service URL is a valid push provider, and the user said yes. + // permissionData.deviceToken is available to use. + setIsEnabled(true); + setStatus(prevState => [...prevState, 'Registration completed.']); + onSubscription(permissionData.deviceToken); + } + } + + function registerSafari() { + // console.log('Service worker is active.'); + // setStatus(prevState => [...prevState, 'Service worker is active.']); + // setErrormsg(prevState => [...prevState, 'No notification permission granted!']); + checkRemotePermission(window.safari.pushNotification.permission('web.ch.cern.notifications')); + } + + function unRegisterSafari() { + setErrormsg(prevState => [ + ...prevState, + 'To remove registration, open Safari Preferences / Notifications, and Remove CERN Notification.', + ]); + } + + function toggleRegistration() { + if (isEnabled) unRegisterSafari(); + else registerSafari(); + } + + return ( + <Form.Field> + <label>Register for Push Notifications</label> + <Popup + trigger={<Checkbox checked={isEnabled} onChange={toggleRegistration} toggle />} + content="Enable Push Notifications" + position="right center" + /> + {status && status.length > 0 && ( + <Message size="tiny"> + <Message.Header>Status</Message.Header> + {status.map((str, i) => ( + <div key={i}>{str} </div> + ))} + </Message> + )} + {errormsg && errormsg.length > 0 && ( + <Message negative size="tiny"> + <Message.Header>Error</Message.Header> + {errormsg.map((str, i) => ( + <div key={i}>{str} </div> + ))} + </Message> + )} + </Form.Field> + ); +} + +export default SafariRegistration; diff --git a/src/devices/components/ServiceWorkerRegistration.js b/src/devices/components/ServiceWorkerRegistration.js new file mode 100644 index 00000000..11398cc7 --- /dev/null +++ b/src/devices/components/ServiceWorkerRegistration.js @@ -0,0 +1,141 @@ +import React, {useState, useEffect} from 'react'; +import {register, unregister} from 'register-service-worker'; +import {Form, Message, Checkbox, Popup} from 'semantic-ui-react'; + +function ServiceWorkerRegistration({onSubscription, onLoadStatus}) { + const [status, setStatus] = useState([]); + const [errorMsg, setErrorMsg] = useState([]); + const [isEnabled, setIsEnabled] = useState(false); + + useEffect(() => { + // Anything in here is fired on component mount. + navigator.serviceWorker.getRegistrations('/').then(function (registrations) { + if (registrations && registrations.length > 0) { + setIsEnabled(true); + onLoadStatus(true); + } else onLoadStatus(false); + }); + }, [onLoadStatus]); + + function registerWorker() { + Notification.requestPermission(function (result) { + if (result !== 'granted') { + setErrorMsg(prevState => [...prevState, 'No notification permission granted!']); + } else { + try { + register('/serviceWorker.js', { + registrationOptions: {scope: '/'}, + ready(registration) { + setStatus(prevState => [...prevState, 'Service worker is active.']); + + // This will be called only once when the service worker is installed for first time. + const applicationServerKey = urlB64ToUint8Array( + process.env.REACT_APP_VAPID_PUBLICKEY + ); + const subscriptionOptions = {applicationServerKey, userVisibleOnly: true}; + registration.pushManager + .subscribe(subscriptionOptions) + .then(subscription => { + console.log('subscribed to pushManager'); + onSubscription(subscription); + }) + .catch(err => { + console.log('Error', err); + if (err.toString().includes('storage error')) + setErrorMsg(prevState => [ + ...prevState, + err + + ' Please unregister with toggle above, refresh the page (F5) and try again', + ]); + else setErrorMsg(prevState => [...prevState, 'Error: ' + err]); + }); + }, + registered(registration) { + setStatus(prevState => [...prevState, 'Service worker has been registered.']); + setIsEnabled(true); + }, + cached(registration) { + setStatus(prevState => [...prevState, 'Content has been cached for offline use.']); + }, + updatefound(registration) { + setStatus(prevState => [...prevState, 'New content is downloading.']); + }, + updated(registration) { + setStatus(prevState => [...prevState, 'New content is available; please refresh.']); + }, + offline() { + setStatus(prevState => [ + ...prevState, + 'No internet connection found. App is running in offline mode.', + ]); + }, + error(error) { + setErrorMsg(prevState => [ + ...prevState, + 'Error during service worker registration:' + error.message, + ]); + }, + }); + } catch (regErr) { + setErrorMsg(prevState => [ + ...prevState, + 'Error during service worker registration:' + regErr.message, + ]); + } + } + }); + } + + function unRegisterWorker() { + unregister(); + setIsEnabled(false); + setStatus(prevState => [...prevState, 'service worker unregistered']); + } + + // urlB64ToUint8Array is a magic function that will encode the base64 public key + // to Array buffer which is needed by the subscription option + function urlB64ToUint8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; + } + + function toggleRegistration() { + if (isEnabled) unRegisterWorker(); + else registerWorker(); + } + + return ( + <Form.Field> + <label>Register for Push Notifications</label> + <Popup + trigger={<Checkbox checked={isEnabled} onChange={toggleRegistration} toggle />} + content="Enable Push Notifications" + position="right center" + /> + {status && status.length > 0 && ( + <Message size="tiny"> + <Message.Header>Status</Message.Header> + {status.map((str, i) => ( + <div key={i}>{str} </div> + ))} + </Message> + )} + {errorMsg && errorMsg.length > 0 && ( + <Message negative size="tiny"> + <Message.Header>Error</Message.Header> + {errorMsg.map((str, i) => ( + <div key={i}>{str} </div> + ))} + </Message> + )} + </Form.Field> + ); +} + +export default ServiceWorkerRegistration; diff --git a/src/devices/pages/DevicesGlobal.js b/src/devices/pages/DevicesGlobal.js new file mode 100644 index 00000000..8eb50fa9 --- /dev/null +++ b/src/devices/pages/DevicesGlobal.js @@ -0,0 +1,56 @@ +import React, {useEffect} from 'react'; +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; +import {Header, Divider, Segment} from 'semantic-ui-react'; + +import DevicesList from 'devices/components/DevicesList'; +import * as devicesActions from 'devices/actions/devices'; +import PropTypes from 'prop-types'; + +function DevicesGlobal({devices, getDevices, loading}) { + useEffect(() => { + getDevices(); + }, [getDevices]); + + return ( + <div + style={{ + width: 933, + marginTop: 100, + marginLeft: 'auto', + marginRight: 'auto', + }} + > + <Header as="h3"> + Devices List + <Header.Subheader> + Register your devices and browsers here for targeted channel notifications + </Header.Subheader> + </Header> + <Divider /> + <Segment basic loading={loading}> + <DevicesList devices={devices} allowDelete /> + </Segment> + </div> + ); +} + +DevicesGlobal.propTypes = { + devices: PropTypes.arrayOf(PropTypes.object).isRequired, + getDevices: PropTypes.func.isRequired, + loading: PropTypes.bool, +}; + +export default connect( + state => ({ + devices: state.devices.userDevices || [], + loading: state.devices.loading, + }), + dispatch => + bindActionCreators( + { + getDevices: devicesActions.getDevices, + }, + dispatch + ) +)(DevicesGlobal); diff --git a/src/devices/reducers/devices.js b/src/devices/reducers/devices.js new file mode 100644 index 00000000..60df4b6c --- /dev/null +++ b/src/devices/reducers/devices.js @@ -0,0 +1,95 @@ +import { + GET_DEVICES, + GET_DEVICES_SUCCESS, + GET_DEVICES_FAILURE, + CREATE_DEVICE_SUCCESS, + DELETE_DEVICE_SUCCESS, + TEST_DEVICE_SUCCESS, + UPDATE_DEVICE_SUCCESS, + CREATE_DEVICE, + CREATE_DEVICE_FAILURE, + DELETE_DEVICE, + DELETE_DEVICE_FAILURE, + UPDATE_DEVICE, + UPDATE_DEVICE_FAILURE, +} from 'devices/actions/devices'; + +const INITIAL_STATE = { + userDevices: [], + error: null, + loading: false, + loadingDelete: false, + loadingUpdate: false, + loadingCreate: false, +}; + +export default function (state = INITIAL_STATE, action) { + switch (action.type) { + case GET_DEVICES: + return {...state, loading: true, error: null}; + case GET_DEVICES_SUCCESS: + return { + ...state, + userDevices: action.payload.userDevices || [], + loading: false, + }; + case GET_DEVICES_FAILURE: + return {...state, error: action.payload, loading: false}; + case CREATE_DEVICE: + return {...state, loadingCreate: true, error: null}; + case CREATE_DEVICE_SUCCESS: { + return {...state, loadingCreate: false, userDevices: [...state.userDevices, action.payload]}; + } + case CREATE_DEVICE_FAILURE: + return { + ...state, + error: action.payload, + loadingCreate: false, + }; + case DELETE_DEVICE: + return {...state, loadingDelete: true, error: null}; + case DELETE_DEVICE_SUCCESS: + return { + ...state, + loadingDelete: false, + userDevices: state.userDevices.filter(p => p.id !== action.payload), + }; + case DELETE_DEVICE_FAILURE: + return { + ...state, + error: action.payload, + loadingDelete: false, + }; + case TEST_DEVICE_SUCCESS: + return state; + case UPDATE_DEVICE: + return {...state, loadingUpdate: true, error: null}; + + case UPDATE_DEVICE_SUCCESS: + // object replace avoiding mutate + return { + ...state, + loadingUpdate: false, + userDevices: state.userDevices.map(item => { + if (item.id === action.payload.id) { + // item to replace - return an updated value + return { + ...action.payload, + }; + } + // the rest keep it as-is + return item; + }), + }; + + case UPDATE_DEVICE_FAILURE: + return { + ...state, + error: action.payload, + loadingUpdate: false, + }; + + default: + return state; + } +} diff --git a/src/preferences/actions/preferences.js b/src/preferences/actions/preferences.js new file mode 100644 index 00000000..e0608ac0 --- /dev/null +++ b/src/preferences/actions/preferences.js @@ -0,0 +1,71 @@ +import {RSAA} from 'redux-api-middleware'; +import {withAuth} from 'auth/utils/authUtils'; + +export const GET_PREFERENCES = 'GET_PREFERENCES'; +export const GET_PREFERENCES_SUCCESS = 'GET_PREFERENCES_SUCCESS'; +export const GET_PREFERENCES_FAILURE = 'GET_PREFERENCES_FAILURE'; + +export const CREATE_PREFERENCE = 'CREATE_PREFERENCE'; +export const CREATE_PREFERENCE_SUCCESS = 'CREATE_PREFERENCE_SUCCESS'; +export const CREATE_PREFERENCE_FAILURE = 'CREATE_PREFERENCE_FAILURE'; + +export const DELETE_PREFERENCE = 'DELETE_PREFERENCE'; +export const DELETE_PREFERENCE_SUCCESS = 'DELETE_PREFERENCE_SUCCESS'; +export const DELETE_PREFERENCE_FAILURE = 'DELETE_PREFERENCE_FAILURE'; + +export const TOGGLE_GLOBAL_PREFERENCE = 'TOGGLE_GLOBAL_PREFERENCE'; +export const TOGGLE_GLOBAL_PREFERENCE_SUCCESS = 'TOGGLE_GLOBAL_PREFERENCE_SUCCESS'; +export const TOGGLE_GLOBAL_PREFERENCE_FAILURE = 'TOGGLE_GLOBAL_PREFERENCE_FAILURE'; + +export const ADD_TARGET_DEVICE = 'ADD_TARGET_DEVICE'; +export const ADD_TARGET_DEVICE_SUCCESS = 'ADD_TARGET_DEVICE_SUCCESS'; +export const ADD_TARGET_DEVICE_FAILURE = 'ADD_TARGET_DEVICE_FAILURE'; + +export const DEL_TARGET_DEVICE = 'DEL_TARGET_DEVICE'; +export const DEL_TARGET_DEVICE_SUCCESS = 'DEL_TARGET_DEVICE_SUCCESS'; +export const DEL_TARGET_DEVICE_FAILURE = 'DEL_TARGET_DEVICE_FAILURE'; + +export const getPreferences = channelId => ({ + [RSAA]: { + endpoint: `${process.env.REACT_APP_BASE_URL}/preferences/${channelId || ''}`, + method: 'GET', + credentials: 'include', + headers: withAuth({'Content-Type': 'application/json'}), + types: [GET_PREFERENCES, GET_PREFERENCES_SUCCESS, GET_PREFERENCES_FAILURE], + }, +}); + +export const createPreference = preference => ({ + [RSAA]: { + endpoint: `${process.env.REACT_APP_BASE_URL}/preferences`, + method: 'POST', + credentials: 'include', + headers: withAuth({'Content-Type': 'application/json'}), + body: JSON.stringify({preference}), + types: [CREATE_PREFERENCE, CREATE_PREFERENCE_SUCCESS, CREATE_PREFERENCE_FAILURE], + }, +}); + +export const deletePreferenceById = preferenceId => ({ + [RSAA]: { + endpoint: `${process.env.REACT_APP_BASE_URL}/preferences/${preferenceId}`, + method: 'DELETE', + credentials: 'include', + headers: withAuth({'Content-Type': 'application/json'}), + types: [DELETE_PREFERENCE, DELETE_PREFERENCE_SUCCESS, DELETE_PREFERENCE_FAILURE], + }, +}); + +export const toggleGlobalPreferenceForChannel = (preferenceId, channelId, isEnabled) => ({ + [RSAA]: { + endpoint: `${process.env.REACT_APP_BASE_URL}/preferences/${preferenceId}/disabled-channels/${channelId}?isEnabled=${isEnabled}`, + method: 'POST', + credentials: 'include', + headers: withAuth({'Content-Type': 'application/json'}), + types: [ + TOGGLE_GLOBAL_PREFERENCE, + TOGGLE_GLOBAL_PREFERENCE_SUCCESS, + TOGGLE_GLOBAL_PREFERENCE_FAILURE, + ], + }, +}); diff --git a/src/preferences/components/AddPreference.js b/src/preferences/components/AddPreference.js new file mode 100644 index 00000000..c3596502 --- /dev/null +++ b/src/preferences/components/AddPreference.js @@ -0,0 +1,337 @@ +import React, {useState, useEffect} from 'react'; +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; +import {useParams, useHistory} from 'react-router-dom'; +import {Form, Radio, Modal, Button, Checkbox} from 'semantic-ui-react'; +import TimeField from 'react-simple-timefield'; + +import * as preferenceActions from 'preferences/actions/preferences'; +import * as devicesActions from 'devices/actions/devices'; +import DeviceTypeIcon from 'utils/device-type-icon'; +import * as showSnackBarActionCreators from 'common/actions/Snackbar'; +import {notificationPriorityTypes} from 'common/types/NotificationPriorityTypes'; +import './AddPreferences.scss'; + +const NotificationPriorityTimes = { + startDefaultTime: '10:00', + endDefaultTime: '11:00', +}; + +const AddPreference = ({ + createPreference, + showSnackbar, + global, + allDevices, + getAllDevices, + loading, +}) => { + const {channelId} = useParams(); + const [channelIdInput, setChannelIdInput] = useState(global ? null : channelId || null); + const [preferenceName, setPreferenceName] = useState(null); + const [notificationPriority, setNotificationPriority] = useState({ + low: true, + normal: true, + important: true, + }); + const [startTime, setStartTime] = useState(null); + const [endTime, setEndTime] = useState(null); + const [frequency, setFrequency] = useState('LIVE'); + const [devices, setDevices] = useState([]); + const [modalOpen, setModelOpen] = useState(false); + const [picker, setPicker] = useState('AllDay'); // useState('TimeRange'); + const history = useHistory(); + useEffect(() => { + getAllDevices(); + }, [getAllDevices]); + // Set default target device to MAIL + useEffect(() => { + if (allDevices.length > 0) setDevices(allDevices.filter(device => device.type === 'MAIL')); + }, [allDevices, setDevices]); + + const resetFormValues = () => { + setChannelIdInput(global ? null : channelId || null); + setPreferenceName(null); + setNotificationPriority({ + low: true, + normal: true, + important: true, + }); + setPicker('AllDay'); // useState('TimeRange'); + setStartTime(null); + setEndTime(null); + setFrequency('LIVE'); + setDevices(devices.filter(device => device.type === 'MAIL')); + }; + + async function handleSubmit() { + if (startTime !== endTime || (startTime === null && endTime === null)) { + const response = await createPreference({ + name: preferenceName, + target: channelIdInput, + type: frequency, + notificationPriority: Object.keys(notificationPriority).filter( + key => notificationPriority[key] + ), + rangeStart: startTime, + rangeEnd: endTime, + devices, + }); + if (response.error) { + showSnackbar('An error occurred while adding your preference', 'error'); + } else { + showSnackbar('The preference has been created successfully', 'success'); + resetFormValues(); + setModelOpen(false); + } + } else { + showSnackbar('Start Time and End Time can“t be the same', 'error'); + } + } + + const renderTargetDevices = (devicetype, targettype) => { + let filter; + if (devicetype === '_other_') { + filter = allDevices.filter(device => device.type !== 'MAIL' && device.type !== 'BROWSER'); + } else { + filter = allDevices && allDevices.filter(device => device.type === devicetype); + } + + return ( + filter && + filter.length > 0 && ( + <Form.Group inline> + <Form.Field width="1" /> + <Form.Field width="2"> + <label> + <DeviceTypeIcon type={devicetype} expanded /> + </label> + </Form.Field> + {filter.map(oneDevice => { + return ( + <Form.Field + key={oneDevice.id} + control={Checkbox} + label={oneDevice.name} + name="LiveTargetsList" + value={oneDevice.id} + checked={devices && devices.some(item => item.id === oneDevice.id)} + onClick={() => { + // Remove if exists, or Add + if (devices && devices.some(d => d.id === oneDevice.id)) + setDevices(devices.filter(item => item.id !== oneDevice.id)); + else setDevices([...devices, oneDevice]); + }} + /> + ); + })} + </Form.Group> + ) + ); + }; + + function onClose() { + setModelOpen(false); + resetFormValues(); + } + + return ( + <Modal + trigger={ + <Button primary onClick={() => setModelOpen(true)}> + Add preference + </Button> + } + open={modalOpen} + onClose={onClose} + > + <Modal.Header>Add preference</Modal.Header> + <Modal.Content> + <Modal.Description> + <Form> + <Form.Field> + {!global && ( + <Form.Input + label="Channel id" + placeholder="Channel id" + value={channelIdInput} + onChange={(e, d) => setChannelIdInput(d.value)} + disabled={channelId !== undefined} + /> + )} + </Form.Field> + <Form.Field> + <Form.Input + label="Preference name" + placeholder="Preference name" + value={preferenceName} + onChange={(e, d) => setPreferenceName(d.value)} + /> + </Form.Field> + <Form.Group grouped> + <label>Priority</label> + <Form.Field + control={Checkbox} + label="Important" + name="notificationPriorityGroup" + value={notificationPriorityTypes.IMPORTANT} + checked={notificationPriority.important} + onClick={() => { + setNotificationPriority({ + ...notificationPriority, + important: !notificationPriority.important, + }); + }} + /> + <Form.Field + control={Checkbox} + label="Normal" + name="notificationPriorityGroup" + value={notificationPriorityTypes.NORMAL} + checked={notificationPriority.normal} + onClick={() => { + setNotificationPriority({ + ...notificationPriority, + normal: !notificationPriority.normal, + }); + }} + /> + <Form.Field + control={Checkbox} + label="Low" + name="notificationPriorityGroup" + value={notificationPriorityTypes.LOW} + checked={notificationPriority.low} + onClick={() => { + setNotificationPriority({ + ...notificationPriority, + low: !notificationPriority.low, + }); + }} + /> + </Form.Group> + + <Form.Group grouped> + <label>Preference Time</label> + <Form.Field + control={Radio} + label="All day" + name="TimeGroup" + value="AllDay" + checked={picker === 'AllDay'} + onChange={(object, time) => { + setPicker(time.value); + setStartTime(null); + setEndTime(null); + }} + /> + <Form.Field + control={Radio} + label="Specific time range" + name="TimeGroup" + value="TimeRange" + checked={picker === 'TimeRange'} + onChange={(object, time) => { + setStartTime(NotificationPriorityTimes.startDefaultTime); + setEndTime(NotificationPriorityTimes.endDefaultTime); + setPicker(time.value); + }} + /> + {picker === 'TimeRange' ? ( + <Form.Group grouped> + <div className="add-preferences-time"> + <div className="add-preferences-time-label"> + <label>Start hour</label> + <div className="add-preferences-time-clock"> + <TimeField + value={NotificationPriorityTimes.startDefaultTime} + onChange={(object, time) => { + setStartTime(time); + }} + /> + </div> + </div> + <div className="add-preferences-time-label"> + <label>End hour</label> + <div className="add-preferences-time-clock"> + <TimeField + value={NotificationPriorityTimes.endDefaultTime} + onChange={(object, time) => { + setEndTime(time); + }} + /> + </div> + </div> + </div> + </Form.Group> + ) : null} + </Form.Group> + + <Form.Group grouped> + <label>Frequency</label> + <Form.Field + control={Radio} + label="Live" + name="frequencyGroup" + value="LIVE" + checked={frequency === 'LIVE'} + onChange={(e, d) => { + setFrequency(d.value); + if (allDevices) setDevices(allDevices.filter(device => device.type === 'MAIL')); + }} + /> + {frequency === 'LIVE' ? ( + <> + {renderTargetDevices('MAIL', 'LIVE')} + {renderTargetDevices('BROWSER', 'LIVE')} + {renderTargetDevices('_other_', 'LIVE')} + <Form.Group inline> + <Form.Field width="1" /> + <Button + content="Manage Devices" + size="mini" + onClick={() => { + history.push(`/main/devices`); + }} + /> + </Form.Group> + </> + ) : null} + + <Form.Field + control={Radio} + label="Daily" + name="frequencyGroup" + value="DAILY" + checked={frequency === 'DAILY'} + onChange={(e, d) => { + setFrequency(d.value); + if (allDevices) setDevices(allDevices.filter(device => device.type === 'MAIL')); + }} + /> + {frequency === 'DAILY' ? renderTargetDevices('MAIL', 'DAILY') : null} + </Form.Group> + <Form.Button primary disabled={loading} loading={loading} onClick={handleSubmit}> + Submit + </Form.Button> + </Form> + </Modal.Description> + </Modal.Content> + </Modal> + ); +}; + +export default connect( + state => ({ + allDevices: state.devices.userDevices || [], + loading: state.preferences.loadingCreate, + }), + dispatch => + bindActionCreators( + { + createPreference: preferenceActions.createPreference, + ...showSnackBarActionCreators, + getAllDevices: devicesActions.getDevices, + }, + dispatch + ) +)(AddPreference); diff --git a/src/preferences/components/AddPreferences.scss b/src/preferences/components/AddPreferences.scss new file mode 100644 index 00000000..357acab2 --- /dev/null +++ b/src/preferences/components/AddPreferences.scss @@ -0,0 +1,23 @@ +@import '../../common/colors/colors.scss'; + +.ui.form { + .add-preferences-time { + display: flex; + font-weight: bold; + flex-wrap: wrap; + vertical-align: middle; + + &-label { + margin-right: 1em; + font-weight: bold; + } + + &-clock > input[type='text'] { + border: 2px solid $light-gray; + width: 60px !important; + padding: 5px 8px; + color: $dark-gray; + border-radius: 3px; + } + } +} diff --git a/src/preferences/components/NoPreferencesWarningBanner.jsx b/src/preferences/components/NoPreferencesWarningBanner.jsx new file mode 100644 index 00000000..2ec8dbbb --- /dev/null +++ b/src/preferences/components/NoPreferencesWarningBanner.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import {Message, Icon} from 'semantic-ui-react'; + +const NoPreferencesWarningBanner = () => { + return ( + <Message warning> + <Message.Header> + Warning <Icon name="warning sign" /> + </Message.Header> + <p> + You disabled all global preferences! You will not receive notifications unless you define + global or channel preferences. + </p> + </Message> + ); +}; + +export default NoPreferencesWarningBanner; diff --git a/src/preferences/components/PreferencesList.jsx b/src/preferences/components/PreferencesList.jsx new file mode 100644 index 00000000..4ded48aa --- /dev/null +++ b/src/preferences/components/PreferencesList.jsx @@ -0,0 +1,176 @@ +import React, {useState} from 'react'; +import PropTypes from 'prop-types'; +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; +import {Button, Checkbox, Popup, Table} from 'semantic-ui-react'; + +import * as preferenceActions from 'preferences/actions/preferences'; +import AddPreference from 'preferences/components/AddPreference'; +import DeviceTypeIcon from 'utils/device-type-icon'; +import * as snackBarActions from 'common/actions/Snackbar'; + +const baseColumns = [ + {id: 'name', label: 'Name'}, + {id: 'targetId', label: 'Channel'}, + {id: 'notificationPriority', label: 'Priority'}, + {id: 'startTime', label: 'Start Time'}, + {id: 'endTime', label: 'End Time'}, + {id: 'type', label: 'Frequency'}, + {id: 'device', label: 'Targets'}, +]; + +const renderDevices = preference => { + let devices; + if (preference.devices) devices = preference.devices.map(d => d.type); + if (devices && devices.length > 0) { + const types = [...new Set(devices)]; + return types.map(t => <DeviceTypeIcon key={t} type={t} />); + } + + return null; +}; + +function PreferencesList({ + preferences, + global, + channel, + allowAdd, + allowDelete, + allowToggleEnable, + deletePreferenceById, + toggleGlobalPreferenceForChannel, + showSnackbar, + loadingDelete, + loadingUpdate, +}) { + const [deleteId, setDeleteId] = useState(null); + + async function handleDelete(preference) { + setDeleteId(preference.id); + const response = await deletePreferenceById(preference.id); + if (response.error) { + setDeleteId(null); + showSnackbar('An error occurred while deleting the preference', 'error'); + } else { + setDeleteId(null); + showSnackbar('Preference removed successfully', 'success'); + } + } + + const shouldShowColumn = column => + column.label !== 'Channel' || (!global && column.label === 'Channel'); + + return ( + <> + <Table striped> + <Table.Header> + <Table.Row> + {baseColumns.map( + column => + shouldShowColumn(column) && ( + <Table.HeaderCell key={column.id}>{column.label}</Table.HeaderCell> + ) + )} + <Table.HeaderCell /> + </Table.Row> + </Table.Header> + <Table.Body> + {preferences.map(preference => { + const isEnabled = + global && channel ? !preference.disabledChannels.some(c => c.id === channel) : false; + return ( + <Table.Row key={preference.id}> + <Table.Cell>{preference.name}</Table.Cell> + {/* eslint-disable-next-line react/no-unescaped-entities */} + {!global && ( + <Table.Cell>{preference.target ? preference.target.id : ''}</Table.Cell> + )} + <Table.Cell style={{textTransform: 'capitalize'}}> + {preference.notificationPriority + ? preference.notificationPriority.join(', ') + : ''} + </Table.Cell> + <Table.Cell>{preference.rangeStart}</Table.Cell> + <Table.Cell>{preference.rangeEnd}</Table.Cell> + <Table.Cell>{preference.type}</Table.Cell> + <Table.Cell>{renderDevices(preference)}</Table.Cell> + <Table.Cell> + {global && allowToggleEnable && ( + <Popup + trigger={ + <Checkbox + checked={isEnabled} + onChange={(_, {checked}) => + toggleGlobalPreferenceForChannel(preference.id, channel, checked) + } + toggle + /> + } + content="Toggle the global preference for this channel" + position="right center" + /> + )} + {allowDelete && ( + <Button + disabled={preference.id === deleteId && loadingDelete} + loading={preference.id === deleteId && loadingDelete} + inverted + color="red" + icon="trash" + onClick={() => handleDelete(preference)} + size="mini" + /> + )} + </Table.Cell> + </Table.Row> + ); + })} + </Table.Body> + </Table> + {allowAdd && <AddPreference global={global} />} + </> + ); +} + +PreferencesList.propTypes = { + preferences: PropTypes.arrayOf(PropTypes.object).isRequired, + global: PropTypes.bool, + channel: PropTypes.string, + allowAdd: PropTypes.bool, + allowDelete: PropTypes.bool, + allowToggleEnable: PropTypes.bool, + deletePreferenceById: PropTypes.func.isRequired, + toggleGlobalPreferenceForChannel: PropTypes.func.isRequired, + showSnackbar: PropTypes.func.isRequired, + loadingDelete: PropTypes.bool, + loadingUpdate: PropTypes.bool, +}; + +PreferencesList.defaultProps = { + global: false, + channel: null, + allowAdd: true, + allowDelete: false, + allowToggleEnable: false, + loadingDelete: false, + loadingUpdate: false, +}; + +const mapStateToProps = state => { + return { + loadingDelete: state.preferences.loadingDelete, + loadingUpdate: state.preferences.loadingUpdate, + }; +}; + +const mapDispatchToProps = dispatch => + bindActionCreators( + { + deletePreferenceById: preferenceActions.deletePreferenceById, + toggleGlobalPreferenceForChannel: preferenceActions.toggleGlobalPreferenceForChannel, + showSnackbar: snackBarActions.showSnackbar, + }, + dispatch + ); + +export default connect(mapStateToProps, mapDispatchToProps)(PreferencesList); diff --git a/src/preferences/reducers/preferences.js b/src/preferences/reducers/preferences.js new file mode 100644 index 00000000..a3cededc --- /dev/null +++ b/src/preferences/reducers/preferences.js @@ -0,0 +1,71 @@ +import { + GET_PREFERENCES, + GET_PREFERENCES_SUCCESS, + GET_PREFERENCES_FAILURE, + CREATE_PREFERENCE_SUCCESS, + DELETE_PREFERENCE_SUCCESS, + TOGGLE_GLOBAL_PREFERENCE_SUCCESS, + CREATE_PREFERENCE, + CREATE_PREFERENCE_FAILURE, + DELETE_PREFERENCE, + DELETE_PREFERENCE_FAILURE, +} from 'preferences/actions/preferences'; + +const INITIAL_STATE = { + global: [], + channel: [], + error: null, + loading: false, + loadingCreate: false, + loadingDelete: false, +}; + +export default function (state = INITIAL_STATE, action) { + switch (action.type) { + case GET_PREFERENCES: + return {...state, loading: true, error: null}; + case GET_PREFERENCES_SUCCESS: + return { + ...state, + global: action.payload.globalPreferences || [], + channel: action.payload.channelPreferences || [], + loading: false, + }; + case GET_PREFERENCES_FAILURE: + return {...state, error: action.payload, loading: false}; + case CREATE_PREFERENCE: + return {...state, loadingCreate: true, error: null}; + case CREATE_PREFERENCE_SUCCESS: { + const isChannelPreference = !!action.payload.target; + if (!isChannelPreference) { + return {...state, loadingCreate: false, global: [...state.global, action.payload]}; + } + return {...state, loadingCreate: false, channel: [...state.channel, action.payload]}; + } + case CREATE_PREFERENCE_FAILURE: + return {...state, error: action.payload, loadingCreate: false}; + case DELETE_PREFERENCE: + return {...state, loadingDelete: true, error: null}; + case DELETE_PREFERENCE_SUCCESS: + return { + ...state, + global: state.global.filter(p => p.id !== action.payload), + channel: state.channel.filter(p => p.id !== action.payload), + loadingDelete: false, + }; + case DELETE_PREFERENCE_FAILURE: + return {...state, error: action.payload, loadingDelete: false}; + case TOGGLE_GLOBAL_PREFERENCE_SUCCESS: { + const globalPreference = action.payload; + return { + ...state, + global: [ + ...state.global.map(pref => (pref.id !== globalPreference.id ? pref : globalPreference)), + ], + }; + } + + default: + return state; + } +} -- GitLab