summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/alert_management/components/alert_details.vue140
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/details.query.graphql1
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue61
-rw-r--r--app/assets/javascripts/monitoring/constants.js11
-rw-r--r--app/assets/javascripts/monitoring/stores/getters.js13
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js21
-rw-r--r--app/assets/javascripts/monitoring/stores/variable_mapping.js3
-rw-r--r--app/assets/javascripts/monitoring/utils.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue38
-rw-r--r--app/assets/stylesheets/pages/alerts_list.scss3
-rw-r--r--app/assets/stylesheets/utilities.scss5
-rw-r--r--app/controllers/concerns/metrics_dashboard.rb1
-rw-r--r--app/graphql/mutations/design_management/base.rb23
-rw-r--r--app/graphql/mutations/design_management/delete.rb66
-rw-r--r--app/graphql/mutations/design_management/upload.rb38
-rw-r--r--app/graphql/resolvers/design_management/design_at_version_resolver.rb46
-rw-r--r--app/graphql/resolvers/design_management/design_resolver.rb57
-rw-r--r--app/graphql/resolvers/design_management/designs_resolver.rb50
-rw-r--r--app/graphql/resolvers/design_management/version/design_at_version_resolver.rb95
-rw-r--r--app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb60
-rw-r--r--app/graphql/resolvers/design_management/version_in_collection_resolver.rb45
-rw-r--r--app/graphql/resolvers/design_management/version_resolver.rb25
-rw-r--r--app/graphql/resolvers/design_management/versions_resolver.rb76
-rw-r--r--app/graphql/types/design_management/design_at_version_type.rb37
-rw-r--r--app/graphql/types/design_management/design_collection_type.rb44
-rw-r--r--app/graphql/types/design_management/design_fields.rb78
-rw-r--r--app/graphql/types/design_management/design_type.rb44
-rw-r--r--app/graphql/types/design_management/design_version_event_enum.rb18
-rw-r--r--app/graphql/types/design_management/version_type.rb37
-rw-r--r--app/graphql/types/design_management_type.rb18
-rw-r--r--app/graphql/types/issue_type.rb8
-rw-r--r--app/graphql/types/mutation_type.rb2
-rw-r--r--app/graphql/types/notes/noteable_type.rb4
-rw-r--r--app/graphql/types/permission_types/issue.rb8
-rw-r--r--app/graphql/types/permission_types/project.rb4
-rw-r--r--app/graphql/types/query_type.rb11
-rw-r--r--app/graphql/types/todo_target_enum.rb1
-rw-r--r--app/helpers/issues_helper.rb7
-rw-r--r--app/models/concerns/diff_positionable_note.rb2
39 files changed, 1059 insertions, 160 deletions
diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue
index cca56bbe763..435b59f91ef 100644
--- a/app/assets/javascripts/alert_management/components/alert_details.vue
+++ b/app/assets/javascripts/alert_management/components/alert_details.vue
@@ -2,18 +2,21 @@
import * as Sentry from '@sentry/browser';
import {
GlAlert,
+ GlIcon,
GlLoadingIcon,
GlNewDropdown,
GlNewDropdownItem,
+ GlSprintf,
GlTabs,
GlTab,
GlButton,
} from '@gitlab/ui';
-import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { s__ } from '~/locale';
import query from '../graphql/queries/details.query.graphql';
import { fetchPolicies } from '~/lib/graphql';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { ALERTS_SEVERITY_LABELS } from '../constants';
export default {
statuses: {
@@ -27,16 +30,21 @@ export default {
),
fullAlertDetailsTitle: s__('AlertManagement|Full alert details'),
overviewTitle: s__('AlertManagement|Overview'),
+ reportedAt: s__('AlertManagement|Reported %{when}'),
+ reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'),
},
+ severityLabels: ALERTS_SEVERITY_LABELS,
components: {
GlAlert,
+ GlIcon,
GlLoadingIcon,
GlNewDropdown,
GlNewDropdownItem,
- timeAgoTooltip,
+ GlSprintf,
GlTab,
GlTabs,
GlButton,
+ TimeAgoTooltip,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -79,6 +87,11 @@ export default {
loading() {
return this.$apollo.queries.alert.loading;
},
+ reportedAtMessage() {
+ return this.alert?.monitoringTool
+ ? this.$options.i18n.reportedAtWithTool
+ : this.$options.i18n.reportedAt;
+ },
showErrorMsg() {
return this.errored && !this.isErrorDismissed;
},
@@ -95,58 +108,79 @@ export default {
<gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError">
{{ $options.i18n.errorMsg }}
</gl-alert>
- <div v-if="loading"><gl-loading-icon size="lg" class="mt-3" /></div>
- <div
- v-if="alert"
- class="gl-display-flex justify-content-end gl-border-b-1 gl-border-b-gray-200 gl-border-b-solid gl-p-4"
- >
- <gl-button
- v-if="glFeatures.createIssueFromAlertEnabled"
- data-testid="createIssueBtn"
- :href="newIssuePath"
- category="primary"
- variant="success"
+ <div v-if="loading"><gl-loading-icon size="lg" class="gl-mt-5" /></div>
+ <div v-if="alert" class="alert-management-details">
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-px-1 gl-py-6 gl-border-b-1 gl-border-b-gray-200 gl-border-b-solid"
>
- {{ s__('AlertManagement|Create issue') }}
- </gl-button>
- </div>
- <div
- v-if="alert"
- class="gl-display-flex gl-justify-content-space-between gl-align-items-center"
- >
- <h2 data-testid="title">{{ alert.title }}</h2>
- <gl-new-dropdown right>
- <gl-new-dropdown-item
- v-for="(label, field) in $options.statuses"
- :key="field"
- data-testid="statusDropdownItem"
- class="align-middle"
- >{{ label }}
- </gl-new-dropdown-item>
- </gl-new-dropdown>
+ <div data-testid="alert-header">
+ <div
+ class="gl-display-inline-flex gl-align-items-center gl-justify-content-space-between"
+ >
+ <gl-icon
+ class="gl-mr-3"
+ :size="12"
+ :name="`severity-${alert.severity.toLowerCase()}`"
+ :class="`icon-${alert.severity.toLowerCase()}`"
+ />
+ <strong>{{ $options.severityLabels[alert.severity] }}</strong>
+ </div>
+ <span class="gl-shim-mx-2">&bull;</span>
+ <gl-sprintf :message="reportedAtMessage">
+ <template #when>
+ <time-ago-tooltip :time="alert.createdAt" />
+ </template>
+ <template #tool>{{ alert.monitoringTool }}</template>
+ </gl-sprintf>
+ </div>
+ <gl-button
+ v-if="glFeatures.createIssueFromAlertEnabled"
+ data-testid="createIssueBtn"
+ :href="newIssuePath"
+ category="primary"
+ variant="success"
+ >
+ {{ s__('AlertManagement|Create issue') }}
+ </gl-button>
+ </div>
+ <div
+ v-if="alert"
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center"
+ >
+ <h2 data-testid="title">{{ alert.title }}</h2>
+ <gl-new-dropdown right>
+ <gl-new-dropdown-item
+ v-for="(label, field) in $options.statuses"
+ :key="field"
+ data-testid="statusDropdownItem"
+ class="gl-vertical-align-middle"
+ >{{ label }}
+ </gl-new-dropdown-item>
+ </gl-new-dropdown>
+ </div>
+ <gl-tabs v-if="alert" data-testid="alertDetailsTabs">
+ <gl-tab data-testid="overviewTab" :title="$options.i18n.overviewTitle">
+ <ul class="pl-4 mb-n1">
+ <li v-if="alert.startedAt" class="my-2">
+ <strong class="bold">{{ s__('AlertManagement|Start time') }}:</strong>
+ <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" />
+ </li>
+ <li v-if="alert.eventCount" class="my-2">
+ <strong class="bold">{{ s__('AlertManagement|Events') }}:</strong>
+ <span data-testid="eventCount">{{ alert.eventCount }}</span>
+ </li>
+ <li v-if="alert.monitoringTool" class="my-2">
+ <strong class="bold">{{ s__('AlertManagement|Tool') }}:</strong>
+ <span data-testid="monitoringTool">{{ alert.monitoringTool }}</span>
+ </li>
+ <li v-if="alert.service" class="my-2">
+ <strong class="bold">{{ s__('AlertManagement|Service') }}:</strong>
+ <span data-testid="service">{{ alert.service }}</span>
+ </li>
+ </ul>
+ </gl-tab>
+ <gl-tab data-testid="fullDetailsTab" :title="$options.i18n.fullAlertDetailsTitle" />
+ </gl-tabs>
</div>
- <gl-tabs v-if="alert" data-testid="alertDetailsTabs">
- <gl-tab data-testid="overviewTab" :title="$options.i18n.overviewTitle">
- <ul class="pl-4 mb-n1">
- <li v-if="alert.startedAt" class="my-2">
- <strong class="bold">{{ s__('AlertManagement|Start time') }}:</strong>
- <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" />
- </li>
- <li v-if="alert.eventCount" class="my-2">
- <strong class="bold">{{ s__('AlertManagement|Events') }}:</strong>
- <span data-testid="eventCount">{{ alert.eventCount }}</span>
- </li>
- <li v-if="alert.monitoringTool" class="my-2">
- <strong class="bold">{{ s__('AlertManagement|Tool') }}:</strong>
- <span data-testid="monitoringTool">{{ alert.monitoringTool }}</span>
- </li>
- <li v-if="alert.service" class="my-2">
- <strong class="bold">{{ s__('AlertManagement|Service') }}:</strong>
- <span data-testid="service">{{ alert.service }}</span>
- </li>
- </ul>
- </gl-tab>
- <gl-tab data-testid="fullDetailsTab" :title="$options.i18n.fullAlertDetailsTitle" />
- </gl-tabs>
</div>
</template>
diff --git a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql
index 81e95500e05..3e86df233d0 100644
--- a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql
+++ b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql
@@ -3,6 +3,7 @@ query alertDetails($fullPath: ID!, $alertId: String) {
alertManagementAlerts(iid: $alertId) {
nodes {
iid
+ createdAt
endedAt
eventCount
monitoringTool
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index f9a27d77498..199a16d8aad 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -11,32 +11,20 @@ export default {
},
data() {
return {
- name: '',
- type: modalTypes.blob,
+ entryName: '',
+ modalType: modalTypes.blob,
path: '',
};
},
computed: {
...mapState(['entries']),
...mapGetters('fileTemplates', ['templateTypes']),
- entryName: {
- get() {
- if (this.type === modalTypes.rename) {
- return this.name || this.path;
- }
-
- return this.name || (this.path ? `${this.path}/` : '');
- },
- set(val) {
- this.name = val.trim();
- },
- },
modalTitle() {
const entry = this.entries[this.path];
- if (this.type === modalTypes.tree) {
+ if (this.modalType === modalTypes.tree) {
return __('Create new directory');
- } else if (this.type === modalTypes.rename) {
+ } else if (this.modalType === modalTypes.rename) {
return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file');
}
@@ -45,16 +33,16 @@ export default {
buttonLabel() {
const entry = this.entries[this.path];
- if (this.type === modalTypes.tree) {
+ if (this.modalType === modalTypes.tree) {
return __('Create directory');
- } else if (this.type === modalTypes.rename) {
+ } else if (this.modalType === modalTypes.rename) {
return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file');
}
return __('Create file');
},
isCreatingNewFile() {
- return this.type === modalTypes.blob;
+ return this.modalType === modalTypes.blob;
},
placeholder() {
return this.isCreatingNewFile ? 'dir/file_name' : 'dir/';
@@ -63,8 +51,8 @@ export default {
methods: {
...mapActions(['createTempEntry', 'renameEntry']),
submitForm() {
- if (this.type === modalTypes.rename) {
- if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) {
+ if (this.modalType === modalTypes.rename) {
+ if (!this.entries[this.entryName]?.deleted) {
flash(
sprintf(s__('The name "%{name}" is already taken in this directory.'), {
name: this.entryName,
@@ -77,32 +65,32 @@ export default {
);
} else {
let parentPath = this.entryName.split('/');
- const entryName = parentPath.pop();
+ const name = parentPath.pop();
parentPath = parentPath.join('/');
this.renameEntry({
path: this.path,
- name: entryName,
+ name,
parentPath,
});
}
} else {
this.createTempEntry({
- name: this.name,
- type: this.type,
+ name: this.entryName,
+ type: this.modalType,
});
}
},
createFromTemplate(template) {
this.createTempEntry({
name: template.name,
- type: this.type,
+ type: this.modalType,
});
this.$refs.modal.toggle();
},
focusInput() {
- const name = this.entries[this.entryName] ? this.entries[this.entryName].name : null;
+ const name = this.entries[this.entryName]?.name;
const inputValue = this.$refs.fieldName.value;
this.$refs.fieldName.focus();
@@ -112,19 +100,24 @@ export default {
}
},
resetData() {
- this.name = '';
+ this.entryName = '';
this.path = '';
- this.type = modalTypes.blob;
+ this.modalType = modalTypes.blob;
},
open(type = modalTypes.blob, path = '') {
- this.type = type;
+ this.modalType = type;
this.path = path;
+
+ if (this.modalType === modalTypes.rename) {
+ this.entryName = path;
+ } else {
+ this.entryName = path ? `${path}/` : '';
+ }
+
this.$refs.modal.show();
// wait for modal to show first
- this.$nextTick(() => {
- this.focusInput();
- });
+ this.$nextTick(() => this.focusInput());
},
close() {
this.$refs.modal.hide();
@@ -150,7 +143,7 @@ export default {
<div class="col-sm-10">
<input
ref="fieldName"
- v-model="entryName"
+ v-model.trim="entryName"
type="text"
class="form-control qa-full-file-path"
:placeholder="placeholder"
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index dbd374f1e1c..0c2eafeed54 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -216,3 +216,14 @@ export const VARIABLE_TYPES = {
custom: 'custom',
text: 'text',
};
+
+/**
+ * The names of templating variables defined in the dashboard yml
+ * file are prefixed with a constant so that it doesn't collide with
+ * other URL params that the monitoring dashboard relies on for
+ * features like panel fullscreen etc.
+ *
+ * The prefix is added before it is appended to the URL and removed
+ * before passing the data to the backend.
+ */
+export const VARIABLE_PREFIX = 'var-';
diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js
index 8f8dc8127a0..12757bed588 100644
--- a/app/assets/javascripts/monitoring/stores/getters.js
+++ b/app/assets/javascripts/monitoring/stores/getters.js
@@ -1,3 +1,5 @@
+import { flatMap } from 'lodash';
+import { removePrefixFromLabels } from './utils';
import { NOT_IN_DB_PREFIX } from '../constants';
const metricsIdsInPanel = panel =>
@@ -110,16 +112,19 @@ export const filteredEnvironments = state =>
);
/**
- * Maps an variables object to an array
+ * Maps an variables object to an array along with stripping
+ * the variable prefix.
+ *
* @param {Object} variables - Custom variables provided by the user
* @returns {Array} The custom variables array to be send to the API
* in the format of [variable1, variable1_value]
*/
export const getCustomVariablesArray = state =>
- Object.entries(state.promVariables)
- .flat()
- .map(encodeURIComponent);
+ flatMap(state.promVariables, (val, key) => [
+ encodeURIComponent(removePrefixFromLabels(key)),
+ encodeURIComponent(val),
+ ]);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index a47e5f598f5..3dc88fa56fc 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -2,7 +2,7 @@ import { slugify } from '~/lib/utils/text_utility';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { NOT_IN_DB_PREFIX } from '../constants';
+import { NOT_IN_DB_PREFIX, VARIABLE_PREFIX } from '../constants';
export const gqClient = createGqClient(
{},
@@ -229,3 +229,22 @@ export const normalizeQueryResult = timeSeries => {
return normalizedResult;
};
+
+/**
+ * Variable labels are used as names for the dropdowns and also
+ * as URL params. Prefixing the name reduces the risk of
+ * collision with other URL params
+ *
+ * @param {String} label label for the template variable
+ * @returns {String}
+ */
+export const addPrefixToLabels = label => `${VARIABLE_PREFIX}${label}`;
+
+/**
+ * Before the templating variables are passed to the backend the
+ * prefix needs to be removed.
+ *
+ * @param {String} label label to remove prefix from
+ * @returns {String}
+ */
+export const removePrefixFromLabels = label => label.replace(VARIABLE_PREFIX, '');
diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js
index af6d46cc786..0e49fc784fe 100644
--- a/app/assets/javascripts/monitoring/stores/variable_mapping.js
+++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js
@@ -1,4 +1,5 @@
import { isString } from 'lodash';
+import { addPrefixToLabels } from './utils';
import { VARIABLE_TYPES } from '../constants';
/**
@@ -149,7 +150,7 @@ export const parseTemplatingVariables = ({ variables = {} } = {}) =>
if (parsedVar) {
acc[key] = {
...parsedVar,
- label: parsedVar.label || key,
+ label: addPrefixToLabels(parsedVar.label || key),
};
}
return acc;
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 9e7f4b05420..4c714a684e9 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -1,10 +1,11 @@
-import { omit } from 'lodash';
+import { pickBy } from 'lodash';
import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
import {
timeRangeParamNames,
timeRangeFromParams,
timeRangeToParams,
} from '~/lib/utils/datetime_range';
+import { VARIABLE_PREFIX } from './constants';
/**
* List of non time range url parameters
@@ -122,19 +123,16 @@ export const timeRangeFromUrl = (search = window.location.search) => {
};
/**
- * Returns an array with user defined variables from the URL
+ * User-defined variables from the URL are extracted. The variables
+ * begin with a constant prefix so that it doesn't collide with
+ * other URL params.
*
- * @returns {Array} The custom variables defined by the
- * user in the URL
* @param {String} New URL
+ * @returns {Object} The custom variables defined by the user in the URL
*/
-export const promCustomVariablesFromUrl = (search = window.location.search) => {
- const params = queryToObject(search);
- const paramsToRemove = timeRangeParamNames.concat(dashboardParams);
-
- return omit(params, paramsToRemove);
-};
+export const promCustomVariablesFromUrl = (search = window.location.search) =>
+ pickBy(queryToObject(search), (val, key) => key.startsWith(VARIABLE_PREFIX));
/**
* Returns a URL with no time range based on the current URL.
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue
deleted file mode 100644
index 7ed4da84120..00000000000
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue
+++ /dev/null
@@ -1,38 +0,0 @@
-<script>
-/* This is a re-usable vue component for rendering a user avatar svg (typically
- for a blank state). It will receive styles comparable to the user avatar,
- but no image is loaded, it isn't wrapped in a link, and tooltips aren't supported.
- The svg and avatar size can be configured by props passed to this component.
-
- Sample configuration:
-
- <user-avatar-svg
- :svg="potentialApproverSvg"
- :size="20"
- />
-
-*/
-
-export default {
- props: {
- svg: {
- type: String,
- required: true,
- },
- size: {
- type: Number,
- required: false,
- default: 20,
- },
- },
- computed: {
- avatarSizeClass() {
- return `s${this.size}`;
- },
- },
-};
-</script>
-
-<template>
- <svg :class="avatarSizeClass" :height="size" :width="size" v-html="svg" />
-</template>
diff --git a/app/assets/stylesheets/pages/alerts_list.scss b/app/assets/stylesheets/pages/alerts_list.scss
index 5974f97b728..7f817d10ffe 100644
--- a/app/assets/stylesheets/pages/alerts_list.scss
+++ b/app/assets/stylesheets/pages/alerts_list.scss
@@ -1,4 +1,5 @@
-.alert-management-list {
+.alert-management-list,
+.alert-management-details {
.icon-critical {
color: $red-800;
}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index a8223a8ff56..8cf5c533f1f 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -97,6 +97,11 @@
padding-top: 16px;
}
+.gl-shim-mx-2 {
+ margin-left: 4px;
+ margin-right: 4px;
+}
+
.gl-text-purple { color: $purple; }
.gl-text-gray-800 { color: $gray-800; }
.gl-bg-purple-light { background-color: $purple-light; }
diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb
index 58715fda152..f177b67b079 100644
--- a/app/controllers/concerns/metrics_dashboard.rb
+++ b/app/controllers/concerns/metrics_dashboard.rb
@@ -38,7 +38,6 @@ module MetricsDashboard
dashboard_finder
.find_all_paths(project_for_dashboard)
.map(&method(:amend_dashboard))
- .sort_by { |dashboard| [dashboard[:starred] ? 0 : 1, dashboard[:display_name].downcase] }
end
def amend_dashboard(dashboard)
diff --git a/app/graphql/mutations/design_management/base.rb b/app/graphql/mutations/design_management/base.rb
new file mode 100644
index 00000000000..918e5709b94
--- /dev/null
+++ b/app/graphql/mutations/design_management/base.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Mutations
+ module DesignManagement
+ class Base < ::Mutations::BaseMutation
+ include Mutations::ResolvesIssuable
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: "The project where the issue is to upload designs for"
+
+ argument :iid, GraphQL::ID_TYPE,
+ required: true,
+ description: "The iid of the issue to modify designs for"
+
+ private
+
+ def find_object(project_path:, iid:)
+ resolve_issuable(type: :issue, parent_path: project_path, iid: iid)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/design_management/delete.rb b/app/graphql/mutations/design_management/delete.rb
new file mode 100644
index 00000000000..d2ef2c9bcca
--- /dev/null
+++ b/app/graphql/mutations/design_management/delete.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+module Mutations
+ module DesignManagement
+ class Delete < Base
+ Errors = ::Gitlab::Graphql::Errors
+
+ graphql_name "DesignManagementDelete"
+
+ argument :filenames, [GraphQL::STRING_TYPE],
+ required: true,
+ description: "The filenames of the designs to delete",
+ prepare: ->(names, _ctx) do
+ names.presence || (raise Errors::ArgumentError, 'no filenames')
+ end
+
+ field :version, Types::DesignManagement::VersionType,
+ null: true, # null on error
+ description: 'The new version in which the designs are deleted'
+
+ authorize :destroy_design
+
+ def resolve(project_path:, iid:, filenames:)
+ issue = authorized_find!(project_path: project_path, iid: iid)
+ project = issue.project
+ designs = resolve_designs(issue, filenames)
+
+ result = ::DesignManagement::DeleteDesignsService
+ .new(project, current_user, issue: issue, designs: designs)
+ .execute
+
+ {
+ version: result[:version],
+ errors: Array.wrap(result[:message])
+ }
+ end
+
+ private
+
+ # Here we check that:
+ # * we find exactly as many designs as filenames
+ def resolve_designs(issue, filenames)
+ designs = issue.design_collection.designs_by_filename(filenames)
+
+ validate_all_were_found!(designs, filenames)
+
+ designs
+ end
+
+ def validate_all_were_found!(designs, filenames)
+ found_filenames = designs.map(&:filename)
+ missing = filenames.difference(found_filenames)
+
+ if missing.present?
+ raise Errors::ArgumentError, <<~MSG
+ Not all the designs you named currently exist.
+ The following filenames were not found:
+ #{missing.join(', ')}
+
+ They may have already been deleted.
+ MSG
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/design_management/upload.rb b/app/graphql/mutations/design_management/upload.rb
new file mode 100644
index 00000000000..1ed7f8e49e6
--- /dev/null
+++ b/app/graphql/mutations/design_management/upload.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Mutations
+ module DesignManagement
+ class Upload < Base
+ graphql_name "DesignManagementUpload"
+
+ argument :files, [ApolloUploadServer::Upload],
+ required: true,
+ description: "The files to upload"
+
+ authorize :create_design
+
+ field :designs, [Types::DesignManagement::DesignType],
+ null: false,
+ description: "The designs that were uploaded by the mutation"
+
+ field :skipped_designs, [Types::DesignManagement::DesignType],
+ null: false,
+ description: "Any designs that were skipped from the upload due to there " \
+ "being no change to their content since their last version"
+
+ def resolve(project_path:, iid:, files:)
+ issue = authorized_find!(project_path: project_path, iid: iid)
+ project = issue.project
+
+ result = ::DesignManagement::SaveDesignsService.new(project, current_user, issue: issue, files: files)
+ .execute
+
+ {
+ designs: Array.wrap(result[:designs]),
+ skipped_designs: Array.wrap(result[:skipped_designs]),
+ errors: Array.wrap(result[:message])
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/design_at_version_resolver.rb b/app/graphql/resolvers/design_management/design_at_version_resolver.rb
new file mode 100644
index 00000000000..fd9b349f974
--- /dev/null
+++ b/app/graphql/resolvers/design_management/design_at_version_resolver.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ class DesignAtVersionResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::DesignManagement::DesignAtVersionType, null: false
+
+ authorize :read_design
+
+ argument :id, GraphQL::ID_TYPE,
+ required: true,
+ description: 'The Global ID of the design at this version'
+
+ def resolve(id:)
+ authorized_find!(id: id)
+ end
+
+ def find_object(id:)
+ dav = GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::DesignAtVersion)
+ return unless consistent?(dav)
+
+ dav
+ end
+
+ def self.single
+ self
+ end
+
+ private
+
+ # If this resolver is mounted on something that has an issue
+ # (such as design collection for instance), then we should check
+ # that the DesignAtVersion as found by its ID does in fact belong
+ # to this issue.
+ def consistent?(dav)
+ issue.nil? || (dav&.design&.issue_id == issue.id)
+ end
+
+ def issue
+ object&.issue
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/design_resolver.rb b/app/graphql/resolvers/design_management/design_resolver.rb
new file mode 100644
index 00000000000..05bdbbbe407
--- /dev/null
+++ b/app/graphql/resolvers/design_management/design_resolver.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ class DesignResolver < BaseResolver
+ argument :id, GraphQL::ID_TYPE,
+ required: false,
+ description: 'Find a design by its ID'
+
+ argument :filename, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Find a design by its filename'
+
+ def resolve(filename: nil, id: nil)
+ params = parse_args(filename, id)
+
+ build_finder(params).execute.first
+ end
+
+ def self.single
+ self
+ end
+
+ private
+
+ def issue
+ object.issue
+ end
+
+ def build_finder(params)
+ ::DesignManagement::DesignsFinder.new(issue, current_user, params)
+ end
+
+ def error(msg)
+ raise ::Gitlab::Graphql::Errors::ArgumentError, msg
+ end
+
+ def parse_args(filename, id)
+ provided = [filename, id].map(&:present?)
+
+ if provided.none?
+ error('one of id or filename must be passed')
+ elsif provided.all?
+ error('only one of id or filename may be passed')
+ elsif filename.present?
+ { filenames: [filename] }
+ else
+ { ids: [parse_gid(id)] }
+ end
+ end
+
+ def parse_gid(gid)
+ GitlabSchema.parse_gid(gid, expected_type: ::DesignManagement::Design).model_id
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/designs_resolver.rb b/app/graphql/resolvers/design_management/designs_resolver.rb
new file mode 100644
index 00000000000..81f94d5cb30
--- /dev/null
+++ b/app/graphql/resolvers/design_management/designs_resolver.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ class DesignsResolver < BaseResolver
+ argument :ids,
+ [GraphQL::ID_TYPE],
+ required: false,
+ description: 'Filters designs by their ID'
+ argument :filenames,
+ [GraphQL::STRING_TYPE],
+ required: false,
+ description: 'Filters designs by their filename'
+ argument :at_version,
+ GraphQL::ID_TYPE,
+ required: false,
+ description: 'Filters designs to only those that existed at the version. ' \
+ 'If argument is omitted or nil then all designs will reflect the latest version'
+
+ def self.single
+ ::Resolvers::DesignManagement::DesignResolver
+ end
+
+ def resolve(ids: nil, filenames: nil, at_version: nil)
+ ::DesignManagement::DesignsFinder.new(
+ issue,
+ current_user,
+ ids: design_ids(ids),
+ filenames: filenames,
+ visible_at_version: version(at_version),
+ order: :id
+ ).execute
+ end
+
+ private
+
+ def version(at_version)
+ GitlabSchema.object_from_id(at_version)&.sync if at_version
+ end
+
+ def design_ids(ids)
+ ids&.map { |id| GlobalID.parse(id).model_id }
+ end
+
+ def issue
+ object.issue
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb
new file mode 100644
index 00000000000..03f7908780c
--- /dev/null
+++ b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ module Version
+ # Resolver for a DesignAtVersion object given an implicit version context
+ class DesignAtVersionResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::DesignManagement::DesignAtVersionType, null: true
+
+ authorize :read_design
+
+ argument :id, GraphQL::ID_TYPE,
+ required: false,
+ as: :design_at_version_id,
+ description: 'The ID of the DesignAtVersion'
+ argument :design_id, GraphQL::ID_TYPE,
+ required: false,
+ description: 'The ID of a specific design'
+ argument :filename, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'The filename of a specific design'
+
+ def self.single
+ self
+ end
+
+ def resolve(design_id: nil, filename: nil, design_at_version_id: nil)
+ validate_arguments(design_id, filename, design_at_version_id)
+
+ return unless Ability.allowed?(current_user, :read_design, issue)
+ return specific_design_at_version(design_at_version_id) if design_at_version_id
+
+ find(design_id, filename).map { |d| make(d) }.first
+ end
+
+ private
+
+ def validate_arguments(design_id, filename, design_at_version_id)
+ args = { filename: filename, id: design_at_version_id, design_id: design_id }
+ passed = args.compact.keys
+
+ return if passed.size == 1
+
+ msg = "Exactly one of #{args.keys.join(', ')} expected, got #{passed}"
+
+ raise Gitlab::Graphql::Errors::ArgumentError, msg
+ end
+
+ def specific_design_at_version(id)
+ dav = GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::DesignAtVersion)
+ return unless consistent?(dav)
+
+ dav
+ end
+
+ # Test that the DAV found by ID actually belongs on this version, and
+ # that it is visible at this version.
+ def consistent?(dav)
+ return false unless dav.present?
+
+ dav.design.issue_id == issue.id &&
+ dav.version.id == version.id &&
+ dav.design.visible_in?(version)
+ end
+
+ def find(id, filename)
+ ids = [parse_design_id(id).model_id] if id
+ filenames = [filename] if filename
+
+ ::DesignManagement::DesignsFinder
+ .new(issue, current_user, ids: ids, filenames: filenames, visible_at_version: version)
+ .execute
+ end
+
+ def parse_design_id(id)
+ GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Design)
+ end
+
+ def issue
+ version.issue
+ end
+
+ def version
+ object
+ end
+
+ def make(design)
+ ::DesignManagement::DesignAtVersion.new(design: design, version: version)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb b/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb
new file mode 100644
index 00000000000..5ccb2f3e311
--- /dev/null
+++ b/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ module Version
+ # Resolver for DesignAtVersion objects given an implicit version context
+ class DesignsAtVersionResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::DesignManagement::DesignAtVersionType, null: true
+
+ authorize :read_design
+
+ argument :ids,
+ [GraphQL::ID_TYPE],
+ required: false,
+ description: 'Filters designs by their ID'
+ argument :filenames,
+ [GraphQL::STRING_TYPE],
+ required: false,
+ description: 'Filters designs by their filename'
+
+ def self.single
+ ::Resolvers::DesignManagement::Version::DesignAtVersionResolver
+ end
+
+ def resolve(ids: nil, filenames: nil)
+ find(ids, filenames).execute.map { |d| make(d) }
+ end
+
+ private
+
+ def find(ids, filenames)
+ ids = ids&.map { |id| parse_design_id(id).model_id }
+
+ ::DesignManagement::DesignsFinder.new(issue, current_user,
+ ids: ids,
+ filenames: filenames,
+ visible_at_version: version)
+ end
+
+ def parse_design_id(id)
+ GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Design)
+ end
+
+ def issue
+ version.issue
+ end
+
+ def version
+ object
+ end
+
+ def make(design)
+ ::DesignManagement::DesignAtVersion.new(design: design, version: version)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/version_in_collection_resolver.rb b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb
new file mode 100644
index 00000000000..9e729172881
--- /dev/null
+++ b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ class VersionInCollectionResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::DesignManagement::VersionType, null: true
+
+ authorize :read_design
+
+ alias_method :collection, :object
+
+ argument :sha, GraphQL::STRING_TYPE,
+ required: false,
+ description: "The SHA256 of a specific version"
+ argument :id, GraphQL::ID_TYPE,
+ required: false,
+ description: 'The Global ID of the version'
+
+ def resolve(id: nil, sha: nil)
+ check_args(id, sha)
+
+ gid = GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Version) if id
+
+ ::DesignManagement::VersionsFinder
+ .new(collection, current_user, sha: sha, version_id: gid&.model_id)
+ .execute
+ .first
+ end
+
+ def self.single
+ self
+ end
+
+ private
+
+ def check_args(id, sha)
+ return if id.present? || sha.present?
+
+ raise ::Gitlab::Graphql::Errors::ArgumentError, 'one of id or sha is required'
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/version_resolver.rb b/app/graphql/resolvers/design_management/version_resolver.rb
new file mode 100644
index 00000000000..b0e0843e6c8
--- /dev/null
+++ b/app/graphql/resolvers/design_management/version_resolver.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ class VersionResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::DesignManagement::VersionType, null: true
+
+ authorize :read_design
+
+ argument :id, GraphQL::ID_TYPE,
+ required: true,
+ description: 'The Global ID of the version'
+
+ def resolve(id:)
+ authorized_find!(id: id)
+ end
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::Version)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/versions_resolver.rb b/app/graphql/resolvers/design_management/versions_resolver.rb
new file mode 100644
index 00000000000..a62258dad5c
--- /dev/null
+++ b/app/graphql/resolvers/design_management/versions_resolver.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ class VersionsResolver < BaseResolver
+ type Types::DesignManagement::VersionType.connection_type, null: false
+
+ alias_method :design_or_collection, :object
+
+ argument :earlier_or_equal_to_sha, GraphQL::STRING_TYPE,
+ as: :sha,
+ required: false,
+ description: 'The SHA256 of the most recent acceptable version'
+
+ argument :earlier_or_equal_to_id, GraphQL::ID_TYPE,
+ as: :id,
+ required: false,
+ description: 'The Global ID of the most recent acceptable version'
+
+ # This resolver has a custom singular resolver
+ def self.single
+ ::Resolvers::DesignManagement::VersionInCollectionResolver
+ end
+
+ def resolve(parent: nil, id: nil, sha: nil)
+ version = cutoff(parent, id, sha)
+
+ raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, 'cutoff not found' unless version.present?
+
+ if version == :unconstrained
+ find
+ else
+ find(earlier_or_equal_to: version)
+ end
+ end
+
+ private
+
+ # Find the most recent version that the client will accept
+ def cutoff(parent, id, sha)
+ if sha.present? || id.present?
+ specific_version(id, sha)
+ elsif at_version = at_version_arg(parent)
+ by_id(at_version)
+ else
+ :unconstrained
+ end
+ end
+
+ def specific_version(id, sha)
+ gid = GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Version) if id
+ find(sha: sha, version_id: gid&.model_id).first
+ end
+
+ def find(**params)
+ ::DesignManagement::VersionsFinder
+ .new(design_or_collection, current_user, params)
+ .execute
+ end
+
+ def by_id(id)
+ GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::Version).sync
+ end
+
+ # Find an `at_version` argument passed to a parent node.
+ #
+ # If one is found, then a design collection further up the AST
+ # has been filtered to reflect designs at that version, and so
+ # for consistency we should only present versions up to the given
+ # version here.
+ def at_version_arg(parent)
+ ::Gitlab::Graphql::FindArgumentInParent.find(parent, :at_version, limit_depth: 4)
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/design_management/design_at_version_type.rb b/app/graphql/types/design_management/design_at_version_type.rb
new file mode 100644
index 00000000000..343d4cf4ff4
--- /dev/null
+++ b/app/graphql/types/design_management/design_at_version_type.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Types
+ module DesignManagement
+ class DesignAtVersionType < BaseObject
+ graphql_name 'DesignAtVersion'
+
+ description 'A design pinned to a specific version. ' \
+ 'The image field reflects the design as of the associated version.'
+
+ authorize :read_design
+
+ delegate :design, :version, to: :object
+ delegate :issue, :filename, :full_path, :diff_refs, to: :design
+
+ implements ::Types::DesignManagement::DesignFields
+
+ field :version,
+ Types::DesignManagement::VersionType,
+ null: false,
+ description: 'The version this design-at-versions is pinned to'
+
+ field :design,
+ Types::DesignManagement::DesignType,
+ null: false,
+ description: 'The underlying design.'
+
+ def cached_stateful_version(_parent)
+ version
+ end
+
+ def notes_count
+ design.user_notes_count
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/design_management/design_collection_type.rb b/app/graphql/types/design_management/design_collection_type.rb
new file mode 100644
index 00000000000..194910831c6
--- /dev/null
+++ b/app/graphql/types/design_management/design_collection_type.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Types
+ module DesignManagement
+ class DesignCollectionType < BaseObject
+ graphql_name 'DesignCollection'
+ description 'A collection of designs.'
+
+ authorize :read_design
+
+ field :project, Types::ProjectType, null: false,
+ description: 'Project associated with the design collection'
+ field :issue, Types::IssueType, null: false,
+ description: 'Issue associated with the design collection'
+
+ field :designs,
+ Types::DesignManagement::DesignType.connection_type,
+ null: false,
+ resolver: Resolvers::DesignManagement::DesignsResolver,
+ description: 'All designs for the design collection',
+ complexity: 5
+
+ field :versions,
+ Types::DesignManagement::VersionType.connection_type,
+ resolver: Resolvers::DesignManagement::VersionsResolver,
+ description: 'All versions related to all designs, ordered newest first'
+
+ field :version,
+ Types::DesignManagement::VersionType,
+ resolver: Resolvers::DesignManagement::VersionsResolver.single,
+ description: 'A specific version'
+
+ field :design_at_version, ::Types::DesignManagement::DesignAtVersionType,
+ null: true,
+ resolver: ::Resolvers::DesignManagement::DesignAtVersionResolver,
+ description: 'Find a design as of a version'
+
+ field :design, ::Types::DesignManagement::DesignType,
+ null: true,
+ resolver: ::Resolvers::DesignManagement::DesignResolver,
+ description: 'Find a specific design'
+ end
+ end
+end
diff --git a/app/graphql/types/design_management/design_fields.rb b/app/graphql/types/design_management/design_fields.rb
new file mode 100644
index 00000000000..b03b3927392
--- /dev/null
+++ b/app/graphql/types/design_management/design_fields.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module Types
+ module DesignManagement
+ module DesignFields
+ include BaseInterface
+
+ field_class Types::BaseField
+
+ field :id, GraphQL::ID_TYPE, description: 'The ID of this design', null: false
+ field :project, Types::ProjectType, null: false, description: 'The project the design belongs to'
+ field :issue, Types::IssueType, null: false, description: 'The issue the design belongs to'
+ field :filename, GraphQL::STRING_TYPE, null: false, description: 'The filename of the design'
+ field :full_path, GraphQL::STRING_TYPE, null: false, description: 'The full path to the design file'
+ field :image, GraphQL::STRING_TYPE, null: false, extras: [:parent], description: 'The URL of the full-sized image'
+ field :image_v432x230, GraphQL::STRING_TYPE, null: true, extras: [:parent],
+ description: 'The URL of the design resized to fit within the bounds of 432x230. ' \
+ 'This will be `null` if the image has not been generated'
+ field :diff_refs, Types::DiffRefsType,
+ null: false,
+ calls_gitaly: true,
+ extras: [:parent],
+ description: 'The diff refs for this design'
+ field :event, Types::DesignManagement::DesignVersionEventEnum,
+ null: false,
+ extras: [:parent],
+ description: 'How this design was changed in the current version'
+ field :notes_count,
+ GraphQL::INT_TYPE,
+ null: false,
+ method: :user_notes_count,
+ description: 'The total count of user-created notes for this design'
+
+ def diff_refs(parent:)
+ version = cached_stateful_version(parent)
+ version.diff_refs
+ end
+
+ def image(parent:)
+ sha = cached_stateful_version(parent).sha
+
+ Gitlab::UrlBuilder.build(design, ref: sha)
+ end
+
+ def image_v432x230(parent:)
+ version = cached_stateful_version(parent)
+ action = design.actions.up_to_version(version).most_recent.first
+
+ # A `nil` return value indicates that the image has not been processed
+ return unless action.image_v432x230.file
+
+ Gitlab::UrlBuilder.build(design, ref: version.sha, size: :v432x230)
+ end
+
+ def event(parent:)
+ version = cached_stateful_version(parent)
+
+ action = cached_actions_for_version(version)[design.id]
+
+ action&.event || ::Types::DesignManagement::DesignVersionEventEnum::NONE
+ end
+
+ def cached_actions_for_version(version)
+ Gitlab::SafeRequestStore.fetch(['DesignFields', 'actions_for_version', version.id]) do
+ version.actions.to_h { |dv| [dv.design_id, dv] }
+ end
+ end
+
+ def project
+ ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Project, design.project_id).find
+ end
+
+ def issue
+ ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Issue, design.issue_id).find
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/design_management/design_type.rb b/app/graphql/types/design_management/design_type.rb
new file mode 100644
index 00000000000..3c84dc151bd
--- /dev/null
+++ b/app/graphql/types/design_management/design_type.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Types
+ module DesignManagement
+ class DesignType < BaseObject
+ graphql_name 'Design'
+ description 'A single design'
+
+ authorize :read_design
+
+ alias_method :design, :object
+
+ implements(Types::Notes::NoteableType)
+ implements(Types::DesignManagement::DesignFields)
+
+ field :versions,
+ Types::DesignManagement::VersionType.connection_type,
+ resolver: Resolvers::DesignManagement::VersionsResolver,
+ description: "All versions related to this design ordered newest first",
+ extras: [:parent]
+
+ # Returns a `DesignManagement::Version` for this query based on the
+ # `atVersion` argument passed to a parent node if present, or otherwise
+ # the most recent `Version` for the issue.
+ def cached_stateful_version(parent_node)
+ version_gid = Gitlab::Graphql::FindArgumentInParent.find(parent_node, :at_version)
+
+ # Caching is scoped to an `issue_id` to allow us to cache the
+ # most recent `Version` for an issue
+ Gitlab::SafeRequestStore.fetch([request_cache_base_key, 'stateful_version', object.issue_id, version_gid]) do
+ if version_gid
+ GitlabSchema.object_from_id(version_gid)&.sync
+ else
+ object.issue.design_versions.most_recent
+ end
+ end
+ end
+
+ def request_cache_base_key
+ self.class.name
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/design_management/design_version_event_enum.rb b/app/graphql/types/design_management/design_version_event_enum.rb
new file mode 100644
index 00000000000..ea4bc1ffbfa
--- /dev/null
+++ b/app/graphql/types/design_management/design_version_event_enum.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Types
+ module DesignManagement
+ class DesignVersionEventEnum < BaseEnum
+ graphql_name 'DesignVersionEvent'
+ description 'Mutation event of a design within a version'
+
+ NONE = 'NONE'
+
+ value NONE, 'No change'
+
+ ::DesignManagement::Action.events.keys.each do |event_name|
+ value event_name.upcase, value: event_name, description: "A #{event_name} event"
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/design_management/version_type.rb b/app/graphql/types/design_management/version_type.rb
new file mode 100644
index 00000000000..c774f5d1bdf
--- /dev/null
+++ b/app/graphql/types/design_management/version_type.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Types
+ module DesignManagement
+ class VersionType < ::Types::BaseObject
+ # Just `Version` might be a bit to general to expose globally so adding
+ # a `Design` prefix to specify the class exposed in GraphQL
+ graphql_name 'DesignVersion'
+
+ description 'A specific version in which designs were added, modified or deleted'
+
+ authorize :read_design
+
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID of the design version'
+ field :sha, GraphQL::ID_TYPE, null: false,
+ description: 'SHA of the design version'
+
+ field :designs,
+ ::Types::DesignManagement::DesignType.connection_type,
+ null: false,
+ description: 'All designs that were changed in the version'
+
+ field :designs_at_version,
+ ::Types::DesignManagement::DesignAtVersionType.connection_type,
+ null: false,
+ description: 'All designs that are visible at this version, as of this version',
+ resolver: ::Resolvers::DesignManagement::Version::DesignsAtVersionResolver
+
+ field :design_at_version,
+ ::Types::DesignManagement::DesignAtVersionType,
+ null: false,
+ description: 'A particular design as of this version, provided it is visible at this version',
+ resolver: ::Resolvers::DesignManagement::Version::DesignsAtVersionResolver.single
+ end
+ end
+end
diff --git a/app/graphql/types/design_management_type.rb b/app/graphql/types/design_management_type.rb
new file mode 100644
index 00000000000..ec85b8a0c1f
--- /dev/null
+++ b/app/graphql/types/design_management_type.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+# rubocop: disable Graphql/AuthorizeTypes
+module Types
+ class DesignManagementType < BaseObject
+ graphql_name 'DesignManagement'
+
+ field :version, ::Types::DesignManagement::VersionType,
+ null: true,
+ resolver: ::Resolvers::DesignManagement::VersionResolver,
+ description: 'Find a version'
+
+ field :design_at_version, ::Types::DesignManagement::DesignAtVersionType,
+ null: true,
+ resolver: ::Resolvers::DesignManagement::DesignAtVersionResolver,
+ description: 'Find a design as of a version'
+ end
+end
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 11850e5865f..73219ca9e1e 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -85,6 +85,14 @@ module Types
field :task_completion_status, Types::TaskCompletionStatus, null: false,
description: 'Task completion status of the issue'
+
+ field :designs, Types::DesignManagement::DesignCollectionType, null: true,
+ method: :design_collection,
+ deprecated: { reason: 'Use `designCollection`', milestone: '12.2' },
+ description: 'The designs associated with this issue'
+
+ field :design_collection, Types::DesignManagement::DesignCollectionType, null: true,
+ description: 'Collection of design images associated with this issue'
end
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index b18a3968a03..6e1bc962cd2 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -42,6 +42,8 @@ module Types
mount_mutation Mutations::Snippets::Create
mount_mutation Mutations::Snippets::MarkAsSpam
mount_mutation Mutations::JiraImport::Start
+ mount_mutation Mutations::DesignManagement::Upload, calls_gitaly: true
+ mount_mutation Mutations::DesignManagement::Delete, calls_gitaly: true
end
end
diff --git a/app/graphql/types/notes/noteable_type.rb b/app/graphql/types/notes/noteable_type.rb
index 2ac66452841..187c9109f8c 100644
--- a/app/graphql/types/notes/noteable_type.rb
+++ b/app/graphql/types/notes/noteable_type.rb
@@ -17,6 +17,8 @@ module Types
Types::MergeRequestType
when Snippet
Types::SnippetType
+ when ::DesignManagement::Design
+ Types::DesignManagement::DesignType
else
raise "Unknown GraphQL type for #{object}"
end
@@ -25,5 +27,3 @@ module Types
end
end
end
-
-Types::Notes::NoteableType.extend_if_ee('::EE::Types::Notes::NoteableType')
diff --git a/app/graphql/types/permission_types/issue.rb b/app/graphql/types/permission_types/issue.rb
index e26c5950e73..94e1bffd685 100644
--- a/app/graphql/types/permission_types/issue.rb
+++ b/app/graphql/types/permission_types/issue.rb
@@ -6,11 +6,9 @@ module Types
description 'Check permissions for the current user on a issue'
graphql_name 'IssuePermissions'
- abilities :read_issue, :admin_issue,
- :update_issue, :create_note,
- :reopen_issue
+ abilities :read_issue, :admin_issue, :update_issue, :reopen_issue,
+ :read_design, :create_design, :destroy_design,
+ :create_note
end
end
end
-
-Types::PermissionTypes::Issue.prepend_if_ee('::EE::Types::PermissionTypes::Issue')
diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb
index f773fce0c63..5747e63d195 100644
--- a/app/graphql/types/permission_types/project.rb
+++ b/app/graphql/types/permission_types/project.rb
@@ -17,7 +17,7 @@ module Types
:admin_wiki, :admin_project, :update_pages,
:admin_remote_mirror, :create_label, :update_wiki, :destroy_wiki,
:create_pages, :destroy_pages, :read_pages_content, :admin_operations,
- :read_merge_request
+ :read_merge_request, :read_design, :create_design, :destroy_design
permission_field :create_snippet
@@ -27,5 +27,3 @@ module Types
end
end
end
-
-Types::PermissionTypes::Project.prepend_if_ee('EE::Types::PermissionTypes::Project')
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index e0479c8227b..70cdcb62bc6 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -4,6 +4,9 @@ module Types
class QueryType < ::Types::BaseObject
graphql_name 'Query'
+ # The design management context object needs to implement #issue
+ DesignManagementObject = Struct.new(:issue)
+
field :project, Types::ProjectType,
null: true,
resolver: Resolvers::ProjectResolver,
@@ -40,9 +43,17 @@ module Types
resolver: Resolvers::SnippetsResolver,
description: 'Find Snippets visible to the current user'
+ field :design_management, Types::DesignManagementType,
+ null: false,
+ description: 'Fields related to design management'
+
field :echo, GraphQL::STRING_TYPE, null: false,
description: 'Text to echo back',
resolver: Resolvers::EchoResolver
+
+ def design_management
+ DesignManagementObject.new(nil)
+ end
end
end
diff --git a/app/graphql/types/todo_target_enum.rb b/app/graphql/types/todo_target_enum.rb
index 8358a86b35c..a377c3aafdc 100644
--- a/app/graphql/types/todo_target_enum.rb
+++ b/app/graphql/types/todo_target_enum.rb
@@ -5,6 +5,7 @@ module Types
value 'COMMIT', value: 'Commit', description: 'A Commit'
value 'ISSUE', value: 'Issue', description: 'An Issue'
value 'MERGEREQUEST', value: 'MergeRequest', description: 'A MergeRequest'
+ value 'DESIGN', value: 'DesignManagement::Design', description: 'A Design'
end
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 52b81153c43..8747430a909 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -9,13 +9,6 @@ module IssuesHelper
classes.join(' ')
end
- # Returns an OpenStruct object suitable for use by <tt>options_from_collection_for_select</tt>
- # to allow filtering issues by an unassigned User or Milestone
- def unassigned_filter
- # Milestone uses :title, Issue uses :name
- OpenStruct.new(id: 0, title: 'None (backlog)', name: 'Unassigned')
- end
-
def url_for_issue(issue_iid, project = @project, options = {})
return '' if project.nil?
diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb
index 8590bc7f4eb..cea3c7d119c 100644
--- a/app/models/concerns/diff_positionable_note.rb
+++ b/app/models/concerns/diff_positionable_note.rb
@@ -23,6 +23,8 @@ module DiffPositionableNote
if new_position.is_a?(Hash)
new_position = new_position.with_indifferent_access
new_position = Gitlab::Diff::Position.new(new_position)
+ elsif !new_position.is_a?(Gitlab::Diff::Position)
+ new_position = nil
end
return if new_position == read_attribute(meth)