diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-06-14 21:08:38 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-06-14 21:08:38 +0300 |
commit | 14160fad80415337f8c08755af53ee994b4a7518 (patch) | |
tree | bfe1bf6bad8cda3e3bbf905c9d8ac742420dd8a3 /app | |
parent | 7a33080fff9a735cbe77968d67b13ffa92c0ffae (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
46 files changed, 618 insertions, 322 deletions
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue index b3befcf1cbc..c2e4c234d2b 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue @@ -3,6 +3,7 @@ import { GlDrawer } from '@gitlab/ui'; import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; import { __ } from '~/locale'; +import { EDITOR_APP_DRAWER_NONE } from '~/ci/pipeline_editor/constants'; import FirstPipelineCard from './cards/first_pipeline_card.vue'; import GettingStartedCard from './cards/getting_started_card.vue'; import PipelineConfigReferenceCard from './cards/pipeline_config_reference_card.vue'; @@ -41,7 +42,7 @@ export default { }, methods: { closeDrawer() { - this.$emit('close-drawer'); + this.$emit('switch-drawer', EDITOR_APP_DRAWER_NONE); }, }, }; diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue index 069704b7bf4..6ba8884f9a6 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue @@ -3,7 +3,14 @@ import { GlButton } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { pipelineEditorTrackingOptions, TEMPLATE_REPOSITORY_URL } from '../../constants'; +import { + EDITOR_APP_DRAWER_AI_ASSISTANT, + EDITOR_APP_DRAWER_HELP, + EDITOR_APP_DRAWER_JOB_ASSISTANT, + EDITOR_APP_DRAWER_NONE, + pipelineEditorTrackingOptions, + TEMPLATE_REPOSITORY_URL, +} from '../../constants'; export default { i18n: { @@ -19,7 +26,7 @@ export default { mixins: [glFeatureFlagMixin(), Tracking.mixin()], inject: ['aiChatAvailable'], props: { - showDrawer: { + showHelpDrawer: { type: Boolean, required: true, }, @@ -38,22 +45,24 @@ export default { }, }, methods: { - toggleDrawer() { - if (this.showDrawer) { - this.$emit('close-drawer'); + toggleHelpDrawer() { + if (this.showHelpDrawer) { + this.$emit('switch-drawer', EDITOR_APP_DRAWER_NONE); } else { - this.$emit('open-drawer'); + this.$emit('switch-drawer', EDITOR_APP_DRAWER_HELP); this.trackHelpDrawerClick(); } }, toggleJobAssistantDrawer() { this.$emit( - this.showJobAssistantDrawer ? 'close-job-assistant-drawer' : 'open-job-assistant-drawer', + 'switch-drawer', + this.showJobAssistantDrawer ? EDITOR_APP_DRAWER_NONE : EDITOR_APP_DRAWER_JOB_ASSISTANT, ); }, toggleAiAssistantDrawer() { this.$emit( - this.showAiAssistantDrawer ? 'close-ai-assistant-drawer' : 'open-ai-assistant-drawer', + 'switch-drawer', + this.showAiAssistantDrawer ? EDITOR_APP_DRAWER_NONE : EDITOR_APP_DRAWER_AI_ASSISTANT, ); }, trackHelpDrawerClick() { @@ -90,7 +99,7 @@ export default { size="small" data-testid="drawer-toggle" data-qa-selector="drawer_toggle" - @click="toggleDrawer" + @click="toggleHelpDrawer" > {{ $options.i18n.help }} </gl-button> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue index 653d01551c4..794763e0cd8 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue @@ -96,6 +96,7 @@ export default { category="tertiary" icon="remove" :data-testid="entry.generateDeleteButtonDataTestId(index)" + :aria-label="entry.generateDeleteButtonDataTestId(index)" @click="deleteStringArrayItem(`${entry.key}[${index}]`)" /> </div> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue index fea9df16038..0b12d0aedd6 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue @@ -74,6 +74,7 @@ export default { category="tertiary" icon="remove" :data-testid="`delete-job-service-button-${index}`" + :aria-label="`delete-job-service-button-${index}`" @click="deleteService(index)" /> <gl-form-group :label="$options.i18n.SERVICE_NAME"> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue index 9eeaac32e95..1a58a112e50 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue @@ -5,6 +5,7 @@ import { get, omit, toPath } from 'lodash'; import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub'; +import { EDITOR_APP_DRAWER_NONE } from '~/ci/pipeline_editor/constants'; import getRunnerTags from '../../graphql/queries/runner_tags.query.graphql'; import { JOB_TEMPLATE, JOB_RULES_WHEN, i18n } from './constants'; import { removeEmptyObj, trimFields, validateEmptyValue, validateStartIn } from './utils'; @@ -101,7 +102,7 @@ export default { methods: { closeDrawer() { this.clearJob(); - this.$emit('close-job-assistant-drawer'); + this.$emit('switch-drawer', EDITOR_APP_DRAWER_NONE); }, addCiConfig() { this.validateJob(); diff --git a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue index f8c8be76bd5..a954615ca8a 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue @@ -87,19 +87,19 @@ export default { type: String, required: true, }, - isNewCiConfigFile: { + showHelpDrawer: { type: Boolean, required: true, }, - showDrawer: { + showJobAssistantDrawer: { type: Boolean, required: true, }, - showJobAssistantDrawer: { + showAiAssistantDrawer: { type: Boolean, required: true, }, - showAiAssistantDrawer: { + isNewCiConfigFile: { type: Boolean, required: true, }, @@ -196,7 +196,7 @@ export default { > <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" /> <ci-editor-header - :show-drawer="showDrawer" + :show-help-drawer="showHelpDrawer" :show-job-assistant-drawer="showJobAssistantDrawer" :show-ai-assistant-drawer="showAiAssistantDrawer" v-on="$listeners" diff --git a/app/assets/javascripts/ci/pipeline_editor/constants.js b/app/assets/javascripts/ci/pipeline_editor/constants.js index 756041315e4..e85138e361f 100644 --- a/app/assets/javascripts/ci/pipeline_editor/constants.js +++ b/app/assets/javascripts/ci/pipeline_editor/constants.js @@ -1,5 +1,10 @@ import { s__ } from '~/locale'; +export const EDITOR_APP_DRAWER_HELP = 'HELP'; +export const EDITOR_APP_DRAWER_JOB_ASSISTANT = 'JOB_ASSISTANT'; +export const EDITOR_APP_DRAWER_AI_ASSISTANT = 'AI_ASSISTANT'; +export const EDITOR_APP_DRAWER_NONE = ''; + // Values for CI_CONFIG_STATUS_* comes from lint graphQL export const CI_CONFIG_STATUS_INVALID = 'INVALID'; export const CI_CONFIG_STATUS_VALID = 'VALID'; diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue index 2e7f3f275a2..0495546529a 100644 --- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue @@ -10,12 +10,22 @@ import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_na import PipelineEditorFileTree from './components/file_tree/container.vue'; import PipelineEditorHeader from './components/header/pipeline_editor_header.vue'; import PipelineEditorTabs from './components/pipeline_editor_tabs.vue'; -import { CREATE_TAB, FILE_TREE_DISPLAY_KEY } from './constants'; +import { + CREATE_TAB, + FILE_TREE_DISPLAY_KEY, + EDITOR_APP_DRAWER_HELP, + EDITOR_APP_DRAWER_JOB_ASSISTANT, + EDITOR_APP_DRAWER_AI_ASSISTANT, + EDITOR_APP_DRAWER_NONE, +} from './constants'; const AiAssistantDrawer = () => import('ee_component/ci/pipeline_editor/components/ai_assistant_drawer.vue'); export default { + EDITOR_APP_DRAWER_HELP, + EDITOR_APP_DRAWER_JOB_ASSISTANT, + EDITOR_APP_DRAWER_AI_ASSISTANT, commitSectionRef: 'commitSectionRef', modal: { switchBranch: { @@ -68,15 +78,16 @@ export default { }, data() { return { + currentDrawer: EDITOR_APP_DRAWER_NONE, currentTab: CREATE_TAB, scrollToCommitForm: false, shouldLoadNewBranch: false, - showDrawer: false, - showJobAssistantDrawer: false, - showAiAssistantDrawer: false, - drawerIndex: DRAWER_Z_INDEX, - jobAssistantIndex: DRAWER_Z_INDEX, - aiAssistantIndex: DRAWER_Z_INDEX, + currentDrawerIndex: DRAWER_Z_INDEX, + drawerIndex: { + [EDITOR_APP_DRAWER_HELP]: DRAWER_Z_INDEX, + [EDITOR_APP_DRAWER_JOB_ASSISTANT]: DRAWER_Z_INDEX, + [EDITOR_APP_DRAWER_AI_ASSISTANT]: DRAWER_Z_INDEX, + }, showFileTree: false, showSwitchBranchModal: false, }; @@ -88,6 +99,15 @@ export default { includesFiles() { return this.ciConfigData?.includes || []; }, + showHelpDrawer() { + return this.currentDrawer === EDITOR_APP_DRAWER_HELP; + }, + showJobAssistantDrawer() { + return this.currentDrawer === EDITOR_APP_DRAWER_JOB_ASSISTANT; + }, + showAiAssistantDrawer() { + return this.currentDrawer === EDITOR_APP_DRAWER_AI_ASSISTANT; + }, }, mounted() { this.showFileTree = JSON.parse(localStorage.getItem(FILE_TREE_DISPLAY_KEY)) || false; @@ -96,29 +116,15 @@ export default { closeBranchModal() { this.showSwitchBranchModal = false; }, - closeDrawer() { - this.showDrawer = false; - }, - closeJobAssistantDrawer() { - this.showJobAssistantDrawer = false; - }, - closeAiAssistantDrawer() { - this.showAiAssistantDrawer = false; - }, - openAiAssistantDrawer() { - this.showAiAssistantDrawer = true; - this.aiAssistantIndex = this.drawerIndex + 1; - }, handleConfirmSwitchBranch() { this.showSwitchBranchModal = true; }, - openDrawer() { - this.showDrawer = true; - this.drawerIndex = this.jobAssistantIndex + 1; - }, - openJobAssistantDrawer() { - this.showJobAssistantDrawer = true; - this.jobAssistantIndex = this.drawerIndex + 1; + switchDrawer(drawerName) { + this.currentDrawer = drawerName; + if (this.drawerIndex[drawerName]) { + this.currentDrawerIndex += 1; + this.drawerIndex[drawerName] = this.currentDrawerIndex; + } }, toggleFileTree() { this.showFileTree = !this.showFileTree; @@ -181,16 +187,11 @@ export default { :commit-sha="commitSha" :current-tab="currentTab" :is-new-ci-config-file="isNewCiConfigFile" - :show-drawer="showDrawer" + :show-help-drawer="showHelpDrawer" :show-job-assistant-drawer="showJobAssistantDrawer" :show-ai-assistant-drawer="showAiAssistantDrawer" v-on="$listeners" - @open-drawer="openDrawer" - @close-drawer="closeDrawer" - @open-job-assistant-drawer="openJobAssistantDrawer" - @close-job-assistant-drawer="closeJobAssistantDrawer" - @open-ai-assistant-drawer="openAiAssistantDrawer" - @close-ai-assistant-drawer="closeAiAssistantDrawer" + @switch-drawer="switchDrawer" @set-current-tab="setCurrentTab" @walkthrough-popover-cta-clicked="setScrollToCommitForm" /> @@ -208,24 +209,24 @@ export default { v-on="$listeners" /> <pipeline-editor-drawer - :is-visible="showDrawer" - :z-index="drawerIndex" + :is-visible="showHelpDrawer" + :z-index="drawerIndex[$options.EDITOR_APP_DRAWER_HELP]" v-on="$listeners" - @close-drawer="closeDrawer" + @switch-drawer="switchDrawer" /> <job-assistant-drawer :ci-config-data="ciConfigData" :ci-file-content="ciFileContent" :is-visible="showJobAssistantDrawer" - :z-index="jobAssistantIndex" + :z-index="drawerIndex[$options.EDITOR_APP_DRAWER_JOB_ASSISTANT]" v-on="$listeners" - @close-job-assistant-drawer="closeJobAssistantDrawer" + @switch-drawer="switchDrawer" /> <ai-assistant-drawer v-if="glFeatures.aiCiConfigGenerator" :is-visible="showAiAssistantDrawer" - :z-index="aiAssistantIndex" - @close-ai-assistant-drawer="closeAiAssistantDrawer" + :z-index="drawerIndex[$options.EDITOR_APP_DRAWER_AI_ASSISTANT]" + @switch-drawer="switchDrawer" /> </div> </template> diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index 74eb124535e..ae7676a3e9e 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -6,8 +6,6 @@ import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.grap import createDefaultClient from '~/lib/graphql'; import typeDefs from '~/work_items/graphql/typedefs.graphql'; import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; -import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; -import { findHierarchyWidgetChildren } from '~/work_items/utils'; import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; export const config = { @@ -183,30 +181,6 @@ export const config = { export const resolvers = { Mutation: { - addHierarchyChild: (_, { fullPath, iid, workItem }, { cache }) => { - const queryArgs = { query: workItemByIidQuery, variables: { fullPath, iid } }; - const sourceData = cache.readQuery(queryArgs); - - const data = produce(sourceData, (draftState) => { - findHierarchyWidgetChildren(draftState.workspace.workItems.nodes[0]).push(workItem); - }); - - cache.writeQuery({ ...queryArgs, data }); - }, - removeHierarchyChild: (_, { fullPath, iid, workItem }, { cache }) => { - const queryArgs = { query: workItemByIidQuery, variables: { fullPath, iid } }; - const sourceData = cache.readQuery(queryArgs); - - const data = produce(sourceData, (draftState) => { - const hierarchyChildren = findHierarchyWidgetChildren( - draftState.workspace.workItems.nodes[0], - ); - const index = hierarchyChildren.findIndex((child) => child.id === workItem.id); - hierarchyChildren.splice(index, 1); - }); - - cache.writeQuery({ ...queryArgs, data }); - }, updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => { const sourceData = cache.readQuery({ query: getIssueStateQuery }); const data = produce(sourceData, (draftData) => { diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index dc7e1ad2c03..3bf4dfc7a99 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -11,8 +11,7 @@ import { TYPE_ISSUE } from '~/issues/constants'; import { __, s__, sprintf } from '~/locale'; import { getSortableDefaultOptions, isDragging } from '~/sortable/utils'; import TaskList from '~/task_list'; -import addHierarchyChildMutation from '~/work_items/graphql/add_hierarchy_child.mutation.graphql'; -import removeHierarchyChildMutation from '~/work_items/graphql/remove_hierarchy_child.mutation.graphql'; +import { addHierarchyChild, removeHierarchyChild } from '~/work_items/graphql/cache_utils'; import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; @@ -360,6 +359,8 @@ export default { workItemTypeId: this.taskWorkItemTypeId, }, }, + update: (cache, { data: { workItemCreate } }) => + addHierarchyChild(cache, this.fullPath, String(this.issueIid), workItemCreate.workItem), }); const { workItem, errors } = data.workItemCreate; @@ -368,11 +369,6 @@ export default { throw new Error(errors); } - await this.$apollo.mutate({ - mutation: addHierarchyChildMutation, - variables: { fullPath: this.fullPath, iid: String(this.issueIid), workItem }, - }); - this.$toast.show(s__('WorkItem|Converted to task'), { action: { text: s__('WorkItem|Undo'), @@ -393,19 +389,14 @@ export default { const { data } = await this.$apollo.mutate({ mutation: deleteWorkItemMutation, variables: { input: { id } }, + update: (cache) => + removeHierarchyChild(cache, this.fullPath, String(this.issueIid), { id }), }); - const { errors } = data.workItemDelete; - - if (errors?.length) { - throw new Error(errors); + if (data.workItemDelete.errors?.length) { + throw new Error(data.workItemDelete.errors); } - await this.$apollo.mutate({ - mutation: removeHierarchyChildMutation, - variables: { fullPath: this.fullPath, iid: String(this.issueIid), workItem: { id } }, - }); - this.$toast.show(s__('WorkItem|Task reverted')); } catch (error) { this.showAlert(I18N_WORK_ITEM_ERROR_DELETING, error); diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue index 09a75a7c8e5..fce0b5f525e 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue @@ -1,44 +1,143 @@ <script> -import { GlIcon, GlLink, GlPopover, GlSprintf } from '@gitlab/ui'; +import { + GlButton, + GlCollapse, + GlIcon, + GlLink, + GlLoadingIcon, + GlPopover, + GlSprintf, +} from '@gitlab/ui'; +import { createAlert } from '~/alert'; import { __, s__ } from '~/locale'; +import getPipelineFailedJobs from '../../../graphql/queries/get_pipeline_failed_jobs.query.graphql'; +import WidgetFailedJobRow from './widget_failed_job_row.vue'; +import { sortJobsByStatus } from './utils'; + +const JOB_ID_HEADER = __('Job ID'); +const JOB_NAME_HEADER = __('Job name'); +const STAGE_HEADER = __('Stage'); export default { components: { + GlButton, + GlCollapse, GlIcon, GlLink, + GlLoadingIcon, GlPopover, GlSprintf, + WidgetFailedJobRow, }, + inject: ['fullPath'], props: { + pipelineIid: { + required: true, + type: Number, + }, pipelinePath: { required: true, type: String, }, }, - + data() { + return { + failedJobs: [], + isExpanded: false, + }; + }, + apollo: { + failedJobs: { + query: getPipelineFailedJobs, + skip() { + return !this.isExpanded; + }, + variables() { + return { + fullPath: this.fullPath, + pipelineIid: this.pipelineIid, + }; + }, + update(data) { + const jobs = data?.project?.pipeline?.jobs?.nodes || []; + return sortJobsByStatus(jobs); + }, + error(e) { + createAlert({ message: e?.message || this.$options.i18n.fetchError, variant: 'danger' }); + }, + }, + }, + computed: { + bodyClasses() { + return this.isExpanded ? '' : 'gl-display-none'; + }, + failedJobsCount() { + return this.failedJobs.length; + }, + iconName() { + return this.isExpanded ? 'chevron-down' : 'chevron-right'; + }, + isLoading() { + return this.$apollo.queries.failedJobs.loading; + }, + }, + methods: { + toggleWidget() { + this.isExpanded = !this.isExpanded; + }, + }, + columns: [ + { text: JOB_NAME_HEADER, class: 'col-6' }, + { text: STAGE_HEADER, class: 'col-2' }, + { text: JOB_ID_HEADER, class: 'col-2' }, + ], i18n: { additionalInfoPopover: s__( 'Pipelines|You will see a maximum of 100 jobs in this list. To view all failed jobs, %{linkStart}go to the details page%{linkEnd} of this pipeline.', ), additionalInfoTitle: __('Limitation on this view'), + fetchError: __('There was a problem fetching failed jobs'), showFailedJobs: __('Show failed jobs'), }, }; </script> <template> <div class="gl-border-none!"> - <gl-icon name="chevron-right" /> - {{ $options.i18n.showFailedJobs }} - <gl-icon id="target" name="information-o" /> - <gl-popover target="target" placement="top"> - <template #title> {{ $options.i18n.additionalInfoTitle }} </template> - <slot> - <gl-sprintf :message="$options.i18n.additionalInfoPopover"> - <template #link="{ content }"> - <gl-link class="gl-font-sm" :href="pipelinePath"> {{ content }}</gl-link> - </template> - </gl-sprintf> - </slot> - </gl-popover> + <gl-button variant="link" @click="toggleWidget"> + <gl-icon :name="iconName" /> + {{ $options.i18n.showFailedJobs }} + <gl-icon id="target" name="information-o" /> + <gl-popover target="target" placement="top"> + <template #title> {{ $options.i18n.additionalInfoTitle }} </template> + <slot> + <gl-sprintf :message="$options.i18n.additionalInfoPopover"> + <template #link="{ content }"> + <gl-link class="gl-font-sm" :href="pipelinePath"> {{ content }}</gl-link> + </template> + </gl-sprintf> + </slot> + </gl-popover> + </gl-button> + <gl-loading-icon v-if="isLoading" /> + <gl-collapse + v-else + v-model="isExpanded" + class="gl-bg-gray-10 gl-border-1 gl-border-t gl-border-color-gray-100 gl-mt-4 gl-pt-3" + > + <div class="container-fluid gl-grid-tpl-rows-auto"> + <div class="row gl-mb-6 gl-text-gray-900"> + <div + v-for="col in $options.columns" + :key="col.text" + class="gl-font-weight-bold gl-text-left" + :class="col.class" + data-testid="header" + > + {{ col.text }} + </div> + </div> + </div> + <widget-failed-job-row v-for="job in failedJobs" :key="job.id" :job="job" /> + </gl-collapse> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js new file mode 100644 index 00000000000..3f395fff7e0 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js @@ -0,0 +1,15 @@ +export const isFailedJob = (job = {}) => { + return job?.detailedStatus?.group === 'failed' || false; +}; + +export const sortJobsByStatus = (jobs = []) => { + const newJobs = [...jobs]; + + return newJobs.sort((a) => { + if (isFailedJob(a)) { + return -1; + } + + return 1; + }); +}; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue new file mode 100644 index 00000000000..d48e6dc289b --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue @@ -0,0 +1,15 @@ +<script> +export default { + props: { + job: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <div class="container-fluid gl-grid-tpl-rows-auto"> + {{ job.name }} + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index 0c25fb45094..d884935d95b 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -219,7 +219,11 @@ export default { </template> <template #row-details="{ item }"> - <pipeline-failed-jobs-widget v-if="showFailedJobsWidget(item)" :pipeline-path="item.path" /> + <pipeline-failed-jobs-widget + v-if="showFailedJobsWidget(item)" + :pipeline-iid="item.iid" + :pipeline-path="item.path" + /> </template> </gl-table-lite> diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql new file mode 100644 index 00000000000..2c842f1ac77 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql @@ -0,0 +1,34 @@ +query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) { + project(fullPath: $fullPath) { + id + pipeline(iid: $pipelineIid) { + id + jobs(statuses: [FAILED], retried: false, jobKind: BUILD) { + nodes { + id + allowFailure + detailedStatus { + id + group + icon + action { + id + path + icon + } + } + name + retried + stage { + id + name + } + trace { + htmlSummary + } + webPath + } + } + } + } +} diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/data.js b/app/assets/javascripts/search/sidebar/components/label_filter/data.js new file mode 100644 index 00000000000..654357da902 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/label_filter/data.js @@ -0,0 +1,23 @@ +import { __ } from '~/locale'; + +export const FIRST_DROPDOWN_INDEX = 0; + +export const SEARCH_BOX_INDEX = 0; + +export const SEARCH_INPUT_DESCRIPTION = 'label-search-input-description'; + +export const SEARCH_RESULTS_DESCRIPTION = 'label-search-results-description'; + +const header = __('Labels'); + +const scopes = { + ISSUES: 'issues', +}; + +const filterParam = 'labels'; + +export const labelFilterData = { + header, + scopes, + filterParam, +}; diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index 3fdc4165baf..077c46bbe22 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -5,6 +5,7 @@ import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { logError } from '~/lib/logger'; import { __ } from '~/locale'; import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; +import { labelFilterData } from '~/search/sidebar/components/label_filter/data'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants'; import * as types from './mutation_types'; import { @@ -108,10 +109,24 @@ export const applyQuery = ({ state }) => { export const resetQuery = ({ state }) => { visitUrl( - setUrlParams({ ...state.query, page: null, state: null, confidential: null }, undefined, true), + setUrlParams( + { ...state.query, page: null, state: null, confidential: null, labels: null }, + undefined, + true, + ), ); }; +export const closeLabel = ({ state, commit }, { key }) => { + const labels = state?.query?.labels.filter((labelKey) => labelKey !== key); + + setQuery({ state, commit }, { key: labelFilterData.filterParam, value: labels }); +}; + +export const setLabelFilterSearch = ({ commit }, { value }) => { + commit(types.SET_LABEL_SEARCH_STRING, value); +}; + export const resetLanguageQueryWithRedirect = ({ state }) => { visitUrl(setUrlParams({ ...state.query, language: null }, undefined, true)); }; diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js index c8ee0a3f9d9..91c16616f02 100644 --- a/app/assets/javascripts/search/store/constants.js +++ b/app/assets/javascripts/search/store/constants.js @@ -1,6 +1,7 @@ import { stateFilterData } from '~/search/sidebar/constants/state_filter_data'; import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data'; import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; +import { labelFilterData } from '~/search/sidebar/components/label_filter/data'; export const MAX_FREQUENT_ITEMS = 5; @@ -14,6 +15,7 @@ export const SIDEBAR_PARAMS = [ stateFilterData.filterParam, confidentialFilterData.filterParam, languageFilterData.filterParam, + labelFilterData.filterParam, ]; export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' }; diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js index 135c9a3d67c..d31d2b5ae11 100644 --- a/app/assets/javascripts/search/store/getters.js +++ b/app/assets/javascripts/search/store/getters.js @@ -1,5 +1,6 @@ import { findKey, has } from 'lodash'; import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; +import { labelFilterData } from '~/search/sidebar/components/label_filter/data'; import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, ICON_MAP } from './constants'; @@ -20,6 +21,40 @@ export const languageAggregationBuckets = (state) => { ); }; +export const labelAggregationBuckets = (state) => { + return ( + state?.aggregations?.data?.find( + (aggregation) => aggregation.name === labelFilterData.filterParam, + )?.buckets || [] + ); +}; + +export const filteredLabels = (state) => { + if (state.searchLabelString === '') { + return labelAggregationBuckets(state); + } + return labelAggregationBuckets(state).filter((label) => { + return label.title.toLowerCase().includes(state.searchLabelString.toLowerCase()); + }); +}; + +export const filteredAppliedSelectedLabels = (state) => + filteredLabels(state)?.filter((label) => state?.urlQuery?.labels?.includes(label.key)); + +export const appliedSelectedLabels = (state) => + labelAggregationBuckets(state)?.filter((label) => state?.urlQuery?.labels?.includes(label.key)); + +export const filteredUnappliedSelectedLabels = (state) => + filteredLabels(state)?.filter((label) => state?.query?.labels?.includes(label.key)); + +export const filteredUnselectedLabels = (state) => { + if (!state?.urlQuery?.labels) { + return filteredLabels(state); + } + + return filteredLabels(state)?.filter((label) => !state?.urlQuery?.labels?.includes(label.key)); +}; + export const currentScope = (state) => findKey(state.navigation, { active: true }); export const queryLanguageFilters = (state) => state.query[languageFilterData.filterParam] || []; diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js index 4ffbadcd083..021dd01ca93 100644 --- a/app/assets/javascripts/search/store/mutation_types.js +++ b/app/assets/javascripts/search/store/mutation_types.js @@ -15,3 +15,5 @@ export const RECEIVE_NAVIGATION_COUNT = 'RECEIVE_NAVIGATION_COUNT'; export const REQUEST_AGGREGATIONS = 'REQUEST_AGGREGATIONS'; export const RECEIVE_AGGREGATIONS_SUCCESS = 'RECEIVE_AGGREGATIONS_SUCCESS'; export const RECEIVE_AGGREGATIONS_ERROR = 'RECEIVE_AGGREGATIONS_ERROR'; + +export const SET_LABEL_SEARCH_STRING = 'SET_LABEL_SEARCH_STRING'; diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js index b2f9f5ab225..65bb21f1b8a 100644 --- a/app/assets/javascripts/search/store/mutations.js +++ b/app/assets/javascripts/search/store/mutations.js @@ -45,4 +45,7 @@ export default { [types.RECEIVE_AGGREGATIONS_ERROR](state) { state.aggregations = { fetching: false, error: true, data: [] }; }, + [types.SET_LABEL_SEARCH_STRING](state, value) { + state.searchLabelString = value; + }, }; diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js index c897d4108a8..5407b08fa83 100644 --- a/app/assets/javascripts/search/store/state.js +++ b/app/assets/javascripts/search/store/state.js @@ -20,6 +20,7 @@ const createState = ({ query, navigation, useSidebarNavigation }) => ({ fetching: false, data: [], }, + searchLabelString: '', }); export default createState; diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue index 083474da23e..074c5fda29b 100644 --- a/app/assets/javascripts/snippets/components/show.vue +++ b/app/assets/javascripts/snippets/components/show.vue @@ -7,7 +7,7 @@ import { } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; import { VISIBILITY_LEVEL_PUBLIC_STRING } from '~/visibility_level/constants'; -import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; +import CloneDropdownButton from '~/vue_shared/components/clone_dropdown/clone_dropdown.vue'; import { getSnippetMixin } from '../mixins/snippets'; import { markBlobPerformance } from '../utils/blob'; diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue deleted file mode 100644 index 69fce23ede4..00000000000 --- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue +++ /dev/null @@ -1,91 +0,0 @@ -<script> -import { - GlButton, - GlDisclosureDropdown, - GlDisclosureDropdownItem, - GlFormGroup, - GlFormInputGroup, - GlTooltipDirective, -} from '@gitlab/ui'; -import { getHTTPProtocol } from '~/lib/utils/url_utility'; -import { __, sprintf } from '~/locale'; - -export default { - components: { - GlDisclosureDropdown, - GlDisclosureDropdownItem, - GlFormGroup, - GlFormInputGroup, - GlButton, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - sshLink: { - type: String, - required: false, - default: '', - }, - httpLink: { - type: String, - required: false, - default: '', - }, - }, - computed: { - httpLabel() { - const protocol = this.httpLink ? getHTTPProtocol(this.httpLink)?.toUpperCase() : ''; - return sprintf(__('Clone with %{protocol}'), { protocol }); - }, - }, - labels: { - defaultLabel: __('Clone'), - ssh: __('Clone with SSH'), - }, - copyURLTooltip: __('Copy URL'), -}; -</script> -<template> - <gl-disclosure-dropdown - :toggle-text="$options.labels.defaultLabel" - category="primary" - variant="confirm" - placement="right" - > - <gl-disclosure-dropdown-item v-if="sshLink"> - <gl-form-group :label="$options.labels.ssh" class="gl-px-3 gl-my-3"> - <gl-form-input-group :value="sshLink" readonly select-on-click> - <template #append> - <gl-button - v-gl-tooltip.hover - :title="$options.copyURLTooltip" - :aria-label="$options.copyURLTooltip" - :data-clipboard-text="sshLink" - data-qa-selector="copy_ssh_url_button" - icon="copy-to-clipboard" - class="gl-display-inline-flex" - /> - </template> - </gl-form-input-group> - </gl-form-group> - </gl-disclosure-dropdown-item> - <gl-disclosure-dropdown-item v-if="httpLink"> - <gl-form-group :label="httpLabel" class="gl-px-3 gl-mb-3"> - <gl-form-input-group :value="httpLink" readonly select-on-click> - <template #append> - <gl-button - v-gl-tooltip.hover - :title="$options.copyURLTooltip" - :aria-label="$options.copyURLTooltip" - :data-clipboard-text="httpLink" - data-qa-selector="copy_http_url_button" - icon="copy-to-clipboard" - class="gl-display-inline-flex" - /> - </template> - </gl-form-input-group> - </gl-form-group> - </gl-disclosure-dropdown-item> - </gl-disclosure-dropdown> -</template> diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.stories.js b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.stories.js new file mode 100644 index 00000000000..ed0e9150bc4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.stories.js @@ -0,0 +1,33 @@ +import CloneDropdown from './clone_dropdown.vue'; + +export default { + component: CloneDropdown, + title: 'vue_shared/components/clone_dropdown', +}; + +const Template = (args, { argTypes }) => ({ + components: { CloneDropdown }, + props: Object.keys(argTypes), + template: '<clone-dropdown v-bind="$props" />', +}); + +const sshLink = 'ssh://some-ssh-link'; +const httpLink = 'https://some-http-link'; + +export const Default = Template.bind({}); +Default.args = { + sshLink, + httpLink, +}; + +export const HttpLink = Template.bind({}); +HttpLink.args = { + httpLink, + sshLink: '', +}; + +export const SSHLink = Template.bind({}); +SSHLink.args = { + sshLink, + httpLink: '', +}; diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.vue new file mode 100644 index 00000000000..fa7c5bc1978 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.vue @@ -0,0 +1,59 @@ +<script> +import { GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui'; +import { getHTTPProtocol } from '~/lib/utils/url_utility'; +import { __, sprintf } from '~/locale'; +import CloneDropdownItem from './clone_dropdown_item.vue'; + +export default { + components: { + GlDisclosureDropdown, + CloneDropdownItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + sshLink: { + type: String, + required: false, + default: '', + }, + httpLink: { + type: String, + required: false, + default: '', + }, + }, + computed: { + httpLabel() { + const protocol = this.httpLink ? getHTTPProtocol(this.httpLink)?.toUpperCase() : ''; + return sprintf(__('Clone with %{protocol}'), { protocol }); + }, + }, + labels: { + defaultLabel: __('Clone'), + ssh: __('Clone with SSH'), + }, +}; +</script> +<template> + <gl-disclosure-dropdown + :toggle-text="$options.labels.defaultLabel" + category="primary" + variant="confirm" + placement="right" + > + <clone-dropdown-item + v-if="sshLink" + :label="$options.labels.ssh" + :link="sshLink" + qa-selector="copy_ssh_url_button" + /> + <clone-dropdown-item + v-if="httpLink" + :label="httpLabel" + :link="httpLink" + qa-selector="copy_http_url_button" + /> + </gl-disclosure-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown_item.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown_item.vue new file mode 100644 index 00000000000..0e322ebc686 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown_item.vue @@ -0,0 +1,56 @@ +<script> +import { + GlButton, + GlDisclosureDropdownItem, + GlFormGroup, + GlFormInputGroup, + GlTooltipDirective, +} from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlDisclosureDropdownItem, + GlFormGroup, + GlFormInputGroup, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + label: { + type: String, + required: true, + }, + link: { + type: String, + required: true, + }, + qaSelector: { + type: String, + required: true, + }, + }, + copyURLTooltip: __('Copy URL'), +}; +</script> +<template> + <gl-disclosure-dropdown-item> + <gl-form-group :label="label" class="gl-px-3 gl-mb-3"> + <gl-form-input-group :value="link" readonly select-on-click> + <template #append> + <gl-button + v-gl-tooltip.hover + :title="$options.copyURLTooltip" + :aria-label="$options.copyURLTooltip" + :data-clipboard-text="link" + :data-qa-selector="qaSelector" + icon="copy-to-clipboard" + class="gl-display-inline-flex" + /> + </template> + </gl-form-input-group> + </gl-form-group> + </gl-disclosure-dropdown-item> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index 65e8999ae03..ad759bc69e9 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -1,6 +1,5 @@ <script> import { isEmpty } from 'lodash'; -import { produce } from 'immer'; import { GlAlert, GlSkeletonLoader, @@ -398,33 +397,6 @@ export default { this.error = this.$options.i18n.fetchError; document.title = s__('404|Not found'); }, - addChild(child) { - const { defaultClient: client } = this.$apollo.provider.clients; - this.toggleChildFromCache(child, child.id, client); - }, - toggleChildFromCache(workItem, childId, store) { - const query = { - query: workItemByIidQuery, - variables: { fullPath: this.fullPath, iid: this.workItemIid }, - }; - - const sourceData = store.readQuery(query); - - const newData = produce(sourceData, (draftState) => { - const { widgets } = draftState.workspace.workItems.nodes[0]; - const widgetHierarchy = widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); - - const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId); - - if (index >= 0) { - widgetHierarchy.children.nodes.splice(index, 1); - } else { - widgetHierarchy.children.nodes.push(workItem); - } - }); - - store.writeQuery({ ...query, data: newData }); - }, updateHasNotes() { this.$emit('has-notes'); }, @@ -678,7 +650,6 @@ export default { :children="children" :can-update="canUpdate" :confidential="workItem.confidential" - @addWorkItemChild="addChild" @show-modal="openInModal" /> <work-item-notes diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue index a18dfd4d9e3..bf427feaa35 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue @@ -10,8 +10,7 @@ import { defaultSortableOptions } from '~/sortable/constants'; import { WORK_ITEM_TYPE_VALUE_OBJECTIVE } from '../../constants'; import { findHierarchyWidgets } from '../../utils'; -import addHierarchyChildMutation from '../../graphql/add_hierarchy_child.mutation.graphql'; -import removeHierarchyChildMutation from '../../graphql/remove_hierarchy_child.mutation.graphql'; +import { addHierarchyChild, removeHierarchyChild } from '../../graphql/cache_utils'; import reorderWorkItem from '../../graphql/reorder_work_item.mutation.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; @@ -84,17 +83,13 @@ export default { const { data } = await this.$apollo.mutate({ mutation: updateWorkItemMutation, variables: { input: { id: child.id, hierarchyWidget: { parentId: null } } }, + update: (cache) => removeHierarchyChild(cache, this.fullPath, this.workItemIid, child), }); if (data.workItemUpdate.errors.length) { throw new Error(data.workItemUpdate.errors); } - await this.$apollo.mutate({ - mutation: removeHierarchyChildMutation, - variables: { fullPath: this.fullPath, iid: this.workItemIid, workItem: child }, - }); - this.$toast.show(s__('WorkItem|Child removed'), { action: { text: s__('WorkItem|Undo'), @@ -114,17 +109,13 @@ export default { const { data } = await this.$apollo.mutate({ mutation: updateWorkItemMutation, variables: { input: { id: child.id, hierarchyWidget: { parentId: this.workItemId } } }, + update: (cache) => addHierarchyChild(cache, this.fullPath, this.workItemIid, child), }); if (data.workItemUpdate.errors.length) { throw new Error(data.workItemUpdate.errors); } - await this.$apollo.mutate({ - mutation: addHierarchyChildMutation, - variables: { fullPath: this.fullPath, iid: this.workItemIid, workItem: child }, - }); - this.$toast.show(s__('WorkItem|Child removal reverted')); } catch (error) { this.$emit('error', s__('WorkItem|Something went wrong while undoing child removal.')); diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index efcbe8062e0..b9fc92304c0 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -11,8 +11,7 @@ import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_sel import { FORM_TYPES, WIDGET_ICONS, WORK_ITEM_STATUS_TEXT } from '../../constants'; import { findHierarchyWidgetChildren } from '../../utils'; -import addHierarchyChildMutation from '../../graphql/add_hierarchy_child.mutation.graphql'; -import removeHierarchyChildMutation from '../../graphql/remove_hierarchy_child.mutation.graphql'; +import { removeHierarchyChild } from '../../graphql/cache_utils'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; import WidgetWrapper from '../widget_wrapper.vue'; import WorkItemDetailModal from '../work_item_detail_modal.vue'; @@ -165,24 +164,13 @@ export default { this.updateWorkItemIdUrlQuery(); }, handleWorkItemDeleted(child) { - this.removeHierarchyChild(child); + const { defaultClient: cache } = this.$apollo.provider.clients; + removeHierarchyChild(cache, this.fullPath, this.iid, child); this.$toast.show(s__('WorkItem|Task deleted')); }, updateWorkItemIdUrlQuery({ iid } = {}) { updateHistory({ url: setUrlParams({ work_item_iid: iid }), replace: true }); }, - async addHierarchyChild(workItem) { - return this.$apollo.mutate({ - mutation: addHierarchyChildMutation, - variables: { fullPath: this.fullPath, iid: this.iid, workItem }, - }); - }, - async removeHierarchyChild(workItem) { - return this.$apollo.mutate({ - mutation: removeHierarchyChildMutation, - variables: { fullPath: this.fullPath, iid: this.iid, workItem }, - }); - }, toggleReportAbuseDrawer(isOpen, reply = {}) { this.isReportDrawerOpen = isOpen; this.reportedUrl = reply.url; @@ -261,6 +249,7 @@ export default { ref="wiLinksForm" data-testid="add-links-form" :issuable-gid="issuableGid" + :work-item-iid="iid" :children-ids="childrenIds" :parent-confidential="confidential" :parent-iteration="issuableIteration" @@ -268,7 +257,6 @@ export default { :form-type="formType" :parent-work-item-type="workItem.workItemType.name" @cancel="hideAddForm" - @addWorkItemChild="addHierarchyChild" /> <work-item-children-wrapper :children="children" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index 51c83784d06..289a48b5eaf 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -13,7 +13,8 @@ import { debounce } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; -import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; +import { addHierarchyChild } from '../../graphql/cache_utils'; +import projectWorkItemTypesQuery from '../../graphql/project_work_item_types.query.graphql'; import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql'; @@ -48,6 +49,11 @@ export default { required: false, default: null, }, + workItemIid: { + type: String, + required: false, + default: null, + }, childrenIds: { type: Array, required: false, @@ -292,13 +298,14 @@ export default { variables: { input: this.workItemInput, }, + update: (cache, { data }) => + addHierarchyChild(cache, this.fullPath, this.workItemIid, data.workItemCreate.workItem), }) .then(({ data }) => { if (data.workItemCreate?.errors?.length) { [this.error] = data.workItemCreate.errors; } else { this.unsetError(); - this.$emit('addWorkItemChild', data.workItemCreate.workItem); } }) .catch(() => { diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue index 4e80e0071ae..44e8dac79c4 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue @@ -136,12 +136,12 @@ export default { ref="wiLinksForm" data-testid="add-tree-form" :issuable-gid="workItemId" + :work-item-iid="workItemIid" :form-type="formType" :parent-work-item-type="parentWorkItemType" :children-type="childType" :children-ids="childrenIds" :parent-confidential="confidential" - @addWorkItemChild="$emit('addWorkItemChild', $event)" @cancel="hideAddForm" /> <work-item-children-wrapper diff --git a/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql b/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql deleted file mode 100644 index 9eb839dc866..00000000000 --- a/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql +++ /dev/null @@ -1,3 +0,0 @@ -mutation addHierarchyChild($fullPath: ID!, $iid: String!, $workItem: WorkItem!) { - addHierarchyChild(fullPath: $fullPath, iid: $iid, workItem: $workItem) @client -} diff --git a/app/assets/javascripts/work_items/graphql/cache_utils.js b/app/assets/javascripts/work_items/graphql/cache_utils.js index 455d8b8ae7b..03b45a45c39 100644 --- a/app/assets/javascripts/work_items/graphql/cache_utils.js +++ b/app/assets/javascripts/work_items/graphql/cache_utils.js @@ -1,5 +1,7 @@ import { produce } from 'immer'; import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; +import { findHierarchyWidgetChildren } from '~/work_items/utils'; const isNotesWidget = (widget) => widget.type === WIDGET_TYPE_NOTES; @@ -17,7 +19,6 @@ const updateNotesWidgetDataInDraftData = (draftData, notesWidget) => { * @param currentNotes * @param subscriptionData */ - export const updateCacheAfterCreatingNote = (currentNotes, subscriptionData) => { if (!subscriptionData.data?.workItemNoteCreated) { return currentNotes; @@ -49,7 +50,6 @@ export const updateCacheAfterCreatingNote = (currentNotes, subscriptionData) => * @param currentNotes * @param subscriptionData */ - export const updateCacheAfterDeletingNote = (currentNotes, subscriptionData) => { if (!subscriptionData.data?.workItemNoteDeleted) { return currentNotes; @@ -86,3 +86,37 @@ export const updateCacheAfterDeletingNote = (currentNotes, subscriptionData) => updateNotesWidgetDataInDraftData(draftData, notesWidget); }); }; + +export const addHierarchyChild = (cache, fullPath, iid, workItem) => { + const queryArgs = { query: workItemByIidQuery, variables: { fullPath, iid } }; + const sourceData = cache.readQuery(queryArgs); + + if (!sourceData) { + return; + } + + cache.writeQuery({ + ...queryArgs, + data: produce(sourceData, (draftState) => { + findHierarchyWidgetChildren(draftState.workspace.workItems.nodes[0]).push(workItem); + }), + }); +}; + +export const removeHierarchyChild = (cache, fullPath, iid, workItem) => { + const queryArgs = { query: workItemByIidQuery, variables: { fullPath, iid } }; + const sourceData = cache.readQuery(queryArgs); + + if (!sourceData) { + return; + } + + cache.writeQuery({ + ...queryArgs, + data: produce(sourceData, (draftState) => { + const children = findHierarchyWidgetChildren(draftState.workspace.workItems.nodes[0]); + const index = children.findIndex((child) => child.id === workItem.id); + children.splice(index, 1); + }), + }); +}; diff --git a/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql b/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql deleted file mode 100644 index 790f392f6e7..00000000000 --- a/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql +++ /dev/null @@ -1,3 +0,0 @@ -mutation removeHierarchyChild($fullPath: ID!, $iid: String!, $workItem: WorkItem!) { - removeHierarchyChild(fullPath: $fullPath, iid: $iid, workItem: $workItem) @client -} diff --git a/app/assets/stylesheets/fonts.scss b/app/assets/stylesheets/fonts.scss index a023b41083d..369f7072aa4 100644 --- a/app/assets/stylesheets/fonts.scss +++ b/app/assets/stylesheets/fonts.scss @@ -14,8 +14,31 @@ Usage: } /* ------------------------------------------------------- +Monospaced font: GitLab Mono. + +Usage: + html { font-family: 'GitLab Mono', sans-serif; } +*/ +@font-face { + font-family: 'GitLab Mono'; + font-display: optional; + font-style: normal; + src: font-url('gitlab-mono/GitLabMono.woff2') format('woff2'); +} + +@font-face { + font-family: 'GitLab Mono'; + font-display: optional; + font-style: italic; + src: font-url('gitlab-mono/GitLabMono-Italic.woff2') format('woff2'); +} + +/* ------------------------------------------------------- Monospaced font: JetBrains Mono. +All of the definitions below can be removed once +`GitLab Mono` is properly rolled out. + Usage: html { font-family: 'JetBrains Mono', sans-serif; } */ @@ -49,11 +72,6 @@ Usage: src: font-url('jetbrains-mono/JetBrainsMono-BoldItalic.woff2') format('woff2'); } -:root { - --default-mono-font: 'JetBrains Mono', 'Menlo'; - --default-regular-font: 'GitLab Sans', -apple-system; -} - // This isn't the best solution, but we needed a quick fix // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/107592/ * { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 39672a5ed7f..f77804fb7fc 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -569,9 +569,9 @@ $diff-jagged-border-gradient-color: darken($white-normal, 8%); /* * Fonts */ -$monospace-font: var(--default-mono-font, 'Menlo'), 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', +$monospace-font: 'GitLab Mono', 'JetBrains Mono', 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace; -$regular-font: var(--default-regular-font, -apple-system), BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans', +$regular-font: 'GitLab Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans', Ubuntu, Cantarell, 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; $gl-monospace-font: $monospace-font; diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index 11eb39a11cb..02cc86a77ea 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -17,10 +17,9 @@ header { } body { margin: 0; - font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, - "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", - sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; font-weight: 400; line-height: 1.5; @@ -48,7 +47,7 @@ a:not([href]):not([class]) { text-decoration: none; } kbd { - font-family: var(--default-mono-font, "Menlo"), "DejaVu Sans Mono", + font-family: "GitLab Mono", "JetBrains Mono", "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; font-size: 1em; @@ -413,10 +412,9 @@ a.gl-badge.badge-warning:active { .gl-form-input, .gl-form-input.form-control { background-color: #333238; - font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, - "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", - sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 0.875rem; line-height: 1rem; padding-top: 0.5rem; diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 28a35c82a59..5e3944e62f1 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -17,10 +17,9 @@ header { } body { margin: 0; - font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, - "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", - sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; font-weight: 400; line-height: 1.5; @@ -48,7 +47,7 @@ a:not([href]):not([class]) { text-decoration: none; } kbd { - font-family: var(--default-mono-font, "Menlo"), "DejaVu Sans Mono", + font-family: "GitLab Mono", "JetBrains Mono", "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; font-size: 1em; @@ -413,10 +412,9 @@ a.gl-badge.badge-warning:active { .gl-form-input, .gl-form-input.form-control { background-color: #fff; - font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, - "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", - sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 0.875rem; line-height: 1rem; padding-top: 0.5rem; diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index 7c37bcd085b..d081fdfa50b 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -16,10 +16,9 @@ header { } body { margin: 0; - font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, - "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", - sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; font-weight: 400; line-height: 1.5; @@ -380,10 +379,9 @@ input.btn-block[type="submit"] { .gl-form-input, .gl-form-input.form-control { background-color: #fff; - font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, - "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", - sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 0.875rem; line-height: 1rem; padding-top: 0.5rem; diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 3a03831ab88..06381315614 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -91,15 +91,17 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap end def target_projects - projects = MergeRequestTargetProjectFinder - .new(current_user: current_user, source_project: @project, project_feature: :repository) - .execute(include_routes: false, search: params[:search]).limit(20) - - render json: ProjectSerializer.new.represent(projects) + render json: ProjectSerializer.new.represent(get_target_projects) end private + def get_target_projects + MergeRequestTargetProjectFinder + .new(current_user: current_user, source_project: @project, project_feature: :repository) + .execute(include_routes: false, search: params[:search]).limit(20) + end + def build_merge_request params[:merge_request] ||= ActionController::Parameters.new(source_project: @project) diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 025720e5e10..98e6459b543 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -22,7 +22,7 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action :ensure_pipeline, only: [:show, :downloadable_artifacts] before_action :reject_if_build_artifacts_size_refreshing!, only: [:destroy] - before_action :push_frontend_feature_flags, only: [:show] + before_action :push_frontend_feature_flags, only: [:show, :builds, :dag, :failures, :test_report] # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596 before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? } diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index af1feb7c206..696790b9dcb 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -8,8 +8,8 @@ module IdeHelper 'new-web-ide-help-page-path' => help_page_path('user/project/web_ide/index.md', anchor: 'vscode-reimplementation'), 'sign-in-path' => new_session_path(current_user), 'user-preferences-path' => profile_preferences_path, - 'editor-font-src-url' => font_url('jetbrains-mono/JetBrainsMono.woff2'), - 'editor-font-family' => 'JetBrains Mono', + 'editor-font-src-url' => font_url('gitlab-mono/GitLabMono.woff2'), + 'editor-font-family' => 'GitLab Mono', 'editor-font-format' => 'woff2' }.merge(use_new_web_ide? ? new_ide_data(project: project) : legacy_ide_data(project: project)) diff --git a/app/views/layouts/_loading_hints.html.haml b/app/views/layouts/_loading_hints.html.haml index b20b95cade8..1e6f671aacb 100644 --- a/app/views/layouts/_loading_hints.html.haml +++ b/app/views/layouts/_loading_hints.html.haml @@ -17,8 +17,6 @@ -# Do not use preload_link_tag for fonts, to work around Firefox double-fetch bug. -# See https://github.com/web-platform-tests/wpt/pull/36930 %link{ rel: 'preload', href: font_path('gitlab-sans/GitLabSans.woff2'), as: 'font', crossorigin: css_crossorigin } - %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono.woff2'), as: 'font', crossorigin: css_crossorigin } - %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono-Bold.woff2'), as: 'font', crossorigin: css_crossorigin } - %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono-Italic.woff2'), as: 'font', crossorigin: css_crossorigin } - %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono-BoldItalic.woff2'), as: 'font', crossorigin: css_crossorigin } + %link{ rel: 'preload', href: font_path('gitlab-mono/GitLabMono.woff2'), as: 'font', crossorigin: css_crossorigin } + %link{ rel: 'preload', href: font_path('gitlab-mono/GitLabMono-Italic.woff2'), as: 'font', crossorigin: css_crossorigin } = preload_link_tag(path_to_stylesheet('fonts'), crossorigin: css_crossorigin) diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml index 0570d22529b..07bae4d2396 100644 --- a/app/views/projects/merge_requests/creations/_new_compare.html.haml +++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml @@ -1,6 +1,16 @@ %h1.page-title.gl-font-size-h-display = _('New merge request') +- if @saml_groups.present? + = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false) do |c| + - c.with_body do + = s_('GroupSAML|Some branches are inaccessible because your SAML session has expired. To access the branches, select the group’s path to reauthenticate.') + - c.with_actions do + .gl-display-flex.gl-flex-wrap + - @saml_groups.each do |group| + = render Pajamas::ButtonComponent.new(href: sso_group_saml_providers_path(group, { token: group.saml_discovery_token, redirect: project_new_merge_request_branch_from_path(@source_project) }), button_options: { class: "gl-mr-3 gl-mb-3" }) do + = group.path + = gitlab_ui_form_for [@project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form js-requires-input" } do |f| - if params[:nav_source].present? = hidden_field_tag(:nav_source, params[:nav_source]) diff --git a/app/views/users/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml index 51736b4970d..7c50031598c 100644 --- a/app/views/users/_profile_basic_info.html.haml +++ b/app/views/users/_profile_basic_info.html.haml @@ -6,4 +6,4 @@ = s_('UserProfile|User ID: %{id}') % { id: @user.id } = clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id) = render 'middle_dot_divider', stacking: true do - = s_('Member since %{date}') % { date: l(@user.created_at, format: :long) } + = s_('Member since %{date}') % { date: l(@user.created_at.to_date, format: :long) } |