Commit 24281d62 authored by Martin Wortschack's avatar Martin Wortschack Committed by Filipa Lacerda

Add metric chart component to app

- Leverage MetricChart component in
productivity analytics app
parent 91aea099
......@@ -10,6 +10,7 @@ import {
} from '@gitlab/ui';
import { GlColumnChart } from '@gitlab/ui/dist/charts';
import Icon from '~/vue_shared/components/icon.vue';
import MetricChart from './metric_chart.vue';
import MergeRequestTable from './mr_table.vue';
import { chartKeys } from '../constants';
......@@ -22,6 +23,7 @@ export default {
GlColumnChart,
GlButton,
Icon,
MetricChart,
MergeRequestTable,
},
directives: {
......@@ -47,7 +49,7 @@ export default {
};
},
computed: {
...mapState('filters', ['groupNamespace', 'projectPath']),
...mapState('filters', ['groupNamespace']),
...mapState('table', ['isLoadingTable', 'mergeRequests', 'pageInfo', 'columnMetric']),
...mapGetters(['getMetricTypes']),
...mapGetters('charts', [
......@@ -56,7 +58,7 @@ export default {
'getChartData',
'getColumnChartDatazoomOption',
'getMetricDropdownLabel',
'isSelectedMetric',
'getSelectedMetric',
'hasNoAccessError',
]),
...mapGetters('table', [
......@@ -85,7 +87,6 @@ export default {
},
methods: {
...mapActions(['setEndpoint']),
...mapActions('filters', ['setProjectPath']),
...mapActions('charts', ['fetchChartData', 'setMetricType', 'chartItemClicked']),
...mapActions('table', [
'setSortField',
......@@ -141,17 +142,16 @@ export default {
/>
<template v-if="showAppContent">
<h4>{{ __('Merge Requests') }}</h4>
<div class="qa-time-to-merge mb-4">
<h5>{{ __('Time to merge') }}</h5>
<gl-loading-icon v-if="chartLoading(chartKeys.main)" size="md" class="my-4 py-4" />
<template v-else>
<div v-if="!chartHasData(chartKeys.main)" class="bs-callout bs-callout-info">
{{ __('There is no data available. Please change your selection.') }}
</div>
<template v-else>
<p class="text-muted">
{{ __('You can filter by "days to merge" by clicking on the columns in the chart.') }}
</p>
<metric-chart
ref="mainChart"
class="mb-4"
:title="__('Time to merge')"
:description="
__('You can filter by \'days to merge\' by clicking on the columns in the chart.')
"
:is-loading="chartLoading(chartKeys.main)"
:chart-data="getChartData(chartKeys.main)"
>
<gl-column-chart
:data="{ full: getChartData(chartKeys.main) }"
:option="getColumnChartOption(chartKeys.main)"
......@@ -160,66 +160,28 @@ export default {
x-axis-type="category"
@chartItemClicked="onMainChartItemClicked"
/>
</template>
</template>
</div>
</metric-chart>
<template v-if="showSecondaryCharts">
<div ref="secondaryCharts">
<div class="row">
<div class="qa-time-based col-lg-6 col-sm-12 mb-4">
<gl-loading-icon
v-if="chartLoading(chartKeys.timeBasedHistogram)"
size="md"
class="my-4 py-4"
/>
<template v-else>
<div
v-if="!chartHasData(chartKeys.timeBasedHistogram)"
class="bs-callout bs-callout-info"
>
{{ __('There is no data for the selected metric. Please change your selection.') }}
</div>
<template v-else>
<gl-dropdown
class="mb-4 metric-dropdown"
toggle-class="dropdown-menu-toggle w-100"
menu-class="w-100 mw-100"
:text="getMetricDropdownLabel(chartKeys.timeBasedHistogram)"
>
<gl-dropdown-item
v-for="metric in getMetricTypes(chartKeys.timeBasedHistogram)"
:key="metric.key"
active-class="is-active"
class="w-100"
@click="
setMetricType({
metricType: metric.key,
chartKey: chartKeys.timeBasedHistogram,
})
"
>
<span class="d-flex">
<icon
class="flex-shrink-0 append-right-4"
:class="{
invisible: !isSelectedMetric({
metric: metric.key,
chartKey: chartKeys.timeBasedHistogram,
}),
}"
name="mobile-issue-close"
/>
{{ metric.label }}
</span>
</gl-dropdown-item>
</gl-dropdown>
<p class="text-muted">
{{
<metric-chart
ref="timeBasedChart"
class="col-lg-6 col-sm-12 mb-4"
:description="
__(
'Not all data has been processed yet, the accuracy of the chart for the selected timeframe is limited.',
)
}}
</p>
"
:is-loading="chartLoading(chartKeys.timeBasedHistogram)"
:metric-types="getMetricTypes(chartKeys.timeBasedHistogram)"
:selected-metric="getSelectedMetric(chartKeys.timeBasedHistogram)"
:chart-data="getChartData(chartKeys.timeBasedHistogram)"
@metricTypeChange="
metric =>
setMetricType({ metricType: metric, chartKey: chartKeys.timeBasedHistogram })
"
>
<gl-column-chart
:data="{ full: getChartData(chartKeys.timeBasedHistogram) }"
:option="getColumnChartOption(chartKeys.timeBasedHistogram)"
......@@ -227,64 +189,25 @@ export default {
:x-axis-title="__('Hours')"
x-axis-type="category"
/>
</template>
</template>
</div>
</metric-chart>
<div class="qa-commit-based col-lg-6 col-sm-12 mb-4">
<gl-loading-icon
v-if="chartLoading(chartKeys.commitBasedHistogram)"
size="md"
class="my-4 py-4"
/>
<template v-else>
<div
v-if="!chartHasData(chartKeys.commitBasedHistogram)"
class="bs-callout bs-callout-info"
>
{{ __('There is no data for the selected metric. Please change your selection.') }}
</div>
<template v-else>
<gl-dropdown
class="mb-4 metric-dropdown"
toggle-class="dropdown-menu-toggle w-100"
menu-class="w-100 mw-100"
:text="getMetricDropdownLabel(chartKeys.commitBasedHistogram)"
>
<gl-dropdown-item
v-for="metric in getMetricTypes(chartKeys.commitBasedHistogram)"
:key="metric.key"
active-class="is-active"
class="w-100"
@click="
setMetricType({
metricType: metric.key,
chartKey: chartKeys.commitBasedHistogram,
})
"
>
<span class="d-flex">
<icon
class="flex-shrink-0 append-right-4"
:class="{
invisible: !isSelectedMetric({
metric: metric.key,
chartKey: chartKeys.commitBasedHistogram,
}),
}"
name="mobile-issue-close"
/>
{{ metric.label }}
</span>
</gl-dropdown-item>
</gl-dropdown>
<p class="text-muted">
{{
<metric-chart
ref="commitBasedChart"
class="col-lg-6 col-sm-12 mb-4"
:description="
__(
'Not all data has been processed yet, the accuracy of the chart for the selected timeframe is limited.',
)
}}
</p>
"
:is-loading="chartLoading(chartKeys.commitBasedHistogram)"
:metric-types="getMetricTypes(chartKeys.commitBasedHistogram)"
:selected-metric="getSelectedMetric(chartKeys.commitBasedHistogram)"
:chart-data="getChartData(chartKeys.commitBasedHistogram)"
@metricTypeChange="
metric =>
setMetricType({ metricType: metric, chartKey: chartKeys.commitBasedHistogram })
"
>
<gl-column-chart
:data="{ full: getChartData(chartKeys.commitBasedHistogram) }"
:option="getColumnChartOption(chartKeys.commitBasedHistogram)"
......@@ -292,13 +215,11 @@ export default {
:x-axis-title="getMetricDropdownLabel(chartKeys.commitBasedHistogram)"
x-axis-type="category"
/>
</template>
</template>
</div>
</metric-chart>
</div>
<div
class="qa-mr-table-sort d-flex flex-column flex-md-row align-items-md-center justify-content-between mb-2"
class="js-mr-table-sort d-flex flex-column flex-md-row align-items-md-center justify-content-between mb-2"
>
<h5>{{ __('List') }}</h5>
<div
......@@ -337,7 +258,10 @@ export default {
</div>
</div>
</div>
<div class="qa-mr-table">
</div>
<div class="js-mr-table">
<div ref="foo"></div>
<gl-loading-icon v-if="isLoadingTable" size="md" class="my-4 py-4" />
<merge-request-table
v-if="showMergeRequestTable"
......
......@@ -107,8 +107,7 @@ export const getColumnChartDatazoomOption = state => chartKey => {
};
};
export const isSelectedMetric = state => ({ metric, chartKey }) =>
state.charts[chartKey].params.metricType === metric;
export const getSelectedMetric = state => chartKey => state.charts[chartKey].params.metricType;
export const hasNoAccessError = state =>
state.charts[chartKeys.main].errorCode === httpStatus.FORBIDDEN;
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import ProductivityApp from 'ee/analytics/productivity_analytics/components/app.vue';
import MergeRequestTable from 'ee/analytics/productivity_analytics/components/mr_table.vue';
import store from 'ee/analytics/productivity_analytics/store';
......@@ -7,13 +9,13 @@ import { chartKeys } from 'ee/analytics/productivity_analytics/constants';
import { TEST_HOST } from 'helpers/test_constants';
import { GlEmptyState, GlLoadingIcon, GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui';
import { GlColumnChart } from '@gitlab/ui/dist/charts';
import resetStore from '../helpers';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('ProductivityApp component', () => {
let wrapper;
let mock;
const propsData = {
endpoint: TEST_HOST,
......@@ -30,7 +32,10 @@ describe('ProductivityApp component', () => {
setColumnMetric: jest.fn(),
};
const mainChartData = { 1: 2, 2: 3 };
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = shallowMount(localVue.extend(ProductivityApp), {
localVue,
store,
......@@ -40,23 +45,22 @@ describe('ProductivityApp component', () => {
...actionSpies,
},
});
jest.spyOn(store, 'dispatch').mockImplementation();
});
afterEach(() => {
wrapper.destroy();
resetStore(store);
mock.restore();
});
const findTimeToMergeSection = () => wrapper.find('.qa-time-to-merge');
const findMrTableSortSection = () => wrapper.find('.qa-mr-table-sort');
const findMrTableSection = () => wrapper.find('.qa-mr-table');
const findMrTable = () => findMrTableSection().find(MergeRequestTable);
const findMainMetricChart = () => wrapper.find({ ref: 'mainChart' });
const findSecondaryChartsSection = () => wrapper.find({ ref: 'secondaryCharts' });
const findTimeBasedMetricChart = () => wrapper.find({ ref: 'timeBasedChart' });
const findCommitBasedMetricChart = () => wrapper.find({ ref: 'commitBasedChart' });
const findMrTableSortSection = () => wrapper.find('.js-mr-table-sort');
const findSortFieldDropdown = () => findMrTableSortSection().find(GlDropdown);
const findSortOrderToggle = () => findMrTableSortSection().find(GlButton);
const findTimeBasedSection = () => wrapper.find('.qa-time-based');
const findCommitBasedSection = () => wrapper.find('.qa-commit-based');
const findMrTableSection = () => wrapper.find('.js-mr-table');
const findMrTable = () => findMrTableSection().find(MergeRequestTable);
describe('template', () => {
describe('without a group being selected', () => {
......@@ -70,12 +74,18 @@ describe('ProductivityApp component', () => {
describe('with a group being selected', () => {
beforeEach(() => {
store.state.filters.groupNamespace = 'gitlab-org';
wrapper.vm.$store.dispatch('filters/setGroupNamespace', 'gitlab-org');
mock.onGet(wrapper.vm.$store.state.endpoint).replyOnce(200);
});
describe('and user has no access to the group', () => {
describe('user has no access to the group', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.main].errorCode = 403;
const error = { response: { status: 403 } };
wrapper.vm.$store.dispatch('charts/receiveChartDataError', {
chartKey: chartKeys.main,
error,
});
wrapper.vm.$store.state.charts.charts[chartKeys.main].errorCode = 403;
});
it('renders the no access illustration', () => {
......@@ -86,46 +96,49 @@ describe('ProductivityApp component', () => {
});
});
describe('and user has access to the group', () => {
describe('user has access to the group', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.main].errorCode = null;
wrapper.vm.$store.state.charts.charts[chartKeys.main].errorCode = null;
});
describe('Time to merge chart', () => {
it('renders the title', () => {
expect(findTimeToMergeSection().text()).toContain('Time to merge');
describe('when the main chart is loading', () => {
beforeEach(() => {
wrapper.vm.$store.dispatch('charts/requestChartData', chartKeys.main);
});
describe('when chart is loading', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.main].isLoading = true;
it('renders a metric chart component for the main chart', () => {
expect(findMainMetricChart().exists()).toBe(true);
});
it('renders a loading indicator', () => {
expect(
findTimeToMergeSection()
.find(GlLoadingIcon)
.exists(),
).toBe(true);
it('sets isLoading=true on the metric chart', () => {
expect(findMainMetricChart().props('isLoading')).toBe(true);
});
it('does not render any other charts', () => {
expect(findSecondaryChartsSection().exists()).toBe(false);
});
describe('when chart finished loading', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.main].isLoading = false;
it('does not render the MR table', () => {
expect(findMrTableSortSection().exists()).toBe(false);
expect(findMrTableSection().exists()).toBe(false);
});
});
describe('and the chart has data', () => {
describe('when the main chart finished loading', () => {
describe('and has data', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.main].data = { 1: 2, 2: 3 };
wrapper.vm.$store.dispatch('charts/receiveChartDataSuccess', {
chartKey: chartKeys.main,
data: mainChartData,
});
});
it('renders a column chart', () => {
expect(
findTimeToMergeSection()
.find(GlColumnChart)
.exists(),
).toBe(true);
it('sets isLoading=false on the metric chart', () => {
expect(findMainMetricChart().props('isLoading')).toBe(false);
});
it('passes non-empty chartData to the metric chart', () => {
expect(findMainMetricChart().props('chartData')).not.toEqual([]);
});
describe('when an item on the chart is clicked', () => {
......@@ -139,7 +152,7 @@ describe('ProductivityApp component', () => {
},
};
findTimeToMergeSection()
findMainMetricChart()
.find(GlColumnChart)
.vm.$emit('chartItemClicked', data);
});
......@@ -155,188 +168,88 @@ describe('ProductivityApp component', () => {
expect(actionSpies.setMergeRequestsPage).toHaveBeenCalledWith(0);
});
});
});
describe("and the chart doesn't have any data", () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.main].data = null;
});
it('renders a "no data" message', () => {
expect(findTimeToMergeSection().text()).toContain(
'There is no data available. Please change your selection.',
);
});
});
});
});
describe('Time based histogram', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.main].isLoading = false;
store.state.charts.charts[chartKeys.main].data = { 1: 2, 2: 3 };
it('renders a metric chart component', () => {
expect(findTimeBasedMetricChart().exists()).toBe(true);
});
describe('when chart is loading', () => {
describe('when chart finished loading', () => {
describe('and the chart has data', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.timeBasedHistogram].isLoading = true;
});
it('renders a loading indicator', () => {
expect(
findTimeBasedSection()
.find(GlLoadingIcon)
.exists(),
).toBe(true);
wrapper.vm.$store.dispatch('charts/receiveChartDataSuccess', {
chartKey: chartKeys.timeBasedHistogram,
data: { 1: 2, 2: 3 },
});
});
describe('when chart finished loading', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.timeBasedHistogram].isLoading = false;
it('sets isLoading=false on the metric chart', () => {
expect(findTimeBasedMetricChart().props('isLoading')).toBe(false);
});
describe('and the chart has data', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.timeBasedHistogram].data = { 1: 2, 2: 3 };
it('passes non-empty chartData to the metric chart', () => {
expect(findTimeBasedMetricChart().props('chartData')).not.toEqual([]);
});
it('renders a metric type dropdown', () => {
expect(
findTimeBasedSection()
.find(GlDropdown)
.exists(),
).toBe(true);
});
it('should change the metric type', () => {
findTimeBasedSection()
.findAll(GlDropdownItem)
.at(0)
.vm.$emit('click');
it('should call setMetricType when `metricTypeChange` is emitted on the metric chart', () => {
findTimeBasedMetricChart().vm.$emit('metricTypeChange', 'time_to_merge');
expect(actionSpies.setMetricType).toHaveBeenCalledWith({
metricType: 'time_to_first_comment',
metricType: 'time_to_merge',
chartKey: chartKeys.timeBasedHistogram,
});
});
it('renders a column chart', () => {
expect(
findTimeBasedSection()
.find(GlColumnChart)
.exists(),
).toBe(true);
});
});
describe("and the chart doesn't have any data", () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.timeBasedHistogram].data = null;
});
it('renders a "no data" message', () => {
expect(findTimeBasedSection().text()).toContain(
'There is no data for the selected metric. Please change your selection.',
);
});
});
});
});
describe('Commit based histogram', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.main].isLoading = false;
store.state.charts.charts[chartKeys.main].data = { 1: 2, 2: 3 };
});
describe('when chart is loading', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.commitBasedHistogram].isLoading = true;
});
it('renders a loading indicator', () => {
expect(
findCommitBasedSection()
.find(GlLoadingIcon)
.exists(),
).toBe(true);
});
it('renders a metric chart component', () => {
expect(findCommitBasedMetricChart().exists()).toBe(true);
});
describe('when chart finished loading', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.commitBasedHistogram].isLoading = false;
});
describe('and the chart has data', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.commitBasedHistogram].data = { 1: 2, 2: 3 };
wrapper.vm.$store.dispatch('charts/receiveChartDataSuccess', {
chartKey: chartKeys.commitBasedHistogram,
data: { 1: 2, 2: 3 },
});
});
it('renders a column chart', () => {
expect(
findCommitBasedSection()
.find(GlColumnChart)
.exists(),
).toBe(true);
it('sets isLoading=false on the metric chart', () => {
expect(findCommitBasedMetricChart().props('isLoading')).toBe(false);
});
describe('metric dropdown', () => {
it('renders a metric type dropdown', () => {
expect(
findCommitBasedSection()
.find(GlDropdown)
.exists(),
).toBe(true);
it('passes non-empty chartData to the metric chart', () => {
expect(findCommitBasedMetricChart().props('chartData')).not.toEqual([]);
});
describe('when the user changes the metric', () => {
beforeEach(() => {
findCommitBasedSection()
.findAll(GlDropdownItem)
.at(0)
.vm.$emit('click');
findCommitBasedMetricChart().vm.$emit('metricTypeChange', 'loc_per_commit');
});
it('should dispatch setMetricType action', () => {
it('should call setMetricType when `metricTypeChange` is emitted on the metric chart', () => {
expect(actionSpies.setMetricType).toHaveBeenCalledWith({
metricType: 'commits_count',
metricType: 'loc_per_commit',
chartKey: chartKeys.commitBasedHistogram,
});
});
it("should update the chart's x axis label", () => {
const columnChart = findCommitBasedSection().find(GlColumnChart);
const columnChart = findCommitBasedMetricChart().find(GlColumnChart);
expect(columnChart.props('xAxisTitle')).toBe('Number of commits per MR');
});
});
});
});
describe("and the chart doesn't have any data", () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.commitBasedHistogram].data = null;
});
it('renders a "no data" message', () => {
expect(findTimeBasedSection().text()).toContain(
'There is no data for the selected metric. Please change your selection.',
);
});
});
});
});
describe('MR table', () => {
beforeEach(() => {
store.state.charts.charts[chartKeys.main].isLoading = false;
store.state.charts.charts[chartKeys.main].data = { 1: 2, 2: 3 };
});
describe('when table is loading', () => {
beforeEach(() => {
store.state.table.isLoadingTable = true;
wrapper.vm.$store.dispatch('table/requestMergeRequests');
});
it('renders a loading indicator', () => {
......@@ -349,13 +262,12 @@ describe('ProductivityApp component', () => {
});
describe('when table finished loading', () => {
beforeEach(() => {
store.state.table.isLoadingTable = false;
});
describe('and the table has data', () => {
beforeEach(() => {
store.state.table.mergeRequests = [{ id: 1, title: 'This is a test MR' }];
wrapper.vm.$store.dispatch('table/receiveMergeRequestsSuccess', {
headers: {},
data: [{ id: 1, title: 'This is a test MR' }],
});
});
it('renders the MR table', () => {
......@@ -372,7 +284,9 @@ describe('ProductivityApp component', () => {
it('should change the column metric', () => {
findMrTable().vm.$emit('columnMetricChange', 'time_to_first_comment');
expect(actionSpies.setColumnMetric).toHaveBeenCalledWith('time_to_first_comment');
expect(actionSpies.setColumnMetric).toHaveBeenCalledWith(
'time_to_first_comment',
);
});
it('should change the page', () => {
......@@ -381,11 +295,6 @@ describe('ProductivityApp component', () => {
expect(actionSpies.setMergeRequestsPage).toHaveBeenCalledWith(page);
});
describe('and there are merge requests available', () => {
beforeEach(() => {
store.state.table.mergeRequests = [{ id: 1 }];
});
describe('sort controls', () => {
it('renders the sort dropdown and button', () => {
expect(findSortFieldDropdown().exists()).toBe(true);
......@@ -407,11 +316,13 @@ describe('ProductivityApp component', () => {
});
});
});
});
describe("and the table doesn't have any data", () => {
beforeEach(() => {
store.state.table.mergeRequests = [];
wrapper.vm.$store.dispatch('table/receiveMergeRequestsSuccess', {
headers: {},
data: [],
});
});
it('renders a "no data" message', () => {
......@@ -434,6 +345,34 @@ describe('ProductivityApp component', () => {
});
});
});
describe('and has no data', () => {
beforeEach(() => {
wrapper.vm.$store.dispatch('charts/receiveChartDataSuccess', {
chartKey: chartKeys.main,
data: {},
});
});
it('sets isLoading=false on the metric chart', () => {
expect(findMainMetricChart().props('isLoading')).toBe(false);
});
it('passes an empty array as chartData to the metric chart', () => {
expect(findMainMetricChart().props('chartData')).toEqual([]);
});
it('does not render any other charts', () => {
expect(findSecondaryChartsSection().exists()).toBe(false);
});
it('does not render the MR table', () => {
expect(findMrTableSortSection().exists()).toBe(false);
expect(findMrTableSection().exists()).toBe(false);
});
});
});
});
});
});
});
......@@ -178,6 +178,19 @@ describe('Productivity analytics chart getters', () => {
});
});
describe('getSelectedMetric', () => {
it('returns the currently selected metric for a given chartKey', () => {
const metricType = 'time_to_last_commit';
state.charts[chartKeys.timeBasedHistogram].params = {
metricType,
};
expect(getters.getSelectedMetric(state)(chartKeys.timeBasedHistogram)).toBe(
'time_to_last_commit',
);
});
});
describe('hasNoAccessError', () => {
it('returns true if errorCode is set to 403', () => {
state.charts[chartKeys.main].errorCode = 403;
......
......@@ -15728,9 +15728,6 @@ msgstr ""
msgid "There is no data available. Please change your selection."
msgstr ""
msgid "There is no data for the selected metric. Please change your selection."
msgstr ""
msgid "There was a problem communicating with your device."
msgstr ""
......@@ -17981,7 +17978,7 @@ msgstr ""
msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
msgstr ""
msgid "You can filter by \"days to merge\" by clicking on the columns in the chart."
msgid "You can filter by 'days to merge' by clicking on the columns in the chart."
msgstr ""
msgid "You can invite a new member to <strong>%{project_name}</strong> or invite another group."
......
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