@gitlab/ui 62.5.2 → 62.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,219 @@
1
+ <script>
2
+ import isFunction from 'lodash/isFunction';
3
+ import mapValues from 'lodash/mapValues';
4
+ import uniqueId from 'lodash/uniqueId';
5
+ import GlFormGroup from '../form_group/form_group.vue';
6
+ import GlFormInput from '../form_input/form_input.vue';
7
+ import GlFormFieldValidator from './form_field_validator.vue';
8
+
9
+ export default {
10
+ name: 'GlFormFields',
11
+ components: {
12
+ GlFormGroup,
13
+ GlFormInput,
14
+ GlFormFieldValidator,
15
+ },
16
+ model: {
17
+ prop: 'values',
18
+ event: 'input',
19
+ },
20
+ props: {
21
+ /**
22
+ * Object of keys to FieldDefinitions.
23
+ * The shape of the keys will be the same for `values` and what's emitted by the `input` event.
24
+ *
25
+ * @typedef {object} FieldDefinition
26
+ * @template TValue=string
27
+ * @property {string} label - Label text to show for this field.
28
+ * @property {undefined | Object} inputAttrs - Properties that are passed to the actual input for this field.
29
+ * @property {undefined | function(string): TValue} mapValue - Function that maps the inputted string value to the field's actual value (e.g. a Number).
30
+ * @property {undefined | Array<function(TValue): string | undefined>=} validators - Collection of validator functions.
31
+ *
32
+ * @type {{ [key: string]: FieldDefinition }}
33
+ */
34
+ fields: {
35
+ type: Object,
36
+ required: true,
37
+ },
38
+ /**
39
+ * The current value for each field, by key.
40
+ * Keys should match between `values` and `fields`.
41
+ */
42
+ values: {
43
+ type: Object,
44
+ required: true,
45
+ },
46
+ /**
47
+ * The id of the form element to handle "submit" listening.
48
+ */
49
+ formId: {
50
+ type: String,
51
+ required: true,
52
+ },
53
+ },
54
+ data() {
55
+ return {
56
+ fieldDirtyStatuses: {},
57
+ fieldValidations: {},
58
+ };
59
+ },
60
+ computed: {
61
+ formElement() {
62
+ return document.getElementById(this.formId);
63
+ },
64
+ fieldValidationProps() {
65
+ return mapValues(this.fields, (_, fieldName) => {
66
+ const invalidFeedback = this.fieldValidations[fieldName] || '';
67
+
68
+ return {
69
+ invalidFeedback,
70
+ state: invalidFeedback ? false : null,
71
+ };
72
+ });
73
+ },
74
+ fieldValues() {
75
+ return mapValues(this.fields, (_, fieldName) => {
76
+ if (fieldName in this.values) {
77
+ return this.values[fieldName];
78
+ }
79
+
80
+ return this.getMappedValue(fieldName, undefined);
81
+ });
82
+ },
83
+ fieldNames() {
84
+ return Object.keys(this.fields);
85
+ },
86
+ fieldsToRender() {
87
+ return mapValues(this.fields, (field, fieldName) => {
88
+ const id = uniqueId('gl-form-field-');
89
+
90
+ const scopedSlotName = `input(${fieldName})`;
91
+ const hasScopedSlot = this.$scopedSlots[scopedSlotName];
92
+ const scopedSlotAttrs = hasScopedSlot && {
93
+ value: this.fieldValues[fieldName],
94
+ input: (val) => this.onFieldInput(fieldName, val),
95
+ blur: () => this.onFieldBlur(fieldName),
96
+ validation: this.fieldValidationProps[fieldName],
97
+ id,
98
+ };
99
+
100
+ return {
101
+ ...field,
102
+ id,
103
+ label: field.label || fieldName,
104
+ scopedSlotName,
105
+ scopedSlotAttrs,
106
+ };
107
+ });
108
+ },
109
+ },
110
+ mounted() {
111
+ // why: We emit initial values as a convenience so that `v-model="values"` can be easily initialized.
112
+ this.$emit('input', this.fieldValues);
113
+
114
+ this.formElement?.addEventListener('submit', this.onFormSubmission);
115
+ },
116
+ destroyed() {
117
+ this.formElement?.removeEventListener('submit', this.onFormSubmission);
118
+ },
119
+ methods: {
120
+ setFieldDirty(fieldName) {
121
+ this.$set(this.fieldDirtyStatuses, fieldName, true);
122
+ },
123
+ setAllFieldsDirty() {
124
+ this.fieldNames.forEach((fieldName) => this.setFieldDirty(fieldName));
125
+ },
126
+ hasAllFieldsValid() {
127
+ // note: Only check "fieldNames" since "fields" could have changed since the life of "fieldValidations"
128
+ return this.fieldNames.every((fieldName) => !this.fieldValidations[fieldName]);
129
+ },
130
+ async checkBeforeSubmission() {
131
+ this.setAllFieldsDirty();
132
+
133
+ await this.$nextTick();
134
+
135
+ return this.hasAllFieldsValid();
136
+ },
137
+ getMappedValue(fieldName, val) {
138
+ const field = this.fields[fieldName];
139
+
140
+ if (isFunction(field?.mapValue)) {
141
+ return field.mapValue(val);
142
+ }
143
+
144
+ return val;
145
+ },
146
+ onFieldValidationUpdate(fieldName, invalidFeedback) {
147
+ this.$set(this.fieldValidations, fieldName, invalidFeedback);
148
+ },
149
+ onFieldBlur(fieldName) {
150
+ this.setFieldDirty(fieldName);
151
+ },
152
+ onFieldInput(fieldName, inputValue) {
153
+ const val = this.getMappedValue(fieldName, inputValue);
154
+
155
+ /**
156
+ * Emitted when any of the form values change. Used by `v-model`.
157
+ */
158
+ this.$emit('input', {
159
+ ...this.values,
160
+ [fieldName]: val,
161
+ });
162
+
163
+ /**
164
+ * Emitted when a form input emits the `input` event.
165
+ */
166
+ this.$emit('input-field', {
167
+ name: fieldName,
168
+ value: val,
169
+ });
170
+ },
171
+ async onFormSubmission(e) {
172
+ e.preventDefault();
173
+
174
+ const isValid = await this.checkBeforeSubmission();
175
+
176
+ if (isValid) {
177
+ /**
178
+ * Emitted when the form is submitted and all of the form fields are valid.
179
+ */
180
+ this.$emit('submit', e);
181
+ }
182
+ },
183
+ },
184
+ };
185
+ </script>
186
+
187
+ <template>
188
+ <div>
189
+ <gl-form-group
190
+ v-for="(field, fieldName) in fieldsToRender"
191
+ :key="fieldName"
192
+ :label="field.label"
193
+ :label-for="field.id"
194
+ :invalid-feedback="fieldValidationProps[fieldName].invalidFeedback"
195
+ :state="fieldValidationProps[fieldName].state"
196
+ >
197
+ <gl-form-field-validator
198
+ :value="fieldValues[fieldName]"
199
+ :validators="field.validators"
200
+ :should-validate="fieldDirtyStatuses[fieldName]"
201
+ @update="onFieldValidationUpdate(fieldName, $event)"
202
+ />
203
+ <template v-if="field.scopedSlotAttrs">
204
+ <!-- @slot scoped slot that can be used for components other than `GlFormInput`. The name of the slot is `input(<fieldName>)`. -->
205
+ <slot :name="field.scopedSlotName" v-bind="field.scopedSlotAttrs"></slot>
206
+ </template>
207
+ <template v-else>
208
+ <gl-form-input
209
+ :id="field.id"
210
+ :value="fieldValues[fieldName]"
211
+ :state="fieldValidationProps[fieldName].state"
212
+ v-bind="field.inputAttrs"
213
+ @input="onFieldInput(fieldName, $event)"
214
+ @blur="onFieldBlur(fieldName)"
215
+ />
216
+ </template>
217
+ </gl-form-group>
218
+ </div>
219
+ </template>
@@ -0,0 +1,11 @@
1
+ // This contains core mapping behavior (like mapping to native JavaScript types)
2
+ // and**should not** contain domain-specific mappers.
3
+ //
4
+ // ```
5
+ // // Good
6
+ // export const mapToBoolean = ...
7
+ //
8
+ // // Bad
9
+ // export const mapToApolloCacheWidget = ...
10
+ // ```
11
+ export const mapToNumber = (x) => Number(x || 0);
@@ -0,0 +1,17 @@
1
+ import { mapToNumber } from './mappers';
2
+
3
+ describe('components/base/form/form_fields/mappers', () => {
4
+ describe('mapToNumber', () => {
5
+ it.each`
6
+ input | output
7
+ ${''} | ${0}
8
+ ${false} | ${0}
9
+ ${{}} | ${Number.NaN}
10
+ ${'888'} | ${888}
11
+ ${'-5e10'} | ${-50000000000}
12
+ ${'55.78'} | ${55.78}
13
+ `('with $input, returns $output', ({ input, output }) => {
14
+ expect(mapToNumber(input)).toBe(output);
15
+ });
16
+ });
17
+ });
@@ -0,0 +1,16 @@
1
+ // This contains core validating behavior and **should not** contain
2
+ // domain-specific validations.
3
+ //
4
+ // Look to what's allowed in HTML attributes as a good basis for what belongs here
5
+ //
6
+ // ```
7
+ // // Good
8
+ // export const required = ...
9
+ //
10
+ // // Bad
11
+ // export const projectPathIsUnique = ...
12
+ // ```
13
+ export const factory = (failMessage, isValid) => (val) => !isValid(val) ? failMessage : '';
14
+
15
+ export const required = (failMessage) =>
16
+ factory(failMessage, (val) => val !== '' && val !== null && val !== undefined);
@@ -0,0 +1,29 @@
1
+ import { required } from './validators';
2
+
3
+ const TEST_FAIL_MESSAGE = 'Yo test failed!';
4
+
5
+ describe('components/base/form/form_fields/validators', () => {
6
+ // note: We used the `factory` to build required, so we implicitly test `factory` heere
7
+ describe('required', () => {
8
+ let validator;
9
+
10
+ beforeEach(() => {
11
+ validator = required(TEST_FAIL_MESSAGE);
12
+ });
13
+
14
+ it.each`
15
+ input | output
16
+ ${''} | ${TEST_FAIL_MESSAGE}
17
+ ${null} | ${TEST_FAIL_MESSAGE}
18
+ ${undefined} | ${TEST_FAIL_MESSAGE}
19
+ ${'123'} | ${''}
20
+ ${{}} | ${''}
21
+ ${0} | ${''}
22
+ ${1} | ${''}
23
+ ${true} | ${''}
24
+ ${false} | ${''}
25
+ `('with $input, returns $output', ({ input, output }) => {
26
+ expect(validator(input)).toBe(output);
27
+ });
28
+ });
29
+ });
package/src/index.js CHANGED
@@ -44,6 +44,7 @@ export { default as GlFormRadioGroup } from './components/base/form/form_radio_g
44
44
  export { default as GlFormSelect } from './components/base/form/form_select/form_select.vue';
45
45
  export { default as GlFormTextarea } from './components/base/form/form_textarea/form_textarea.vue';
46
46
  export { default as GlFormGroup } from './components/base/form/form_group/form_group.vue';
47
+ export { default as GlFormFields } from './components/base/form/form_fields/form_fields.vue';
47
48
  export { default as GlSearchBoxByType } from './components/base/search_box_by_type/search_box_by_type.vue';
48
49
  export { default as GlSearchBoxByClick } from './components/base/search_box_by_click/search_box_by_click.vue';
49
50
  export { default as GlDropdownItem } from './components/base/dropdown/dropdown_item.vue';
@@ -6745,6 +6745,12 @@
6745
6745
  .gl-column-gap-6\! {
6746
6746
  column-gap: $gl-spacing-scale-6 !important;
6747
6747
  }
6748
+ .gl-row-gap-6 {
6749
+ row-gap: $gl-spacing-scale-6;
6750
+ }
6751
+ .gl-row-gap-6\! {
6752
+ row-gap: $gl-spacing-scale-6 !important;
6753
+ }
6748
6754
  .gl-xs-mb-3 {
6749
6755
  @include gl-media-breakpoint-down(sm) {
6750
6756
  margin-bottom: $gl-spacing-scale-3;
@@ -890,6 +890,18 @@
890
890
  column-gap: $gl-spacing-scale-6;
891
891
  }
892
892
 
893
+ /**
894
+ * Row gap utilities
895
+ *
896
+ * naming convention: gl-row-gap-{spacing-scale-index}
897
+ * notes:
898
+ * - Utilities should strictly follow $gl-spacing-scale
899
+ */
900
+
901
+ @mixin gl-row-gap-6 {
902
+ row-gap: $gl-spacing-scale-6;
903
+ }
904
+
893
905
  /**
894
906
  * Responsive margin utilities.
895
907
  *
package/src/utils.js CHANGED
@@ -1 +1,4 @@
1
1
  export { GlBreakpointInstance, breakpoints } from './utils/breakpoints';
2
+
3
+ export * as formValidators from './components/base/form/form_fields/validators';
4
+ export * as formMappers from './components/base/form/form_fields/mappers';