Skip to content
代码片段 群组 项目
提交 73bdd360 编辑于 作者: Eduardo Bonet's avatar Eduardo Bonet 提交者: Phil Hughes
浏览文件

Improve UI for Candidate CI info

- Includes the title of the MR, if exists
- Displays a labeled avatar for the CI user, if exists

Changelog: changed
上级 4ccb5176
No related branches found
No related tags found
无相关合并请求
<script> <script>
import { GlLink } from '@gitlab/ui';
export default { export default {
name: 'CandidateDetailRow', name: 'CandidateDetailRow',
components: {
GlLink,
},
props: { props: {
label: { label: {
type: String, type: String,
required: true, required: true,
}, },
text: {
type: [String, Number],
required: true,
},
href: {
type: String,
required: false,
default: '',
},
sectionLabel: { sectionLabel: {
type: String, type: String,
required: false, required: false,
...@@ -34,8 +20,7 @@ export default { ...@@ -34,8 +20,7 @@ export default {
<td class="gl-text-secondary gl-font-weight-bold">{{ sectionLabel }}</td> <td class="gl-text-secondary gl-font-weight-bold">{{ sectionLabel }}</td>
<td class="gl-font-weight-bold">{{ label }}</td> <td class="gl-font-weight-bold">{{ label }}</td>
<td> <td>
<gl-link v-if="href" :href="href">{{ text }}</gl-link> <slot></slot>
<template v-else>{{ text }}</template>
</td> </td>
</tr> </tr>
</template> </template>
<script> <script>
import { GlAvatarLabeled, GlLink } from '@gitlab/ui';
import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue'; import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue';
import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue';
import DetailRow from './components/candidate_detail_row.vue'; import DetailRow from './components/candidate_detail_row.vue';
...@@ -29,6 +30,8 @@ export default { ...@@ -29,6 +30,8 @@ export default {
ModelExperimentsHeader, ModelExperimentsHeader,
DeleteButton, DeleteButton,
DetailRow, DetailRow,
GlAvatarLabeled,
GlLink,
}, },
props: { props: {
candidate: { candidate: {
...@@ -94,52 +97,51 @@ export default { ...@@ -94,52 +97,51 @@ export default {
<tbody> <tbody>
<tr class="divider"></tr> <tr class="divider"></tr>
<detail-row <detail-row :label="$options.i18n.ID_LABEL" :section-label="$options.i18n.INFO_LABEL">
:label="$options.i18n.ID_LABEL" {{ info.iid }}
:section-label="$options.i18n.INFO_LABEL" </detail-row>
:text="info.iid"
/>
<detail-row :label="$options.i18n.MLFLOW_ID_LABEL" :text="info.eid" /> <detail-row :label="$options.i18n.MLFLOW_ID_LABEL">{{ info.eid }}</detail-row>
<detail-row :label="$options.i18n.STATUS_LABEL" :text="info.status" /> <detail-row :label="$options.i18n.STATUS_LABEL">{{ info.status }}</detail-row>
<detail-row <detail-row :label="$options.i18n.EXPERIMENT_LABEL">
:label="$options.i18n.EXPERIMENT_LABEL" <gl-link :href="info.path_to_experiment">
:text="info.experiment_name" {{ info.experiment_name }}
:href="info.path_to_experiment" </gl-link>
/> </detail-row>
<detail-row <detail-row v-if="info.path_to_artifact" :label="$options.i18n.ARTIFACTS_LABEL">
v-if="info.path_to_artifact" <gl-link :href="info.path_to_artifact">
:label="$options.i18n.ARTIFACTS_LABEL" {{ $options.i18n.ARTIFACTS_LABEL }}
:href="info.path_to_artifact" </gl-link>
:text="$options.i18n.ARTIFACTS_LABEL" </detail-row>
/>
<template v-if="ciJob"> <template v-if="ciJob">
<tr class="divider"></tr> <tr class="divider"></tr>
<detail-row <detail-row
:label="$options.i18n.JOB_LABEL" :label="$options.i18n.JOB_LABEL"
:text="ciJob.name"
:href="ciJob.path"
:section-label="$options.i18n.CI_SECTION_LABEL" :section-label="$options.i18n.CI_SECTION_LABEL"
/> >
<gl-link :href="ciJob.path">
{{ ciJob.name }}
</gl-link>
</detail-row>
<detail-row <detail-row v-if="ciJob.user" :label="$options.i18n.CI_USER_LABEL">
v-if="ciJob.user" <gl-avatar-labeled label="" :size="24" :src="ciJob.user.avatar">
:label="$options.i18n.CI_USER_LABEL" <gl-link :href="ciJob.user.path">
:href="ciJob.user.path" {{ ciJob.user.name }}
:text="ciJob.user.username" </gl-link>
/> </gl-avatar-labeled>
</detail-row>
<detail-row <detail-row v-if="ciJob.merge_request" :label="$options.i18n.CI_MR_LABEL">
v-if="ciJob.merge_request" <gl-link :href="ciJob.merge_request.path">
:label="$options.i18n.CI_MR_LABEL" !{{ ciJob.merge_request.iid }} {{ ciJob.merge_request.title }}
:text="ciJob.merge_request.title" </gl-link>
:href="ciJob.merge_request.path" </detail-row>
/>
</template> </template>
<template v-for="{ sectionName, sectionValues } in sections"> <template v-for="{ sectionName, sectionValues } in sections">
...@@ -150,8 +152,9 @@ export default { ...@@ -150,8 +152,9 @@ export default {
:key="item.name" :key="item.name"
:label="item.name" :label="item.name"
:section-label="index === 0 ? sectionName : ''" :section-label="index === 0 ? sectionName : ''"
:text="item.value" >
/> {{ item.value }}
</detail-row>
</template> </template>
</tbody> </tbody>
</table> </table>
......
...@@ -53,7 +53,9 @@ def user_info(user) ...@@ -53,7 +53,9 @@ def user_info(user)
{ {
user: { user: {
path: user_path(user), path: user_path(user),
username: user.username username: user.username,
name: user.name,
avatar: user.avatar_url
} }
} }
end end
...@@ -64,6 +66,7 @@ def mr_info(mr) ...@@ -64,6 +66,7 @@ def mr_info(mr)
{ {
merge_request: { merge_request: {
path: project_merge_request_path(mr.project, mr), path: project_merge_request_path(mr.project, mr),
iid: mr.iid,
title: mr.title title: mr.title
} }
} }
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui';
import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue'; import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue';
describe('CandidateDetailRow', () => { describe('CandidateDetailRow', () => {
...@@ -9,14 +8,14 @@ describe('CandidateDetailRow', () => { ...@@ -9,14 +8,14 @@ describe('CandidateDetailRow', () => {
let wrapper; let wrapper;
const createWrapper = (href = '') => { const createWrapper = ({ slots = {} } = {}) => {
wrapper = shallowMount(DetailRow, { wrapper = shallowMount(DetailRow, {
propsData: { sectionLabel: 'Section', label: 'Item', text: 'Text', href }, propsData: { sectionLabel: 'Section', label: 'Item' },
slots,
}); });
}; };
const findCellAt = (index) => wrapper.findAll('td').at(index); const findCellAt = (index) => wrapper.findAll('td').at(index);
const findLink = () => findCellAt(ROW_VALUE_CELL).findComponent(GlLink);
beforeEach(() => createWrapper()); beforeEach(() => createWrapper());
...@@ -28,22 +27,15 @@ describe('CandidateDetailRow', () => { ...@@ -28,22 +27,15 @@ describe('CandidateDetailRow', () => {
expect(findCellAt(ROW_LABEL_CELL).text()).toBe('Item'); expect(findCellAt(ROW_LABEL_CELL).text()).toBe('Item');
}); });
describe('No href', () => { it('renders nothing on item cell', () => {
it('Renders text', () => { expect(findCellAt(ROW_VALUE_CELL).text()).toBe('');
expect(findCellAt(ROW_VALUE_CELL).text()).toBe('Text');
});
it('Does not render as link', () => {
expect(findLink().exists()).toBe(false);
});
}); });
describe('With href', () => { describe('With slot', () => {
beforeEach(() => createWrapper('LINK')); beforeEach(() => createWrapper({ slots: { default: 'Some content' } }));
it('Renders link', () => { it('Renders slot', () => {
expect(findLink().attributes().href).toBe('LINK'); expect(findCellAt(ROW_VALUE_CELL).text()).toBe('Some content');
expect(findLink().text()).toBe('Text');
}); });
}); });
}); });
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlAvatarLabeled, GlLink } from '@gitlab/ui';
import MlCandidatesShow from '~/ml/experiment_tracking/routes/candidates/show'; import MlCandidatesShow from '~/ml/experiment_tracking/routes/candidates/show';
import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue'; import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue';
import { TITLE_LABEL } from '~/ml/experiment_tracking/routes/candidates/show/translations'; import { TITLE_LABEL } from '~/ml/experiment_tracking/routes/candidates/show/translations';
...@@ -9,6 +10,7 @@ import { newCandidate } from './mock_data'; ...@@ -9,6 +10,7 @@ import { newCandidate } from './mock_data';
describe('MlCandidatesShow', () => { describe('MlCandidatesShow', () => {
let wrapper; let wrapper;
const CANDIDATE = newCandidate(); const CANDIDATE = newCandidate();
const USER_ROW = 6;
const createWrapper = (createCandidate = () => CANDIDATE) => { const createWrapper = (createCandidate = () => CANDIDATE) => {
wrapper = shallowMount(MlCandidatesShow, { wrapper = shallowMount(MlCandidatesShow, {
...@@ -19,8 +21,12 @@ describe('MlCandidatesShow', () => { ...@@ -19,8 +21,12 @@ describe('MlCandidatesShow', () => {
const findDeleteButton = () => wrapper.findComponent(DeleteButton); const findDeleteButton = () => wrapper.findComponent(DeleteButton);
const findHeader = () => wrapper.findComponent(ModelExperimentsHeader); const findHeader = () => wrapper.findComponent(ModelExperimentsHeader);
const findNthDetailRow = (index) => wrapper.findAllComponents(DetailRow).at(index); const findNthDetailRow = (index) => wrapper.findAllComponents(DetailRow).at(index);
const findLinkInNthDetailRow = (index) => findNthDetailRow(index).findComponent(GlLink);
const findSectionLabel = (label) => wrapper.find(`[sectionLabel='${label}']`); const findSectionLabel = (label) => wrapper.find(`[sectionLabel='${label}']`);
const findLabel = (label) => wrapper.find(`[label='${label}']`); const findLabel = (label) => wrapper.find(`[label='${label}']`);
const findCiUserDetailRow = () => findNthDetailRow(USER_ROW);
const findCiUserAvatar = () => findCiUserDetailRow().findComponent(GlAvatarLabeled);
const findCiUserAvatarNameLink = () => findCiUserAvatar().findComponent(GlLink);
describe('Header', () => { describe('Header', () => {
beforeEach(() => createWrapper()); beforeEach(() => createWrapper());
...@@ -42,36 +48,64 @@ describe('MlCandidatesShow', () => { ...@@ -42,36 +48,64 @@ describe('MlCandidatesShow', () => {
describe('All info available', () => { describe('All info available', () => {
beforeEach(() => createWrapper()); beforeEach(() => createWrapper());
const mrText = `!${CANDIDATE.info.ci_job.merge_request.iid} ${CANDIDATE.info.ci_job.merge_request.title}`;
const expectedTable = [ const expectedTable = [
['Info', 'ID', CANDIDATE.info.iid, ''], ['Info', 'ID', CANDIDATE.info.iid],
['', 'MLflow run ID', CANDIDATE.info.eid, ''], ['', 'MLflow run ID', CANDIDATE.info.eid],
['', 'Status', CANDIDATE.info.status, ''], ['', 'Status', CANDIDATE.info.status],
['', 'Experiment', CANDIDATE.info.experiment_name, CANDIDATE.info.path_to_experiment], ['', 'Experiment', CANDIDATE.info.experiment_name],
['', 'Artifacts', 'Artifacts', CANDIDATE.info.path_to_artifact], ['', 'Artifacts', 'Artifacts'],
['CI', 'Job', CANDIDATE.info.ci_job.name, CANDIDATE.info.ci_job.path], ['CI', 'Job', CANDIDATE.info.ci_job.name],
['', 'Triggered by', CANDIDATE.info.ci_job.user.username, CANDIDATE.info.ci_job.user.path], ['', 'Triggered by', 'CI User'],
[ ['', 'Merge request', mrText],
'', ['Parameters', CANDIDATE.params[0].name, CANDIDATE.params[0].value],
'Merge request', ['', CANDIDATE.params[1].name, CANDIDATE.params[1].value],
CANDIDATE.info.ci_job.merge_request.title, ['Metrics', CANDIDATE.metrics[0].name, CANDIDATE.metrics[0].value],
CANDIDATE.info.ci_job.merge_request.path, ['', CANDIDATE.metrics[1].name, CANDIDATE.metrics[1].value],
], ['Metadata', CANDIDATE.metadata[0].name, CANDIDATE.metadata[0].value],
['Parameters', CANDIDATE.params[0].name, CANDIDATE.params[0].value, ''], ['', CANDIDATE.metadata[1].name, CANDIDATE.metadata[1].value],
['', CANDIDATE.params[1].name, CANDIDATE.params[1].value, ''],
['Metrics', CANDIDATE.metrics[0].name, CANDIDATE.metrics[0].value, ''],
['', CANDIDATE.metrics[1].name, CANDIDATE.metrics[1].value, ''],
['Metadata', CANDIDATE.metadata[0].name, CANDIDATE.metadata[0].value, ''],
['', CANDIDATE.metadata[1].name, CANDIDATE.metadata[1].value, ''],
].map((row, index) => [index, ...row]); ].map((row, index) => [index, ...row]);
it.each(expectedTable)( it.each(expectedTable)(
'row %s is created correctly', 'row %s is created correctly',
(index, sectionLabel, label, text, href) => { (rowIndex, sectionLabel, label, text) => {
const row = findNthDetailRow(index); const row = findNthDetailRow(rowIndex);
expect(row.props()).toMatchObject({ sectionLabel, label, text, href }); expect(row.props()).toMatchObject({ sectionLabel, label });
expect(row.text()).toBe(text);
}, },
); );
describe('Table links', () => {
const linkRows = [
[3, CANDIDATE.info.path_to_experiment],
[4, CANDIDATE.info.path_to_artifact],
[5, CANDIDATE.info.ci_job.path],
[7, CANDIDATE.info.ci_job.merge_request.path],
];
it.each(linkRows)('row %s is created correctly', (rowIndex, href) => {
expect(findLinkInNthDetailRow(rowIndex).attributes().href).toBe(href);
});
});
describe('CI triggerer', () => {
it('renders user row', () => {
const avatar = findCiUserAvatar();
expect(avatar.props()).toMatchObject({
label: '',
});
expect(avatar.attributes().src).toEqual('/img.png');
});
it('renders user name', () => {
const nameLink = findCiUserAvatarNameLink();
expect(nameLink.attributes().href).toEqual('path/to/ci/user');
expect(nameLink.text()).toEqual('CI User');
});
});
it('does not render params', () => { it('does not render params', () => {
expect(findSectionLabel('Parameters').exists()).toBe(true); expect(findSectionLabel('Parameters').exists()).toBe(true);
}); });
......
...@@ -24,11 +24,14 @@ export const newCandidate = () => ({ ...@@ -24,11 +24,14 @@ export const newCandidate = () => ({
path: 'path/to/job', path: 'path/to/job',
merge_request: { merge_request: {
path: 'path/to/mr', path: 'path/to/mr',
iid: 1,
title: 'Some MR', title: 'Some MR',
}, },
user: { user: {
path: 'path/to/ci/user', path: 'path/to/ci/user',
name: 'CI User',
username: 'ciuser', username: 'ciuser',
avatar: '/img.png',
}, },
}, },
}, },
......
...@@ -3,8 +3,8 @@ ...@@ -3,8 +3,8 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe ::Ml::CandidateDetailsPresenter, feature_category: :mlops do RSpec.describe ::Ml::CandidateDetailsPresenter, feature_category: :mlops do
let_it_be(:project) { create(:project, :private) } # rubocop:disable RSpec/FactoryBot/AvoidCreate let_it_be(:user) { create(:user, :with_avatar) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
let_it_be(:user) { project.creator } let_it_be(:project) { create(:project, :private, creator: user) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
let_it_be(:experiment) { create(:ml_experiments, user: user, project: project) } # rubocop:disable RSpec/FactoryBot/AvoidCreate let_it_be(:experiment) { create(:ml_experiments, user: user, project: project) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
let_it_be(:candidate) do let_it_be(:candidate) do
create(:ml_candidates, :with_artifact, experiment: experiment, user: user, project: project) # rubocop:disable RSpec/FactoryBot/AvoidCreate create(:ml_candidates, :with_artifact, experiment: experiment, user: user, project: project) # rubocop:disable RSpec/FactoryBot/AvoidCreate
...@@ -74,7 +74,9 @@ ...@@ -74,7 +74,9 @@
'name' => 'test', 'name' => 'test',
'user' => { 'user' => {
'path' => "/#{pipeline.user.username}", 'path' => "/#{pipeline.user.username}",
'username' => pipeline.user.username 'name' => pipeline.user.name,
'username' => pipeline.user.username,
'avatar' => user.avatar_url
} }
} }
...@@ -100,6 +102,7 @@ ...@@ -100,6 +102,7 @@
it 'generates the correct ci' do it 'generates the correct ci' do
expected_info = { expected_info = {
'path' => "/#{project.full_path}/-/merge_requests/#{mr.iid}", 'path' => "/#{project.full_path}/-/merge_requests/#{mr.iid}",
'iid' => mr.iid,
'title' => mr.title 'title' => mr.title
} }
......
0% 加载中 .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册