From ba8cac60f88f7e269d5f6138a20e9f18bc7a8d7f Mon Sep 17 00:00:00 2001 From: csrapr <26280794+csrapr@users.noreply.github.com> Date: Fri, 16 Apr 2021 18:03:53 +0100 Subject: [PATCH] Chore: Add basic screen and toggle Chore: form skeleton Feat: wizard step 1 and 2 Feat: toggle button group for formik Feat: select input type Form and styling Feat: text entry page Feat: Choice list and CSS Fix: scroll to bottom on Add choice button click Feat: format data at end of wizard Feat: wizard toggle button and background blur Feat: data table for custom info requests Feat: editing and deleting custom info request Feat: add icons Fix: Wizard changes Feat: custom requests migrations Feat: fetch custom info requests Feat: add mutations Feat: add custom request option in trigger wizard Feat: show customrequests on table Feat: Triggers page code refactor Feat: integrate custom info requests on Customer graphql type Feat: Show custom info requests on user page Fix: use normal table instead of datatable Feat: modal for custom information request details Feat: poller returns custom request information details Feat: send customer custom info requests to machine Chore: add field CustomInfoRequestsData on customer updates Feat: customer custom info request data saving Chore: variable name changes and lots of fixes Feat: remove default value in query, sort request on customer profile Signed-off-by: csrapr <26280794+csrapr@users.noreply.github.com> Fix: return promise when array of ids is empty Feat: TitleSection can receive more than one button --- lib/customers.js | 11 + .../resolvers/customInfoRequests.resolver.js | 28 ++ lib/new-admin/graphql/resolvers/index.js | 2 + .../graphql/types/customInfoRequests.type.js | 54 ++++ lib/new-admin/graphql/types/customer.type.js | 1 + lib/new-admin/graphql/types/index.js | 2 + lib/new-admin/services/customInfoRequests.js | 122 +++++++++ lib/routes/customerRoutes.js | 11 + lib/routes/pollingRoutes.js | 25 +- .../1620165712260-custom-info-requests.js | 24 ++ .../src/components/buttons/SubpageButton.js | 21 +- .../src/components/inputs/base/Dropdown.js | 28 ++ .../src/components/inputs/base/RadioGroup.js | 29 +- .../inputs/base/ToggleButtonGroup.js | 75 ++++++ .../src/components/inputs/base/index.js | 7 +- .../src/components/inputs/formik/Dropdown.js | 25 ++ .../inputs/formik/ToggleButtonGroup.js | 27 ++ .../src/components/inputs/formik/index.js | 4 +- .../src/components/layout/TitleSection.js | 24 +- .../src/pages/Commissions/Commissions.js | 14 +- .../src/pages/Customers/CustomerData.js | 40 ++- .../src/pages/Customers/CustomerProfile.js | 30 ++- .../components/CustomInfoRequestsData.js | 195 ++++++++++++++ .../CustomInfoRequests/CustomInfoRequests.js | 249 ++++++++++++++++++ .../CustomInfoRequests.styles.js | 19 ++ .../CustomInfoRequests/DetailsCard.js | 112 ++++++++ .../CustomInfoRequests/Forms/ChooseType.js | 76 ++++++ .../Forms/NameOfRequirement.js | 37 +++ .../Forms/Screen1Information.js | 45 ++++ .../Forms/Screen2Information.js | 43 +++ .../Forms/TypeFields/ChoiceList.js | 102 +++++++ .../Forms/TypeFields/NumericalEntry.js | 62 +++++ .../Forms/TypeFields/TextEntry.js | 79 ++++++ .../Forms/TypeFields/formStyles.styles.js | 50 ++++ .../Forms/TypeFields/index.js | 79 ++++++ .../Triggers/CustomInfoRequests/Wizard.js | 244 +++++++++++++++++ .../CustomInfoRequests/WizardSplash.js | 57 ++++ .../Triggers/CustomInfoRequests/index.js | 2 + .../src/pages/Triggers/TriggerView.js | 92 +++++++ .../src/pages/Triggers/Triggers.js | 182 +++++++------ .../src/pages/Triggers/Wizard.js | 12 +- .../src/pages/Triggers/helper.js | 122 +++++++-- new-lamassu-admin/src/styling/global/index.js | 4 + .../icons/compliance/custom-requirement.svg | 1 + .../src/styling/icons/compliance/keyboard.svg | 25 ++ .../src/styling/icons/compliance/keypad.svg | 18 ++ .../src/styling/icons/compliance/list.svg | 16 ++ new-lamassu-admin/src/styling/theme.js | 43 ++- 48 files changed, 2424 insertions(+), 146 deletions(-) create mode 100644 lib/new-admin/graphql/resolvers/customInfoRequests.resolver.js create mode 100644 lib/new-admin/graphql/types/customInfoRequests.type.js create mode 100644 lib/new-admin/services/customInfoRequests.js create mode 100644 migrations/1620165712260-custom-info-requests.js create mode 100644 new-lamassu-admin/src/components/inputs/base/Dropdown.js create mode 100644 new-lamassu-admin/src/components/inputs/base/ToggleButtonGroup.js create mode 100644 new-lamassu-admin/src/components/inputs/formik/Dropdown.js create mode 100644 new-lamassu-admin/src/components/inputs/formik/ToggleButtonGroup.js create mode 100644 new-lamassu-admin/src/pages/Customers/components/CustomInfoRequestsData.js create mode 100644 new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/CustomInfoRequests.js create mode 100644 new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/CustomInfoRequests.styles.js create mode 100644 new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/DetailsCard.js create mode 100644 new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/ChooseType.js create mode 100644 new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/NameOfRequirement.js create mode 100644 new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/Screen1Information.js create mode 100644 new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/Screen2Information.js create mode 100644 new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/ChoiceList.js create mode 100644 new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/NumericalEntry.js create mode 100644 new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/TextEntry.js create mode 100644 new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/formStyles.styles.js create mode 100644 new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/index.js create mode 100644 new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Wizard.js create mode 100644 new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/WizardSplash.js create mode 100644 new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/index.js create mode 100644 new-lamassu-admin/src/pages/Triggers/TriggerView.js create mode 100644 new-lamassu-admin/src/styling/icons/compliance/custom-requirement.svg create mode 100644 new-lamassu-admin/src/styling/icons/compliance/keyboard.svg create mode 100644 new-lamassu-admin/src/styling/icons/compliance/keypad.svg create mode 100644 new-lamassu-admin/src/styling/icons/compliance/list.svg diff --git a/lib/customers.js b/lib/customers.js index 7586267d..5384a320 100644 --- a/lib/customers.js +++ b/lib/customers.js @@ -43,6 +43,7 @@ function add (customer) { .then(populateOverrideUsernames) .then(computeStatus) .then(populateDailyVolume) + .then(getCustomInfoRequestsData) .then(camelize) } @@ -61,6 +62,7 @@ function get (phone) { const sql = 'select * from customers where phone=$1' return db.oneOrNone(sql, [phone]) .then(populateDailyVolume) + .then(getCustomInfoRequestsData) .then(camelize) } @@ -90,6 +92,7 @@ function update (id, data, userToken, txId) { .then(populateOverrideUsernames) .then(computeStatus) .then((it) => populateDailyVolume(it, txId)) + .then(getCustomInfoRequestsData) .then(camelize) } @@ -315,6 +318,7 @@ function getById (id, userToken) { .then(populateOverrideUsernames) .then(computeStatus) .then(populateDailyVolume) + .then(getCustomInfoRequestsData) .then(camelize) } @@ -606,6 +610,7 @@ function batch () { return populateOverrideUsernames(customer) .then(computeStatus) .then(populateDailyVolume) + .then(getCustomInfoRequestsData) .then(camelize) }, customers))) } @@ -1001,6 +1006,12 @@ function removeCustomField (customerId, fieldId) { })) } +function getCustomInfoRequestsData (customer) { + if (!customer) return + const sql = `SELECT * FROM customers_custom_info_requests WHERE customer_id = $1` + return db.any(sql, [customer.id]).then(res => _.set('custom_info_request_data', res, customer)) +} + module.exports = { add, get, diff --git a/lib/new-admin/graphql/resolvers/customInfoRequests.resolver.js b/lib/new-admin/graphql/resolvers/customInfoRequests.resolver.js new file mode 100644 index 00000000..01a04068 --- /dev/null +++ b/lib/new-admin/graphql/resolvers/customInfoRequests.resolver.js @@ -0,0 +1,28 @@ +const queries = require('../../services/customInfoRequests') +const DataLoader = require('dataloader') + +const customerCustomInfoRequestsLoader = new DataLoader(ids => queries.batchGetAllCustomInfoRequestsForCustomer(ids), { cache: false }) + +const customInfoRequestLoader = new DataLoader(ids => queries.batchGetCustomInfoRequest(ids), { cache: false }) + +const resolvers = { + Customer: { + customInfoRequests: parent => customerCustomInfoRequestsLoader.load(parent.id) + }, + CustomRequestData: { + customInfoRequest: parent => customInfoRequestLoader.load(parent.infoRequestId) + }, + Query: { + customInfoRequests: (...[, { onlyEnabled }]) => queries.getCustomInfoRequests(onlyEnabled), + customerCustomInfoRequests: (...[, { customerId }]) => queries.getAllCustomInfoRequestsForCustomer(customerId), + customerCustomInfoRequest: (...[, { customerId, infoRequestId }]) => queries.getCustomInfoRequestForCustomer(customerId, infoRequestId) + }, + Mutation: { + insertCustomInfoRequest: (...[, { customRequest }]) => queries.addCustomInfoRequest(customRequest), + removeCustomInfoRequest: (...[, { id }]) => queries.removeCustomInfoRequest(id), + editCustomInfoRequest: (...[, { id, customRequest }]) => queries.editCustomInfoRequest(id, customRequest), + setAuthorizedCustomRequest: (...[, { customerId, infoRequestId, isAuthorized }]) => queries.setAuthorizedCustomRequest(customerId, infoRequestId, isAuthorized) + } +} + +module.exports = resolvers diff --git a/lib/new-admin/graphql/resolvers/index.js b/lib/new-admin/graphql/resolvers/index.js index f93c2bc5..81e79614 100644 --- a/lib/new-admin/graphql/resolvers/index.js +++ b/lib/new-admin/graphql/resolvers/index.js @@ -6,6 +6,7 @@ const cashbox = require('./cashbox.resolver') const config = require('./config.resolver') const currency = require('./currency.resolver') const customer = require('./customer.resolver') +const customInfoRequests = require('./customInfoRequests.resolver') const funding = require('./funding.resolver') const log = require('./log.resolver') const loyalty = require('./loyalty.resolver') @@ -28,6 +29,7 @@ const resolvers = [ config, currency, customer, + customInfoRequests, funding, log, loyalty, diff --git a/lib/new-admin/graphql/types/customInfoRequests.type.js b/lib/new-admin/graphql/types/customInfoRequests.type.js new file mode 100644 index 00000000..78b08a78 --- /dev/null +++ b/lib/new-admin/graphql/types/customInfoRequests.type.js @@ -0,0 +1,54 @@ +const { gql } = require('apollo-server-express') + +const typeDef = gql` + + type CustomInfoRequest { + id: ID!, + enabled: Boolean, + customRequest: JSON + } + + input CustomRequestInputField { + choiceList: [String] + constraintType: String + type: String + numDigits: String + label1: String + label2: String + } + + input CustomRequestInputScreen { + text: String + title: String + } + + input CustomRequestInput { + name: String + input: CustomRequestInputField + screen1: CustomRequestInputScreen + screen2: CustomRequestInputScreen + } + + type CustomRequestData { + customerId: ID + infoRequestId: ID + approved: Boolean + customerData: JSON + customInfoRequest: CustomInfoRequest + } + + type Query { + customInfoRequests(onlyEnabled: Boolean): [CustomInfoRequest] @auth + customerCustomInfoRequests(customerId: ID!): [CustomRequestData] @auth + customerCustomInfoRequest(customerId: ID!, infoRequestId: ID!): CustomRequestData @auth + } + + type Mutation { + insertCustomInfoRequest(customRequest: CustomRequestInput!): CustomInfoRequest @auth + removeCustomInfoRequest(id: ID!): CustomInfoRequest @auth + editCustomInfoRequest(id: ID!, customRequest: CustomRequestInput!): CustomInfoRequest @auth + setAuthorizedCustomRequest(customerId: ID!, infoRequestId: ID!, isAuthorized: Boolean!): Boolean @auth + } +` + +module.exports = typeDef diff --git a/lib/new-admin/graphql/types/customer.type.js b/lib/new-admin/graphql/types/customer.type.js index bbb90aa0..14336086 100644 --- a/lib/new-admin/graphql/types/customer.type.js +++ b/lib/new-admin/graphql/types/customer.type.js @@ -40,6 +40,7 @@ const typeDef = gql` transactions: [Transaction] subscriberInfo: JSONObject customFields: [CustomerCustomField] + customInfoRequests: [CustomRequestData] } input CustomerInput { diff --git a/lib/new-admin/graphql/types/index.js b/lib/new-admin/graphql/types/index.js index e24322ef..a1886a28 100644 --- a/lib/new-admin/graphql/types/index.js +++ b/lib/new-admin/graphql/types/index.js @@ -6,6 +6,7 @@ const cashbox = require('./cashbox.type') const config = require('./config.type') const currency = require('./currency.type') const customer = require('./customer.type') +const customInfoRequests = require('./customInfoRequests.type') const funding = require('./funding.type') const log = require('./log.type') const loyalty = require('./loyalty.type') @@ -28,6 +29,7 @@ const types = [ config, currency, customer, + customInfoRequests, funding, log, loyalty, diff --git a/lib/new-admin/services/customInfoRequests.js b/lib/new-admin/services/customInfoRequests.js new file mode 100644 index 00000000..c19c112a --- /dev/null +++ b/lib/new-admin/services/customInfoRequests.js @@ -0,0 +1,122 @@ +const db = require('../../db') +const uuid = require('uuid') +const _ = require('lodash/fp') +const pgp = require('pg-promise')() + +const getCustomInfoRequests = (onlyEnabled = false) => { + const sql = onlyEnabled + ? `SELECT * FROM custom_info_requests WHERE enabled = true ORDER BY custom_request->>'name'` + : `SELECT * FROM custom_info_requests ORDER BY custom_request->>'name'` + return db.any(sql).then(res => { + return res.map(item => ({ + id: item.id, + enabled: item.enabled, + customRequest: item.custom_request + })) + }) +} + +const addCustomInfoRequest = (customRequest) => { + const sql = 'INSERT INTO custom_info_requests (id, custom_request) VALUES ($1, $2)' + const id = uuid.v4() + return db.none(sql, [id, customRequest]).then(() => ({ id })) +} + +const removeCustomInfoRequest = (id) => { + return db.none('UPDATE custom_info_requests SET enabled = false WHERE id = $1', [id]).then(() => ({ id })) +} + +const editCustomInfoRequest = (id, customRequest) => { + return db.none('UPDATE custom_info_requests SET custom_request = $1 WHERE id=$2', [customRequest, id]).then(() => ({ id, customRequest })) +} + +const getAllCustomInfoRequestsForCustomer = (customerId) => { + const sql = `SELECT * FROM customers_custom_info_requests WHERE customer_id = $1` + return db.any(sql, [customerId]).then(res => res.map(item => ({ + customerId: item.customer_id, + infoRequestId: item.info_request_id, + approved: item.approved, + customerData: item.customer_data + }))) +} + +const getCustomInfoRequestForCustomer = (customerId, infoRequestId) => { + const sql = `SELECT * FROM customers_custom_info_requests WHERE customer_id = $1 AND info_request_id = $2` + return db.one(sql, [customerId, infoRequestId]).then(item => { + return { + customerId: item.customer_id, + infoRequestId: item.info_request_id, + approved: item.approved, + customerData: item.customer_data + } + }) +} + +const batchGetAllCustomInfoRequestsForCustomer = (customerIds) => { + const sql = `SELECT * FROM customers_custom_info_requests WHERE customer_id IN ($1^)` + return db.any(sql, [_.map(pgp.as.text, customerIds).join(',')]).then(res => { + const map = _.groupBy('customer_id', res) + return customerIds.map(id => { + const items = map[id] || [] + return items.map(item => ({ + customerId: item.customer_id, + infoRequestId: item.info_request_id, + approved: item.approved, + customerData: item.customer_data + })) + }) + }) +} + +const getCustomInfoRequest = (infoRequestId) => { + const sql = `SELECT * FROM custom_info_requests WHERE id = $1` + return db.one(sql, [infoRequestId]).then(item => ({ + id: item.id, + enabled: item.enabled, + customRequest: item.custom_request + })) +} + +const batchGetCustomInfoRequest = (infoRequestIds) => { + if (infoRequestIds.length === 0) return Promise.resolve([]) + const sql = `SELECT * FROM custom_info_requests WHERE id IN ($1^)` + return db.any(sql, [_.map(pgp.as.text, infoRequestIds).join(',')]).then(res => { + const map = _.groupBy('id', res) + return infoRequestIds.map(id => { + const item = map[id][0] // since id is primary key the array always has 1 element + return { + id: item.id, + enabled: item.enabled, + customRequest: item.custom_request + } + }) + }) +} + +const setAuthorizedCustomRequest = (customerId, infoRequestId, isAuthorized) => { + const sql = `UPDATE customers_custom_info_requests SET approved = $1 WHERE customer_id = $2 AND info_request_id = $3` + return db.none(sql, [isAuthorized, customerId, infoRequestId]).then(() => true) +} + +const setCustomerData = (customerId, infoRequestId, data) => { + const sql = ` + INSERT INTO customers_custom_info_requests (customer_id, info_request_id, customer_data) + VALUES ($1, $2, $3) + ON CONFLICT (customer_id, info_request_id) + DO UPDATE SET customer_data = $3, approved = null` + return db.none(sql, [customerId, infoRequestId, data]) +} + +module.exports = { + getCustomInfoRequests, + addCustomInfoRequest, + removeCustomInfoRequest, + editCustomInfoRequest, + getAllCustomInfoRequestsForCustomer, + getCustomInfoRequestForCustomer, + batchGetAllCustomInfoRequestsForCustomer, + getCustomInfoRequest, + batchGetCustomInfoRequest, + setAuthorizedCustomRequest, + setCustomerData +} diff --git a/lib/routes/customerRoutes.js b/lib/routes/customerRoutes.js index 2ea098e3..de4e356d 100644 --- a/lib/routes/customerRoutes.js +++ b/lib/routes/customerRoutes.js @@ -18,6 +18,13 @@ const { getTx } = require('../new-admin/services/transactions.js') const { getCustomerById } = require('../customers') const machineLoader = require('../machine-loader') const { loadLatestConfig } = require('../new-settings-loader') +const customInfoRequestQueries = require('../new-admin/services/customInfoRequests') + +function updateCustomerCustomInfoRequest (customerId, dataToSave, req, res) { + return customInfoRequestQueries.setCustomerData(customerId, dataToSave.info_request_id, dataToSave) + .then(() => customers.getById(customerId)) + .then(customer => respond(req, res, { customer })) +} function updateCustomer (req, res, next) { const id = req.params.id @@ -27,6 +34,10 @@ function updateCustomer (req, res, next) { const triggers = configManager.getTriggers(req.settings.config) const compatTriggers = complianceTriggers.getBackwardsCompatibleTriggers(triggers) + if (patch.customRequestPatch) { + return updateCustomerCustomInfoRequest(id, patch.dataToSave, req, res).catch(next) + } + customers.getById(id) .then(customer => { if (!customer) { throw httpError('Not Found', 404) } diff --git a/lib/routes/pollingRoutes.js b/lib/routes/pollingRoutes.js index 3be6e178..ad7d0118 100644 --- a/lib/routes/pollingRoutes.js +++ b/lib/routes/pollingRoutes.js @@ -10,6 +10,7 @@ const plugins = require('../plugins') const semver = require('semver') const state = require('../middlewares/state') const version = require('../../package.json').version +const customRequestQueries = require('../new-admin/services/customInfoRequests') const urlsToPing = [ `us.archive.ubuntu.com`, @@ -37,6 +38,24 @@ const createTerms = terms => (terms.active && terms.text) ? ({ cancel: terms.cancelButtonText }) : null +const buildTriggers = (allTriggers) => { + const normalTriggers = [] + const customTriggers = _.filter(o => { + if (o.customInfoRequestId === '') normalTriggers.push(o) + return o.customInfoRequestId !== '' + }, allTriggers) + + return _.flow([_.map(_.get('customInfoRequestId')), customRequestQueries.batchGetCustomInfoRequest])(customTriggers) + .then(res => { + res.forEach((details, index) => { + // make sure we aren't attaching the details to the wrong trigger + if (customTriggers[index].customInfoRequestId !== details.id) return + customTriggers[index] = { ...customTriggers[index], customInfoRequest: details } + }) + return [...normalTriggers, ...customTriggers] + }) +} + function poll (req, res, next) { const machineVersion = req.query.version const machineModel = req.query.model @@ -54,8 +73,8 @@ function poll (req, res, next) { const pi = plugins(settings, deviceId) const hasLightning = checkHasLightning(settings) - const triggers = configManager.getTriggers(settings.config) const triggersAutomation = configManager.getTriggersAutomation(settings.config) + const triggersPromise = buildTriggers(configManager.getTriggers(settings.config)) const operatorInfo = configManager.getOperatorInfo(settings.config) const machineInfo = { deviceId: req.deviceId, deviceName: req.deviceName } @@ -65,8 +84,8 @@ function poll (req, res, next) { state.pids[operatorId] = { [deviceId]: { pid, ts: Date.now() } } - return pi.pollQueries(serialNumber, deviceTime, req.query, machineVersion, machineModel) - .then(results => { + return Promise.all([pi.pollQueries(serialNumber, deviceTime, req.query, machineVersion, machineModel), triggersPromise]) + .then(([results, triggers]) => { const cassettes = results.cassettes const reboot = pid && state.reboots?.[operatorId]?.[deviceId] === pid diff --git a/migrations/1620165712260-custom-info-requests.js b/migrations/1620165712260-custom-info-requests.js new file mode 100644 index 00000000..578168e2 --- /dev/null +++ b/migrations/1620165712260-custom-info-requests.js @@ -0,0 +1,24 @@ +const db = require('./db') + +exports.up = function (next) { + const sql = [ + `CREATE TABLE custom_info_requests( + id UUID PRIMARY KEY, + enabled BOOLEAN NOT NULL DEFAULT true, + custom_request JSONB + ); + CREATE TABLE customers_custom_info_requests( + customer_id UUID REFERENCES customers, + info_request_id UUID REFERENCES custom_info_requests, + approved BOOLEAN, + customer_data JSONB NOT NULL, + PRIMARY KEY(customer_id, info_request_id) + );` + ] + + db.multi(sql, next) +} + +exports.down = function (next) { + next() +} diff --git a/new-lamassu-admin/src/components/buttons/SubpageButton.js b/new-lamassu-admin/src/components/buttons/SubpageButton.js index f9979cba..66a2b62d 100644 --- a/new-lamassu-admin/src/components/buttons/SubpageButton.js +++ b/new-lamassu-admin/src/components/buttons/SubpageButton.js @@ -10,15 +10,21 @@ import subpageButtonStyles from './SubpageButton.styles' const useStyles = makeStyles(subpageButtonStyles) const SubpageButton = memo( - ({ className, Icon, InverseIcon, toggle, children }) => { + ({ + className, + Icon, + InverseIcon, + toggle, + forceDisable = false, + children + }) => { const [active, setActive] = useState(false) - + const isActive = forceDisable ? false : active const classes = useStyles() - const classNames = { [classes.button]: true, - [classes.normalButton]: !active, - [classes.activeButton]: active + [classes.normalButton]: !isActive, + [classes.activeButton]: isActive } const normalButton = @@ -42,7 +48,8 @@ const SubpageButton = memo( ) const innerToggle = () => { - const newActiveState = !active + forceDisable = false + const newActiveState = !isActive toggle(newActiveState) setActive(newActiveState) } @@ -51,7 +58,7 @@ const SubpageButton = memo( ) } diff --git a/new-lamassu-admin/src/components/inputs/base/Dropdown.js b/new-lamassu-admin/src/components/inputs/base/Dropdown.js new file mode 100644 index 00000000..3acac050 --- /dev/null +++ b/new-lamassu-admin/src/components/inputs/base/Dropdown.js @@ -0,0 +1,28 @@ +import FormControl from '@material-ui/core/FormControl' +import InputLabel from '@material-ui/core/InputLabel' +import MenuItem from '@material-ui/core/MenuItem' +import Select from '@material-ui/core/Select' +import classnames from 'classnames' +import React from 'react' + +const Dropdown = ({ label, name, options, onChange, value, className }) => { + return ( + + {label} + + + ) +} + +export default Dropdown diff --git a/new-lamassu-admin/src/components/inputs/base/RadioGroup.js b/new-lamassu-admin/src/components/inputs/base/RadioGroup.js index 6d1e1029..4d11f4ed 100644 --- a/new-lamassu-admin/src/components/inputs/base/RadioGroup.js +++ b/new-lamassu-admin/src/components/inputs/base/RadioGroup.js @@ -8,13 +8,18 @@ import classnames from 'classnames' import React from 'react' import { Label1 } from 'src/components/typography' - +import { offColor } from 'src/styling/variables' const styles = { label: { height: 16, lineHeight: '16px', margin: [[0, 0, 4, 0]], paddingLeft: 3 + }, + subtitle: { + marginTop: -8, + marginLeft: 32, + color: offColor } } @@ -31,7 +36,6 @@ const RadioGroup = ({ radioClassName }) => { const classes = useStyles() - return ( <> {label && {label}} @@ -41,13 +45,20 @@ const RadioGroup = ({ onChange={onChange} className={classnames(className)}> {options.map((option, idx) => ( - } - label={option.display} - className={classnames(labelClassName)} - /> + +
+ } + label={option.display} + className={classnames(labelClassName)} + /> + {option.subtitle && ( + {option.subtitle} + )} +
+
))} diff --git a/new-lamassu-admin/src/components/inputs/base/ToggleButtonGroup.js b/new-lamassu-admin/src/components/inputs/base/ToggleButtonGroup.js new file mode 100644 index 00000000..bbd48ff0 --- /dev/null +++ b/new-lamassu-admin/src/components/inputs/base/ToggleButtonGroup.js @@ -0,0 +1,75 @@ +import { makeStyles } from '@material-ui/core' +import { ToggleButtonGroup as MUIToggleButtonGroup } from '@material-ui/lab' +import ToggleButton from '@material-ui/lab/ToggleButton' +import React from 'react' + +import { H4, P } from 'src/components/typography' +import { backgroundColor, comet } from 'src/styling/variables' +const styles = { + noTextTransform: { + textTransform: 'none' + }, + flex: { + display: 'flex', + alignItems: 'center', + justifyContent: 'start', + width: '90%', + overflow: 'hidden', + maxHeight: 80 + }, + buttonTextContent: { + marginLeft: 32, + textTransform: 'none', + textAlign: 'left' + }, + button: { + backgroundColor: backgroundColor, + marginBottom: 16 + }, + paragraph: { + color: comet, + marginTop: -10 + } +} + +const useStyles = makeStyles(styles) +const ToggleButtonGroup = ({ + name, + orientation = 'vertical', + value, + exclusive = true, + onChange, + size = 'small', + ...props +}) => { + const classes = useStyles() + return ( + + {props.options.map(option => { + return ( + +
+ +
+

{option.title}

+

{option.description}

+
+
+
+ ) + })} +
+ ) +} + +export default ToggleButtonGroup diff --git a/new-lamassu-admin/src/components/inputs/base/index.js b/new-lamassu-admin/src/components/inputs/base/index.js index 5ac32b8f..9dc6ecad 100644 --- a/new-lamassu-admin/src/components/inputs/base/index.js +++ b/new-lamassu-admin/src/components/inputs/base/index.js @@ -1,12 +1,13 @@ import Autocomplete from './Autocomplete' import Checkbox from './Checkbox' import CodeInput from './CodeInput' +import Dropdown from './Dropdown' import NumberInput from './NumberInput' import RadioGroup from './RadioGroup' import SecretInput from './SecretInput' import Switch from './Switch' import TextInput from './TextInput' - +import ToggleButtonGroup from './ToggleButtonGroup' export { Checkbox, CodeInput, @@ -15,5 +16,7 @@ export { Switch, SecretInput, RadioGroup, - Autocomplete + Autocomplete, + ToggleButtonGroup, + Dropdown } diff --git a/new-lamassu-admin/src/components/inputs/formik/Dropdown.js b/new-lamassu-admin/src/components/inputs/formik/Dropdown.js new file mode 100644 index 00000000..e640da21 --- /dev/null +++ b/new-lamassu-admin/src/components/inputs/formik/Dropdown.js @@ -0,0 +1,25 @@ +import React, { memo } from 'react' + +import { Dropdown } from '../base' + +const RadioGroupFormik = memo(({ label, ...props }) => { + const { name, value } = props.field + const { setFieldValue } = props.form + return ( + { + setFieldValue(name, e.target.value) + props.resetError && props.resetError() + }} + className={props.className} + {...props} + /> + ) +}) + +export default RadioGroupFormik diff --git a/new-lamassu-admin/src/components/inputs/formik/ToggleButtonGroup.js b/new-lamassu-admin/src/components/inputs/formik/ToggleButtonGroup.js new file mode 100644 index 00000000..51d5f74a --- /dev/null +++ b/new-lamassu-admin/src/components/inputs/formik/ToggleButtonGroup.js @@ -0,0 +1,27 @@ +import React, { memo } from 'react' + +import { ToggleButtonGroup } from '../base' + +const ToggleButtonGroupFormik = memo(({ enforceValueSet = true, ...props }) => { + const { name, value } = props.field + const { setFieldValue } = props.form + return ( + { + // enforceValueSet prevents you from not having any button selected + // after selecting one the first time + if (enforceValueSet && !value) return null + setFieldValue(name, value) + props.resetError && props.resetError() + }} + className={props.className} + {...props} + /> + ) +}) + +export default ToggleButtonGroupFormik diff --git a/new-lamassu-admin/src/components/inputs/formik/index.js b/new-lamassu-admin/src/components/inputs/formik/index.js index b5719013..ce6b3400 100644 --- a/new-lamassu-admin/src/components/inputs/formik/index.js +++ b/new-lamassu-admin/src/components/inputs/formik/index.js @@ -1,6 +1,7 @@ import Autocomplete from './Autocomplete' import CashCassetteInput from './CashCassetteInput' import Checkbox from './Checkbox' +import Dropdown from './Dropdown' import NumberInput from './NumberInput' import RadioGroup from './RadioGroup' import SecretInput from './SecretInput' @@ -13,5 +14,6 @@ export { NumberInput, SecretInput, RadioGroup, - CashCassetteInput + CashCassetteInput, + Dropdown } diff --git a/new-lamassu-admin/src/components/layout/TitleSection.js b/new-lamassu-admin/src/components/layout/TitleSection.js index 95c36367..6858ed5f 100644 --- a/new-lamassu-admin/src/components/layout/TitleSection.js +++ b/new-lamassu-admin/src/components/layout/TitleSection.js @@ -16,7 +16,7 @@ const TitleSection = ({ title, error, labels, - button, + buttons = [], children, appendix, appendixClassName @@ -30,14 +30,20 @@ const TitleSection = ({ {error && ( Failed to save )} - {button && ( - - {button.text} - + {buttons.length > 0 && ( + <> + {buttons.map((button, idx) => ( + + {button.text} + + ))} + )} diff --git a/new-lamassu-admin/src/pages/Commissions/Commissions.js b/new-lamassu-admin/src/pages/Commissions/Commissions.js index d52e9d09..08f48f27 100644 --- a/new-lamassu-admin/src/pages/Commissions/Commissions.js +++ b/new-lamassu-admin/src/pages/Commissions/Commissions.js @@ -109,12 +109,14 @@ const Commissions = ({ name: SCREEN_KEY }) => { diff --git a/new-lamassu-admin/src/pages/Customers/CustomerData.js b/new-lamassu-admin/src/pages/Customers/CustomerData.js index b9eb837a..6a90fafe 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerData.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerData.js @@ -61,7 +61,8 @@ const CustomerData = ({ customer, updateCustomer, editCustomer, - deleteEditedData + deleteEditedData, + updateCustomRequest }) => { const classes = useStyles() const [listView, setListView] = useState(false) @@ -79,8 +80,15 @@ const CustomerData = ({ ? 'Passed' : 'Failed' + const sortByName = R.sortBy( + R.compose(R.toLower, R.path(['customInfoRequest', 'customRequest', 'name'])) + ) + const customEntries = null // get customer custom entries const customRequirements = null // get customer custom requirements + const customInfoRequests = sortByName( + R.path(['customInfoRequests'])(customer) ?? [] + ) const isEven = elem => elem % 2 === 0 @@ -291,6 +299,36 @@ const CustomerData = ({ } ] + R.forEach(it => { + cards.push({ + data: [ + { + value: it.customerData.data, + component: TextInput + } + ], + title: it.customInfoRequest.customRequest.name, + titleIcon: , + authorize: () => + updateCustomRequest({ + variables: { + customerId: it.customerId, + infoRequestId: it.customInfoRequest.id, + isAuthorized: true + } + }), + reject: () => + updateCustomRequest({ + variables: { + customerId: it.customerId, + infoRequestId: it.customInfoRequest.id, + isAuthorized: false + } + }), + save: () => {} + }) + }, customInfoRequests) + const editableCard = ( { title, diff --git a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js index 6b083eb5..bfaf1e3c 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js @@ -83,6 +83,16 @@ const GET_CUSTOMER = gql` txCustomerPhotoAt txCustomerPhotoPath } + customInfoRequests { + customerId + approved + customerData + customInfoRequest { + id + enabled + customRequest + } + } } } ` @@ -156,6 +166,20 @@ const DELETE_EDITED_CUSTOMER = gql` } ` +const SET_AUTHORIZED_REQUEST = gql` + mutation setAuthorizedCustomRequest( + $customerId: ID! + $infoRequestId: ID! + $isAuthorized: Boolean! + ) { + setAuthorizedCustomRequest( + customerId: $customerId + infoRequestId: $infoRequestId + isAuthorized: $isAuthorized + ) + } +` + const CustomerProfile = memo(() => { const history = useHistory() @@ -187,6 +211,9 @@ const CustomerProfile = memo(() => { const [setCustomer] = useMutation(SET_CUSTOMER, { onCompleted: () => getCustomer() }) + const [updateCustomRequest] = useMutation(SET_AUTHORIZED_REQUEST, { + onCompleted: () => getCustomer() + }) const updateCustomer = it => setCustomer({ @@ -375,7 +402,8 @@ const CustomerProfile = memo(() => { updateCustomer={updateCustomer} replacePhoto={replacePhoto} editCustomer={editCustomer} - deleteEditedData={deleteEditedData}> + deleteEditedData={deleteEditedData} + updateCustomRequest={updateCustomRequest}> )} diff --git a/new-lamassu-admin/src/pages/Customers/components/CustomInfoRequestsData.js b/new-lamassu-admin/src/pages/Customers/components/CustomInfoRequestsData.js new file mode 100644 index 00000000..ced31cef --- /dev/null +++ b/new-lamassu-admin/src/pages/Customers/components/CustomInfoRequestsData.js @@ -0,0 +1,195 @@ +import { useMutation } from '@apollo/react-hooks' +import { makeStyles } from '@material-ui/core' +import classnames from 'classnames' +import gql from 'graphql-tag' +import React, { useState } from 'react' + +import Modal from 'src/components/Modal' +import { MainStatus } from 'src/components/Status' +import { ActionButton } from 'src/components/buttons' +import { + Table, + THead, + Th, + Tr, + Td, + TBody +} from 'src/components/fake-table/Table' +import { H3, Label1 } from 'src/components/typography' +import { ReactComponent as AuthorizeReversedIcon } from 'src/styling/icons/button/authorize/white.svg' +import { ReactComponent as AuthorizeIcon } from 'src/styling/icons/button/authorize/zodiac.svg' +import { ReactComponent as RejectReversedIcon } from 'src/styling/icons/button/cancel/white.svg' +import { ReactComponent as RejectIcon } from 'src/styling/icons/button/cancel/zodiac.svg' +import { ReactComponent as LinkIcon } from 'src/styling/icons/month arrows/right.svg' +import { white, disabledColor } from 'src/styling/variables' + +import DetailsCard from '../../Triggers/CustomInfoRequests/DetailsCard' +const styles = { + white: { + color: white + }, + actionButton: { + display: 'flex', + height: 28, + marginRight: 'auto' + }, + flex: { + display: 'flex' + }, + disabledBtn: { + backgroundColor: disabledColor, + '&:hover': { + backgroundColor: disabledColor + } + }, + linkIcon: { + marginTop: 12, + marginLeft: 4, + cursor: 'pointer' + } +} + +const SET_AUTHORIZED_REQUEST = gql` + mutation setAuthorizedCustomRequest( + $customerId: ID! + $infoRequestId: ID! + $isAuthorized: Boolean! + ) { + setAuthorizedCustomRequest( + customerId: $customerId + infoRequestId: $infoRequestId + isAuthorized: $isAuthorized + ) + } +` + +const useStyles = makeStyles(styles) +const CustomInfoRequestsData = ({ data }) => { + const classes = useStyles() + const [toView, setToView] = useState(null) + const [setAuthorized] = useMutation(SET_AUTHORIZED_REQUEST, { + onError: () => console.error('Error while clearing notification'), + refetchQueries: () => ['customer'] + }) + + const authorize = it => + setAuthorized({ + variables: { + customerId: it.customerId, + infoRequestId: it.customInfoRequest.id, + isAuthorized: true + } + }) + + const reject = it => + setAuthorized({ + variables: { + customerId: it.customerId, + infoRequestId: it.customInfoRequest.id, + isAuthorized: false + } + }) + + const getBtnClasses = (it, isAuthorize) => { + return { + [classes.actionButton]: true, + [classes.disabledBtn]: + (isAuthorize && it.approved === true) || + (!isAuthorize && it.approved === false) + } + } + + const AuthorizeButton = it => ( + authorize(it)}> + Authorize + + ) + + const RejectButton = it => ( + reject(it)}> + Reject + + ) + + const getActionButtons = it => { + return ( + <> + {AuthorizeButton(it)} + {RejectButton(it)} + + ) + } + + const getAuthorizedStatus = it => { + return it.approved === null + ? { label: 'Pending', type: 'neutral' } + : it.approved === false + ? { label: 'Rejected', type: 'error' } + : { label: 'Accepted', type: 'success' } + } + + return ( + <> +

Custom Info Requests Data

+
+ + + + + + + + + {data.map((it, idx) => ( + + + + + + + + + ))} + +
Custom Request NameCustom Request DataStatus + Actions +
+
+ {it.customInfoRequest.customRequest.name} +
setToView(it)}> + +
+
+
+
{JSON.stringify(it.customerData.data, null, 2)}
+
+ + +
{getActionButtons(it)}
+
+ {toView && ( + setToView(null)}> +

Custom Information Request Details

+ +
+ )} +
+ + ) +} + +export default CustomInfoRequestsData diff --git a/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/CustomInfoRequests.js b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/CustomInfoRequests.js new file mode 100644 index 00000000..2a020a02 --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/CustomInfoRequests.js @@ -0,0 +1,249 @@ +import { useMutation } from '@apollo/react-hooks' +import { makeStyles } from '@material-ui/core' +import classnames from 'classnames' +import gql from 'graphql-tag' +import * as R from 'ramda' +import React, { useState } from 'react' + +import { DeleteDialog } from 'src/components/DeleteDialog' +import { IconButton, Button, Link } from 'src/components/buttons' +import DataTable from 'src/components/tables/DataTable' +import { Info1, Info3 } from 'src/components/typography' +import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg' +import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg' + +import styles from './CustomInfoRequests.styles' +import DetailsRow from './DetailsCard' +import Wizard from './Wizard' +const useStyles = makeStyles(styles) + +const inputTypeDisplay = { + numerical: 'Numerical', + text: 'Text', + choiceList: 'Choice list' +} + +const constraintTypeDisplay = { + date: 'Date', + none: 'None', + email: 'Email', + length: 'Length', + selectOne: 'Select one', + selectMultiple: 'Select multiple', + spaceSeparation: 'Space separation' +} + +const ADD_ROW = gql` + mutation insertCustomInfoRequest($customRequest: CustomRequestInput!) { + insertCustomInfoRequest(customRequest: $customRequest) { + id + } + } +` +const EDIT_ROW = gql` + mutation editCustomInfoRequest( + $id: ID! + $customRequest: CustomRequestInput! + ) { + editCustomInfoRequest(id: $id, customRequest: $customRequest) { + id + } + } +` + +const REMOVE_ROW = gql` + mutation removeCustomInfoRequest($id: ID!) { + removeCustomInfoRequest(id: $id) { + id + } + } +` + +const CustomInfoRequests = ({ + showWizard, + toggleWizard, + data: customRequests +}) => { + const classes = useStyles() + + const [toBeDeleted, setToBeDeleted] = useState() + const [toBeEdited, setToBeEdited] = useState() + const [deleteDialog, setDeleteDialog] = useState(false) + const [hasError, setHasError] = useState(false) + + const [addEntry] = useMutation(ADD_ROW, { + onError: () => { + console.log('Error while adding custom info request') + setHasError(true) + }, + onCompleted: () => { + setHasError(false) + toggleWizard() + }, + refetchQueries: () => ['customInfoRequests'] + }) + + const [editEntry] = useMutation(EDIT_ROW, { + onError: () => { + console.log('Error while editing custom info request') + setHasError(true) + }, + onCompleted: () => { + setHasError(false) + setToBeEdited(null) + toggleWizard() + }, + refetchQueries: () => ['customInfoRequests'] + }) + + const [removeEntry] = useMutation(REMOVE_ROW, { + onError: () => { + console.log('Error while removing custom info request') + setHasError(true) + }, + onCompleted: () => { + setDeleteDialog(false) + setHasError(false) + }, + refetchQueries: () => ['customInfoRequests'] + }) + + const handleDelete = id => { + removeEntry({ + variables: { + id + } + }) + } + + const handleSave = (values, isEditing) => { + if (isEditing) { + return editEntry({ + variables: { + id: values.id, + customRequest: R.omit(['id'])(values) + } + }) + } + return addEntry({ + variables: { + customRequest: { + ...values + } + } + }) + } + + return ( + <> + {customRequests.length > 0 && ( + it.customRequest.name + }, + { + header: 'Data entry type', + width: 300, + textAlign: 'left', + size: 'sm', + view: it => inputTypeDisplay[it.customRequest.input.type] + }, + { + header: 'Constraints', + width: 300, + textAlign: 'left', + size: 'sm', + view: it => + constraintTypeDisplay[it.customRequest.input.constraintType] + }, + { + header: 'Edit', + width: 100, + textAlign: 'center', + size: 'sm', + view: it => { + return ( + { + setToBeEdited(it) + return toggleWizard() + }}> + + + ) + } + }, + { + header: 'Delete', + width: 100, + textAlign: 'center', + size: 'sm', + view: it => { + return ( + { + setToBeDeleted(it.id) + return setDeleteDialog(true) + }}> + + + ) + } + } + ]} + data={customRequests} + Details={DetailsRow} + expandable + rowSize="sm" + /> + )} + {!customRequests.length && ( +
+ + It seems you haven't added any custom information requests yet. + + + Please read our{' '} + + Support Article + {' '} + on Compliance before adding new information requests. + + +
+ )} + {showWizard && ( + { + setToBeEdited(null) + setHasError(false) + toggleWizard() + }} + toBeEdited={toBeEdited} + onSave={(...args) => handleSave(...args)} + /> + )} + + { + setDeleteDialog(false) + setHasError(false) + }} + onConfirmed={() => handleDelete(toBeDeleted)} + /> + + ) +} + +export default CustomInfoRequests diff --git a/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/CustomInfoRequests.styles.js b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/CustomInfoRequests.styles.js new file mode 100644 index 00000000..07a1bf87 --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/CustomInfoRequests.styles.js @@ -0,0 +1,19 @@ +export default { + m0: { + margin: 0 + }, + mb10: { + marginBottom: 10 + }, + centerItems: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + height: '50%', + justifyContent: 'center' + }, + alignWithTitleSection: { + marginTop: -47, + display: 'flex' + } +} diff --git a/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/DetailsCard.js b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/DetailsCard.js new file mode 100644 index 00000000..dda9bb4c --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/DetailsCard.js @@ -0,0 +1,112 @@ +import { makeStyles } from '@material-ui/core' +import classnames from 'classnames' +import React from 'react' + +import { Label1, Info2 } from 'src/components/typography' + +const styles = { + flex: { + display: 'flex' + }, + column: { + flexDirection: 'column' + }, + halfWidth: { + width: '50%', + marginBottom: 15, + marginRight: 50 + }, + marginTop: { + marginTop: 20 + }, + marginBottom: { + marginBottom: 20 + } +} +const useStyles = makeStyles(styles) +const DetailsCard = ({ it }) => { + const customRequest = it.customRequest + const classes = useStyles() + + const getScreen2Data = () => { + const label1Display = + customRequest.input.constraintType === 'spaceSeparation' + ? 'First word label' + : 'Text entry label' + switch (customRequest.input.type) { + case 'text': + return ( + <> +
+ {label1Display} + {customRequest.input.label1} +
+ {customRequest.input.constraintType === 'spaceSeparation' && ( +
+ Second word label + {customRequest.input.label2} +
+ )} + + ) + default: + return ( + <> +
+ Screen 2 input title + {customRequest.screen2.title} +
+
+ Screen 2 input description + {customRequest.screen2.text} +
+ + ) + } + } + + const getInputData = () => { + return ( + <> + {customRequest.input.choiceList && ( + <> + Choices + {customRequest.input.choiceList.map((choice, idx) => { + return {choice} + })} + + )} + {customRequest.input.numDigits && ( + <> + Number of digits + {customRequest.input.numDigits} + + )} + + ) + } + + return ( +
+
+
+ Screen 1 title + {customRequest.screen1.title} +
+
+ {getScreen2Data()} +
+
+
+
+ Screen 1 text + {customRequest.screen1.text} +
+
{getInputData()}
+
+
+ ) +} + +export default DetailsCard diff --git a/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/ChooseType.js b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/ChooseType.js new file mode 100644 index 00000000..3fc81127 --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/ChooseType.js @@ -0,0 +1,76 @@ +import { Field } from 'formik' +import React from 'react' +import * as Yup from 'yup' + +import ToggleButtonGroup from 'src/components/inputs/formik/ToggleButtonGroup' +import { H4 } from 'src/components/typography' +import { ReactComponent as Keyboard } from 'src/styling/icons/compliance/keyboard.svg' +import { ReactComponent as Keypad } from 'src/styling/icons/compliance/keypad.svg' +import { ReactComponent as List } from 'src/styling/icons/compliance/list.svg' +import { zircon } from 'src/styling/variables' + +const MakeIcon = IconSvg => ( +
+ +
+) + +const ChooseType = () => { + const options = [ + { + value: 'numerical', + title: 'Numerical entry', + description: + 'User will enter information with a keypad. Good for dates, ID numbers, etc.', + icon: () => MakeIcon(Keypad) + }, + { + value: 'text', + title: 'Text entry', + description: + 'User will entry information with a keyboard. Good for names, email, address, etc.', + icon: () => MakeIcon(Keyboard) + }, + { + value: 'choiceList', + title: 'Choice list', + description: 'Gives user multiple options to choose from.', + icon: () => MakeIcon(List) + } + ] + + return ( + <> +

Choose the type of data entry

+ + + ) +} + +const validationSchema = Yup.object().shape({ + inputType: Yup.string().required() +}) + +const defaultValues = { + inputType: '' +} + +export default ChooseType +export { validationSchema, defaultValues } diff --git a/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/NameOfRequirement.js b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/NameOfRequirement.js new file mode 100644 index 00000000..cdf9237a --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/NameOfRequirement.js @@ -0,0 +1,37 @@ +import { Field } from 'formik' +import React from 'react' +import * as Yup from 'yup' + +import TextInputFormik from 'src/components/inputs/formik/TextInput' +import { H4, P } from 'src/components/typography' + +const NameOfRequirement = () => { + return ( + <> +

Name of the requirement

{/* TODO Add ? icon */} +

+ The name of the requirement will only be visible to you on the dashboard + on the requirement list, as well as on the custom information request + list. The user won't see this name. Make sure to make it distinguishable + and short. +

+ + + ) +} + +const validationSchema = Yup.object().shape({ + requirementName: Yup.string().required() +}) + +const defaultValues = { + requirementName: '' +} + +export default NameOfRequirement +export { validationSchema, defaultValues } diff --git a/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/Screen1Information.js b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/Screen1Information.js new file mode 100644 index 00000000..85d0ceef --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/Screen1Information.js @@ -0,0 +1,45 @@ +import { Field } from 'formik' +import React from 'react' +import * as Yup from 'yup' + +import TextInputFormik from 'src/components/inputs/formik/TextInput' +import { H4, P } from 'src/components/typography' + +const Screen1Information = () => { + return ( + <> +

Screen 1 Information

{/* TODO Add ? icon */} +

+ On screen 1 you will request the user if he agrees on providing this + information, or if he wishes to terminate the transaction instead. +

+ + + + ) +} + +const validationSchema = Yup.object().shape({ + screen1Title: Yup.string().required(), + screen1Text: Yup.string().required() +}) + +const defaultValues = { + screen1Title: '', + screen1Text: '' +} + +export default Screen1Information +export { validationSchema, defaultValues } diff --git a/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/Screen2Information.js b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/Screen2Information.js new file mode 100644 index 00000000..3b8957b3 --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/Screen2Information.js @@ -0,0 +1,43 @@ +import { Field } from 'formik' +import React from 'react' +import * as Yup from 'yup' + +import TextInputFormik from 'src/components/inputs/formik/TextInput' +import { H4, P } from 'src/components/typography' + +const ScreenInformation = () => { + return ( + <> +

Screen 2 Information

{/* TODO Add ? icon */} +

+ If the user agrees, on screen 2 is where the user will enter the custom + information. +

+ + + + ) +} + +const validationSchema = Yup.object().shape({ + screen2Title: Yup.string().required(), + screen2Text: Yup.string().required() +}) + +const defaultValues = { + screen2Title: '', + screen2Text: '' +} + +export default ScreenInformation +export { validationSchema, defaultValues } diff --git a/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/ChoiceList.js b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/ChoiceList.js new file mode 100644 index 00000000..20a82f82 --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/ChoiceList.js @@ -0,0 +1,102 @@ +import { makeStyles } from '@material-ui/core' +import classnames from 'classnames' +import { Field, useFormikContext, FieldArray } from 'formik' +import * as R from 'ramda' +import React, { useEffect, useRef } from 'react' + +import Button from 'src/components/buttons/ActionButton' +import RadioGroup from 'src/components/inputs/formik/RadioGroup' +import TextInput from 'src/components/inputs/formik/TextInput' +import { H4 } from 'src/components/typography' +import { ReactComponent as AddIconInverse } from 'src/styling/icons/button/add/white.svg' +import { ReactComponent as AddIcon } from 'src/styling/icons/button/add/zodiac.svg' + +import styles from './formStyles.styles' +const useStyles = makeStyles(styles) + +const nonEmptyStr = obj => obj.text && obj.text.length + +const options = [ + { display: 'Select just one', code: 'selectOne' }, + { display: 'Select multiple', code: 'selectMultiple' } +] + +const ChoiceList = () => { + const classes = useStyles() + const context = useFormikContext() + const choiceListRef = useRef(null) + const listChoices = R.path(['values', 'listChoices'])(context) ?? [] + const choiceListError = R.path(['errors', 'listChoices'])(context) ?? false + + const showErrorColor = { + [classes.radioSubtitle]: true, + [classes.error]: + !R.path(['values', 'constraintType'])(context) && + R.path(['errors', 'constraintType'])(context) + } + + const hasError = choice => { + return ( + choiceListError && + R.filter(nonEmptyStr)(listChoices).length < 2 && + choice.text.length === 0 + ) + } + + useEffect(() => { + scrollToBottom() + }, [listChoices.length]) + + const scrollToBottom = () => { + choiceListRef.current?.scrollIntoView() + } + + return ( + <> +

Choice list constraints

+ + + {({ push }) => { + return ( +
+

Choices

+
+ {listChoices.map((choice, idx) => { + return ( +
+ +
+ ) + })} +
+ +
+ ) + }} +
+ + ) +} + +export default ChoiceList diff --git a/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/NumericalEntry.js b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/NumericalEntry.js new file mode 100644 index 00000000..7f68c8ee --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/NumericalEntry.js @@ -0,0 +1,62 @@ +import { makeStyles } from '@material-ui/core' +import classnames from 'classnames' +import { Field, useFormikContext } from 'formik' +import * as R from 'ramda' +import React from 'react' + +import NumberInput from 'src/components/inputs/formik/NumberInput' +import RadioGroup from 'src/components/inputs/formik/RadioGroup' +import { TL1, H4 } from 'src/components/typography' + +import styles from './formStyles.styles' +const useStyles = makeStyles(styles) + +const options = [ + { display: 'None', code: 'none' }, + { display: 'Date', code: 'date' }, + { display: 'Length', code: 'length' } +] + +const NumericalEntry = () => { + const classes = useStyles() + const context = useFormikContext() + + const isLength = + (R.path(['values', 'constraintType'])(useFormikContext()) ?? null) === + 'length' + + const showErrorColor = { + [classes.radioSubtitle]: true, + [classes.error]: + !R.path(['values', 'constraintType'])(context) && + R.path(['errors', 'constraintType'])(context) + } + + return ( + <> +

+ Numerical entry constraints +

+ + {isLength && ( +
+ + digits +
+ )} + + ) +} + +export default NumericalEntry diff --git a/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/TextEntry.js b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/TextEntry.js new file mode 100644 index 00000000..27bfb226 --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/TextEntry.js @@ -0,0 +1,79 @@ +import { makeStyles } from '@material-ui/core' +import classnames from 'classnames' +import { Field, useFormikContext } from 'formik' +import * as R from 'ramda' +import React from 'react' + +import RadioGroup from 'src/components/inputs/formik/RadioGroup' +import TextInput from 'src/components/inputs/formik/TextInput' +import { H4 } from 'src/components/typography' + +import styles from './formStyles.styles' +const useStyles = makeStyles(styles) + +const options = [ + { display: 'None', code: 'none' }, + { display: 'Email', code: 'email' }, + { + display: 'Space separation', + subtitle: '(e.g. first and last name)', + code: 'spaceSeparation' + } +] + +const TextEntry = () => { + const classes = useStyles() + const context = useFormikContext() + const showErrorColor = { + [classes.radioSubtitle]: true, + [classes.error]: + !R.path(['values', 'constraintType'])(context) && + R.path(['errors', 'constraintType'])(context) + } + + const getLabelInputs = () => { + switch (context.values.constraintType) { + case 'spaceSeparation': + return ( +
+ + +
+ ) + default: + return ( + + ) + } + } + + return ( + <> +

Text entry constraints

+ + {getLabelInputs()} + + ) +} + +export default TextEntry diff --git a/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/formStyles.styles.js b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/formStyles.styles.js new file mode 100644 index 00000000..60dcf28d --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/formStyles.styles.js @@ -0,0 +1,50 @@ +import { errorColor, spacer } from 'src/styling/variables' + +const styles = { + flex: { + display: 'flex' + }, + column: { + flexDirection: 'column' + }, + choiceList: { + display: 'flex', + flexDirection: 'column', + maxHeight: 240, + overflowY: 'auto' + }, + button: { + width: 120, + height: 28, + marginTop: 28 + }, + textInput: { + width: 420 + }, + row: { + flexDirection: 'row' + }, + subtitle: { + marginBottom: 0 + }, + radioSubtitle: { + marginBottom: 0 + }, + error: { + color: errorColor + }, + tl1: { + marginLeft: 8, + marginTop: 25 + }, + numberField: { + marginTop: 109, + maxWidth: 115 + }, + label: { + width: 200, + marginRight: spacer + } +} + +export default styles diff --git a/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/index.js b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/index.js new file mode 100644 index 00000000..a55345e8 --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Forms/TypeFields/index.js @@ -0,0 +1,79 @@ +import { useFormikContext } from 'formik' +import * as R from 'ramda' +import React from 'react' +import * as Yup from 'yup' + +import ChoiceList from './ChoiceList' +import NumericalEntry from './NumericalEntry' +import TextEntry from './TextEntry' + +const nonEmptyStr = obj => obj.text && obj.text.length + +const getForm = inputType => { + switch (inputType) { + case 'numerical': + return NumericalEntry + case 'text': + return TextEntry + case 'choiceList': + return ChoiceList + default: + return NumericalEntry + } +} + +const TypeFields = () => { + const inputType = R.path(['values', 'inputType'])(useFormikContext()) ?? null + const Component = getForm(inputType) + return inputType && +} + +const defaultValues = { + constraintType: '', + inputLength: '', + inputLabel1: '', + inputLabel2: '', + listChoices: [{ text: '' }, { text: '' }] +} + +const validationSchema = Yup.lazy(values => { + switch (values.inputType) { + case 'numerical': + return Yup.object({ + constraintType: Yup.string().required(), + inputLength: Yup.number().when('constraintType', { + is: 'length', + then: Yup.number() + .min(0) + .required(), + else: Yup.mixed().notRequired() + }) + }) + case 'text': + return Yup.object({ + constraintType: Yup.string().required(), + inputLabel1: Yup.string().required(), + inputLabel2: Yup.string().when('constraintType', { + is: 'spaceSeparation', + then: Yup.string().required(), + else: Yup.mixed().notRequired() + }) + }) + case 'choiceList': + return Yup.object({ + constraintType: Yup.string().required(), + listChoices: Yup.array().test( + 'has-2-or-more', + 'Choice list needs to have two or more non empty fields', + (values, ctx) => { + return R.filter(nonEmptyStr)(values).length > 1 + } + ) + }) + default: + return Yup.mixed().notRequired() + } +}) + +export default TypeFields +export { defaultValues, validationSchema } diff --git a/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Wizard.js b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Wizard.js new file mode 100644 index 00000000..d4231a7d --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/Wizard.js @@ -0,0 +1,244 @@ +import { makeStyles } from '@material-ui/core' +import { Form, Formik } from 'formik' +import * as R from 'ramda' +import React, { useState } from 'react' + +import ErrorMessage from 'src/components/ErrorMessage' +import Modal from 'src/components/Modal' +import Stepper from 'src/components/Stepper' +import { Button } from 'src/components/buttons' + +import ChooseType, { + validationSchema as chooseTypeSchema, + defaultValues as chooseTypeDefaults +} from './Forms/ChooseType' +import NameOfRequirement, { + validationSchema as nameOfReqSchema, + defaultValues as nameOfReqDefaults +} from './Forms/NameOfRequirement' +import Screen1Information, { + validationSchema as screen1InfoSchema, + defaultValues as screen1InfoDefaults +} from './Forms/Screen1Information' +import Screen2Information, { + validationSchema as screen2InfoSchema, + defaultValues as screen2InfoDefaults +} from './Forms/Screen2Information' +import TypeFields, { + defaultValues as typeFieldsDefaults, + validationSchema as typeFieldsValidationSchema +} from './Forms/TypeFields' +import WizardSplash from './WizardSplash' + +const LAST_STEP = 5 + +const styles = { + stepper: { + margin: [[16, 0, 14, 0]] + }, + submit: { + display: 'flex', + flexDirection: 'row', + margin: [['auto', 0, 24]] + }, + button: { + marginLeft: 'auto' + }, + form: { + height: '100%', + display: 'flex', + flexDirection: 'column' + } +} + +const useStyles = makeStyles(styles) + +const getStep = step => { + switch (step) { + case 1: + return { + schema: nameOfReqSchema, + Component: NameOfRequirement + } + case 2: + return { + schema: screen1InfoSchema, + Component: Screen1Information + } + case 3: + return { schema: chooseTypeSchema, Component: ChooseType } + case 4: + return { + schema: screen2InfoSchema, + Component: Screen2Information + } + case 5: + return { + schema: typeFieldsValidationSchema, + Component: TypeFields + } + default: + return { + schema: {}, + Component: () => { + return

Default component step

+ } + } + } +} + +const nonEmptyStr = obj => obj.text && obj.text.length + +const formatValues = (values, isEditing) => { + const isChoiceList = values.inputType === 'choiceList' + const choices = isChoiceList + ? isEditing + ? R.path(['listChoices'])(values) + : R.map(o => o.text)(R.filter(nonEmptyStr)(values.listChoices) ?? []) + : [] + + const hasInputLength = values.constraintType === 'length' + const inputLength = hasInputLength ? values.inputLength : '' + + let resObj = { + name: values.requirementName, + screen1: { + text: values.screen1Text, + title: values.screen1Title + }, + screen2: { + title: values.screen2Title, + text: values.screen2Text + }, + input: { + type: values.inputType, + constraintType: values.constraintType + } + } + + if (isChoiceList) { + resObj = R.assocPath(['input', 'choiceList'], choices, resObj) + } + + if (hasInputLength) { + resObj = R.assocPath(['input', 'numDigits'], inputLength, resObj) + } + + if (values.inputLabel1) { + resObj = R.assocPath(['input', 'label1'], values.inputLabel1, resObj) + } + + if (values.inputLabel2) { + resObj = R.assocPath(['input', 'label2'], values.inputLabel2, resObj) + } + + if (isEditing) { + resObj = R.assocPath(['id'], values.id, resObj) + } + + return resObj +} + +const makeEditingValues = it => { + const { customRequest } = it + return { + id: it.id, + requirementName: customRequest.name, + screen1Title: customRequest.screen1.title, + screen1Text: customRequest.screen1.text, + screen2Title: customRequest.screen2.title, + screen2Text: customRequest.screen2.text, + inputType: customRequest.input.type, + inputLabel1: customRequest.input.label1, + inputLabel2: customRequest.input.label2, + listChoices: customRequest.input.choiceList, + constraintType: customRequest.input.constraintType, + inputLength: customRequest.input.numDigits + } +} + +const chooseNotNull = (a, b) => { + if (!R.isNil(b)) return b + return a +} + +const Wizard = ({ onClose, error = false, toBeEdited, onSave, hasError }) => { + const classes = useStyles() + const isEditing = !R.isNil(toBeEdited) + const [step, setStep] = useState(isEditing ? 1 : 0) + const stepOptions = getStep(step) + const isLastStep = step === LAST_STEP + + const onContinue = (values, actions) => { + const showScreen2 = + values.inputType === 'numerical' || values.inputType === 'choiceList' + if (isEditing && step === 2) { + return showScreen2 + ? setStep(4) + : onSave(formatValues(values, isEditing), isEditing) + } + if (isEditing && step === 4) { + return onSave(formatValues(values, isEditing), isEditing) + } + if (step === 3) { + return showScreen2 ? setStep(step + 1) : setStep(step + 2) + } + if (!isLastStep) { + return setStep(step + 1) + } + return onSave(formatValues(values, isEditing), isEditing) + } + + const editingValues = isEditing ? makeEditingValues(toBeEdited) : {} + const wizardTitle = isEditing + ? 'Editing custom requirement' + : 'New custom requirement' + return ( + 0 ? wizardTitle : ''} + handleClose={onClose} + width={520} + height={620} + open={true}> + {step > 0 && ( + + )} + {step === 0 && !isEditing && } + {step > 0 && ( + +
+ +
+ {hasError && Failed to save} + +
+ +
+ )} +
+ ) +} + +export default Wizard diff --git a/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/WizardSplash.js b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/WizardSplash.js new file mode 100644 index 00000000..494d5775 --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/WizardSplash.js @@ -0,0 +1,57 @@ +import { makeStyles } from '@material-ui/core' +import React from 'react' + +import { Button } from 'src/components/buttons' +import { H1, P } from 'src/components/typography' +import { ReactComponent as CustomReqLogo } from 'src/styling/icons/compliance/custom-requirement.svg' + +const styles = { + logo: { + maxHeight: 150, + maxWidth: 200 + }, + title: { + margin: [[24, 0, 32, 0]] + }, + text: { + margin: 0 + }, + button: { + marginTop: 'auto', + marginBottom: 58 + }, + modalContent: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: [[0, 42]], + flex: 1 + } +} + +const useStyles = makeStyles(styles) + +const WizardSplash = ({ onContinue }) => { + const classes = useStyles() + return ( +
+ +

Custom information request

+

+ A custom information request allows you to have an extra option to ask + specific information about your customers when adding a trigger that + isn't an option on the default requirements list. +

+

+ Note that adding a custom information request isn't the same as adding + triggers. You will still need to add a trigger with the new requirement + to get this information from your customers. +

+ +
+ ) +} + +export default WizardSplash diff --git a/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/index.js b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/index.js new file mode 100644 index 00000000..5d99663f --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/CustomInfoRequests/index.js @@ -0,0 +1,2 @@ +import CustomInfoRequests from './CustomInfoRequests' +export default CustomInfoRequests diff --git a/new-lamassu-admin/src/pages/Triggers/TriggerView.js b/new-lamassu-admin/src/pages/Triggers/TriggerView.js new file mode 100644 index 00000000..52256f55 --- /dev/null +++ b/new-lamassu-admin/src/pages/Triggers/TriggerView.js @@ -0,0 +1,92 @@ +import { useMutation } from '@apollo/react-hooks' +import { makeStyles, Box } from '@material-ui/core' +import gql from 'graphql-tag' +import * as R from 'ramda' +import React, { useState } from 'react' +import { v4 } from 'uuid' + +import { Button } from 'src/components/buttons' +import { Table as EditableTable } from 'src/components/editableTable' +import { H2 } from 'src/components/typography' +import { fromNamespace, namespaces } from 'src/utils/config' + +import styles from './Triggers.styles' +import Wizard from './Wizard' +import { Schema, getElements, sortBy, toServer } from './helper' + +const useStyles = makeStyles(styles) + +const SAVE_CONFIG = gql` + mutation Save($config: JSONObject) { + saveConfig(config: $config) + } +` + +const TriggerView = ({ + triggers, + showWizard, + config, + toggleWizard, + customInfoRequests +}) => { + const currency = R.path(['fiatCurrency'])( + fromNamespace(namespaces.LOCALE)(config) + ) + const classes = useStyles() + const [error, setError] = useState(null) + + const [saveConfig] = useMutation(SAVE_CONFIG, { + onCompleted: () => toggleWizard('off'), + refetchQueries: () => ['getData'], + onError: error => setError(error) + }) + + const save = config => { + setError(null) + return saveConfig({ + variables: { config: { triggers: toServer(config.triggers) } } + }) + } + + const add = rawConfig => { + const toSave = R.concat([{ id: v4(), direction: 'both', ...rawConfig }])( + triggers + ) + return saveConfig({ variables: { config: { triggers: toServer(toSave) } } }) + } + + return ( + <> + + {showWizard && ( + + )} + {R.isEmpty(triggers) && ( + +

+ It seems there are no active compliance triggers on your network +

+ +
+ )} + + ) +} + +export default TriggerView diff --git a/new-lamassu-admin/src/pages/Triggers/Triggers.js b/new-lamassu-admin/src/pages/Triggers/Triggers.js index 6332f034..af4046bc 100644 --- a/new-lamassu-admin/src/pages/Triggers/Triggers.js +++ b/new-lamassu-admin/src/pages/Triggers/Triggers.js @@ -1,25 +1,26 @@ import { useQuery, useMutation } from '@apollo/react-hooks' import { makeStyles, Box } from '@material-ui/core' +import classnames from 'classnames' import gql from 'graphql-tag' import * as R from 'ramda' import React, { useState } from 'react' -import { v4 } from 'uuid' import { Tooltip } from 'src/components/Tooltip' -import { Link, Button } from 'src/components/buttons' -import { Table as EditableTable } from 'src/components/editableTable' +import { Link } from 'src/components/buttons' import { Switch } from 'src/components/inputs' import TitleSection from 'src/components/layout/TitleSection' -import { P, Label2, H2 } from 'src/components/typography' +import { P, Label2 } from 'src/components/typography' +import { ReactComponent as ReverseCustomInfoIcon } from 'src/styling/icons/circle buttons/filter/white.svg' +import { ReactComponent as CustomInfoIcon } from 'src/styling/icons/circle buttons/filter/zodiac.svg' import { ReactComponent as ReverseSettingsIcon } from 'src/styling/icons/circle buttons/settings/white.svg' import { ReactComponent as SettingsIcon } from 'src/styling/icons/circle buttons/settings/zodiac.svg' -import { fromNamespace, toNamespace, namespaces } from 'src/utils/config' +import { fromNamespace, toNamespace } from 'src/utils/config' +import CustomInfoRequests from './CustomInfoRequests' +import TriggerView from './TriggerView' import styles from './Triggers.styles' -import Wizard from './Wizard' import AdvancedTriggers from './components/AdvancedTriggers' -import { Schema, getElements, sortBy, fromServer, toServer } from './helper' - +import { fromServer } from './helper' const useStyles = makeStyles(styles) const SAVE_CONFIG = gql` @@ -28,24 +29,40 @@ const SAVE_CONFIG = gql` } ` -const GET_INFO = gql` +const GET_CONFIG = gql` query getData { config } ` +const GET_CUSTOM_REQUESTS = gql` + query customInfoRequests { + customInfoRequests { + id + customRequest + enabled + } + } +` + const Triggers = () => { const classes = useStyles() - const [wizard, setWizard] = useState(false) - const [advancedSettings, setAdvancedSettings] = useState(false) + const [wizardType, setWizard] = useState(false) + const { data, loading } = useQuery(GET_CONFIG) + const { data: customInfoReqData } = useQuery(GET_CUSTOM_REQUESTS) + const [error, setError] = useState(null) + const [subMenu, setSubMenu] = useState(false) + + const customInfoRequests = + R.path(['customInfoRequests'])(customInfoReqData) ?? [] + const enabledCustomInfoRequests = R.filter(R.propEq('enabled', true))( + customInfoRequests + ) - const { data, loading } = useQuery(GET_INFO) const triggers = fromServer(data?.config?.triggers ?? []) - const complianceConfig = data?.config && fromNamespace('compliance')(data.config) const rejectAddressReuse = complianceConfig?.rejectAddressReuse ?? false - const [error, setError] = useState(null) const [saveConfig] = useMutation(SAVE_CONFIG, { onCompleted: () => setWizard(false), @@ -53,41 +70,56 @@ const Triggers = () => { onError: error => setError(error) }) - const add = rawConfig => { - const toSave = R.concat([{ id: v4(), direction: 'both', ...rawConfig }])( - triggers - ) - return saveConfig({ variables: { config: { triggers: toServer(toSave) } } }) - } - const addressReuseSave = rawConfig => { const config = toNamespace('compliance')(rawConfig) return saveConfig({ variables: { config } }) } - const save = config => { - setError(null) - return saveConfig({ - variables: { config: { triggers: toServer(config.triggers) } } - }) + const titleSectionWidth = { + [classes.tableWidth]: !subMenu === 'customInfoRequests' } - const currency = R.path(['fiatCurrency'])( - fromNamespace(namespaces.LOCALE)(data?.config) - ) + const setBlur = shouldBlur => { + return shouldBlur + ? document.querySelector('#root').classList.add('root-blur') + : document.querySelector('#root').classList.remove('root-blur') + } + + const toggleWizard = wizardName => forceDisable => { + if (wizardType === wizardName || forceDisable) { + setBlur(false) + return setWizard(null) + } + setBlur(true) + return setWizard(wizardName) + } return ( <> - {!advancedSettings && ( + buttons={[ + { + text: 'Advanced settings', + icon: SettingsIcon, + inverseIcon: ReverseSettingsIcon, + forceDisable: !(subMenu === 'advancedSettings'), + toggle: show => { + setSubMenu(show ? 'advancedSettings' : false) + } + }, + { + text: 'Custom info requests', + icon: CustomInfoIcon, + inverseIcon: ReverseCustomInfoIcon, + forceDisable: !(subMenu === 'customInfoRequests'), + toggle: show => { + setSubMenu(show ? 'customInfoRequests' : false) + } + } + ]} + className={classnames(titleSectionWidth)}> + {!subMenu && ( { )} - - {!advancedSettings && ( - <> - - {!loading && !R.isEmpty(triggers) && ( - setWizard(true)}> - + Add new trigger + {subMenu === 'customInfoRequests' && + !R.isEmpty(enabledCustomInfoRequests) && ( + + toggleWizard('newCustomRequest')()}> + + Add new custom info request - )} - - - {wizard && ( - setWizard(null)} - /> - )} - {!loading && R.isEmpty(triggers) && ( - -

- It seems there are no active compliance triggers on your network -

-
)} - + {!loading && !subMenu && !R.isEmpty(triggers) && ( + + toggleWizard('newTrigger')()}> + + Add new trigger + + + )} +
+ {!loading && subMenu === 'customInfoRequests' && ( + )} - {advancedSettings && ( + {!loading && !subMenu && ( + + )} + {!loading && subMenu === 'advancedSettings' && ( { ) case 'block': return <>blocked + case 'custom': + return <>asked to fulfill a custom requirement default: return orUnderline(null, classes) } @@ -170,9 +172,11 @@ const getRequirementText = (config, classes) => { const InfoPanel = ({ step, config = {}, liveValues = {}, currency }) => { const classes = useStyles() - const oldText = R.range(1, step).map(it => - getText(it, config, currency, classes) - ) + const oldText = R.range(1, step).map((it, idx) => ( + + {getText(it, config, currency, classes)} + + )) const newText = getText(step, liveValues, currency, classes) const isLastStep = step === LAST_STEP @@ -262,7 +266,7 @@ const Wizard = ({ onClose, save, error, currency }) => { { const classes = useStyles() const { @@ -510,6 +526,19 @@ const Requirement = () => { handleChange, setTouched } = useFormikContext() + const { data } = useQuery(GET_ACTIVE_CUSTOM_REQUESTS, { + variables: { + onlyEnabled: true + } + }) + + const isSuspend = values?.requirement?.requirement === 'suspend' + const isCustom = values?.requirement?.requirement === 'custom' + const makeCustomReqOptions = () => + customInfoRequests.map(it => ({ + value: it.id, + display: it.customRequest.name + })) const hasRequirementError = !!errors.requirement && @@ -517,8 +546,15 @@ const Requirement = () => { (!values.requirement?.suspensionDays || values.requirement?.suspensionDays < 0) - const isSuspend = values?.requirement?.requirement === 'suspend' - + const customInfoRequests = R.path(['customInfoRequests'])(data) ?? [] + const enableCustomRequirement = customInfoRequests.length > 0 + const customInfoOption = { + display: 'Custom information requirement', + code: 'custom' + } + const options = enableCustomRequirement + ? [...requirementOptions, customInfoOption] + : [...requirementOptions, { ...customInfoOption, disabled: true }] const titleClass = { [classes.error]: (!!errors.requirement && !isSuspend) || (isSuspend && hasRequirementError) @@ -532,7 +568,7 @@ const Requirement = () => { { }) }} /> - {isSuspend && ( { error={hasRequirementError} /> )} + {isCustom && ( +
+ +
+ )} ) } @@ -562,7 +608,13 @@ const requirements = { schema: requirementSchema, options: requirementOptions, Component: Requirement, - initialValues: { requirement: { requirement: '', suspensionDays: '' } } + initialValues: { + requirement: { + requirement: '', + suspensionDays: '', + customInfoRequestId: '' + } + } } const getView = (data, code, compare) => it => { @@ -586,14 +638,23 @@ const getView = (data, code, compare) => it => { // ) // } -const RequirementInput = () => { +const customReqIdMatches = customReqId => it => { + return it.id === customReqId +} + +const RequirementInput = ({ customInfoRequests }) => { const { values } = useFormikContext() const classes = useStyles() const requirement = values?.requirement?.requirement + const customRequestId = + R.path(['requirement', 'customInfoRequestId'])(values) ?? '' const isSuspend = requirement === 'suspend' - - const display = getView(requirementOptions, 'display')(requirement) + const display = customRequestId + ? R.path(['customRequest', 'name'])( + R.find(customReqIdMatches(customRequestId))(customInfoRequests) + ) ?? '' + : getView(requirementOptions, 'display')(requirement) return ( @@ -612,11 +673,20 @@ const RequirementInput = () => { ) } -const RequirementView = ({ requirement, suspensionDays }) => { +const RequirementView = ({ + requirement, + suspensionDays, + customInfoRequestId, + customInfoRequests +}) => { const classes = useStyles() - const display = getView(requirementOptions, 'display')(requirement) + const display = + requirement === 'custom' + ? R.path(['customRequest', 'name'])( + R.find(customReqIdMatches(customInfoRequestId))(customInfoRequests) + ) ?? '' + : getView(requirementOptions, 'display')(requirement) const isSuspend = requirement === 'suspend' - return ( {`${display} ${isSuspend ? 'for' : ''}`} @@ -728,7 +798,7 @@ const ThresholdView = ({ config, currency }) => { return } -const getElements = (currency, classes) => [ +const getElements = (currency, classes, customInfoRequests) => [ { name: 'triggerType', size: 'sm', @@ -749,8 +819,10 @@ const getElements = (currency, classes) => [ size: 'sm', width: 230, bypassField: true, - input: RequirementInput, - view: it => + input: () => , + view: it => ( + + ) }, { name: 'threshold', @@ -782,12 +854,20 @@ const sortBy = [ ) ] -const fromServer = triggers => - R.map( - ({ requirement, suspensionDays, threshold, thresholdDays, ...rest }) => ({ +const fromServer = (triggers, customInfoRequests) => { + return R.map( + ({ + requirement, + suspensionDays, + threshold, + thresholdDays, + customInfoRequestId, + ...rest + }) => ({ requirement: { requirement, - suspensionDays + suspensionDays, + customInfoRequestId }, threshold: { threshold, @@ -796,6 +876,7 @@ const fromServer = triggers => ...rest }) )(triggers) +} const toServer = triggers => R.map(({ requirement, threshold, ...rest }) => ({ @@ -803,6 +884,7 @@ const toServer = triggers => suspensionDays: requirement.suspensionDays, threshold: threshold.threshold, thresholdDays: threshold.thresholdDays, + customInfoRequestId: requirement.customInfoRequestId, ...rest }))(triggers) diff --git a/new-lamassu-admin/src/styling/global/index.js b/new-lamassu-admin/src/styling/global/index.js index 69c68b14..7c57661e 100644 --- a/new-lamassu-admin/src/styling/global/index.js +++ b/new-lamassu-admin/src/styling/global/index.js @@ -23,6 +23,10 @@ export default { // for when notification center is open overflow: 'hidden' }, + '.root-blur': { + filter: 'blur(1px)', + pointerEvents: 'none' + }, html: { height: fill }, diff --git a/new-lamassu-admin/src/styling/icons/compliance/custom-requirement.svg b/new-lamassu-admin/src/styling/icons/compliance/custom-requirement.svg new file mode 100644 index 00000000..4fdf906d --- /dev/null +++ b/new-lamassu-admin/src/styling/icons/compliance/custom-requirement.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/new-lamassu-admin/src/styling/icons/compliance/keyboard.svg b/new-lamassu-admin/src/styling/icons/compliance/keyboard.svg new file mode 100644 index 00000000..f7d68740 --- /dev/null +++ b/new-lamassu-admin/src/styling/icons/compliance/keyboard.svg @@ -0,0 +1,25 @@ + + + entry-icon/keyboard + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/new-lamassu-admin/src/styling/icons/compliance/keypad.svg b/new-lamassu-admin/src/styling/icons/compliance/keypad.svg new file mode 100644 index 00000000..02b01b3f --- /dev/null +++ b/new-lamassu-admin/src/styling/icons/compliance/keypad.svg @@ -0,0 +1,18 @@ + + + entry-icon/keypad + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/new-lamassu-admin/src/styling/icons/compliance/list.svg b/new-lamassu-admin/src/styling/icons/compliance/list.svg new file mode 100644 index 00000000..459fde15 --- /dev/null +++ b/new-lamassu-admin/src/styling/icons/compliance/list.svg @@ -0,0 +1,16 @@ + + + entry-icon/list + + + + + + + + + + + + + \ No newline at end of file diff --git a/new-lamassu-admin/src/styling/theme.js b/new-lamassu-admin/src/styling/theme.js index 7fed1fa2..6c98cbc3 100644 --- a/new-lamassu-admin/src/styling/theme.js +++ b/new-lamassu-admin/src/styling/theme.js @@ -10,7 +10,10 @@ import { offColor, subheaderColor, fontSize3, - fontSize5 + fontSize5, + zircon, + zircon2, + primaryColor } from './variables' const { p } = typographyStyles @@ -115,6 +118,44 @@ export default createMuiTheme({ backgroundColor: backgroundColor } } + }, + MuiToggleButton: { + root: { + '&$selected': { + backgroundColor: zircon, + borderColor: primaryColor, + borderTopColor: [primaryColor, '!important'], + '&:hover': { + backgroundColor: zircon2 + } + }, + '&:hover': { + backgroundColor: zircon2 + } + } + }, + MuiToggleButtonGroup: { + groupedVertical: { + borderRadius: 8, + border: '1px solid', + borderColor: zircon, + '&:not(:first-child)': { + borderTop: '1px solid', + borderTopColor: zircon, + borderTopRightRadius: 8, + borderTopLeftRadius: 8, + borderBottomRightRadius: 8, + borderBottomLeftRadius: 8 + }, + '&:not(:last-child)': { + borderTop: '1px solid', + borderTopColor: zircon, + borderTopRightRadius: 8, + borderTopLeftRadius: 8, + borderBottomRightRadius: 8, + borderBottomLeftRadius: 8 + } + } } } })