Commit f45c07d7 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'feature/swimlanes-filtering-issues' into 'master'

Swimlanes - Filtering for issues

See merge request gitlab-org/gitlab!41542
parents 0c5fd0ec 217004d7
...@@ -96,10 +96,11 @@ export default { ...@@ -96,10 +96,11 @@ export default {
showAssigneeListDetails() { showAssigneeListDetails() {
return this.list.type === 'assignee' && (this.list.isExpanded || !this.isSwimlanesHeader); return this.list.type === 'assignee' && (this.list.isExpanded || !this.isSwimlanesHeader);
}, },
issuesCount() {
return this.list.issuesSize;
},
issuesTooltipLabel() { issuesTooltipLabel() {
const { issuesSize } = this.list; return n__(`%d issue`, `%d issues`, this.issuesCount);
return n__(`%d issue`, `%d issues`, issuesSize);
}, },
chevronTooltip() { chevronTooltip() {
return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
...@@ -299,7 +300,7 @@ export default { ...@@ -299,7 +300,7 @@ export default {
<gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" /> <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" />
<span ref="issueCount" class="issue-count-badge-count"> <span ref="issueCount" class="issue-count-badge-count">
<gl-icon class="gl-mr-2" name="issues" /> <gl-icon class="gl-mr-2" name="issues" />
<issue-count :issues-size="list.issuesSize" :max-issue-count="list.maxIssueCount" /> <issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" />
</span> </span>
<!-- The following is only true in EE. --> <!-- The following is only true in EE. -->
<template v-if="weightFeatureAvailable"> <template v-if="weightFeatureAvailable">
......
...@@ -161,7 +161,12 @@ export default () => { ...@@ -161,7 +161,12 @@ export default () => {
} }
}, },
methods: { methods: {
...mapActions(['setInitialBoardData', 'setFilters', 'fetchEpicsSwimlanes']), ...mapActions([
'setInitialBoardData',
'setFilters',
'fetchEpicsSwimlanes',
'fetchIssuesForAllLists',
]),
updateTokens() { updateTokens() {
this.filterManager.updateTokens(); this.filterManager.updateTokens();
}, },
...@@ -169,6 +174,7 @@ export default () => { ...@@ -169,6 +174,7 @@ export default () => {
this.setFilters(convertObjectPropsToCamelCase(urlParamsToObject(window.location.search))); this.setFilters(convertObjectPropsToCamelCase(urlParamsToObject(window.location.search)));
if (gon.features.boardsWithSwimlanes && this.isShowingEpicsSwimlanes) { if (gon.features.boardsWithSwimlanes && this.isShowingEpicsSwimlanes) {
this.fetchEpicsSwimlanes(false); this.fetchEpicsSwimlanes(false);
this.fetchIssuesForAllLists();
} }
}, },
updateDetailIssue(newIssue, multiSelect = false) { updateDetailIssue(newIssue, multiSelect = false) {
......
#import "./issue.fragment.graphql"
query GroupListIssues($fullPath: ID!, $boardId: ID!) {
group(fullPath: $fullPath) {
board(id: $boardId) {
lists {
nodes {
id
issues {
nodes {
...IssueNode
}
}
}
}
}
}
}
#import "./issue.fragment.graphql"
query ListIssues(
$fullPath: ID!
$boardId: ID!
$filters: BoardIssueInput
$isGroup: Boolean = false
$isProject: Boolean = false
) {
group(fullPath: $fullPath) @include(if: $isGroup) {
board(id: $boardId) {
lists {
nodes {
id
issues(filters: $filters) {
nodes {
...IssueNode
}
}
}
}
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
board(id: $boardId) {
lists {
nodes {
id
issues(filters: $filters) {
nodes {
...IssueNode
}
}
}
}
}
}
}
#import "./issue.fragment.graphql"
query ProjectListIssues($fullPath: ID!, $boardId: ID!) {
project(fullPath: $fullPath) {
board(id: $boardId) {
lists {
nodes {
id
issues {
nodes {
...IssueNode
}
}
}
}
}
}
}
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { sortBy } from 'lodash'; import { sortBy, pick } from 'lodash';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
...@@ -10,8 +10,7 @@ import * as types from './mutation_types'; ...@@ -10,8 +10,7 @@ import * as types from './mutation_types';
import { formatListIssues, fullBoardId } from '../boards_util'; import { formatListIssues, fullBoardId } from '../boards_util';
import boardStore from '~/boards/stores/boards_store'; import boardStore from '~/boards/stores/boards_store';
import groupListsIssuesQuery from '../queries/group_lists_issues.query.graphql'; import listsIssuesQuery from '../queries/lists_issues.query.graphql';
import projectListsIssuesQuery from '../queries/project_lists_issues.query.graphql';
import projectBoardQuery from '../queries/project_board.query.graphql'; import projectBoardQuery from '../queries/project_board.query.graphql';
import groupBoardQuery from '../queries/group_board.query.graphql'; import groupBoardQuery from '../queries/group_board.query.graphql';
import createBoardListMutation from '../queries/board_list_create.mutation.graphql'; import createBoardListMutation from '../queries/board_list_create.mutation.graphql';
...@@ -38,7 +37,14 @@ export default { ...@@ -38,7 +37,14 @@ export default {
}, },
setFilters: ({ commit }, filters) => { setFilters: ({ commit }, filters) => {
const { scope, utf8, state, ...filterParams } = filters; const filterParams = pick(filters, [
'assigneeUsername',
'authorUsername',
'labelName',
'milestoneTitle',
'releaseTag',
'search',
]);
commit(types.SET_FILTERS, filterParams); commit(types.SET_FILTERS, filterParams);
}, },
...@@ -197,19 +203,20 @@ export default { ...@@ -197,19 +203,20 @@ export default {
fetchIssuesForAllLists: ({ state, commit }) => { fetchIssuesForAllLists: ({ state, commit }) => {
commit(types.REQUEST_ISSUES_FOR_ALL_LISTS); commit(types.REQUEST_ISSUES_FOR_ALL_LISTS);
const { endpoints, boardType } = state; const { endpoints, boardType, filterParams } = state;
const { fullPath, boardId } = endpoints; const { fullPath, boardId } = endpoints;
const query = boardType === BoardType.group ? groupListsIssuesQuery : projectListsIssuesQuery;
const variables = { const variables = {
fullPath, fullPath,
boardId: fullBoardId(boardId), boardId: fullBoardId(boardId),
filters: filterParams,
isGroup: boardType === BoardType.group,
isProject: boardType === BoardType.project,
}; };
return gqlClient return gqlClient
.query({ .query({
query, query: listsIssuesQuery,
variables, variables,
}) })
.then(({ data }) => { .then(({ data }) => {
......
...@@ -2,6 +2,11 @@ export function getMilestone({ milestone }) { ...@@ -2,6 +2,11 @@ export function getMilestone({ milestone }) {
return milestone || null; return milestone || null;
} }
export function fullEpicId(epicId) {
return `gid://gitlab/Epic/${epicId}`;
}
export default { export default {
getMilestone, getMilestone,
fullEpicId,
}; };
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions, mapGetters } from 'vuex';
import BoardListHeaderFoss from '~/boards/components/board_list_header.vue'; import BoardListHeaderFoss from '~/boards/components/board_list_header.vue';
import { __, sprintf, s__ } from '~/locale'; import { __, sprintf, s__, n__ } from '~/locale';
import boardsStore from '~/boards/stores/boards_store'; import boardsStore from '~/boards/stores/boards_store';
import { inactiveId, LIST } from '~/boards/constants'; import { inactiveId, LIST } from '~/boards/constants';
import eventHub from '~/sidebar/event_hub'; import eventHub from '~/sidebar/event_hub';
...@@ -14,13 +14,21 @@ export default { ...@@ -14,13 +14,21 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['activeId']), ...mapState(['activeId', 'issuesByListId']),
...mapGetters(['isSwimlanesOn']),
issuesCount() {
if (this.isSwimlanesOn) {
return this.issuesByListId[this.list.id] ? this.issuesByListId[this.list.id].length : 0;
}
return this.list.issuesSize;
},
issuesTooltip() { issuesTooltip() {
const { issuesSize, maxIssueCount } = this.list; const { maxIssueCount } = this.list;
if (maxIssueCount > 0) { if (maxIssueCount > 0) {
return sprintf(__('%{issuesSize} issues with a limit of %{maxIssueCount}'), { return sprintf(__('%{issuesCount} issues with a limit of %{maxIssueCount}'), {
issuesSize, issuesCount: this.issuesCount,
maxIssueCount, maxIssueCount,
}); });
} }
...@@ -28,6 +36,9 @@ export default { ...@@ -28,6 +36,9 @@ export default {
// TODO: Remove this pattern. // TODO: Remove this pattern.
return BoardListHeaderFoss.computed.issuesTooltip.call(this); return BoardListHeaderFoss.computed.issuesTooltip.call(this);
}, },
issuesTooltipLabel() {
return n__(`%d issue`, `%d issues`, this.issuesCount);
},
weightCountToolTip() { weightCountToolTip() {
const { totalWeight } = this.list; const { totalWeight } = this.list;
......
...@@ -134,7 +134,7 @@ export default { ...@@ -134,7 +134,7 @@ export default {
:disabled="disabled" :disabled="disabled"
:root-path="rootPath" :root-path="rootPath"
/> />
<div class="board-lane-unassigned-issues gl-sticky gl-display-inline-block gl-left-0"> <div class="board-lane-unassigned-issues-title gl-sticky gl-display-inline-block gl-left-0">
<div class="gl-left-0 gl-py-5 gl-px-3 gl-display-flex gl-align-items-center"> <div class="gl-left-0 gl-py-5 gl-px-3 gl-display-flex gl-align-items-center">
<span <span
class="gl-mr-3 gl-font-weight-bold gl-white-space-nowrap gl-text-overflow-ellipsis gl-overflow-hidden" class="gl-mr-3 gl-font-weight-bold gl-white-space-nowrap gl-text-overflow-ellipsis gl-overflow-hidden"
...@@ -154,7 +154,7 @@ export default { ...@@ -154,7 +154,7 @@ export default {
</span> </span>
</div> </div>
</div> </div>
<div class="gl-display-flex"> <div class="gl-display-flex" data-testid="board-lane-unassigned-issues">
<issues-lane-list <issues-lane-list
v-for="list in lists" v-for="list in lists"
:key="`${list.id}-issues`" :key="`${list.id}-issues`"
......
export const DRAGGABLE_TAG = 'div'; export const DRAGGABLE_TAG = 'div';
/* eslint-disable @gitlab/require-i18n-strings */
export const EpicFilterType = {
any: 'Any',
none: 'None',
};
export default { export default {
DRAGGABLE_TAG, DRAGGABLE_TAG,
EpicFilterType,
}; };
import { sortBy } from 'lodash'; import { sortBy, pick } from 'lodash';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import boardsStore from '~/boards/stores/boards_store'; import boardsStore from '~/boards/stores/boards_store';
...@@ -6,11 +6,13 @@ import { __ } from '~/locale'; ...@@ -6,11 +6,13 @@ import { __ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import actionsCE from '~/boards/stores/actions'; import actionsCE from '~/boards/stores/actions';
import { BoardType, ListType } from '~/boards/constants'; import { BoardType, ListType } from '~/boards/constants';
import { EpicFilterType } from '../constants';
import boardsStoreEE from './boards_store_ee'; import boardsStoreEE from './boards_store_ee';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { fullEpicId } from '../boards_util';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import groupEpicsSwimlanesQuery from '../queries/group_epics_swimlanes.query.graphql'; import epicsSwimlanesQuery from '../queries/epics_swimlanes.query.graphql';
const notImplemented = () => { const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */ /* eslint-disable-next-line @gitlab/require-i18n-strings */
...@@ -22,6 +24,27 @@ const gqlClient = createDefaultClient(); ...@@ -22,6 +24,27 @@ const gqlClient = createDefaultClient();
export default { export default {
...actionsCE, ...actionsCE,
setFilters: ({ commit }, filters) => {
const filterParams = pick(filters, [
'assigneeUsername',
'authorUsername',
'epicId',
'labelName',
'milestoneTitle',
'releaseTag',
'search',
'weight',
]);
if (filterParams.epicId === EpicFilterType.any || filterParams.epicId === EpicFilterType.none) {
filterParams.epicWildcardId = filterParams.epicId.toUpperCase();
filterParams.epicId = undefined;
} else if (filterParams.epicId) {
filterParams.epicId = fullEpicId(filterParams.epicId);
}
commit(types.SET_FILTERS, filterParams);
},
fetchEpicsSwimlanes({ state, commit }, withLists = true) { fetchEpicsSwimlanes({ state, commit }, withLists = true) {
const { endpoints, boardType, filterParams } = state; const { endpoints, boardType, filterParams } = state;
const { fullPath, boardId } = endpoints; const { fullPath, boardId } = endpoints;
...@@ -37,7 +60,7 @@ export default { ...@@ -37,7 +60,7 @@ export default {
return gqlClient return gqlClient
.query({ .query({
query: groupEpicsSwimlanesQuery, query: epicsSwimlanesQuery,
variables, variables,
}) })
.then(({ data }) => { .then(({ data }) => {
......
...@@ -16,3 +16,4 @@ export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS'; ...@@ -16,3 +16,4 @@ export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS';
export const RECEIVE_SWIMLANES_FAILURE = 'RECEIVE_SWIMLANES_FAILURE'; export const RECEIVE_SWIMLANES_FAILURE = 'RECEIVE_SWIMLANES_FAILURE';
export const RECEIVE_EPICS_SUCCESS = 'RECEIVE_EPICS_SUCCESS'; export const RECEIVE_EPICS_SUCCESS = 'RECEIVE_EPICS_SUCCESS';
export const SET_SHOW_LABELS = 'SET_SHOW_LABELS'; export const SET_SHOW_LABELS = 'SET_SHOW_LABELS';
export const SET_FILTERS = 'SET_FILTERS';
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'epics swimlanes filtering', :js do
let_it_be(:user) { create(:user) }
let_it_be(:user2) { create(:user) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, group: group) }
let_it_be(:board) { create(:board, project: project) }
let_it_be(:milestone) { create(:milestone, project: project) }
let_it_be(:planning) { create(:label, project: project, name: 'Planning', description: 'Test') }
let_it_be(:development) { create(:label, project: project, name: 'Development') }
let_it_be(:testing) { create(:label, project: project, name: 'Testing') }
let_it_be(:backlog) { create(:label, project: project, name: 'Backlog') }
let_it_be(:closed) { create(:label, project: project, name: 'Closed') }
let_it_be(:list1) { create(:list, board: board, label: planning, position: 0) }
let_it_be(:list2) { create(:list, board: board, label: development, position: 1) }
let_it_be(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning], relative_position: 9) }
let_it_be(:issue1) { create(:labeled_issue, project: project, title: 'aaa', description: '111', assignees: [user], labels: [planning], relative_position: 8) }
let_it_be(:issue2) { create(:labeled_issue, project: project, title: 'bbb', description: '222', author: user2, labels: [planning], relative_position: 7) }
let_it_be(:issue3) { create(:labeled_issue, project: project, title: 'ccc', description: '333', labels: [planning, testing], relative_position: 6) }
let_it_be(:issue4) { create(:labeled_issue, project: project, title: 'ddd', description: '444', labels: [planning], relative_position: 5) }
let_it_be(:issue5) { create(:labeled_issue, project: project, title: 'eee', description: '555', labels: [planning], milestone: milestone, relative_position: 4) }
let_it_be(:issue6) { create(:labeled_issue, project: project, title: 'fff', description: '666', labels: [planning, development], relative_position: 3) }
let_it_be(:issue7) { create(:labeled_issue, project: project, title: 'ggg', description: '777', labels: [development], relative_position: 2) }
let_it_be(:issue8) { create(:closed_issue, project: project, title: 'hhh', description: '888') }
context 'filtering' do
before do
project.add_maintainer(user)
project.add_maintainer(user2)
stub_licensed_features(epics: true)
sign_in(user)
visit_board_page
page.within('.board-swimlanes-toggle-wrapper') do
page.find('.dropdown-toggle').click
page.find('.dropdown-item', text: 'Epic').click
end
stub_const("Gitlab::QueryLimiting::Transaction::THRESHOLD", 200)
end
it 'filters by author' do
set_filter("author", user2.username)
click_filter_link(user2.username)
submit_filter
wait_for_requests
wait_for_board_cards(2, 1)
wait_for_empty_boards((3..4))
end
it 'filters by assignee' do
set_filter("assignee", user.username)
click_filter_link(user.username)
submit_filter
wait_for_requests
wait_for_board_cards(2, 1)
wait_for_empty_boards((3..4))
end
it 'filters by milestone' do
set_filter("milestone", "\"#{milestone.title}")
click_filter_link(milestone.title)
submit_filter
wait_for_requests
wait_for_board_cards(2, 1)
wait_for_board_cards(3, 0)
wait_for_board_cards(4, 0)
end
it 'filters by label' do
set_filter("label", testing.title)
click_filter_link(testing.title)
submit_filter
wait_for_requests
wait_for_board_cards(2, 1)
wait_for_empty_boards((3..4))
end
end
def visit_board_page
visit project_boards_path(project)
wait_for_requests
end
def wait_for_board_cards(board_number, expected_cards)
page.within(find(".board-swimlanes-headers .board:nth-child(#{board_number})")) do
expect(page.find('.board-header')).to have_content(expected_cards.to_s)
end
page.within(find("[data-testid='board-lane-unassigned-issues'] .board:nth-child(#{board_number})")) do
expect(page).to have_selector('.board-card', count: expected_cards)
end
end
def wait_for_empty_boards(board_numbers)
board_numbers.each do |board|
wait_for_board_cards(board, 0)
end
end
def set_filter(type, text)
find('.filtered-search').native.send_keys("#{type}:=#{text}")
end
def submit_filter
find('.filtered-search').native.send_keys(:enter)
end
def click_filter_link(link_text)
page.within('.filtered-search-box') do
expect(page).to have_button(link_text)
click_button(link_text)
end
end
end
...@@ -22,7 +22,6 @@ RSpec.describe 'epics swimlanes', :js do ...@@ -22,7 +22,6 @@ RSpec.describe 'epics swimlanes', :js do
let_it_be(:epic_issue2) { create(:epic_issue, epic: epic2, issue: issue2) } let_it_be(:epic_issue2) { create(:epic_issue, epic: epic2, issue: issue2) }
context 'switch to swimlanes view' do context 'switch to swimlanes view' do
context 'feature flag on' do
before do before do
stub_licensed_features(epics: true) stub_licensed_features(epics: true)
sign_in(user) sign_in(user)
...@@ -42,12 +41,11 @@ RSpec.describe 'epics swimlanes', :js do ...@@ -42,12 +41,11 @@ RSpec.describe 'epics swimlanes', :js do
end end
it 'displays issue not assigned to epic in unassigned issues lane' do it 'displays issue not assigned to epic in unassigned issues lane' do
page.within('.board-lane-unassigned-issues') do page.within('.board-lane-unassigned-issues-title') do
expect(page.find('span[data-testid="issues-lane-issue-count"]')).to have_content('1') expect(page.find('span[data-testid="issues-lane-issue-count"]')).to have_content('1')
end end
end end
end end
end
def visit_board_page def visit_board_page
visit project_boards_path(project) visit project_boards_path(project)
......
...@@ -6,6 +6,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; ...@@ -6,6 +6,7 @@ import AxiosMockAdapter from 'axios-mock-adapter';
import BoardListHeader from 'ee/boards/components/board_list_header.vue'; import BoardListHeader from 'ee/boards/components/board_list_header.vue';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import { listObj } from 'jest/boards/mock_data'; import { listObj } from 'jest/boards/mock_data';
import getters from 'ee/boards/stores/getters';
import List from '~/boards/models/list'; import List from '~/boards/models/list';
import { ListType, inactiveId } from '~/boards/constants'; import { ListType, inactiveId } from '~/boards/constants';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
...@@ -28,7 +29,7 @@ describe('Board List Header Component', () => { ...@@ -28,7 +29,7 @@ describe('Board List Header Component', () => {
window.gon = {}; window.gon = {};
axiosMock = new AxiosMockAdapter(axios); axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] }); axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
store = new Vuex.Store({ state: { activeId: inactiveId } }); store = new Vuex.Store({ state: { activeId: inactiveId }, getters });
jest.spyOn(store, 'dispatch').mockImplementation(); jest.spyOn(store, 'dispatch').mockImplementation();
}); });
......
...@@ -13,6 +13,44 @@ const expectNotImplemented = action => { ...@@ -13,6 +13,44 @@ const expectNotImplemented = action => {
}); });
}; };
describe('setFilters', () => {
it('should commit mutation SET_FILTERS, updates epicId with global id', done => {
const state = {
filters: {},
};
const filters = { labelName: 'label', epicId: 1 };
const updatedFilters = { labelName: 'label', epicId: 'gid://gitlab/Epic/1' };
testAction(
actions.setFilters,
filters,
state,
[{ type: types.SET_FILTERS, payload: updatedFilters }],
[],
done,
);
});
it('should commit mutation SET_FILTERS, updates epicWildcardId', done => {
const state = {
filters: {},
};
const filters = { labelName: 'label', epicId: 'None' };
const updatedFilters = { labelName: 'label', epicWildcardId: 'NONE' };
testAction(
actions.setFilters,
filters,
state,
[{ type: types.SET_FILTERS, payload: updatedFilters }],
[],
done,
);
});
});
describe('setShowLabels', () => { describe('setShowLabels', () => {
it('should commit mutation SET_SHOW_LABELS', done => { it('should commit mutation SET_SHOW_LABELS', done => {
const state = { const state = {
......
...@@ -452,7 +452,7 @@ msgstr "" ...@@ -452,7 +452,7 @@ msgstr ""
msgid "%{issuableType} will be removed! Are you sure?" msgid "%{issuableType} will be removed! Are you sure?"
msgstr "" msgstr ""
msgid "%{issuesSize} issues with a limit of %{maxIssueCount}" msgid "%{issuesCount} issues with a limit of %{maxIssueCount}"
msgstr "" msgstr ""
msgid "%{issuesSize} with a limit of %{maxIssueCount}" msgid "%{issuesSize} with a limit of %{maxIssueCount}"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment