Commit 041050a6 authored by Mark Florian's avatar Mark Florian Committed by Nicolò Maria Mezzopera

Add DynamicFields component

This adds a dynamic form field component, to be used in the [SAST
Configuration UI][epic].

So far, this component is only able to render a single type of form
field: a text input (via the [`FormInput`][input] component). Future
iterations will add support for more types.

This component is driven by an array of configuration entities (in fact,
a `SastCiConfigurationEntity` from GraphQL), each representing
a configurable variable when setting up SAST scanning for a project. In
a follow-up MR, this array will be provided by the GraphQL query
`Project.sastCiConfiguration`.

Addresses https://gitlab.com/gitlab-org/gitlab/-/issues/231370.

[epic]: https://gitlab.com/groups/gitlab-org/-/epics/3659
[input]: https://gitlab.com/gitlab-org/gitlab/-/issues/225224
parent 8449665f
<script>
import FormInput from './form_input.vue';
import { isValidConfigurationEntity } from './utils';
export default {
components: {
FormInput,
},
model: {
prop: 'entities',
event: 'input',
},
props: {
entities: {
type: Array,
required: true,
validator: value => value.every(isValidConfigurationEntity),
},
},
methods: {
componentForEntity({ type }) {
return this.$options.entityTypeToComponent[type];
},
onInput(fieldName, newValue) {
const entityIndex = this.entities.findIndex(({ field }) => field === fieldName);
const updatedEntity = {
...this.entities[entityIndex],
value: newValue,
};
const newEntities = [...this.entities];
newEntities.splice(entityIndex, 1, updatedEntity);
this.$emit('input', newEntities);
},
},
// Entities with types not listed here are not rendered, since Vue does not
// render <component :is="undefined" />. This means that unsupported entities
// are omitted silently, which is actually the *desirable* behaviour, as it
// decouples the frontend from the backend: the backend may add new types
// before the frontend adds support for them.
entityTypeToComponent: {
string: FormInput,
},
};
</script>
<template>
<div>
<component
:is="componentForEntity(entity)"
v-for="entity in entities"
ref="fields"
:key="entity.field"
v-bind="entity"
@input="onInput(entity.field, $event)"
/>
</div>
</template>
const isString = value => typeof value === 'string';
// eslint-disable-next-line import/prefer-default-export
export const isValidConfigurationEntity = object => {
if (object == null) {
return false;
}
const { field, type, description, label, defaultValue, value } = object;
return (
isString(field) &&
isString(type) &&
isString(description) &&
isString(label) &&
defaultValue !== undefined &&
value !== undefined
);
};
import { shallowMount } from '@vue/test-utils';
import DynamicFields from 'ee/security_configuration/sast/components/dynamic_fields.vue';
import { makeEntities } from './helpers';
describe('DynamicFields component', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(DynamicFields, {
propsData: {
...props,
},
});
};
const findFields = () => wrapper.findAll({ ref: 'fields' });
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe.each`
context | entities
${'no entities'} | ${[]}
${'entities with unsupported entity types'} | ${makeEntities(3, { type: 'foo' })}
`('given $context', ({ entities }) => {
beforeEach(() => {
createComponent({ entities });
});
it('renders no fields', () => {
expect(findFields()).toHaveLength(0);
});
});
describe('given valid entities', () => {
let entities;
let fields;
beforeEach(() => {
entities = makeEntities(3);
createComponent({ entities });
fields = findFields();
});
it('renders each field with the correct component', () => {
entities.forEach((entity, i) => {
const field = fields.at(i);
expect(field.is(DynamicFields.entityTypeToComponent[entity.type])).toBe(true);
});
});
it('passes the correct props to each field', () => {
entities.forEach((entity, i) => {
const field = fields.at(i);
expect(field.props()).toMatchObject({
field: entity.field,
label: entity.label,
description: entity.description,
defaultValue: entity.defaultValue,
value: entity.value,
});
});
});
describe.each`
fieldIndex | newValue
${0} | ${'foo'}
${1} | ${'bar'}
${2} | ${'qux'}
`(
'when a field at index $fieldIndex emits an input event value $newValue',
({ fieldIndex, newValue }) => {
beforeEach(() => {
fields.at(fieldIndex).vm.$emit('input', newValue);
});
it('emits an input event with the correct entity value changed', () => {
const [[payload]] = wrapper.emitted('input');
entities.forEach((entity, i) => {
if (i === fieldIndex) {
const expectedChangedEntity = {
...entities[fieldIndex],
value: newValue,
};
expect(payload[i]).not.toBe(entities[i]);
expect(payload[i]).toEqual(expectedChangedEntity);
} else {
expect(payload[i]).toBe(entities[i]);
}
});
});
},
);
});
});
/**
* Creates an array of objects matching the shape of a GraphQl
* SastCiConfigurationEntity.
*
* @param {number} count - The number of entities to create.
* @param {Object} [changes] - Object representing changes to apply to the
* generated entities.
* @returns {Object[]}
*/
// eslint-disable-next-line import/prefer-default-export
export const makeEntities = (count, changes) =>
[...Array(count).keys()].map(i => ({
field: `field${i}`,
label: `label${i}`,
description: `description${i}`,
defaultValue: `defaultValue${i}`,
value: `defaultValue${i}`,
type: 'string',
...changes,
}));
import { isValidConfigurationEntity } from 'ee/security_configuration/sast/components/utils';
import { makeEntities } from './helpers';
describe('isValidConfigurationEntity', () => {
const validEntities = makeEntities(3);
const invalidEntities = [
null,
undefined,
[],
{},
...makeEntities(1, { field: undefined }),
...makeEntities(1, { type: undefined }),
...makeEntities(1, { description: undefined }),
...makeEntities(1, { label: undefined }),
...makeEntities(1, { value: undefined }),
...makeEntities(1, { defaultValue: undefined }),
];
it.each(validEntities)('returns true for a valid entity', entity => {
expect(isValidConfigurationEntity(entity)).toBe(true);
});
it.each(invalidEntities)('returns false for an invalid entity', invalidEntity => {
expect(isValidConfigurationEntity(invalidEntity)).toBe(false);
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment