diff --git a/packages/ripple-nuxt-tide/lib/core/mapping.js b/packages/ripple-nuxt-tide/lib/core/mapping.js index 003daa66f..09f9fc560 100644 --- a/packages/ripple-nuxt-tide/lib/core/mapping.js +++ b/packages/ripple-nuxt-tide/lib/core/mapping.js @@ -27,36 +27,51 @@ export class Mapping { } get (data, type = 'tideField') { - let result - let dataMode = 'single' + let result = [] + let dataMode if (Array.isArray(data)) { dataMode = 'array' - // If the data is array, return an array. - result = [] - for (const item of data) { - // Mapping items only for those in mapping configs. - if (this.mappingConfig[type][item.type]) { - const component = this[_getComponent](item, type) - if (component) { - result.push(component) - } + } else { + dataMode = 'single' + data = [data] + } + + for (const item of data) { + // Mapping items only for those in mapping configs. + if (this.mappingConfig[type][item.type]) { + const component = this[_getComponent](item, type) + result.push(component) + } else { + if (process.server) { + logger.warn(`"${item.type}" is not a supported component in map.`, { label: 'Mapping' }) } } - } else { - // If the data is just one item object, return a single item. - result = this[_getComponent](data, type) || {} } // Return a promise as some props' value need to be fetched from Tide. return new Promise(function (resolve, reject) { if (dataMode === 'single') { - resolve(result) + if (result[0]) { + result[0].catch(error => { + if (process.server) { + logger.error('Mapping failed to get result due to a error.', { error, label: 'Mapping' }) + } + reject(error) + }) + resolve(result[0]) + } else { + reject(new Error('Mapping failed to get result.')) + } } else { - let allFetched = Promise.all(result).catch(error => { - reject(new Error(`Mapping failed by resolve the fetching. Error: ${error}`)) + Promise.all(result).catch(error => { + if (process.server) { + logger.error('Mapping failed to get result due to a error.', { error, label: 'Mapping' }) + } + reject(error) + }).then(res => { + resolve(res) }) - resolve(allFetched) } }) } @@ -70,6 +85,9 @@ export class Mapping { for (const filter of filters) { const mapping = this // Pass mapping instance into filters, so we can use filters inside filter. + if (typeof this.mappingFilters[filter] !== 'function') { + return new Error(`Mapping filter "${filter}" is not a function or not defined.`) + } fieldValue = this.mappingFilters[filter](fieldValue, { mapping }) } return fieldValue @@ -129,10 +147,6 @@ export class Mapping { default: let itemConfig = this.mappingConfig[type][item.type] - if (typeof itemConfig === 'undefined') { - throw new Error(`"${item.type}" is not a supported component in map.`) - } - // If it's one to multiple mode, we switch to the right one based on expression. if (this.mappingConfig[type][item.type].components) { const components = this.mappingConfig[type][item.type].components @@ -143,9 +157,14 @@ export class Mapping { itemConfig = component[0] } + const data = await this[_getProps](item, itemConfig) + if (data instanceof Error) { + throw data + } + return { name: this[_getName](itemConfig), - data: await this[_getProps](item, itemConfig), + data: data, cols: this[_getCols](itemConfig), childCols: this[_getChildCols](itemConfig), class: this[_getClass](itemConfig), @@ -233,12 +252,28 @@ export class Mapping { const propMapping = itemConfig.props[prop] const getProp = this[_getFieldVal](propMapping, item).then(res => { props[prop] = res + }).catch(error => { + if (process.server) { + logger.error('Failed to get prop "%s" value.', prop, { error, label: 'Mapping' }) + } }) getProps.push(getProp) } return new Promise(function (resolve, reject) { const allFetched = Promise.all(getProps).then(() => { - return props + // If we got any coding error(e.g. custom mapping configuration error), stop doing further and fail the mapping. + // So developer can notice it and fix it. + let getPropsError = null + const propsValues = Object.values(props) + const noError = propsValues.every(value => { + if (value instanceof Error) { + getPropsError = value + return false + } + return true + }) + + return noError ? props : getPropsError }).catch(error => { reject(new Error(`Get props failed by resolve the fetching. Error: ${error}`)) }) diff --git a/packages/ripple-nuxt-tide/lib/core/middleware.js b/packages/ripple-nuxt-tide/lib/core/middleware.js index 0e4e35a9b..3b7e22c40 100644 --- a/packages/ripple-nuxt-tide/lib/core/middleware.js +++ b/packages/ripple-nuxt-tide/lib/core/middleware.js @@ -91,7 +91,9 @@ export default async function (context, pageData) { if (typeof context.res !== 'undefined') { context.res.statusCode = 500 } - logger.error('Failed to get the page data.', { error }) + if (process.server) { + logger.error('Failed to get the page data.', { error, label: 'Middleware' }) + } } } } @@ -115,6 +117,10 @@ export default async function (context, pageData) { const addComponentFromPromise = (promise, name) => { const addMapping = promise.then(res => { pageData.tidePage[name] = res + }).catch(error => { + if (process.server) { + logger.error('Failed to add component for "%s"', name, { error, label: 'Middleware' }) + } }) asyncTasks.push(addMapping) } @@ -203,7 +209,9 @@ export default async function (context, pageData) { const siteSectionData = await context.app.$tide.getSiteData(headersConfig, pageData.tidePage.section) if (siteSectionData instanceof Error) { - logger.error('Could not get site section data from Tide API.', { error: siteSectionData, label: 'Middleware' }) + if (process.server) { + logger.error('Could not get site section data from Tide API.', { error: siteSectionData, label: 'Middleware' }) + } } else { // Section navigation component will only use the main menu. const addSectionNavMenu = siteSectionData.hierarchicalMenus.menuMain @@ -253,9 +261,8 @@ export default async function (context, pageData) { } } } catch (error) { - // TODO: Take some action if above mapping error happens. if (process.server) { - logger.error('Failed to get the mapped component.', { error }) + logger.error('Failed to get the mapped component.', { error, label: 'Middleware' }) } } diff --git a/packages/ripple-nuxt-tide/lib/server-middleware/request-log.js b/packages/ripple-nuxt-tide/lib/server-middleware/request-log.js index 88d62162b..61ca24098 100644 --- a/packages/ripple-nuxt-tide/lib/server-middleware/request-log.js +++ b/packages/ripple-nuxt-tide/lib/server-middleware/request-log.js @@ -9,7 +9,7 @@ module.exports = function (req, res, next) { const reqUrl = url.parse(req.url) const reqPath = decodeURI(reqUrl.pathname) if (reqPath.includes('/api/v')) { - logger.info('Proxy %s %s to backend, res status code: %s.', method, reqPath, status, { label: 'Connect', requestQuery: reqUrl.query, requestId: req.headers['x-request-id'] }) + logger.info('Proxy %s request to %s, res status code: %s.', method, process.env.CONTENT_API_SERVER + reqPath, status, { label: 'Connect', requestQuery: reqUrl.query, requestId: req.headers['x-request-id'] }) } else { logger.info('Server got request: %s %s %s', status, method, reqPath, { label: 'Connect', requestQuery: reqUrl.query, requestId: req.requestId }) } diff --git a/packages/ripple-nuxt-tide/lib/templates/axios.js b/packages/ripple-nuxt-tide/lib/templates/axios.js index 729c557b1..0ca9b99cc 100644 --- a/packages/ripple-nuxt-tide/lib/templates/axios.js +++ b/packages/ripple-nuxt-tide/lib/templates/axios.js @@ -5,7 +5,9 @@ export default function ({ $axios, app, res }) { $axios.onRequest(config => { // Log all axios' requests if (process.server) { - logger.info('Making %s request to %s', config.method.toUpperCase(), config.url, {label: 'Axios', requestId: config.headers['X-Request-Id']}) + const baseURL = config.baseURL.length > 1 ? config.baseURL.substring(0, config.baseURL.length - 1) : '' + const fullUrl = baseURL + config.url + logger.info('Making %s request to %s', config.method.toUpperCase(), fullUrl, {label: 'Axios', requestId: config.headers['X-Request-Id']}) logger.debug('Headers %O', config.headers, {label: 'Axios'}) } }) diff --git a/packages/ripple-nuxt-tide/modules/webform/conditional-logic.js b/packages/ripple-nuxt-tide/modules/webform/conditional-logic.js index 08e440ccb..10fe0c1cc 100644 --- a/packages/ripple-nuxt-tide/modules/webform/conditional-logic.js +++ b/packages/ripple-nuxt-tide/modules/webform/conditional-logic.js @@ -5,6 +5,10 @@ import { logger } from './../../lib/core' * Will update the field object based on it's state. * Supports the following states: * - required + * - disabled + * - enabled + * - visible + * - invisible * @param {Object} field * @param {Object} data uses data.model property */ @@ -25,6 +29,25 @@ function testField (field, data) { field.validator.splice(idxRequired, 1) } break + + case 'disabled': + field.disabled = isPass + break + + case 'enabled': + const enable = isPass + field.disabled = !enable + break + + case 'visible': + field.visible = isPass + break + + case 'invisible': + const invisible = isPass + field.visible = !invisible + break + default: logger.warn('Form: State "%s" is not supported.', state, { label: 'Webform' }) break @@ -113,9 +136,11 @@ function performTriggerCheck (rule) { result = (rule.modelValue != null && rule.modelValue.length > 0) break case 'checked': + // This will only work with Drupal Webform "checkbox", not "checkboxes". "checkboxes" is not supported form element at this stage. result = (rule.modelValue === true) break case 'unchecked': + // This will only work with Drupal Webform "checkbox", not "checkboxes". "checkboxes" is not supported form element at this stage. result = (rule.modelValue == null || rule.modelValue === false) break case 'value': diff --git a/packages/ripple-nuxt-tide/modules/webform/mapping-filters.js b/packages/ripple-nuxt-tide/modules/webform/mapping-filters.js index 67560080d..98c09ab79 100644 --- a/packages/ripple-nuxt-tide/modules/webform/mapping-filters.js +++ b/packages/ripple-nuxt-tide/modules/webform/mapping-filters.js @@ -1,5 +1,3 @@ -import { logger } from './../../lib/core' - module.exports = { // Convert Drupal webform data struture to Vue Form Generator structure webform: async (drupalFormEntity, { mapping }) => { @@ -353,8 +351,9 @@ module.exports = { } else if (!group.hasOwnProperty('fields') && field.type !== null) { data.schema.groups.push({ 'fields': [field] }) } else { + const logger = require('@dpc-sdp/ripple-nuxt-tide/lib/core').logger if (process.server) { - logger.warn(`Webform element type "%s" is not supported in nuxt-tide at this stage, please ask site admin to remove it from relative Tide webform or addd support for it.`, element['#type'], { label: 'Webform' }) + logger.warn(`Webform element type "%s" is not supported in "ripple-nuxt-tide" at this stage, please ask site admin to remove it from relative Tide webform or addd support for it.`, element['#type'], { label: 'Webform' }) } } } diff --git a/packages/ripple-nuxt-tide/test/unit/mapping.test.js b/packages/ripple-nuxt-tide/test/unit/mapping.test.js index 204421345..2862960bd 100644 --- a/packages/ripple-nuxt-tide/test/unit/mapping.test.js +++ b/packages/ripple-nuxt-tide/test/unit/mapping.test.js @@ -43,6 +43,17 @@ describe('mapping', () => { class: ['test-class-a', 'test-class-b'] }, + testUndefinedFilter: { + component: 'rpl-test-component', + props: { + a: { + field: 'a', + filters: ['undefinedFilter'] + }, + b: 'b' + } + }, + testFetchItem: { component: 'rpl-test-fetch-component', props: { @@ -193,16 +204,46 @@ describe('mapping', () => { test('should get error by given a item not in mapping config', async () => { const mapping = new Mapping(config) - expect.assertions(1) + expect.assertions(2) const item = { type: 'testItemNotMapped' } try { + // Test single mode await mapping.get(item, 'testField') } catch (e) { - expect(e).toEqual(new Error('"testItemNotMapped" is not a supported component in map.')) + expect(e).toEqual(new Error('Mapping failed to get result.')) + } + + // Test array mode + const result = await mapping.get([item], 'testField') + expect(result).toEqual([]) + }) + + test('should get error by given an undefined filter in mapping config', async () => { + const mapping = new Mapping(config) + expect.assertions(2) + + const item = { + type: 'testUndefinedFilter', + a: 'value a', + b: 'value b' + } + + try { + // Test single mode + await mapping.get(item, 'testField') + } catch (e) { + expect(e).toEqual(new Error('Mapping filter "undefinedFilter" is not a function or not defined.')) + } + + try { + // Test array mode + await mapping.get([item], 'testField') + } catch (e) { + expect(e).toEqual(new Error('Mapping filter "undefinedFilter" is not a function or not defined.')) } }) @@ -244,7 +285,7 @@ describe('mapping', () => { expect(result).toEqual(components) }) - test('should still get resovled if fetcher failed', async () => { + test('should still get resolved if fetcher failed', async () => { const mapping = new Mapping(config, tideApi) expect.assertions(1) diff --git a/packages/ripple-nuxt-tide/test/unit/webform.test.js b/packages/ripple-nuxt-tide/test/unit/webform.test.js index 19b97e669..37dd61bd4 100644 --- a/packages/ripple-nuxt-tide/test/unit/webform.test.js +++ b/packages/ripple-nuxt-tide/test/unit/webform.test.js @@ -1,169 +1,206 @@ import testField from '../../modules/webform/conditional-logic' -const baseField = { - validator: [], - required: false, - states: {} +function performTriggerTest (state, trigger, modelValue = '') { + let model = modelValue + if (typeof modelValue.model === 'undefined') { + model = { + model: { 'field_a': modelValue } + } + } + return performTest(state, trigger, model) } -const baseFormData = { - model: { - field_a: null, - field_b: null, - field_c: null, - actions: null +function performTest (state, conditions, model) { + const field = { + validator: [], + required: false, + disabled: false, + visible: true, + states: {} } -} -function performTriggerTest (state, trigger, modelValue, resultStateProperty) { - performTest(state, { ':input[name="field_a"]': trigger }, { model: { 'field_a': modelValue } }, resultStateProperty) -} + const baseFormData = { + model: { + field_a: null, + field_b: null, + field_c: null, + actions: null + } + } -function performTest (state, conditions, model, resultStateProperty) { - const field = { ...baseField } const data = { ...baseFormData, ...model } field.states[state] = conditions testField(field, data) - expect(field).toHaveProperty(state, resultStateProperty) + return field +} + +function testAllStates (trigger, valMatchTrigger, valNotMatchTrigger) { + expect(performTriggerTest('required', trigger, valMatchTrigger)).toHaveProperty('required', true) + expect(performTriggerTest('required', trigger, valNotMatchTrigger)).toHaveProperty('required', false) + expect(performTriggerTest('disabled', trigger, valMatchTrigger)).toHaveProperty('disabled', true) + expect(performTriggerTest('disabled', trigger, valNotMatchTrigger)).toHaveProperty('disabled', false) + expect(performTriggerTest('enabled', trigger, valMatchTrigger)).toHaveProperty('disabled', false) + expect(performTriggerTest('enabled', trigger, valNotMatchTrigger)).toHaveProperty('disabled', true) + expect(performTriggerTest('visible', trigger, valMatchTrigger)).toHaveProperty('visible', true) + expect(performTriggerTest('visible', trigger, valNotMatchTrigger)).toHaveProperty('visible', false) + expect(performTriggerTest('invisible', trigger, valMatchTrigger)).toHaveProperty('visible', false) + expect(performTriggerTest('invisible', trigger, valNotMatchTrigger)).toHaveProperty('visible', true) } describe('Webform: conditional logic', () => { - test('Field is required if input is empty', async () => { - performTriggerTest('required', { empty: true }, '', true) // Pass - performTriggerTest('required', { empty: true }, 'failtest', false) // Fail + test('Condition is applied if input is empty', async () => { + const trigger = { ':input[name="field_a"]': { empty: true } } + const valMatchTrigger = '' + const valNotMatchTrigger = 'This input is filled.' + + testAllStates(trigger, valMatchTrigger, valNotMatchTrigger) }) - test('Field is required if input is filled', async () => { - performTriggerTest('required', { filled: true }, 'test', true) // Pass - performTriggerTest('required', { filled: true }, '', false) // Fail + test('Condition is applied if input is filled', async () => { + const trigger = { ':input[name="field_a"]': { filled: true } } + const valMatchTrigger = 'This input is filled.' + const valNotMatchTrigger = '' + + testAllStates(trigger, valMatchTrigger, valNotMatchTrigger) }) - test('Field is required if input is checked', async () => { - performTriggerTest('required', { checked: true }, true, true) // Pass - performTriggerTest('required', { checked: true }, false, false) // Fail + test('Condition is applied if input is checked', async () => { + const trigger = { ':input[name="field_a"]': { checked: true } } + const valMatchTrigger = true + const valNotMatchTrigger = false + + testAllStates(trigger, valMatchTrigger, valNotMatchTrigger) }) - test('Field is required if input is unchecked', async () => { - performTriggerTest('required', { unchecked: true }, false, true) // Pass - performTriggerTest('required', { unchecked: true }, true, false) // Fail + test('Condition is applied if input is unchecked', async () => { + const trigger = { ':input[name="field_a"]': { unchecked: true } } + const valMatchTrigger = false + const valNotMatchTrigger = true + + testAllStates(trigger, valMatchTrigger, valNotMatchTrigger) }) - test('Field is required if input is value', async () => { - performTriggerTest('required', { value: 'test' }, 'test', true) // Pass - performTriggerTest('required', { value: 'test' }, 'failtest', false) // Fail + test('Condition is applied if input is value', async () => { + const trigger = { ':input[name="field_a"]': { value: 'test' } } + const valMatchTrigger = 'test' + const valNotMatchTrigger = 'failtest' + + testAllStates(trigger, valMatchTrigger, valNotMatchTrigger) }) - test('Field is required if input is pattern', async () => { - performTriggerTest('required', { value: { pattern: '^vic' } }, 'victoria', true) // Pass - performTriggerTest('required', { value: { pattern: '^vic' } }, 'melbourne', false) // Fail + test('Condition is applied if input is pattern', async () => { + const trigger = { ':input[name="field_a"]': { value: { pattern: '^vic' } } } + const valMatchTrigger = 'victoria' + const valNotMatchTrigger = 'melbourne' + + testAllStates(trigger, valMatchTrigger, valNotMatchTrigger) }) - test('Field is required if input is not pattern', async () => { - performTriggerTest('required', { value: { '!pattern': '^mel' } }, 'victoria', true) // Pass - performTriggerTest('required', { value: { '!pattern': '^mel' } }, 'melbourne', false) // Fail + test('Condition is applied if input is not pattern', async () => { + const trigger = { ':input[name="field_a"]': { value: { '!pattern': '^mel' } } } + const valMatchTrigger = 'victoria' + const valNotMatchTrigger = 'melbourne' + + testAllStates(trigger, valMatchTrigger, valNotMatchTrigger) }) - test('Field is required if input is less than', async () => { - performTriggerTest('required', { value: { less: '10' } }, '9', true) // Pass - performTriggerTest('required', { value: { less: '10' } }, '10', false) // Fail + test('Condition is applied if input is less than', async () => { + const trigger = { ':input[name="field_a"]': { value: { less: '10' } } } + const valMatchTrigger = '9' + const valNotMatchTrigger = '10' + + testAllStates(trigger, valMatchTrigger, valNotMatchTrigger) }) - test('Field is required if input is greater than', async () => { - performTriggerTest('required', { value: { greater: '10' } }, '11', true) // Pass - performTriggerTest('required', { value: { greater: '10' } }, '9', false) // Fail + test('Condition is applied if input is greater than', async () => { + const trigger = { ':input[name="field_a"]': { value: { greater: '10' } } } + const valMatchTrigger = '11' + const valNotMatchTrigger = '9' + + testAllStates(trigger, valMatchTrigger, valNotMatchTrigger) }) - test('Field is required if input is not value', async () => { - performTriggerTest('required', { '!value': 'melbourne' }, 'victoria', true) // Pass - performTriggerTest('required', { '!value': 'melbourne' }, 'melbourne', false) // Fail + test('Condition is applied if input is not value', async () => { + const trigger = { ':input[name="field_a"]': { '!value': 'melbourne' } } + const valMatchTrigger = 'victoria' + const valNotMatchTrigger = 'melbourne' + + testAllStates(trigger, valMatchTrigger, valNotMatchTrigger) }) - test('Field is required if all inputs are checked (AND)', async () => { - // Pass - performTest('required', { + test('Condition is applied if all inputs are checked (AND)', async () => { + const trigger = { ':input[name="field_a"]': { checked: true }, ':input[name="field_b"]': { checked: true }, ':input[name="field_c"]': { checked: true } - }, { + } + const valMatchTrigger = { model: { field_a: true, field_b: true, field_c: true } - }, true) - // Fail - performTest('required', { - ':input[name="field_a"]': { checked: true }, - ':input[name="field_b"]': { checked: true }, - ':input[name="field_c"]': { checked: true } - }, { + } + const valNotMatchTrigger = { model: { field_a: true, field_b: false, field_c: true } - }, false) + } + + testAllStates(trigger, valMatchTrigger, valNotMatchTrigger) }) - test('Field is required if any inputs are checked (OR)', async () => { - // Pass - performTest('required', [ + test('Condition is applied if any inputs are checked (OR)', async () => { + const trigger = [ { ':input[name="field_a"]': { checked: true } }, 'or', { ':input[name="field_b"]': { checked: true } }, 'or', { ':input[name="field_c"]': { checked: true } } - ], { + ] + const valMatchTrigger = { model: { field_a: false, field_b: true, field_c: true } - }, true) - // Fail - performTest('required', [ - { ':input[name="field_a"]': { checked: true } }, - 'or', - { ':input[name="field_b"]': { checked: true } }, - 'or', - { ':input[name="field_c"]': { checked: true } } - ], { + } + const valNotMatchTrigger = { model: { field_a: false, field_b: false, field_c: false } - }, false) + } + + testAllStates(trigger, valMatchTrigger, valNotMatchTrigger) }) - test('Field is required if 1 input is checked (XOR)', async () => { - // Pass - performTest('required', [ + test('Condition is applied if 1 input is checked (XOR)', async () => { + const trigger = [ { ':input[name="field_a"]': { checked: true } }, 'xor', { ':input[name="field_b"]': { checked: true } }, 'xor', { ':input[name="field_c"]': { checked: true } } - ], { + ] + const valMatchTrigger = { model: { field_a: false, field_b: false, field_c: true } - }, true) - // Fail - performTest('required', [ - { ':input[name="field_a"]': { checked: true } }, - 'xor', - { ':input[name="field_b"]': { checked: true } }, - 'xor', - { ':input[name="field_c"]': { checked: true } } - ], { + } + const valNotMatchTrigger = { model: { field_a: false, field_b: true, field_c: true } - }, false) + } + + testAllStates(trigger, valMatchTrigger, valNotMatchTrigger) }) })