diff --git a/.github/workflows/dist.yml b/.github/workflows/dist.yml index 6f1883a86..c0be8dadb 100644 --- a/.github/workflows/dist.yml +++ b/.github/workflows/dist.yml @@ -18,6 +18,9 @@ jobs: - uses: actions/setup-python@v4 with: python-version: '2.7.18' + # update apt cache + - name: Update apt cache + run: sudo apt-get update -y # Set node version - uses: actions/setup-node@v2 with: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bd1b01169..4e394b3f0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,6 +18,9 @@ jobs: - uses: actions/setup-python@v4 with: python-version: '2.7.18' + # update apt cache + - name: Update apt cache + run: sudo apt-get update -y # Set node version - uses: actions/setup-node@v2 with: @@ -55,6 +58,9 @@ jobs: - uses: actions/setup-python@v4 with: python-version: '2.7.18' + # update apt cache + - name: Update apt cache + run: sudo apt-get update -y # Set node version - uses: actions/setup-node@v2 with: diff --git a/src/components/Codebook/Codebook.js b/src/components/Codebook/Codebook.js index de852602a..235480cd6 100644 --- a/src/components/Codebook/Codebook.js +++ b/src/components/Codebook/Codebook.js @@ -9,7 +9,12 @@ import EntityType from './EntityType'; import ExternalEntity from './ExternalEntity'; import EgoType from './EgoType'; import CodebookCategory from './CodebookCategory'; -import { getUsage, getUsageAsStageMeta } from './helpers'; +import { + getStageMetaByIndex, + getUsage, + getUsageAsStageMeta, + getVariableMetaByIndex, +} from './helpers'; const Codebook = ({ edges, @@ -21,59 +26,59 @@ const Codebook = ({ nodes, }) => (
- { !hasEgoVariables && !hasNodes && !hasEdges + {!hasEgoVariables && !hasNodes && !hasEdges && ( -

- There are currently no types or variables defined in this protocol. - When you have created some interview stages, the types and variables will be shown here. -

+

+ There are currently no types or variables defined in this protocol. + When you have created some interview stages, the types and variables will be shown here. +

)} - { hasEgoVariables + {hasEgoVariables && ( - - - + + + )} - { hasNodes + {hasNodes && ( - - {nodes.map((node) => ( - - ))} - + + {nodes.map((node) => ( + + ))} + )} - { hasEdges + {hasEdges && ( - - {edges.map((edge) => ( - - ))} - + + {edges.map((edge) => ( + + ))} + )} - { hasNetworkAssets + {hasNetworkAssets && ( - - {networkAssets.map( - (networkAsset) => ( - - ), - )} - + + {networkAssets.map( + (networkAsset) => ( + + ), + )} + )}
@@ -92,14 +97,18 @@ Codebook.propTypes = { nodes: PropTypes.array.isRequired, }; +// TODO: replace this with helpers getEntityProperties. This code was +// duplicated and needs to be reconciled. const getEntityWithUsage = (state, index, mergeProps) => { const search = utils.buildSearch([index]); - return (_, id) => { const inUse = search.has(id); + const variableMeta = getVariableMetaByIndex(state); + const stageMetaByIndex = getStageMetaByIndex(state); + const usage = inUse - ? getUsageAsStageMeta(state, getUsage(index, id)) + ? getUsageAsStageMeta(stageMetaByIndex, variableMeta, getUsage(index, id)) : []; return { diff --git a/src/components/Codebook/EgoType.js b/src/components/Codebook/EgoType.js index f9a0ab91e..54adf79f7 100644 --- a/src/components/Codebook/EgoType.js +++ b/src/components/Codebook/EgoType.js @@ -9,15 +9,15 @@ const EgoType = ({ variables, }) => (
- { variables.length > 0 + {variables.length > 0 && ( -
-

Variables:

- -
+
+

Variables:

+ +
)}
); @@ -33,7 +33,6 @@ EgoType.defaultProps = { const mapStateToProps = (state) => { const entityProperties = getEntityProperties(state, { entity: 'ego' }); - return entityProperties; }; diff --git a/src/components/Codebook/Tag.js b/src/components/Codebook/Tag.js index 597e4a33c..8adbdea1d 100644 --- a/src/components/Codebook/Tag.js +++ b/src/components/Codebook/Tag.js @@ -1,14 +1,19 @@ import React from 'react'; import PropTypes from 'prop-types'; -const Tag = ({ children }) => (
{children}
); +const Tag = ({ children, notUsed = false }) => { + const classes = notUsed ? 'codebook__tag codebook__tag--not-used' : 'codebook__tag'; + return (
{children}
); +}; Tag.propTypes = { children: PropTypes.node, + notUsed: PropTypes.bool, }; Tag.defaultProps = { children: null, + notUsed: false, }; export default Tag; diff --git a/src/components/Codebook/UsageColumn.js b/src/components/Codebook/UsageColumn.js index 0fd856e9f..c183ed217 100644 --- a/src/components/Codebook/UsageColumn.js +++ b/src/components/Codebook/UsageColumn.js @@ -7,12 +7,21 @@ const UsageColumn = ({ inUse, usage, }) => { - if (!inUse) { return (not in use); } + if (!inUse) { return (not in use); } const stages = usage - .map(({ id, label }) => ( - {label} - )); + .map(({ id, label }) => { + // If there is no id, don't create a link. This is the case for + // variables that are only in use as validation options. + if (!id) { + return ( + {label} + ); + } + return ( + {label} + ); + }); return (
diff --git a/src/components/Codebook/__tests__/helpers.test.js b/src/components/Codebook/__tests__/helpers.test.js index 818f33c89..5e14d42b3 100644 --- a/src/components/Codebook/__tests__/helpers.test.js +++ b/src/components/Codebook/__tests__/helpers.test.js @@ -1,10 +1,41 @@ /* eslint-env jest */ +import { getAllVariablesByUUID } from '../../../selectors/codebook'; import { getUsage, getUsageAsStageMeta } from '../helpers'; const state = { protocol: { present: { + codebook: { + ego: { + variables: { + 1: { + name: 'name', + type: 'text', + }, + }, + }, + node: { + person: { + variables: { + 2: { + name: 'name', + type: 'text', + }, + }, + }, + }, + edge: { + friend: { + variables: { + 3: { + name: 'name', + type: 'text', + }, + }, + }, + }, + }, stages: [ { label: 'foo', id: 'abcd', other: 'ignored' }, { label: 'bar', id: 'efgh', other: 'ignored' }, @@ -30,9 +61,22 @@ it('getUsage() ', () => { it('getUsageAsStageMeta()', () => { const usage = ['stages[0].foo.bar', 'stages[0].foo.bar.bazz', 'stages[1].foo.bar.bazz']; + + const mockStageMetaByIndex = [ + { label: 'foo', id: 'abcd' }, + { label: 'bar', id: 'efgh' }, + { label: 'bazz', id: 'ijkl' }, + ]; + + const mockVariableMetaByIndex = getAllVariablesByUUID(state.protocol.present.codebook); + const expectedResult = [ { label: 'foo', id: 'abcd' }, { label: 'bar', id: 'efgh' }, ]; - expect(getUsageAsStageMeta(state, usage)).toEqual(expectedResult); + expect(getUsageAsStageMeta( + mockStageMetaByIndex, + mockVariableMetaByIndex, + usage, + )).toEqual(expectedResult); }); diff --git a/src/components/Codebook/helpers.js b/src/components/Codebook/helpers.js index 05941ecc8..e0845a069 100644 --- a/src/components/Codebook/helpers.js +++ b/src/components/Codebook/helpers.js @@ -1,10 +1,10 @@ import { reduce, get, compact, uniq, map, } from 'lodash'; -import { getType } from '@selectors/codebook'; +import { getType, getAllVariablesByUUID } from '@selectors/codebook'; import { makeGetIsUsed } from '@selectors/codebook/isUsed'; import { getVariableIndex } from '@selectors/indexes'; -import { getProtocol } from '@selectors/protocol'; +import { getProtocol, getCodebook } from '@selectors/protocol'; const getIsUsed = makeGetIsUsed({ formNames: [] }); @@ -13,12 +13,18 @@ const getIsUsed = makeGetIsUsed({ formNames: [] }); * @param {Object} state Application state * @returns {Object[]} Stage meta sorted by index in state */ -const getStageMetaByIndex = (state) => { +export const getStageMetaByIndex = (state) => { const protocol = getProtocol(state); return protocol.stages .map(({ label, id }) => ({ label, id })); }; +export const getVariableMetaByIndex = (state) => { + const codebook = getCodebook(state); + const variables = getAllVariablesByUUID(codebook); + return variables; +}; + /** * Extract the stage name from a path string * @param {string} path {} @@ -29,9 +35,19 @@ const getStageIndexFromPath = (path) => { return get(matches, 1, null); }; +const codebookVariableReferenceRegex = /codebook\.(ego|node\[([^\]]+)\]|edge\[([^\]]+)\])\.variables\[(.*?)\].validation\.(sameAs|differentFrom)/; + +export const getCodebookVariableIndexFromValidationPath = (path) => { + const match = path.match(codebookVariableReferenceRegex); + + return get(match, 4, null); +}; + /** - * Filters a usage index for items that match value. - * @param {Object.}} index Usage index in (in format `{[path]: value}`) + * Takes an object in the format of `{[path]: variableID}` and a variableID to + * search for. Returns an array of paths that match the variableID. + * + * @param {Object.}} index Usage index in (in format `{[path]: variableID}`) * @param {any} value Value to match in usage index * @returns {string[]} List of paths ("usage array") */ @@ -41,27 +57,61 @@ export const getUsage = (index, value) => reduce(index, (acc, indexValue, path) }, []); /** - * Get stage meta that matches "usage array" (deduped). - * See `getUsage()` for details of usage array, + * Get stage meta (wtf is stage meta, Steve? 🤦) that matches "usage array" + * (with duplicates removed). + * + * See `getUsage()` for how the usage array is generated. + * * Any stages that can't be found in the index are omitted. - * @param {Object} state Application state - * @param {string[]} usage "Usage array" + * + * @param {Object[]} stageMetaByIndex Stage meta by index (as created by `getStageMetaByIndex()`) + * @param {Object[]} variableMetaByIndex Variable meta by index (as created by + * `getVariableMetaByIndex()`) + * @param {string[]} usageArray "Usage array" as created by `getUsage()` * @returns {Object[]} List of stage meta `{ label, id }`. */ -export const getUsageAsStageMeta = (state, usage) => { - const stageMetaByIndex = getStageMetaByIndex(state); - const stageIndexes = compact(uniq(usage.map(getStageIndexFromPath))); - return stageIndexes.map((stageIndex) => get(stageMetaByIndex, stageIndex)); +export const getUsageAsStageMeta = (stageMetaByIndex, variableMetaByIndex, usageArray) => { + // Filter codebook variables from usage array + const codebookVariablePaths = usageArray.filter(getCodebookVariableIndexFromValidationPath); + const codebookVariablesWithMeta = codebookVariablePaths.map((path) => { + const variableId = getCodebookVariableIndexFromValidationPath(path); + const { name } = variableMetaByIndex[variableId]; + return { + label: `Used as validation for "${name || 'unknown'}"`, + }; + }); + + const stageIndexes = compact(uniq(usageArray.map(getStageIndexFromPath))); + const stageVariablesWithMeta = stageIndexes.map( + (stageIndex) => get(stageMetaByIndex, stageIndex), + ); + + return [ + ...stageVariablesWithMeta, + ...codebookVariablesWithMeta, + ]; }; -// Function to be used with Array.sort. Sorts a collection of variable -// definitions by the label property. +/** + * Helper function to be used with Array.sort. Sorts a collection of variable + * definitions by the label property. + * + * @param {Object} a { label: string } + * @param {Object} b { label: string } + * @returns {number} -1 if a < b, 1 if a > b, 0 if a === b + */ export const sortByLabel = (a, b) => { if (a.label < b.label) { return -1; } if (a.label > b.label) { return 1; } return 0; }; +/** + * Returns entity meta data for use in the codebook. + * @param {*} state + * @param {*} param1 + * @returns + */ export const getEntityProperties = (state, { entity, type }) => { const { name, @@ -70,6 +120,8 @@ export const getEntityProperties = (state, { entity, type }) => { } = getType(state, { entity, type }); const variableIndex = getVariableIndex(state); + const variableMeta = getVariableMetaByIndex(state); + const stageMetaByIndex = getStageMetaByIndex(state); const isUsedIndex = getIsUsed(state); const variablesWithUsage = map( @@ -77,16 +129,25 @@ export const getEntityProperties = (state, { entity, type }) => { (variable, id) => { const inUse = get(isUsedIndex, id, false); - const usage = inUse - ? getUsageAsStageMeta(state, getUsage(variableIndex, id)).sort(sortByLabel) - : []; - - const usageString = usage.map(({ label }) => label).join(', ').toUpperCase(); - - return ({ + const baseProperties = { ...variable, id, inUse, + }; + + if (!inUse) { + return (baseProperties); + } + + const usage = getUsageAsStageMeta( + stageMetaByIndex, + variableMeta, + getUsage(variableIndex, id), + ).sort(sortByLabel); + + const usageString = usage.map(({ label }) => label).join(', ').toUpperCase(); + return ({ + ...baseProperties, usage, usageString, }); diff --git a/src/components/Query/ruleValidator.js b/src/components/Query/ruleValidator.js index 057a89b07..b981625ca 100644 --- a/src/components/Query/ruleValidator.js +++ b/src/components/Query/ruleValidator.js @@ -1,14 +1,21 @@ import { get } from 'lodash'; const validateRules = (value) => { - const rules = get(value, 'rules', []); + // BUGFIX: If the section containing the filter is not expanded, we set + // the filter value to null. In this case, we don't want to + // validate the filter, because it will be invisible and will simply + // prevent the form from being submitted without an error. + if (!value) { + return undefined; + } + const rules = get(value, 'rules'); const join = get(value, 'join'); - if (rules.length > 1 && !join) { + if (rules && rules.length > 1 && !join) { return 'Please select a join type'; } - if (rules.length === 0) { + if (rules && rules.length === 0) { return 'Please create at least one rule'; } diff --git a/src/components/Validations/withStoreState.js b/src/components/Validations/withStoreState.js index 7e25b022a..773049ff0 100644 --- a/src/components/Validations/withStoreState.js +++ b/src/components/Validations/withStoreState.js @@ -1,7 +1,5 @@ -import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import { formValueSelector, change } from 'redux-form'; -import { actionCreators as dialogsActions } from '../../ducks/modules/dialogs'; import { getValidationOptionsForVariableType } from './options'; const mapStateToProps = (state, { @@ -15,7 +13,6 @@ const mapStateToProps = (state, { }; const mapDispatchToProps = (dispatch, { form, name }) => ({ - openDialog: bindActionCreators(dialogsActions.openDialog, dispatch), update: (value) => dispatch(change(form, name, value)), }); diff --git a/src/components/Validations/withUpdateHandlers.js b/src/components/Validations/withUpdateHandlers.js index d57555f27..e3ea07af3 100644 --- a/src/components/Validations/withUpdateHandlers.js +++ b/src/components/Validations/withUpdateHandlers.js @@ -2,10 +2,37 @@ import { omit } from 'lodash'; import { withHandlers } from 'recompose'; import { isValidationWithListValue, isValidationWithNumberValue } from './options'; +/** + * Function called when a validation is added or updated. Returns a value + * based on the validation type, and the previous value (if any). + * + * @param {string} type - The validation type. + * @param {string} oldType - The previous validation type. + * @param {string} value - The current value. + * @returns {string} The new value. + */ +const getAutoValue = (type, oldType, value) => { + // Required is special - always return true. + if (type === 'required') { + return true; + } + + // If the new type and the old type are both numbers, keep the value + if (isValidationWithNumberValue(type) && isValidationWithNumberValue(oldType)) { + return value; + } + + // If the new type and the old type both reference variables, keep the value. + if (isValidationWithListValue(type) && isValidationWithListValue(oldType)) { + return value; + } + + // Otherwise, set an empty value to force the user to enter a value. + return null; +}; + const getUpdatedValue = (previousValue, key, value, oldKey = null) => { - const autoValue = isValidationWithNumberValue(key) || isValidationWithListValue(key) - ? value - : true; + const autoValue = getAutoValue(key, oldKey, value); if (!oldKey) { return { ...previousValue, [key]: autoValue }; } @@ -16,16 +43,9 @@ const getUpdatedValue = (previousValue, key, value, oldKey = null) => { }; const withUpdateHandlers = withHandlers({ - handleDelete: ({ openDialog, update, value: previousValue }) => (key) => { + handleDelete: ({ update, value: previousValue }) => (key) => { const newValue = omit(previousValue, key); - - openDialog({ - type: 'Warning', - title: 'Remove validation', - message: 'Are you sure you want to remove this rule?', - onConfirm: () => { update(newValue); }, - confirmLabel: 'Remove validation', - }); + update(newValue); }, handleChange: ({ update, value: previousValue }) => (key, value, oldKey) => { const newValue = getUpdatedValue(previousValue, key, value, oldKey); diff --git a/src/components/sections/ValidationSection.js b/src/components/sections/ValidationSection.js index 631087a50..84a9678eb 100644 --- a/src/components/sections/ValidationSection.js +++ b/src/components/sections/ValidationSection.js @@ -18,7 +18,7 @@ const ValidationSection = ({ const getFormValue = formValueSelector(form); const hasValidation = useSelector((state) => { const validation = getFormValue(state, 'validation'); - return validation && Object.keys(pickBy(validation)).length > 0; + return validation && Object.keys(validation).length > 0; }); const handleToggleValidation = (nextState) => { diff --git a/src/selectors/__tests__/__snapshots__/indexes.test.js.snap b/src/selectors/__tests__/__snapshots__/indexes.test.js.snap index 83bf46f38..aed96b881 100644 --- a/src/selectors/__tests__/__snapshots__/indexes.test.js.snap +++ b/src/selectors/__tests__/__snapshots__/indexes.test.js.snap @@ -67,6 +67,9 @@ Object { exports[`indexes selectors getVariableIndex() extracts variables into index 1`] = ` Object { + "codebook.edge[77199445-9d50-4646-b0bc-6d6b0c0e06bd].variables[e343a91f-628d-4175-870c-957beffa0003].validation.sameAs": "e343a91f-628d-4175-870c-957beffa0002", + "codebook.ego.variables[9c47f32e-b72f-4b1b-93a2-e29766dc3012].validation.differentFrom": "23a10bcb-bd71-46a1-a36d-4ab80cb5a1d1", + "codebook.node[4aebf73e-95e3-4fd1-95e7-237dcc4a4466].variables[0e75ec18-2cb1-4606-9f18-034d28b07c19].validation.differentFrom": "6be95f85-c2d9-4daf-9de1-3939418af888", "stages[0].form.fields[0].variable": "3377af3f-3c79-41da-9b0b-6570fb519b93", "stages[0].form.fields[10].variable": "9c47f32e-b72f-4b1b-93a2-e29766dc3012", "stages[0].form.fields[1].variable": "b4956387-cada-412f-9e97-fe352c3cc44e", diff --git a/src/selectors/codebook/__tests__/variables.test.js b/src/selectors/codebook/__tests__/variables.test.js index ffd43f5ad..87d20fa3c 100644 --- a/src/selectors/codebook/__tests__/variables.test.js +++ b/src/selectors/codebook/__tests__/variables.test.js @@ -6,9 +6,19 @@ const variable1 = '1234-1234-1234-1'; const variable2 = '1234-1234-1234-2'; const variable3 = '1234-1234-1234-3'; const variable4 = '1234-1234-1234-4'; - -const mockCodebook = { - nodes: { +const variable5 = '1234-1234-1234-5'; +const variable6 = '1234-1234-1234-6'; +const variable7 = '1234-1234-1234-7'; +const variable8 = '1234-1234-1234-8'; + +const mockCodebookWithoutUse = { + ego: { + variables: { + [variable5]: {}, + [variable6]: {}, + }, + }, + node: { person: { variables: { [variable1]: {}, @@ -18,44 +28,75 @@ const mockCodebook = { }, }, }, + edge: { + friendship: { + variables: { + [variable7]: {}, + [variable8]: {}, + }, + }, + }, }; -describe('makeGetIsUsed', () => { - it('returns false when variables are not in use', () => { - const notUsedState = { - protocol: { - present: { - codebook: mockCodebook, - stages: [ - ], - }, +const mockProtocolWithoutUse = { + present: { + codebook: mockCodebookWithoutUse, + stages: [ + { + id: '1', }, - }; + ], + }, +}; - const notUsedResult = makeGetIsUsed()(notUsedState); +const mockReduxFormsWithoutUse = { + 'edit-stage': { + values: {}, + }, +}; + +const mockStateWithoutUse = { + protocol: mockProtocolWithoutUse, + form: mockReduxFormsWithoutUse, +}; - expect(notUsedResult).toEqual({ +describe('makeGetIsUsed', () => { + it('returns false when a variable is not present', () => { + const result = makeGetIsUsed()(mockStateWithoutUse); + + expect(result).toEqual({ [variable1]: false, [variable2]: false, [variable3]: false, [variable4]: false, + [variable5]: false, + [variable6]: false, + [variable7]: false, + [variable8]: false, }); }); - it('checks codebook for variable usage', () => { - const state = { + it('returns true when a variable is present in the protocol', () => { + const stateWithProtocolUse = { + ...mockStateWithoutUse, protocol: { + ...mockProtocolWithoutUse, present: { - codebook: mockCodebook, + ...mockProtocolWithoutUse.present, stages: [ - { [variable1]: 'foo' }, - { variableAsKey: variable2 }, { - nestedVariable: { - moreNested: { - nestedFurther: { - [variable3]: false, - }, + id: '1', + [variable1]: 'foo', + thing: { + variableAsKey: variable2, + nested: { + even: [ + { + moreNested: { + with: variable3, + }, + }, + ], }, }, }, @@ -64,78 +105,150 @@ describe('makeGetIsUsed', () => { }, }; - const result = makeGetIsUsed()(state); + const result = makeGetIsUsed()(stateWithProtocolUse); expect(result).toEqual({ [variable1]: true, [variable2]: true, [variable3]: true, [variable4]: false, + [variable5]: false, + [variable6]: false, + [variable7]: false, + [variable8]: false, }); }); - it('checks form for variable usage', () => { - const state = { - protocol: { - present: { - codebook: mockCodebook, - stages: [], - }, - }, + describe('redux forms', () => { + const stateWithFormUse = { + ...mockStateWithoutUse, form: { + ...mockReduxFormsWithoutUse, formName: { values: { - foo: variable1, + [variable1]: 'foo', + }, + }, + 'edit-stage': { + values: { + [variable2]: 'foo', + thing: { + foo: variable3, + }, }, }, }, }; - // Also check we can set form name - const result = makeGetIsUsed({ formNames: ['formName'] })(state); - - expect(result).toEqual({ - [variable1]: true, - [variable2]: false, - [variable3]: false, - [variable4]: false, + describe('returns true when a variable is present in redux forms', () => { + it('returns from default form names only without parameters', () => { + const result = makeGetIsUsed()(stateWithFormUse); + + expect(result).toEqual({ + [variable1]: false, + [variable2]: true, + [variable3]: true, + [variable4]: false, + [variable5]: false, + [variable6]: false, + [variable7]: false, + [variable8]: false, + }); + }); + + it('allows the redux form name to be specified to return specific form', () => { + // Also check we can set form name + const result = makeGetIsUsed({ formNames: ['formName'] })(stateWithFormUse); + + expect(result).toEqual({ + [variable1]: true, + [variable2]: false, + [variable3]: false, + [variable4]: false, + [variable5]: false, + [variable6]: false, + [variable7]: false, + [variable8]: false, + }); + }); }); }); -}); -describe('makeOptionsWithIsUsed', () => { - it('appends used state to options', () => { - const state = { + it('checks codebook for variable validation use', () => { + const stateWithCodebookUse = { + ...mockStateWithoutUse, protocol: { + ...mockProtocolWithoutUse, present: { - codebook: mockCodebook, - stages: [], - }, - }, - form: { - formName: { - values: { - foo: variable1, + codebook: { + ...mockCodebookWithoutUse, + node: { + ...mockCodebookWithoutUse.node, + person: { + ...mockCodebookWithoutUse.node.person, + variables: { + ...mockCodebookWithoutUse.node.person.variables, + [variable1]: { + validation: { + sameAs: variable2, + }, + }, + }, + }, + }, }, }, }, }; - const mockOptions = [ - { value: variable1, label: '1' }, - { value: variable2, label: '2' }, - { value: variable3, label: '3' }, - { value: variable4, label: '4' }, - ]; - - // Also check we can set form name - const result = makeOptionsWithIsUsed({ formNames: ['formName'] })(state, mockOptions); - - expect(result).toEqual([ - { value: variable1, label: '1', isUsed: true }, - { value: variable2, label: '2', isUsed: false }, - { value: variable3, label: '3', isUsed: false }, - { value: variable4, label: '4', isUsed: false }, - ]); + const result = makeGetIsUsed()(stateWithCodebookUse); + + expect(result).toEqual({ + [variable1]: false, + [variable2]: true, + [variable3]: false, + [variable4]: false, + [variable5]: false, + [variable6]: false, + [variable7]: false, + [variable8]: false, + }); + }); + + describe('makeOptionsWithIsUsed', () => { + it('appends used state to options', () => { + const state = { + protocol: { + present: { + codebook: mockCodebookWithoutUse, + stages: [], + }, + }, + form: { + formName: { + values: { + foo: variable1, + }, + }, + }, + }; + + const mockOptions = [ + { value: variable1, label: '1' }, + { value: variable2, label: '2' }, + { value: variable3, label: '3' }, + { value: variable4, label: '4' }, + ]; + + // Also check we can set form name + const result = makeOptionsWithIsUsed({ formNames: ['formName'] })(state, mockOptions); + + expect(result).toEqual([ + { value: variable1, label: '1', isUsed: true }, + { value: variable2, label: '2', isUsed: false }, + { value: variable3, label: '3', isUsed: false }, + { value: variable4, label: '4', isUsed: false }, + ]); + }); }); }); diff --git a/src/selectors/codebook/helpers.js b/src/selectors/codebook/helpers.js index b51c1180b..070acff52 100644 --- a/src/selectors/codebook/helpers.js +++ b/src/selectors/codebook/helpers.js @@ -4,6 +4,11 @@ import { flatMap } from 'lodash'; const getIdsFromEntity = (entity) => (entity.variables ? Object.keys(entity.variables) : []); +/** + * + * @param {*} codebook + * @returns + */ export const getIdsFromCodebook = (codebook) => flatMap( codebook, (entityOrEntities, type) => ( diff --git a/src/selectors/codebook/isUsed.js b/src/selectors/codebook/isUsed.js index a4907418f..addb36f19 100644 --- a/src/selectors/codebook/isUsed.js +++ b/src/selectors/codebook/isUsed.js @@ -1,26 +1,74 @@ import { get, omit, cloneDeep } from 'lodash'; import { getForms } from '../reduxForm'; -import { getProtocol } from '../protocol'; +import { getCodebook, getProtocol } from '../protocol'; import { getIdsFromCodebook } from './helpers'; /** * Gets a key value object describing variables are - * in use (including in redux forms) - * @returns {object} in format: { [variableId]: boolean } + * in use (including in redux forms). + * + * Naive implementation: just checks if the variable id is in the flattened + * protocol, or any redux forms. + * + * JRM BUGFIX: This previously did not check for `sameAs` or `differentFrom` + * variable references that are contained within codebook variable definitions. + * This caused a bug where these variables were able to be removed, creating + * references to variables that no longer existed. + * + * @param {object} options - options object + * @param {Array} options.formNames - names of forms to check for variable usage + * @param {Array} options.excludePaths - paths to exclude from the check (e.g. 'stages') + * + * @returns {function} - selector function that returns a key value object + * describing variables are in use */ -export const makeGetIsUsed = (isUsedOptions = {}) => (state) => { +export const makeGetIsUsed = (options = {}) => (state) => { const { formNames = ['edit-stage', 'editable-list-form'], excludePaths = [], - } = isUsedOptions; + } = options; const protocol = getProtocol(state); const forms = getForms(formNames)(state); const variableIds = getIdsFromCodebook(protocol.codebook); + const codebook = getCodebook(state); + + // Get all codebook[entityType][entityId].variables.validation references + const variableValidations = () => { + const validations = []; + const getEntityVariableValidations = (entityDefinition) => { + if (!entityDefinition.variables) { + return []; + } + + return Object.values(entityDefinition.variables).reduce((memo, variable) => { + if (variable.validation) { + memo.push(variable.validation); + } + return memo; + }, []); + }; + + Object.keys(codebook).forEach((entityType) => { + if (entityType === 'ego') { + validations.push(...getEntityVariableValidations(codebook[entityType])); + } + + Object.keys(codebook[entityType]).forEach((entityId) => { + validations.push(...getEntityVariableValidations(codebook[entityType][entityId])); + }); + }); + + return validations; + }; + + const searchLocations = { stages: protocol.stages, forms, validations: variableValidations() }; const data = excludePaths.length > 0 - ? omit(cloneDeep({ stages: protocol.stages, forms }), excludePaths) - : { stages: protocol.stages, forms }; + ? omit(cloneDeep( + searchLocations, + ), excludePaths) + : searchLocations; const flattenedData = JSON.stringify(data); diff --git a/src/selectors/indexes.js b/src/selectors/indexes.js index 5ede588f1..f01ac9920 100644 --- a/src/selectors/indexes.js +++ b/src/selectors/indexes.js @@ -14,9 +14,13 @@ const mapAssetItems = ({ type, content }, path) => { }; /** - * Locations of referenceable entities in a standard protocol. + * Master list of paths where variables are used. + * + * ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️ + * It is VITAL that this be updated when any new variable use occurs in the + * protocol schema! + * ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️ * - * This is generally used to find which entities are used where. */ export const paths = { edges: [ @@ -53,6 +57,13 @@ export const paths = { 'stages[].presets[].groupVariable', 'stages[].presets[].edges.display[]', 'stages[].presets[].highlight[]', + // `sameAs` and `differentFrom` are variable references in these locations + 'codebook.ego.variables[].validation.sameAs', + 'codebook.ego.variables[].validation.differentFrom', + 'codebook.node[].variables[].validation.sameAs', + 'codebook.node[].variables[].validation.differentFrom', + 'codebook.edge[].variables[].validation.sameAs', + 'codebook.edge[].variables[].validation.differentFrom', ], assets: [ 'stages[].panels[].dataSource', diff --git a/src/selectors/reduxForm.js b/src/selectors/reduxForm.js index 79c0a9c25..75084c1b4 100644 --- a/src/selectors/reduxForm.js +++ b/src/selectors/reduxForm.js @@ -1,9 +1,25 @@ -import { getFormValues } from 'redux-form'; +import { getFormValues, getFormNames } from 'redux-form'; -export const getForms = (formNames = []) => (state) => formNames.reduce( - (memo, formName) => ({ - ...memo, - [formName]: getFormValues(formName)(state), - }), - {}, -); +/** + * Returns the redux form values for the given form names. + * If no form names are given, returns all form values. + * @param {Array} formNames - names of forms to get values for + * @returns {function} - selector function that returns an object of form values + * keyed by form name + */ +export const getForms = (formNames) => (state) => { + const reduce = (names) => names.reduce( + (memo, formName) => ({ + ...memo, + [formName]: getFormValues(formName)(state), + }), + {}, + ); + + if (!formNames) { + const allFormNames = getFormNames(state); + return reduce(allFormNames); + } + + return reduce(formNames); +}; diff --git a/src/styles/components/_codebook.scss b/src/styles/components/_codebook.scss index a08f04a70..a5a1ee3b3 100644 --- a/src/styles/components/_codebook.scss +++ b/src/styles/components/_codebook.scss @@ -15,13 +15,16 @@ &__tag { display: inline-block; - border-radius: unit(.5); - padding: .1em .3em; - margin-top: -.05em; + border-radius: unit(1); + padding: unit(.5) unit(.75); font-size: .9em; - color: var(--text-light); - background-color: var(--architect-warning); - text-transform: lowercase; + color: var(--color-white); + background-color: var(--color-mustard--dark); + word-break: break-word; + + &--not-used { + background-color: var(--architect-warning); + } } &__category { @@ -160,10 +163,18 @@ } } + &--usage { + max-width: 300px; + } + &-usage-container { display: flex; flex-direction: column; align-items: flex-start; + + > * { + margin: .25rem; + } }; }