diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-20 18:10:58 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-20 18:10:58 +0300 |
commit | 049d16d168fdee408b78f5f38619c092fd3b2265 (patch) | |
tree | 22d1db5ab4fae0967a4da4b1a6b097ef9e5d7aa2 /app | |
parent | bf18f3295b550c564086efd0a32d9a25435ce216 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
34 files changed, 872 insertions, 112 deletions
diff --git a/app/assets/javascripts/artifacts/components/artifact_row.vue b/app/assets/javascripts/artifacts/components/artifact_row.vue new file mode 100644 index 00000000000..92044a3641a --- /dev/null +++ b/app/assets/javascripts/artifacts/components/artifact_row.vue @@ -0,0 +1,88 @@ +<script> +import { GlButtonGroup, GlButton, GlBadge } from '@gitlab/ui'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { I18N_EXPIRED, I18N_DOWNLOAD, I18N_DELETE } from '../constants'; + +export default { + name: 'ArtifactRow', + components: { + GlButtonGroup, + GlButton, + GlBadge, + }, + props: { + artifact: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: true, + }, + isLastRow: { + type: Boolean, + required: true, + }, + }, + computed: { + isExpired() { + if (!this.artifact.expireAt) { + return false; + } + return Date.now() > new Date(this.artifact.expireAt).getTime(); + }, + artifactSize() { + return numberToHumanSize(this.artifact.size); + }, + }, + i18n: { + expired: I18N_EXPIRED, + download: I18N_DOWNLOAD, + delete: I18N_DELETE, + }, +}; +</script> +<template> + <div + class="gl-py-4" + :class="{ 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': !isLastRow }" + > + <div class="gl-display-inline-flex gl-align-items-center gl-w-full"> + <span class="gl-w-half gl-pl-8 gl-display-flex" data-testid="job-artifact-row-name"> + {{ artifact.name }} + <gl-badge size="sm" variant="neutral" class="gl-ml-2"> + {{ artifact.fileType.toLowerCase() }} + </gl-badge> + <gl-badge v-if="isExpired" size="sm" variant="warning" icon="expire" class="gl-ml-2"> + {{ $options.i18n.expired }} + </gl-badge> + </span> + + <span class="gl-w-quarter gl-text-right gl-pr-5" data-testid="job-artifact-row-size"> + {{ artifactSize }} + </span> + + <span class="gl-w-quarter gl-text-right gl-pr-5"> + <gl-button-group> + <gl-button + category="tertiary" + icon="download" + :title="$options.i18n.download" + :aria-label="$options.i18n.download" + :href="artifact.downloadPath" + data-testid="job-artifact-row-download-button" + /> + <gl-button + category="tertiary" + icon="remove" + :title="$options.i18n.delete" + :aria-label="$options.i18n.delete" + :loading="isLoading" + data-testid="job-artifact-row-delete-button" + @click="$emit('delete')" + /> + </gl-button-group> + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue b/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue new file mode 100644 index 00000000000..089bfd80222 --- /dev/null +++ b/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue @@ -0,0 +1,89 @@ +<script> +import { createAlert } from '~/flash'; +import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller'; +import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql'; +import destroyArtifactMutation from '../graphql/mutations/destroy_artifact.mutation.graphql'; +import { removeArtifactFromStore } from '../graphql/cache_update'; +import { + I18N_DESTROY_ERROR, + ARTIFACT_ROW_HEIGHT, + ARTIFACTS_SHOWN_WITHOUT_SCROLLING, +} from '../constants'; +import ArtifactRow from './artifact_row.vue'; + +export default { + name: 'ArtifactsTableRowDetails', + components: { + DynamicScroller, + DynamicScrollerItem, + ArtifactRow, + }, + props: { + artifacts: { + type: Object, + required: true, + }, + queryVariables: { + type: Object, + required: true, + }, + }, + data() { + return { + deletingArtifactId: null, + }; + }, + computed: { + scrollContainerStyle() { + /* + limit the height of the expanded artifacts container to a number of artifacts + if a job has more artifacts than ARTIFACTS_SHOWN_WITHOUT_SCROLLING, scroll to see the rest + add one pixel to row height to account for borders + */ + return { maxHeight: `${ARTIFACTS_SHOWN_WITHOUT_SCROLLING * (ARTIFACT_ROW_HEIGHT + 1)}px` }; + }, + }, + methods: { + isLastRow(index) { + return index === this.artifacts.nodes.length - 1; + }, + destroyArtifact(id) { + this.deletingArtifactId = id; + this.$apollo + .mutate({ + mutation: destroyArtifactMutation, + variables: { id }, + update: (store) => { + removeArtifactFromStore(store, id, getJobArtifactsQuery, this.queryVariables); + }, + }) + .catch(() => { + createAlert({ + message: I18N_DESTROY_ERROR, + }); + this.$emit('refetch'); + }) + .finally(() => { + this.deletingArtifactId = null; + }); + }, + }, + ARTIFACT_ROW_HEIGHT, +}; +</script> +<template> + <div :style="scrollContainerStyle"> + <dynamic-scroller :items="artifacts.nodes" :min-item-size="$options.ARTIFACT_ROW_HEIGHT"> + <template #default="{ item, index, active }"> + <dynamic-scroller-item :item="item" :active="active" :class="{ active }"> + <artifact-row + :artifact="item" + :is-last-row="isLastRow(index)" + :is-loading="item.id === deletingArtifactId" + @delete="destroyArtifact(item.id)" + /> + </dynamic-scroller-item> + </template> + </dynamic-scroller> + </div> +</template> diff --git a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/artifacts/components/job_artifacts_table.vue new file mode 100644 index 00000000000..7b11e4f17f3 --- /dev/null +++ b/app/assets/javascripts/artifacts/components/job_artifacts_table.vue @@ -0,0 +1,314 @@ +<script> +import { + GlLoadingIcon, + GlTable, + GlLink, + GlButtonGroup, + GlButton, + GlBadge, + GlIcon, + GlPagination, +} from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql'; +import { totalArtifactsSizeForJob, mapArchivesToJobNodes, mapBooleansToJobNodes } from '../utils'; +import { + STATUS_BADGE_VARIANTS, + I18N_DOWNLOAD, + I18N_BROWSE, + I18N_DELETE, + I18N_EXPIRED, + I18N_DESTROY_ERROR, + I18N_FETCH_ERROR, + I18N_ARTIFACTS, + I18N_JOB, + I18N_SIZE, + I18N_CREATED, + I18N_ARTIFACTS_COUNT, + INITIAL_CURRENT_PAGE, + INITIAL_PREVIOUS_PAGE_CURSOR, + INITIAL_NEXT_PAGE_CURSOR, + JOBS_PER_PAGE, + INITIAL_LAST_PAGE_SIZE, +} from '../constants'; +import ArtifactsTableRowDetails from './artifacts_table_row_details.vue'; + +const INITIAL_PAGINATION_STATE = { + currentPage: INITIAL_CURRENT_PAGE, + prevPageCursor: INITIAL_PREVIOUS_PAGE_CURSOR, + nextPageCursor: INITIAL_NEXT_PAGE_CURSOR, + firstPageSize: JOBS_PER_PAGE, + lastPageSize: INITIAL_LAST_PAGE_SIZE, +}; + +export default { + name: 'JobArtifactsTable', + components: { + GlLoadingIcon, + GlTable, + GlLink, + GlButtonGroup, + GlButton, + GlBadge, + GlIcon, + GlPagination, + CiIcon, + TimeAgo, + ArtifactsTableRowDetails, + }, + inject: ['projectPath'], + apollo: { + jobArtifacts: { + query: getJobArtifactsQuery, + variables() { + return this.queryVariables; + }, + update({ project: { jobs: { nodes = [], pageInfo = {}, count = 0 } = {} } }) { + return { + nodes: nodes.map(mapArchivesToJobNodes).map(mapBooleansToJobNodes), + count, + pageInfo, + }; + }, + error() { + createAlert({ + message: I18N_FETCH_ERROR, + }); + }, + }, + }, + data() { + return { + jobArtifacts: { + nodes: [], + count: 0, + pageInfo: {}, + }, + pagination: INITIAL_PAGINATION_STATE, + }; + }, + computed: { + queryVariables() { + return { + projectPath: this.projectPath, + firstPageSize: this.pagination.firstPageSize, + lastPageSize: this.pagination.lastPageSize, + prevPageCursor: this.pagination.prevPageCursor, + nextPageCursor: this.pagination.nextPageCursor, + }; + }, + showPagination() { + return this.jobArtifacts.count > JOBS_PER_PAGE; + }, + prevPage() { + return Number(this.jobArtifacts.pageInfo.hasPreviousPage); + }, + nextPage() { + return Number(this.jobArtifacts.pageInfo.hasNextPage); + }, + }, + methods: { + refetchArtifacts() { + this.$apollo.queries.jobArtifacts.refetch(); + }, + artifactsSize(item) { + return totalArtifactsSizeForJob(item); + }, + pipelineId(item) { + const id = getIdFromGraphQLId(item.pipeline.id); + return `#${id}`; + }, + handlePageChange(page) { + const { startCursor, endCursor } = this.jobArtifacts.pageInfo; + + if (page > this.pagination.currentPage) { + this.pagination = { + ...INITIAL_PAGINATION_STATE, + nextPageCursor: endCursor, + currentPage: page, + }; + } else { + this.pagination = { + lastPageSize: JOBS_PER_PAGE, + firstPageSize: null, + prevPageCursor: startCursor, + currentPage: page, + }; + } + }, + handleRowToggle(toggleDetails, hasArtifacts) { + if (!hasArtifacts) return; + toggleDetails(); + }, + }, + fields: [ + { + key: 'artifacts', + label: I18N_ARTIFACTS, + thClass: 'gl-w-quarter', + }, + { + key: 'job', + label: I18N_JOB, + thClass: 'gl-w-35p', + }, + { + key: 'size', + label: I18N_SIZE, + thClass: 'gl-w-15p gl-text-right', + tdClass: 'gl-text-right', + }, + { + key: 'created', + label: I18N_CREATED, + thClass: 'gl-w-eighth gl-text-center', + tdClass: 'gl-text-center', + }, + { + key: 'actions', + label: '', + thClass: 'gl-w-eighth', + tdClass: 'gl-text-right', + }, + ], + STATUS_BADGE_VARIANTS, + i18n: { + download: I18N_DOWNLOAD, + browse: I18N_BROWSE, + delete: I18N_DELETE, + expired: I18N_EXPIRED, + destroyArtifactError: I18N_DESTROY_ERROR, + fetchArtifactsError: I18N_FETCH_ERROR, + artifactsLabel: I18N_ARTIFACTS, + jobLabel: I18N_JOB, + sizeLabel: I18N_SIZE, + createdLabel: I18N_CREATED, + artifactsCount: I18N_ARTIFACTS_COUNT, + }, +}; +</script> +<template> + <div> + <gl-table + :items="jobArtifacts.nodes" + :fields="$options.fields" + :busy="$apollo.queries.jobArtifacts.loading" + stacked="sm" + details-td-class="gl-bg-gray-10! gl-p-0! gl-overflow-auto" + > + <template #table-busy> + <gl-loading-icon size="lg" /> + </template> + <template + #cell(artifacts)="{ item: { artifacts, hasArtifacts }, toggleDetails, detailsShowing }" + > + <span + :class="{ 'gl-cursor-pointer': hasArtifacts }" + data-testid="job-artifacts-count" + @click="handleRowToggle(toggleDetails, hasArtifacts)" + > + <gl-icon + v-if="hasArtifacts" + :name="detailsShowing ? 'chevron-down' : 'chevron-right'" + class="gl-mr-2" + /> + <strong> + {{ $options.i18n.artifactsCount(artifacts.nodes.length) }} + </strong> + </span> + </template> + <template #cell(job)="{ item }"> + <span class="gl-display-inline-flex gl-align-items-center gl-w-full gl-mb-4"> + <span data-testid="job-artifacts-job-status"> + <ci-icon v-if="item.succeeded" :status="item.detailedStatus" class="gl-mr-3" /> + <gl-badge + v-else + :icon="item.detailedStatus.icon" + :variant="$options.STATUS_BADGE_VARIANTS[item.detailedStatus.group]" + class="gl-mr-3" + > + {{ item.detailedStatus.label }} + </gl-badge> + </span> + <gl-link :href="item.webPath" class="gl-font-weight-bold"> + {{ item.name }} + </gl-link> + </span> + <span class="gl-display-inline-flex"> + <gl-icon name="pipeline" class="gl-mr-2" /> + <gl-link + :href="item.pipeline.path" + class="gl-text-black-normal gl-text-decoration-underline gl-mr-4" + > + {{ pipelineId(item) }} + </gl-link> + <gl-icon name="branch" class="gl-mr-2" /> + <gl-link + :href="item.refPath" + class="gl-text-black-normal gl-text-decoration-underline gl-mr-4" + > + {{ item.refName }} + </gl-link> + <gl-icon name="commit" class="gl-mr-2" /> + <gl-link + :href="item.commitPath" + class="gl-text-black-normal gl-text-decoration-underline gl-mr-4" + > + {{ item.shortSha }} + </gl-link> + </span> + </template> + <template #cell(size)="{ item }"> + <span data-testid="job-artifacts-size">{{ artifactsSize(item) }}</span> + </template> + <template #cell(created)="{ item }"> + <time-ago data-testid="job-artifacts-created" :time="item.finishedAt" /> + </template> + <template #cell(actions)="{ item }"> + <gl-button-group> + <gl-button + icon="download" + :disabled="!item.archive.downloadPath" + :href="item.archive.downloadPath" + :title="$options.i18n.download" + :aria-label="$options.i18n.download" + data-testid="job-artifacts-download-button" + /> + <gl-button + icon="folder-open" + :title="$options.i18n.browse" + :aria-label="$options.i18n.browse" + data-testid="job-artifacts-browse-button" + disabled + /> + <gl-button + icon="remove" + :title="$options.i18n.delete" + :aria-label="$options.i18n.delete" + data-testid="job-artifacts-delete-button" + disabled + /> + </gl-button-group> + </template> + <template #row-details="{ item: { artifacts } }"> + <artifacts-table-row-details + :artifacts="artifacts" + :query-variables="queryVariables" + @refetch="refetchArtifacts" + /> + </template> + </gl-table> + <gl-pagination + v-if="showPagination" + :value="pagination.currentPage" + :prev-page="prevPage" + :next-page="nextPage" + align="center" + class="gl-mt-3" + @input="handlePageChange" + /> + </div> +</template> diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/artifacts/constants.js new file mode 100644 index 00000000000..9ed0821ac2d --- /dev/null +++ b/app/assets/javascripts/artifacts/constants.js @@ -0,0 +1,47 @@ +import { __, s__, n__ } from '~/locale'; + +export const JOB_STATUS_GROUP_SUCCESS = 'success'; + +export const STATUS_BADGE_VARIANTS = { + success: 'success', + passed: 'success', + error: 'danger', + failed: 'danger', + pending: 'warning', + 'waiting-for-resource': 'warning', + 'failed-with-warnings': 'warning', + 'success-with-warnings': 'warning', + running: 'info', + canceled: 'neutral', + disabled: 'neutral', + scheduled: 'neutral', + manual: 'neutral', + notification: 'muted', + preparing: 'muted', + created: 'muted', + skipped: 'muted', + notfound: 'muted', +}; + +export const I18N_DOWNLOAD = __('Download'); +export const I18N_BROWSE = s__('Artifacts|Browse'); +export const I18N_DELETE = __('Delete'); +export const I18N_EXPIRED = __('Expired'); +export const I18N_DESTROY_ERROR = s__('Artifacts|An error occurred while deleting the artifact'); +export const I18N_FETCH_ERROR = s__('Artifacts|An error occurred while retrieving job artifacts'); +export const I18N_ARTIFACTS = __('Artifacts'); +export const I18N_JOB = __('Job'); +export const I18N_SIZE = __('Size'); +export const I18N_CREATED = __('Created'); +export const I18N_ARTIFACTS_COUNT = (count) => n__('%d file', '%d files', count); + +export const INITIAL_CURRENT_PAGE = 1; +export const INITIAL_PREVIOUS_PAGE_CURSOR = ''; +export const INITIAL_NEXT_PAGE_CURSOR = ''; +export const JOBS_PER_PAGE = 20; +export const INITIAL_LAST_PAGE_SIZE = null; + +export const ARCHIVE_FILE_TYPE = 'ARCHIVE'; + +export const ARTIFACT_ROW_HEIGHT = 56; +export const ARTIFACTS_SHOWN_WITHOUT_SCROLLING = 4; diff --git a/app/assets/javascripts/artifacts/graphql/cache_update.js b/app/assets/javascripts/artifacts/graphql/cache_update.js new file mode 100644 index 00000000000..c620e03c80d --- /dev/null +++ b/app/assets/javascripts/artifacts/graphql/cache_update.js @@ -0,0 +1,30 @@ +import produce from 'immer'; + +export const hasErrors = ({ errors = [] }) => errors?.length; + +export function removeArtifactFromStore(store, deletedArtifactId, query, variables) { + if (!hasErrors(deletedArtifactId)) { + const sourceData = store.readQuery({ + query, + variables, + }); + + const data = produce(sourceData, (draftData) => { + draftData.project.jobs.nodes = draftData.project.jobs.nodes.map((jobNode) => { + return { + ...jobNode, + artifacts: { + ...jobNode.artifacts, + nodes: jobNode.artifacts.nodes.filter(({ id }) => id !== deletedArtifactId), + }, + }; + }); + }); + + store.writeQuery({ + query, + variables, + data, + }); + } +} diff --git a/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql b/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql new file mode 100644 index 00000000000..529224b47e6 --- /dev/null +++ b/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql @@ -0,0 +1,7 @@ +mutation destroyArtifact($id: CiJobArtifactID!) { + artifactDestroy(input: { id: $id }) { + artifact { + id + } + } +} diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql new file mode 100644 index 00000000000..685196e28d5 --- /dev/null +++ b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql @@ -0,0 +1,56 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getJobArtifacts( + $projectPath: ID! + $firstPageSize: Int + $lastPageSize: Int + $prevPageCursor: String = "" + $nextPageCursor: String = "" +) { + project(fullPath: $projectPath) { + id + jobs( + statuses: [SUCCESS, FAILED] + first: $firstPageSize + last: $lastPageSize + after: $nextPageCursor + before: $prevPageCursor + ) { + count + nodes { + id + name + webPath + detailedStatus { + id + group + icon + label + } + pipeline { + id + iid + path + } + refName + refPath + shortSha + commitPath + finishedAt + artifacts { + nodes { + id + name + fileType + downloadPath + size + expireAt + } + } + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/artifacts/index.js b/app/assets/javascripts/artifacts/index.js new file mode 100644 index 00000000000..b5146e0f0e9 --- /dev/null +++ b/app/assets/javascripts/artifacts/index.js @@ -0,0 +1,29 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import JobArtifactsTable from './components/job_artifacts_table.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const initArtifactsTable = () => { + const el = document.querySelector('#js-artifact-management'); + + if (!el) { + return false; + } + + const { projectPath } = el.dataset; + + return new Vue({ + el, + apolloProvider, + provide: { + projectPath, + }, + render: (createElement) => createElement(JobArtifactsTable), + }); +}; diff --git a/app/assets/javascripts/artifacts/utils.js b/app/assets/javascripts/artifacts/utils.js new file mode 100644 index 00000000000..ebcf0af8d2a --- /dev/null +++ b/app/assets/javascripts/artifacts/utils.js @@ -0,0 +1,26 @@ +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { ARCHIVE_FILE_TYPE, JOB_STATUS_GROUP_SUCCESS } from './constants'; + +export const totalArtifactsSizeForJob = (job) => + numberToHumanSize( + job.artifacts.nodes + .map((artifact) => artifact.size) + .reduce((total, artifact) => total + artifact, 0), + ); + +export const mapArchivesToJobNodes = (jobNode) => { + return { + archive: { + ...jobNode.artifacts.nodes.find((artifact) => artifact.fileType === ARCHIVE_FILE_TYPE), + }, + ...jobNode, + }; +}; + +export const mapBooleansToJobNodes = (jobNode) => { + return { + succeeded: jobNode.detailedStatus.group === JOB_STATUS_GROUP_SUCCESS, + hasArtifacts: jobNode.artifacts.nodes.length > 0, + ...jobNode, + }; +}; diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js index 7732badde34..e0749b63021 100644 --- a/app/assets/javascripts/diffs/components/diff_row_utils.js +++ b/app/assets/javascripts/diffs/components/diff_row_utils.js @@ -57,21 +57,32 @@ export const classNameMapCell = ({ line, hll, isLoggedIn, isHover }) => { export const addCommentTooltip = (line) => { let tooltip; - if (!line) return tooltip; + if (!line) { + return tooltip; + } tooltip = __('Add a comment to this line or drag for multiple lines'); - const brokenSymlinks = line.commentsDisabled; - if (brokenSymlinks) { - if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) { + if (!line.problems) { + return tooltip; + } + + const { brokenSymlink, brokenLineCode, fileOnlyMoved } = line.problems; + + if (brokenSymlink) { + if (brokenSymlink.wasSymbolic || brokenSymlink.isSymbolic) { tooltip = __( 'Commenting on symbolic links that replace or are replaced by files is currently not supported.', ); - } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) { + } else if (brokenSymlink.wasReal || brokenSymlink.isReal) { tooltip = __( 'Commenting on files that replace or are replaced by symbolic links is currently not supported.', ); } + } else if (fileOnlyMoved) { + tooltip = __('Commenting on files that are only moved or renamed is currently not supported'); + } else if (brokenLineCode) { + tooltip = __('Commenting on this line is currently not supported'); } return tooltip; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index cf86ebea4a9..0519ca3d715 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -324,15 +324,24 @@ function cleanRichText(text) { } function prepareLine(line, file) { + const problems = { + brokenSymlink: file.brokenSymlink, + brokenLineCode: !line.line_code, + fileOnlyMoved: file.renamed_file && file.added_lines === 0 && file.removed_lines === 0, + }; + if (!line.alreadyPrepared) { Object.assign(line, { - commentsDisabled: file.brokenSymlink, + commentsDisabled: Boolean( + problems.brokenSymlink || problems.fileOnlyMoved || problems.brokenLineCode, + ), rich_text: cleanRichText(line.rich_text), discussionsExpanded: true, discussions: [], hasForm: false, text: undefined, alreadyPrepared: true, + problems, }); } } diff --git a/app/assets/javascripts/pages/projects/artifacts/index.js b/app/assets/javascripts/pages/projects/artifacts/index.js new file mode 100644 index 00000000000..4aa9b225790 --- /dev/null +++ b/app/assets/javascripts/pages/projects/artifacts/index.js @@ -0,0 +1,3 @@ +import { initArtifactsTable } from '~/artifacts/index'; + +initArtifactsTable(); diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue index ad061dd2e6b..5f1350690eb 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -9,6 +9,8 @@ import eventHub from '~/sidebar/event_hub'; import Store from '~/sidebar/stores/sidebar_store'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getMergeRequestReviewersQuery from '~/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql'; +import mergeRequestReviewersUpdatedSubscription from '~/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import ReviewerTitle from './reviewer_title.vue'; import Reviewers from './reviewers.vue'; @@ -66,6 +68,36 @@ export default { error() { createAlert({ message: __('An error occurred while fetching reviewers.') }); }, + subscribeToMore: { + document() { + return mergeRequestReviewersUpdatedSubscription; + }, + variables() { + return { + issuableId: this.issuable?.id, + }; + }, + skip() { + return !this.issuable?.id || !this.isRealtimeEnabled; + }, + updateQuery( + _, + { + subscriptionData: { + data: { mergeRequestReviewersUpdated }, + }, + }, + ) { + if (mergeRequestReviewersUpdated) { + this.store.setReviewersFromRealtime( + mergeRequestReviewersUpdated.reviewers.nodes.map((r) => ({ + ...r, + id: getIdFromGraphQLId(r.id), + })), + ); + } + }, + }, }, }, data() { @@ -87,6 +119,9 @@ export default { canUpdate() { return this.issuable.userPermissions?.adminMergeRequest || false; }, + isRealtimeEnabled() { + return this.glFeatures.realtimeReviewers; + }, }, created() { this.store = new Store(); diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers_inputs.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers_inputs.vue new file mode 100644 index 00000000000..a135dfdca72 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers_inputs.vue @@ -0,0 +1,34 @@ +<script> +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { state } from './sidebar_reviewers.vue'; + +export default { + data() { + return state; + }, + computed: { + reviewers() { + return this.issuable?.reviewers?.nodes || []; + }, + }, + methods: { + getIdFromGraphQLId, + }, +}; +</script> + +<template> + <div> + <input + v-for="reviewer in reviewers" + :key="reviewer.id" + type="hidden" + name="merge_request[reviewer_ids][]" + :value="getIdFromGraphQLId(reviewer.id)" + :data-avatar-url="reviewer.avatarUrl" + :data-name="reviewer.name" + :data-username="reviewer.username" + :data-can-merge="reviewer.mergeRequestInteraction.canMerge" + /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 9b5bad710dd..1d9ddfe6bfd 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -33,6 +33,7 @@ import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue'; import SidebarEscalationStatus from './components/incidents/sidebar_escalation_status.vue'; import IssuableLockForm from './components/lock/issuable_lock_form.vue'; import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; +import SidebarReviewersInputs from './components/reviewers/sidebar_reviewers_inputs.vue'; import SidebarSeverity from './components/severity/sidebar_severity.vue'; import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; @@ -210,6 +211,18 @@ function mountReviewersComponent(mediator) { }), }); + const reviewersInputEl = document.querySelector('.js-reviewers-inputs'); + + if (reviewersInputEl) { + // eslint-disable-next-line no-new + new Vue({ + el: reviewersInputEl, + render(createElement) { + return createElement(SidebarReviewersInputs); + }, + }); + } + const reviewerDropdown = document.querySelector('.js-sidebar-reviewer-dropdown'); if (reviewerDropdown) { diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index e2581a8f30e..baf906bb96c 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -138,6 +138,10 @@ export default class SidebarStore { this.assignees = data; } + setReviewersFromRealtime(data) { + this.reviewers = data; + } + setAutocompleteProjects(projects) { this.autocompleteProjects = projects; } diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index bd425bdc2a8..8fc5c354802 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -431,6 +431,10 @@ function UsersSelect(currentUser, els, options = {}) { hidden() { if ($dropdown.hasClass('js-multiselect')) { if ($dropdown.hasClass(elsClassName)) { + if (window.gon?.features?.realtimeReviewers) { + $dropdown.data('deprecatedJQueryDropdown').clearMenu(); + $dropdown.closest('.selectbox').children('input[type="hidden"]').remove(); + } emitSidebarEvent('sidebar.saveReviewers'); } else { emitSidebarEvent('sidebar.saveAssignees'); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql new file mode 100644 index 00000000000..a1b16b378b3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql @@ -0,0 +1,22 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" + +subscription mergeRequestReviewersUpdated($issuableId: IssuableID!) { + mergeRequestReviewersUpdated(issuableId: $issuableId) { + ... on MergeRequest { + id + reviewers { + nodes { + ...User + ...UserAvailability + mergeRequestInteraction { + canMerge + canUpdate + approved + reviewed + } + } + } + } + } +} diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 438b7b1afa6..955dcdc1c0f 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -968,7 +968,7 @@ $system-note-svg-size: 1rem; height: 12px; } - &:hover, + &:hover:not([disabled]), &.inverted { &::before { background-color: $white; diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 0e4592259d8..89d362c88a4 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -66,11 +66,10 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController end def load_projects(finder_params) - @total_user_projects_count = ProjectsFinder.new(params: { non_public: true, without_deleted: true, not_aimed_for_deletion: true }, current_user: current_user).execute - @total_starred_projects_count = ProjectsFinder.new(params: { starred: true, without_deleted: true, not_aimed_for_deletion: true }, current_user: current_user).execute + @total_user_projects_count = ProjectsFinder.new(params: { non_public: true, not_aimed_for_deletion: true }, current_user: current_user).execute + @total_starred_projects_count = ProjectsFinder.new(params: { starred: true, not_aimed_for_deletion: true }, current_user: current_user).execute finder_params[:use_cte] = true if use_cte_for_finder? - finder_params[:without_deleted] = true projects = ProjectsFinder.new(params: finder_params, current_user: current_user).execute @@ -93,7 +92,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController def load_events projects = ProjectsFinder - .new(params: params.merge(non_public: true, without_deleted: true), current_user: current_user) + .new(params: params.merge(non_public: true, not_aimed_for_deletion: true), current_user: current_user) .execute @events = EventCollection diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 9c139733248..36c050be76b 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -44,6 +44,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:paginated_mr_discussions, project) push_frontend_feature_flag(:mr_review_submit_comment, project) push_frontend_feature_flag(:mr_experience_survey, project) + push_frontend_feature_flag(:realtime_reviewers, project) end before_action do diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index c35aa8e4346..0f03333d793 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -274,8 +274,6 @@ class UsersController < ApplicationController def finder_params { - # don't display projects pending deletion - without_deleted: true, # don't display projects marked for deletion not_aimed_for_deletion: true } diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 6bfe730ebc9..126687ae41f 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -27,7 +27,6 @@ # last_activity_after: datetime # last_activity_before: datetime # repository_storage: string -# without_deleted: boolean # not_aimed_for_deletion: boolean # class ProjectsFinder < UnionFinder @@ -76,6 +75,7 @@ class ProjectsFinder < UnionFinder # EE would override this to add more filters def filter_projects(collection) + collection = collection.without_deleted collection = by_ids(collection) collection = by_personal(collection) collection = by_starred(collection) @@ -86,7 +86,6 @@ class ProjectsFinder < UnionFinder collection = by_search(collection) collection = by_archived(collection) collection = by_custom_attributes(collection) - collection = by_deleted_status(collection) collection = by_not_aimed_for_deletion(collection) collection = by_last_activity_after(collection) collection = by_last_activity_before(collection) @@ -212,10 +211,6 @@ class ProjectsFinder < UnionFinder items.optionally_search(params[:search], include_namespace: params[:search_namespaces].present?) end - def by_deleted_status(items) - params[:without_deleted].present? ? items.without_deleted : items - end - def by_not_aimed_for_deletion(items) params[:not_aimed_for_deletion].present? ? items.not_aimed_for_deletion : items end diff --git a/app/graphql/resolvers/concerns/project_search_arguments.rb b/app/graphql/resolvers/concerns/project_search_arguments.rb index 7e03963f412..faf3b85fc14 100644 --- a/app/graphql/resolvers/concerns/project_search_arguments.rb +++ b/app/graphql/resolvers/concerns/project_search_arguments.rb @@ -25,7 +25,6 @@ module ProjectSearchArguments def project_finder_params(params) { - without_deleted: true, non_public: params[:membership], search: params[:search], search_namespaces: params[:search_namespaces], diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index ec56f4a32af..7e1ebd1eba3 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -7,7 +7,6 @@ module ProtectedRef belongs_to :project, touch: true validates :name, presence: true - validates :project, presence: true delegate :matching, :matches?, :wildcard?, to: :ref_matcher diff --git a/app/models/group.rb b/app/models/group.rb index 38623d91705..708fe83a7e5 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -119,6 +119,8 @@ class Group < Namespace has_many :group_callouts, class_name: 'Users::GroupCallout', foreign_key: :group_id + has_many :protected_branches, inverse_of: :group + has_one :group_feature, inverse_of: :group, class_name: 'Groups::FeatureSetting' delegate :prevent_sharing_groups_outside_hierarchy, :new_user_signups_cap, :setup_for_company, :jobs_to_be_done, to: :namespace_settings diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index 8b43e5e5d63..3623b3be20d 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -31,6 +31,7 @@ class ProjectAuthorization < ApplicationRecord def self.insert_all_in_batches(attributes, per_batch = BATCH_SIZE) add_delay = add_delay_between_batches?(entire_size: attributes.size, batch_size: per_batch) + log_details(entire_size: attributes.size) if add_delay attributes.each_slice(per_batch) do |attributes_batch| insert_all(attributes_batch) @@ -40,6 +41,7 @@ class ProjectAuthorization < ApplicationRecord def self.delete_all_in_batches_for_project(project:, user_ids:, per_batch: BATCH_SIZE) add_delay = add_delay_between_batches?(entire_size: user_ids.size, batch_size: per_batch) + log_details(entire_size: user_ids.size) if add_delay user_ids.each_slice(per_batch) do |user_ids_batch| project.project_authorizations.where(user_id: user_ids_batch).delete_all @@ -49,6 +51,7 @@ class ProjectAuthorization < ApplicationRecord def self.delete_all_in_batches_for_user(user:, project_ids:, per_batch: BATCH_SIZE) add_delay = add_delay_between_batches?(entire_size: project_ids.size, batch_size: per_batch) + log_details(entire_size: project_ids.size) if add_delay project_ids.each_slice(per_batch) do |project_ids_batch| user.project_authorizations.where(project_id: project_ids_batch).delete_all @@ -65,6 +68,13 @@ class ProjectAuthorization < ApplicationRecord Feature.enabled?(:enable_minor_delay_during_project_authorizations_refresh) end + private_class_method def self.log_details(entire_size:) + Gitlab::AppLogger.info( + entire_size: entire_size, + message: 'Project authorizations refresh performed with delay' + ) + end + private_class_method def self.perform_delay sleep(SLEEP_DELAY) end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index dfd5c315f6e..80967c1b072 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -4,6 +4,10 @@ class ProtectedBranch < ApplicationRecord include ProtectedRef include Gitlab::SQL::Pattern + belongs_to :group, foreign_key: :namespace_id, touch: true, inverse_of: :protected_branches + + validate :validate_either_project_or_top_group + scope :requiring_code_owner_approval, -> { where(code_owner_approval_required: true) } @@ -99,6 +103,18 @@ class ProtectedBranch < ApplicationRecord def default_branch? name == project.default_branch end + + private + + def validate_either_project_or_top_group + if !project && !group + errors.add(:base, _('must be associated with a Group or a Project')) + elsif project && group + errors.add(:base, _('cannot be associated with both a Group and a Project')) + elsif group && group.root_ancestor != group + errors.add(:base, _('cannot be associated with a subgroup')) + end + end end ProtectedBranch.prepend_mod_with('ProtectedBranch') diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb index 5b2467daddc..e89cb3aabc7 100644 --- a/app/models/protected_tag.rb +++ b/app/models/protected_tag.rb @@ -4,6 +4,7 @@ class ProtectedTag < ApplicationRecord include ProtectedRef validates :name, uniqueness: { scope: :project_id } + validates :project, presence: true protected_ref_access_levels :create diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index c091a2180c5..0f7b10f822d 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -69,4 +69,6 @@ = render 'admin/application_settings/invitation_flow_enforcement', form: f = render 'admin/application_settings/user_restrictions', form: f = render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f + -# This is added for Jihu edition which should not be deleted without notifying Jihu + = render_if_exists 'admin/application_settings/password_expiration_setting', form: f = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } diff --git a/app/views/projects/artifacts/_artifact.html.haml b/app/views/projects/artifacts/_artifact.html.haml deleted file mode 100644 index 9e548582396..00000000000 --- a/app/views/projects/artifacts/_artifact.html.haml +++ /dev/null @@ -1,61 +0,0 @@ -.gl-responsive-table-row.px-md-3 - .table-section.section-25.section-wrap.commit - .table-mobile-header{ role: 'rowheader' }= _('Job') - .table-mobile-content - .branch-commit.cgray - - if can?(current_user, :read_build, @project) - = link_to project_job_path(@project, artifact.job) do - %span.build-link ##{artifact.job_id} - - else - %span.build-link ##{artifact.job_id} - - - if artifact.job.ref - .icon-container.gl-display-inline-block{ "aria-label" => artifact.job.tag? ? _('Tag') : _('Branch') } - = artifact.job.tag? ? sprite_icon('tag', css_class: 'sprite') : sprite_icon('branch', css_class: 'sprite') - = link_to artifact.job.ref, project_ref_path(@project, artifact.job.ref), class: 'ref-name' - - else - .light= _('none') - .icon-container.commit-icon{ "aria-label" => _('Commit') } - = sprite_icon('commit') - - - if artifact.job.sha - = link_to artifact.job.short_sha, project_commit_path(@project, artifact.job.sha), class: 'commit-sha mr-0' - - .table-section.section-15.section-wrap - .table-mobile-header{ role: 'rowheader' }= _('Name') - .table-mobile-content - = artifact.job.name - - .table-section.section-20 - .table-mobile-header{ role: 'rowheader' }= _('Creation date') - .table-mobile-content - %p.finished-at - = sprite_icon("calendar") - %span= time_ago_with_tooltip(artifact.created_at) - - .table-section.section-20 - .table-mobile-header{ role: 'rowheader' }= _('Expiration date') - .table-mobile-content - - if artifact.expire_at - %p.finished-at - = sprite_icon("calendar") - %span= time_ago_with_tooltip(artifact.expire_at) - - .table-section.section-10 - .table-mobile-header{ role: 'rowheader' }= _('Size') - .table-mobile-content - = number_to_human_size(artifact.size, precision: 2) - - .table-section.table-button-footer.section-10 - .table-action-buttons - .btn-group - - if can?(current_user, :read_build, @project) - = link_to download_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', download: '', title: _('Download artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Download artifacts') }, class: 'gl-button btn btn-default btn-icon has-tooltip' do - = sprite_icon('download', css_class: 'gl-icon') - - = link_to browse_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', title: _('Browse artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Browse artifacts') }, class: 'gl-button btn btn-default btn-icon has-tooltip' do - = sprite_icon('folder-open', css_class: 'gl-icon') - - - if can?(current_user, :destroy_artifacts, @project) - = link_to project_artifact_path(@project, artifact), data: { placement: 'top', container: 'body', confirm: _('Are you sure you want to delete these artifacts?'), confirm_btn_variant: "danger" }, method: :delete, title: _('Delete artifacts'), ref: 'tooltip', aria: { label: _('Delete artifacts') }, class: 'gl-button btn btn-danger btn-icon has-tooltip' do - = sprite_icon('remove', css_class: 'gl-icon') diff --git a/app/views/projects/artifacts/_table.html.haml b/app/views/projects/artifacts/_table.html.haml deleted file mode 100644 index 1963449d704..00000000000 --- a/app/views/projects/artifacts/_table.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -- if artifacts.blank? - .nothing-here-block= _('No jobs to show') -- else - .table-holder - .ci-table - .gl-responsive-table-row.table-row-header.px-md-3{ role: 'row' } - .table-section.section-25{ role: 'rowheader' }= _('Job') - .table-section.section-15{ role: 'rowheader' }= _('Name') - .table-section.section-20{ role: 'rowheader' }= _('Creation date') - .table-section.section-20{ role: 'rowheader' }= _('Expiration date') - .table-section.section-10{ role: 'rowheader' }= _('Size') - .table-section.section-10{ role: 'rowheader' } - - = render partial: 'artifact', collection: artifacts, as: :artifact - - = paginate artifacts, theme: "gitlab", total_pages: @total_pages diff --git a/app/views/projects/artifacts/index.html.haml b/app/views/projects/artifacts/index.html.haml index 1ab3e8e67d8..9cbc149177c 100644 --- a/app/views/projects/artifacts/index.html.haml +++ b/app/views/projects/artifacts/index.html.haml @@ -1,10 +1,9 @@ -- @no_container = true - page_title _('Artifacts') %div{ class: container_class } - .top-area.py-3 - .align-self-center - = _('Total artifacts size: %{total_size}') % { total_size: number_to_human_size(@total_size, precicion: 2) } - - .content-list.builds-content-list - = render "table", artifacts: @artifacts, project: @project + %h1.page-title.gl-font-size-h-display.gl-mb-0 + = s_('Artifacts|Artifacts') + .gl-mb-6 + %strong= s_('Artifacts|Total artifacts size') + = number_to_human_size(@total_size, precicion: 2) + #js-artifact-management{ data: { "project-path" => @project.full_path } } diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml index 771db8af6a8..c764cfff2fd 100644 --- a/app/views/shared/issuable/_sidebar_reviewers.html.haml +++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml @@ -6,11 +6,7 @@ = gl_loading_icon(inline: true) .selectbox.hide-collapsed - - if reviewers.none? - = hidden_field_tag "#{issuable_type}[reviewer_ids][]", 0, id: nil - - else - - reviewers.each do |reviewer| - = hidden_field_tag "#{issuable_type}[reviewer_ids][]", reviewer.id, id: nil, data: reviewer_sidebar_data(reviewer, merge_request: @merge_request) + .js-reviewers-inputs - options = { toggle_class: 'js-reviewer-search js-author-search', title: _('Request review from'), @@ -32,8 +28,7 @@ - dropdown_options = reviewers_dropdown_options(issuable_type) - title = dropdown_options[:title] - options[:toggle_class] += ' js-multiselect js-save-user-data' - - data = { field_name: "#{issuable_type}[reviewer_ids][]" } - - data[:multi_select] = true + - data = { multi_select: true } - data['dropdown-title'] = title - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header'] - data[:suggested_reviewers_header] = dropdown_options[:data][:suggested_reviewers_header] |