Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/crm/components/crm_form.vue')
-rw-r--r--app/assets/javascripts/crm/components/crm_form.vue310
1 files changed, 310 insertions, 0 deletions
diff --git a/app/assets/javascripts/crm/components/crm_form.vue b/app/assets/javascripts/crm/components/crm_form.vue
new file mode 100644
index 00000000000..ea6a6892bbd
--- /dev/null
+++ b/app/assets/javascripts/crm/components/crm_form.vue
@@ -0,0 +1,310 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlDrawer,
+ GlFormCheckbox,
+ GlFormGroup,
+ GlFormInput,
+ GlFormSelect,
+} from '@gitlab/ui';
+import { get as getPropValueByPath, isEmpty } from 'lodash';
+import { produce } from 'immer';
+import { MountingPortal } from 'portal-vue';
+import { __ } from '~/locale';
+import { logError } from '~/lib/logger';
+import { getFirstPropertyValue } from '~/lib/utils/common_utils';
+import { INDEX_ROUTE_NAME } from '../constants';
+
+const MSG_SAVE_CHANGES = __('Save changes');
+const MSG_ERROR = __('Something went wrong. Please try again.');
+const MSG_OPTIONAL = __('(optional)');
+const MSG_CANCEL = __('Cancel');
+
+/**
+ * This component is a first iteration towards a general reusable Create/Update component
+ *
+ * There's some opportunity to improve cohesion of this module which we are planning
+ * to address after solidifying the abstraction's requirements.
+ *
+ * Please see https://gitlab.com/gitlab-org/gitlab/-/issues/349441
+ */
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlDrawer,
+ GlFormCheckbox,
+ GlFormGroup,
+ GlFormInput,
+ GlFormSelect,
+ MountingPortal,
+ },
+ props: {
+ drawerOpen: {
+ type: Boolean,
+ required: true,
+ },
+ fields: {
+ type: Array,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ successMessage: {
+ type: String,
+ required: true,
+ },
+ mutation: {
+ type: Object,
+ required: true,
+ },
+ getQuery: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ getQueryNodePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ additionalCreateParams: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ buttonLabel: {
+ type: String,
+ required: false,
+ default: () => MSG_SAVE_CHANGES,
+ },
+ existingId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ model: null,
+ submitting: false,
+ errorMessages: [],
+ records: [],
+ loading: true,
+ };
+ },
+ apollo: {
+ records: {
+ query() {
+ return this.getQuery.query;
+ },
+ variables() {
+ return this.getQuery.variables;
+ },
+ update(data) {
+ this.records = getPropValueByPath(data, this.getQueryNodePath).nodes || [];
+ this.setInitialModel();
+ this.loading = false;
+ },
+ error() {
+ this.errorMessages = [MSG_ERROR];
+ },
+ },
+ },
+ computed: {
+ isEditMode() {
+ return this.existingId;
+ },
+ isInvalid() {
+ const { fields, model } = this;
+
+ return fields.some((field) => {
+ return (
+ field.required && isEmpty(model[field.name]) && typeof model[field.name] !== 'boolean'
+ );
+ });
+ },
+ variables() {
+ const { additionalCreateParams, fields, isEditMode, model } = this;
+
+ const variables = fields.reduce(
+ (map, field) =>
+ Object.assign(map, {
+ [field.name]: this.formatValue(model, field),
+ }),
+ {},
+ );
+
+ if (isEditMode) {
+ return { input: { id: this.existingId, ...variables } };
+ }
+
+ return { input: { ...additionalCreateParams, ...variables } };
+ },
+ },
+ methods: {
+ setInitialModel() {
+ const existingModel = this.records.find(({ id }) => id === this.existingId);
+ const noModel = !this.isEditMode || !existingModel;
+
+ this.model = this.fields.reduce(
+ (map, field) =>
+ Object.assign(map, {
+ [field.name]: noModel ? null : this.extractValue(existingModel, field.name),
+ }),
+ {},
+ );
+ },
+ extractValue(existingModel, fieldName) {
+ const value = existingModel[fieldName];
+ if (value != null) return value;
+
+ /* eslint-disable-next-line @gitlab/require-i18n-strings */
+ if (!fieldName.endsWith('Id')) return null;
+
+ return existingModel[fieldName.slice(0, -2)]?.id;
+ },
+ formatValue(model, field) {
+ if (!isEmpty(model[field.name]) && field.input?.type === 'number') {
+ return parseFloat(model[field.name]);
+ }
+
+ return model[field.name];
+ },
+ save() {
+ const { mutation, variables, close } = this;
+
+ this.submitting = true;
+
+ return this.$apollo
+ .mutate({
+ mutation,
+ variables,
+ update: (store, { data }) => {
+ const { errors, ...result } = getFirstPropertyValue(data);
+
+ if (errors?.length) {
+ this.errorMessages = errors;
+ } else {
+ this.updateCache(store, result);
+ close(true);
+ }
+ },
+ })
+ .catch((e) => {
+ logError(e);
+ this.errorMessages = [MSG_ERROR];
+ })
+ .finally(() => {
+ this.submitting = false;
+ });
+ },
+ close(success) {
+ if (success) {
+ // This is needed so toast perists when route is changed
+ this.$root.$toast.show(this.successMessage);
+ }
+
+ this.$router.replace({ name: this.$options.INDEX_ROUTE_NAME });
+ },
+ updateCache(store, result) {
+ const { getQuery, isEditMode, getQueryNodePath } = this;
+
+ if (isEditMode || !getQuery) return;
+
+ const sourceData = store.readQuery(getQuery);
+
+ const newData = produce(sourceData, (draftState) => {
+ getPropValueByPath(draftState, getQueryNodePath).nodes.push(this.getPayload(result));
+ });
+
+ store.writeQuery({
+ ...getQuery,
+ data: newData,
+ });
+ },
+ getFieldLabel(field) {
+ if (field.bool) return null;
+
+ const optionalSuffix = field.required ? '' : ` ${MSG_OPTIONAL}`;
+ return field.label + optionalSuffix;
+ },
+ getPayload(data) {
+ if (!data) return null;
+
+ const keys = Object.keys(data);
+ if (keys[0] === '__typename') return data[keys[1]];
+
+ return data[keys[0]];
+ },
+ getDrawerHeaderHeight() {
+ const wrapperEl = document.querySelector('.content-wrapper');
+
+ if (wrapperEl) {
+ return `${wrapperEl.offsetTop}px`;
+ }
+
+ return '';
+ },
+ },
+ MSG_CANCEL,
+ INDEX_ROUTE_NAME,
+};
+</script>
+
+<template>
+ <mounting-portal v-if="!loading" mount-to="#js-crm-form-portal" append>
+ <gl-drawer
+ :header-height="getDrawerHeaderHeight()"
+ class="gl-drawer-responsive"
+ :open="drawerOpen"
+ @close="close(false)"
+ >
+ <template #title>
+ <h3>{{ title }}</h3>
+ </template>
+ <gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []">
+ <ul class="gl-mb-0! gl-ml-5">
+ <li v-for="error in errorMessages" :key="error">
+ {{ error }}
+ </li>
+ </ul>
+ </gl-alert>
+ <form @submit.prevent="save">
+ <gl-form-group
+ v-for="field in fields"
+ :key="field.name"
+ :label="getFieldLabel(field)"
+ :label-for="field.name"
+ >
+ <gl-form-select
+ v-if="field.values"
+ :id="field.name"
+ v-model="model[field.name]"
+ :options="field.values"
+ />
+ <gl-form-checkbox v-else-if="field.bool" :id="field.name" v-model="model[field.name]"
+ ><span class="gl-font-weight-bold">{{ field.label }}</span></gl-form-checkbox
+ >
+ <gl-form-input v-else :id="field.name" v-bind="field.input" v-model="model[field.name]" />
+ </gl-form-group>
+ <span class="gl-float-right">
+ <gl-button data-testid="cancel-button" @click="close(false)">
+ {{ $options.MSG_CANCEL }}
+ </gl-button>
+ <gl-button
+ variant="confirm"
+ :disabled="isInvalid"
+ :loading="submitting"
+ data-testid="save-button"
+ type="submit"
+ >{{ buttonLabel }}</gl-button
+ >
+ </span>
+ </form>
+ </gl-drawer>
+ </mounting-portal>
+</template>