Skip to content
Snippets Groups Projects
Verified Commit 237fef72 authored by Phil Hughes's avatar Phil Hughes
Browse files

Merge request activity filter

parent 89bd8e6f
No related branches found
No related tags found
No related merge requests found
Showing
with 364 additions and 3 deletions
......@@ -43,6 +43,7 @@ export default ({ editorAiActions = [] } = {}) => {
reportAbusePath: notesDataset.reportAbusePath,
newCommentTemplatePath: notesDataset.newCommentTemplatePath,
editorAiActions,
mrFilter: true,
},
data() {
const noteableData = JSON.parse(notesDataset.noteableData);
......
<script>
import { GlCollapsibleListbox, GlButton, GlIcon, GlSprintf, GlButtonGroup } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { __ } from '~/locale';
import { MR_FILTER_OPTIONS } from '~/notes/constants';
export default {
components: {
GlCollapsibleListbox,
GlButton,
GlButtonGroup,
GlIcon,
GlSprintf,
LocalStorageSync,
},
data() {
return {
selectedFilters: MR_FILTER_OPTIONS.map((f) => f.value),
};
},
computed: {
...mapState({
mergeRequestFilters: (state) => state.notes.mergeRequestFilters,
discussionSortOrder: (state) => state.notes.discussionSortOrder,
}),
selectedFilterText() {
if (this.mergeRequestFilters.length === 0) return __('None');
const firstSelected = MR_FILTER_OPTIONS.find(
({ value }) => this.mergeRequestFilters[0] === value,
);
if (this.mergeRequestFilters.length === MR_FILTER_OPTIONS.length) {
return __('All activity');
} else if (this.mergeRequestFilters.length > 1) {
return `%{strongStart}${firstSelected.text}%{strongEnd} +${
this.mergeRequestFilters.length - 1
} more`;
}
return firstSelected.text;
},
},
methods: {
...mapActions(['updateMergeRequestFilters', 'setDiscussionSortDirection']),
updateSortDirection() {
this.setDiscussionSortDirection({
direction: this.discussionSortOrder === 'asc' ? 'desc' : 'asc',
});
},
applyFilters() {
this.updateMergeRequestFilters(this.selectedFilters);
},
localSyncFilters(filters) {
this.updateMergeRequestFilters(filters);
this.selectedFilters = filters;
},
},
MR_FILTER_OPTIONS,
};
</script>
<template>
<div>
<local-storage-sync
:value="discussionSortOrder"
storage-key="sort_direction_merge_request"
as-string
@input="setDiscussionSortDirection({ direction: $event })"
/>
<local-storage-sync
:value="mergeRequestFilters"
storage-key="mr_activity_filters"
@input="localSyncFilters"
/>
<gl-button-group>
<gl-collapsible-listbox
v-model="selectedFilters"
:items="$options.MR_FILTER_OPTIONS"
multiple
placement="right"
@hidden="applyFilters"
>
<template #toggle>
<gl-button class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!">
<gl-sprintf :message="selectedFilterText">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
<gl-icon name="chevron-down" />
</gl-button>
</template>
<template #list-item="{ item }">
<strong v-if="item.value === '*'">{{ item.text }}</strong>
<span v-else>{{ item.text }}</span>
</template>
</gl-collapsible-listbox>
<gl-button
:icon="discussionSortOrder === 'asc' ? 'sort-lowest' : 'sort-highest'"
@click="updateSortDirection"
/>
</gl-button-group>
</div>
</template>
......@@ -8,6 +8,7 @@ export default {
DiscussionFilter,
AiSummarizeNotes: () =>
import('ee_component/notes/components/note_actions/ai_summarize_notes.vue'),
MrDiscussionFilter: () => import('./mr_discussion_filter.vue'),
},
mixins: [glFeatureFlagsMixin()],
inject: {
......@@ -15,6 +16,9 @@ export default {
default: false,
},
resourceGlobalId: { default: null },
mrFilter: {
default: false,
},
},
props: {
notesFilters: {
......@@ -52,7 +56,8 @@ export default {
:loading="aiLoading"
/>
<timeline-toggle v-if="showTimelineViewToggle" />
<discussion-filter :filters="notesFilters" :selected-value="notesFilterValue" />
<mr-discussion-filter v-if="mrFilter && glFeatures.mrActivityFilters" />
<discussion-filter v-else :filters="notesFilters" :selected-value="notesFilterValue" />
</div>
</div>
</template>
import { STATUS_CLOSED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants';
import { __ } from '~/locale';
import { __, s__ } from '~/locale';
export const DISCUSSION_NOTE = 'DiscussionNote';
export const DIFF_NOTE = 'DiffNote';
......@@ -56,3 +56,63 @@ export const toggleStateErrorMessage = {
),
},
};
export const MR_FILTER_OPTIONS = [
{
text: __('Approvals'),
value: 'approval',
systemNoteIcons: ['approval', 'unapproval'],
},
{
text: __('Commits & branches'),
value: 'commit_branches',
systemNoteIcons: ['commit', 'fork'],
},
{
text: __('Merge request status'),
value: 'status',
systemNoteIcons: ['git-merge', 'issue-close', 'issues'],
},
{
text: __('Assignees & reviewers'),
value: 'assignees_reviewers',
noteText: [
s__('IssuableEvents|requested review from'),
s__('IssuableEvents|removed review request for'),
s__('IssuableEvents|assigned to'),
s__('IssuableEvents|unassigned'),
],
},
{
text: __('Edits'),
value: 'edits',
systemNoteIcons: ['pencil', 'task-done'],
},
{
text: __('Labels'),
value: 'labels',
systemNoteIcons: ['label'],
},
{
text: __('Mentions'),
value: 'mentions',
systemNoteIcons: ['comment-dots'],
},
{
text: __('Tracking'),
value: 'tracking',
noteType: ['MilestoneNote'],
systemNoteIcons: ['timer'],
},
{
text: __('Comments'),
value: 'comments',
noteType: ['DiscussionNote', 'DiffNote'],
individualNote: true,
},
{
text: __('Lock status'),
value: 'lock_status',
systemNoteIcons: ['lock', 'lock-open'],
},
];
......@@ -897,3 +897,6 @@ export const updateAssignees = ({ commit }, assignees) => {
export const updateDiscussionPosition = ({ commit }, updatedPosition) => {
commit(types.UPDATE_DISCUSSION_POSITION, updatedPosition);
};
export const updateMergeRequestFilters = ({ commit }, newFilters) =>
commit(types.SET_MERGE_REQUEST_FILTERS, newFilters);
......@@ -22,10 +22,44 @@ const getDraftComments = (state) => {
.sort((a, b) => a.id - b.id);
};
const hideActivity = (filters, discussion) => {
const firstNote = discussion.notes[0];
return constants.MR_FILTER_OPTIONS.some((f) => {
if (filters.includes(f.value) || f.value === '*') return false;
if (
f.systemNoteIcons?.includes(firstNote.system_note_icon_name) ||
f.noteType?.includes(firstNote.type) ||
(firstNote.system && f.noteText?.some((t) => firstNote.note.includes(t))) ||
(f.individualNote === discussion.individual_note && !firstNote.system)
) {
return true;
}
return false;
});
};
export const discussions = (state, getters, rootState) => {
let discussionsInState = clone(state.discussions);
// NOTE: not testing bc will be removed when backend is finished.
if (
state.noteableData.targetType === 'merge_request' &&
window.gon?.features?.mrActivityFilters
) {
discussionsInState = discussionsInState.reduce((acc, discussion) => {
if (hideActivity(state.mergeRequestFilters, discussion)) {
return acc;
}
acc.push(discussion);
return acc;
}, []);
}
if (state.isTimelineEnabled) {
discussionsInState = discussionsInState
.reduce((acc, discussion) => {
......
import { ASC } from '../../constants';
import { ASC, MR_FILTER_OPTIONS } from '../../constants';
import * as actions from '../actions';
import * as getters from '../getters';
import mutations from '../mutations';
......@@ -51,6 +51,7 @@ export default () => ({
isTimelineEnabled: false,
isFetching: false,
isPollingInitialized: false,
mergeRequestFilters: MR_FILTER_OPTIONS.map((f) => f.value),
},
actions,
getters,
......
......@@ -61,3 +61,5 @@ export const RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DELETE_DESCRIPT
// Incidents
export const SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS = 'SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS';
export const SET_MERGE_REQUEST_FILTERS = 'SET_MERGE_REQUEST_FILTERS';
......@@ -432,4 +432,7 @@ export default {
[types.SET_IS_POLLING_INITIALIZED](state, value) {
state.isPollingInitialized = value;
},
[types.SET_MERGE_REQUEST_FILTERS](state, value) {
state.mergeRequestFilters = value;
},
};
......@@ -52,6 +52,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:hide_create_issue_resolve_all, project)
push_frontend_feature_flag(:auto_merge_labels_mr_widget, project)
push_frontend_feature_flag(:summarize_my_code_review, current_user)
push_frontend_feature_flag(:mr_activity_filters, current_user)
end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :diffs, :discussions]
......
---
name: mr_activity_filters
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/115383
rollout_issue_url:
milestone: '15.11'
type: development
group: group::code review
default_enabled: false
......@@ -4385,6 +4385,9 @@ msgstr ""
msgid "All Members"
msgstr ""
 
msgid "All activity"
msgstr ""
msgid "All branch names must match %{link_start}this regular expression%{link_end}. If empty, any branch name is allowed."
msgstr ""
 
......@@ -5651,6 +5654,9 @@ msgstr ""
msgid "ApprovalSettings|When a commit is added:"
msgstr ""
 
msgid "Approvals"
msgstr ""
msgid "Approvals are optional."
msgstr ""
 
......@@ -6163,6 +6169,9 @@ msgstr ""
msgid "Assignees"
msgstr ""
 
msgid "Assignees & reviewers"
msgstr ""
msgid "Assigns %{assignee_users_sentence}."
msgstr ""
 
......@@ -10966,6 +10975,9 @@ msgstr ""
msgid "Commits"
msgstr ""
 
msgid "Commits & branches"
msgstr ""
msgid "Commits feed"
msgstr ""
 
......@@ -16212,6 +16224,9 @@ msgstr ""
msgid "Editing rich text"
msgstr ""
 
msgid "Edits"
msgstr ""
msgid "Elapsed time"
msgstr ""
 
......@@ -26699,6 +26714,9 @@ msgstr ""
msgid "Lock not found"
msgstr ""
 
msgid "Lock status"
msgstr ""
msgid "Lock the discussion"
msgstr ""
 
......@@ -27600,6 +27618,9 @@ msgstr ""
msgid "Memory Usage"
msgstr ""
 
msgid "Mentions"
msgstr ""
msgid "Menu"
msgstr ""
 
......@@ -27690,6 +27711,9 @@ msgstr ""
msgid "Merge request reports"
msgstr ""
 
msgid "Merge request status"
msgstr ""
msgid "Merge request unlocked."
msgstr ""
 
......@@ -46983,6 +47007,9 @@ msgstr ""
msgid "Track time with quick actions"
msgstr ""
 
msgid "Tracking"
msgstr ""
msgid "Training mode"
msgstr ""
 
import { mount } from '@vue/test-utils';
import { GlCollapsibleListbox, GlListboxItem, GlButton } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import DiscussionFilter from '~/notes/components/mr_discussion_filter.vue';
import { MR_FILTER_OPTIONS } from '~/notes/constants';
Vue.use(Vuex);
describe('Merge request discussion filter component', () => {
let wrapper;
let store;
let updateMergeRequestFilters;
let setDiscussionSortDirection;
function createComponent(mergeRequestFilters = MR_FILTER_OPTIONS.map((f) => f.value)) {
updateMergeRequestFilters = jest.fn();
setDiscussionSortDirection = jest.fn();
store = new Vuex.Store({
modules: {
notes: {
state: {
mergeRequestFilters,
discussionSortOrder: 'asc',
},
actions: {
updateMergeRequestFilters,
setDiscussionSortDirection,
},
},
},
});
wrapper = mount(DiscussionFilter, {
store,
});
}
afterEach(() => {
localStorage.removeItem('mr_activity_filters');
localStorage.removeItem('sort_direction_merge_request');
});
describe('local sync sort direction', () => {
it('calls setDiscussionSortDirection when mounted', () => {
localStorage.setItem('sort_direction_merge_request', 'desc');
createComponent();
expect(setDiscussionSortDirection).toHaveBeenCalledWith(expect.anything(), {
direction: 'desc',
});
});
});
describe('local sync sort filters', () => {
it('calls setDiscussionSortDirection when mounted', () => {
localStorage.setItem('mr_activity_filters', '["comments"]');
createComponent();
expect(updateMergeRequestFilters).toHaveBeenCalledWith(expect.anything(), ['comments']);
});
});
it('lists current filters', () => {
createComponent();
expect(wrapper.findAllComponents(GlListboxItem).length).toBe(MR_FILTER_OPTIONS.length);
});
it('updates store when selecting filter', async () => {
createComponent();
wrapper.findComponent(GlListboxItem).vm.$emit('select');
await nextTick();
wrapper.findComponent(GlCollapsibleListbox).vm.$emit('hidden');
expect(updateMergeRequestFilters).toHaveBeenCalledWith(expect.anything(), [
'commit_branches',
'status',
'assignees_reviewers',
'edits',
'labels',
'mentions',
'tracking',
'comments',
'lock_status',
]);
});
it.each`
state | expectedText
${['status']} | ${'Merge request status'}
${['status', 'comments']} | ${'Merge request status +1 more'}
${[]} | ${'None'}
${MR_FILTER_OPTIONS.map((f) => f.value)} | ${'All activity'}
`('updates toggle text to $expectedText with $state', async ({ state, expectedText }) => {
createComponent();
store.state.notes.mergeRequestFilters = state;
await nextTick();
expect(wrapper.findComponent(GlButton).text()).toBe(expectedText);
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment