diff options
Diffstat (limited to 'app')
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">•</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) |