Commit 449dbc43 authored by Kerri Miller's avatar Kerri Miller

Merge branch '42742-move-triggers-table-to-vue' into 'master'

Migrate triggers setting table to Vue

See merge request gitlab-org/gitlab!41864
parents 69caaa62 8633c996
<script>
import { GlTable, GlButton, GlBadge, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
GlTable,
GlButton,
GlBadge,
ClipboardButton,
TooltipOnTruncate,
UserAvatarLink,
TimeAgoTooltip,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
triggers: {
type: Array,
required: false,
default: () => [],
},
},
fields: [
{
key: 'token',
label: s__('Pipelines|Token'),
},
{
key: 'description',
label: s__('Pipelines|Description'),
},
{
key: 'owner',
label: s__('Pipelines|Owner'),
},
{
key: 'lastUsed',
label: s__('Pipelines|Last Used'),
},
{
key: 'actions',
label: '',
tdClass: 'gl-text-right gl-white-space-nowrap',
},
],
};
</script>
<template>
<div>
<gl-table
v-if="triggers.length"
:fields="$options.fields"
:items="triggers"
class="triggers-list"
responsive
>
<template #cell(token)="{item}">
{{ item.token }}
<clipboard-button
v-if="item.hasTokenExposed"
:text="item.token"
data-testid="clipboard-btn"
data-qa-selector="clipboard_button"
:title="s__('Pipelines|Copy trigger token')"
css-class="gl-border-none gl-py-0 gl-px-2"
/>
<div class="label-container">
<gl-badge v-if="!item.canAccessProject" variant="danger">
<span
v-gl-tooltip.viewport
boundary="viewport"
:title="s__('Pipelines|Trigger user has insufficient permissions to project')"
>{{ s__('Pipelines|invalid') }}</span
>
</gl-badge>
</div>
</template>
<template #cell(description)="{item}">
<tooltip-on-truncate
:title="item.description"
truncate-target="child"
placement="top"
class="trigger-description gl-display-flex"
>
<div class="gl-flex-fill-1 gl-text-truncate">{{ item.description }}</div>
</tooltip-on-truncate>
</template>
<template #cell(owner)="{item}">
<span class="trigger-owner sr-only">{{ item.owner.name }}</span>
<user-avatar-link
v-if="item.owner"
:link-href="item.owner.path"
:img-src="item.owner.avatarUrl"
:tooltip-text="item.owner.name"
:img-alt="item.owner.name"
/>
</template>
<template #cell(lastUsed)="{item}">
<time-ago-tooltip v-if="item.lastUsed" :time="item.lastUsed" />
<span v-else>{{ __('Never') }}</span>
</template>
<template #cell(actions)="{item}">
<gl-button
:title="s__('Pipelines|Edit')"
icon="pencil"
data-testid="edit-btn"
:href="item.editProjectTriggerPath"
/>
<gl-button
:title="s__('Pipelines|Revoke')"
icon="remove"
variant="warning"
:data-confirm="
s__(
'Pipelines|By revoking a trigger you will break any processes making use of it. Are you sure?',
)
"
data-method="delete"
rel="nofollow"
class="gl-ml-3"
data-testid="trigger_revoke_button"
data-qa-selector="trigger_revoke_button"
:href="item.projectTriggerPath"
/>
</template>
</gl-table>
<div
v-else
data-testid="no_triggers_content"
data-qa-selector="no_triggers_content"
class="settings-message gl-text-center gl-mb-3"
>
{{ s__('Pipelines|No triggers have been created yet. Add one using the form above.') }}
</div>
</div>
</template>
import Vue from 'vue';
import TriggersList from './components/triggers_list.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
const parseJsonArray = triggers => {
try {
return convertObjectPropsToCamelCase(JSON.parse(triggers), { deep: true });
} catch {
return [];
}
};
export default (containerId = 'js-ci-pipeline-triggers-list') => {
const containerEl = document.getElementById(containerId);
// Note: Remove this check when FF `ci_pipeline_triggers_settings_vue_ui` is removed.
if (!containerEl) {
return null;
}
const triggers = parseJsonArray(containerEl.dataset.triggers);
return new Vue({
el: containerEl,
components: {
TriggersList,
},
render(h) {
return h(TriggersList, {
props: {
triggers,
},
});
},
});
};
......@@ -4,6 +4,7 @@ import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
import registrySettingsApp from '~/registry/settings/registry_settings_bundle';
import initVariableList from '~/ci_variable_list';
import initDeployFreeze from '~/deploy_freeze';
import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
......@@ -42,4 +43,6 @@ document.addEventListener('DOMContentLoaded', () => {
registrySettingsApp();
initDeployFreeze();
initSettingsPipelinesTriggers();
});
......@@ -5,6 +5,10 @@
}
}
.trigger-description {
max-width: 100px;
}
.trigger-actions {
white-space: nowrap;
......
......@@ -12,6 +12,11 @@ module Projects
end
def show
if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project)
@triggers_json = ::Ci::TriggerSerializer.new.represent(
@project.triggers, current_user: current_user, project: @project
).to_json
end
end
def update
......@@ -116,6 +121,7 @@ module Projects
def define_triggers_variables
@triggers = @project.triggers
.present(current_user: current_user)
@trigger = ::Ci::Trigger.new
.present(current_user: current_user)
end
......
# frozen_string_literal: true
module Ci
class TriggerEntity < Grape::Entity
include Gitlab::Routing
include Gitlab::Allowable
expose :description
expose :owner, using: UserEntity
expose :last_used
expose :token do |trigger|
can_admin_trigger?(trigger) ? trigger.token : trigger.short_token
end
expose :has_token_exposed do |trigger|
can_admin_trigger?(trigger)
end
expose :can_access_project do |trigger|
trigger.can_access_project?
end
expose :project_trigger_path, if: -> (trigger) { can_manage_trigger?(trigger) } do |trigger|
project_trigger_path(options[:project], trigger)
end
expose :edit_project_trigger_path, if: -> (trigger) { can_admin_trigger?(trigger) } do |trigger|
edit_project_trigger_path(options[:project], trigger)
end
private
def can_manage_trigger?(trigger)
can?(options[:current_user], :manage_trigger, trigger)
end
def can_admin_trigger?(trigger)
can?(options[:current_user], :admin_trigger, trigger)
end
end
end
# frozen_string_literal: true
module Ci
class TriggerSerializer < BaseSerializer
entity ::Ci::TriggerEntity
end
end
......@@ -6,23 +6,26 @@
.card-body
= render "projects/triggers/form", btn_text: "Add trigger"
%hr
- if @triggers.any?
.table-responsive.triggers-list
%table.table
%thead
%th
%strong Token
%th
%strong Description
%th
%strong Owner
%th
%strong Last used
%th
= render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger
- if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project)
#js-ci-pipeline-triggers-list.triggers-list{ data: { triggers: @triggers_json } }
- else
%p.settings-message.text-center.gl-mb-3
No triggers have been created yet. Add one using the form above.
- if @triggers.any?
.table-responsive.triggers-list
%table.table
%thead
%th
%strong Token
%th
%strong Description
%th
%strong Owner
%th
%strong Last used
%th
= render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger
- else
%p.settings-message.text-center.gl-mb-3{ data: { testid: 'no_triggers_content' } }
No triggers have been created yet. Add one using the form above.
.card-footer
......
......@@ -2,7 +2,7 @@
%td
- if trigger.has_token_exposed?
%span= trigger.token
= clipboard_button(text: trigger.token, title: _("Copy trigger token"))
= clipboard_button(text: trigger.token, title: _("Copy trigger token"), testid: 'clipboard-btn')
- else
%span= trigger.short_token
......@@ -33,5 +33,5 @@
= link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "btn btn-default btn-sm" do
= sprite_icon('pencil')
- if can?(current_user, :manage_trigger, trigger)
= link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do
= link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation, testid: 'trigger_revoke_button' }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do
= sprite_icon('remove')
---
name: ci_pipeline_triggers_settings_vue_ui
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41864
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/247486
group: group::continuous integration
type: development
default_enabled: false
......@@ -18665,6 +18665,9 @@ msgstr ""
msgid "Pipelines|Build with confidence"
msgstr ""
msgid "Pipelines|By revoking a trigger you will break any processes making use of it. Are you sure?"
msgstr ""
msgid "Pipelines|CI Lint"
msgstr ""
......@@ -18677,6 +18680,15 @@ msgstr ""
msgid "Pipelines|Continuous Integration can help catch bugs by running your tests automatically, while Continuous Deployment can help you deliver code to your product environment."
msgstr ""
msgid "Pipelines|Copy trigger token"
msgstr ""
msgid "Pipelines|Description"
msgstr ""
msgid "Pipelines|Edit"
msgstr ""
msgid "Pipelines|Get started with Pipelines"
msgstr ""
......@@ -18692,15 +18704,27 @@ msgstr ""
msgid "Pipelines|It is recommended the code is reviewed thoroughly before running this pipeline with the parent project's CI resource."
msgstr ""
msgid "Pipelines|Last Used"
msgstr ""
msgid "Pipelines|Loading Pipelines"
msgstr ""
msgid "Pipelines|More Information"
msgstr ""
msgid "Pipelines|No triggers have been created yet. Add one using the form above."
msgstr ""
msgid "Pipelines|Owner"
msgstr ""
msgid "Pipelines|Project cache successfully reset."
msgstr ""
msgid "Pipelines|Revoke"
msgstr ""
msgid "Pipelines|Run Pipeline"
msgstr ""
......@@ -18725,6 +18749,15 @@ msgstr ""
msgid "Pipelines|This project is not currently set up to run pipelines."
msgstr ""
msgid "Pipelines|Token"
msgstr ""
msgid "Pipelines|Trigger user has insufficient permissions to project"
msgstr ""
msgid "Pipelines|invalid"
msgstr ""
msgid "Pipelines|parent"
msgstr ""
......
This diff is collapsed.
{
"type": "object",
"required": [
"description",
"owner",
"last_used",
"has_token_exposed",
"token",
"can_access_project"
],
"properties": {
"description": {
"type": ["string", "null"]
},
"owner": {
"type": "object",
"$ref": "user.json"
},
"last_used": {
"type": ["datetime", "null"]
},
"token": {
"type": "string"
},
"has_token_exposed": {
"type": "boolean"
},
"can_access_project": {
"type": "boolean"
},
"edit_project_trigger_path": {
"type": "string"
},
"project_trigger_path": {
"type": "string"
}
},
"additionalProperties": false
}
import { mount } from '@vue/test-utils';
import { GlTable, GlBadge } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import TriggersList from '~/ci_settings_pipeline_triggers/components/triggers_list.vue';
import { triggers } from '../mock_data';
describe('TriggersList', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = mount(TriggersList, {
propsData: { triggers, ...props },
});
};
const findTable = () => wrapper.find(GlTable);
const findHeaderAt = i => wrapper.findAll('thead th').at(i);
const findRows = () => wrapper.findAll('tbody tr');
const findRowAt = i => findRows().at(i);
const findCell = (i, col) =>
findRowAt(i)
.findAll('td')
.at(col);
const findClipboardBtn = i => findCell(i, 0).find(ClipboardButton);
const findInvalidBadge = i => findCell(i, 0).find(GlBadge);
const findEditBtn = i => findRowAt(i).find('[data-testid="edit-btn"]');
const findRevokeBtn = i => findRowAt(i).find('[data-testid="trigger_revoke_button"]');
beforeEach(() => {
createComponent();
return wrapper.vm.$nextTick();
});
it('displays a table with expected headers', () => {
const headers = ['Token', 'Description', 'Owner', 'Last Used', ''];
headers.forEach((header, i) => {
expect(findHeaderAt(i).text()).toBe(header);
});
});
it('displays a table with rows', () => {
expect(findRows()).toHaveLength(triggers.length);
const [trigger] = triggers;
expect(findCell(0, 0).text()).toBe(trigger.token);
expect(findCell(0, 1).text()).toBe(trigger.description);
expect(findCell(0, 2).text()).toContain(trigger.owner.name);
});
it('displays a "copy to cliboard" button for exposed tokens', () => {
expect(findClipboardBtn(0).exists()).toBe(true);
expect(findClipboardBtn(0).props('text')).toBe(triggers[0].token);
expect(findClipboardBtn(1).exists()).toBe(false);
});
it('displays an "invalid" label for tokens without access', () => {
expect(findInvalidBadge(0).exists()).toBe(false);
expect(findInvalidBadge(1).exists()).toBe(true);
});
it('displays a time ago label when last used', () => {
expect(findCell(0, 3).text()).toBe('Never');
expect(
findCell(1, 3)
.find(TimeAgoTooltip)
.props('time'),
).toBe(triggers[1].lastUsed);
});
it('displays actions in a rows', () => {
const [data] = triggers;
expect(findEditBtn(0).attributes('href')).toBe(data.editProjectTriggerPath);
expect(findRevokeBtn(0).attributes('href')).toBe(data.projectTriggerPath);
expect(findRevokeBtn(0).attributes('data-method')).toBe('delete');
expect(findRevokeBtn(0).attributes('data-confirm')).toBeTruthy();
});
describe('when there are no triggers set', () => {
beforeEach(() => {
createComponent({ triggers: [] });
});
it('does not display a table', () => {
expect(findTable().exists()).toBe(false);
});
it('displays a message', () => {
expect(wrapper.text()).toBe(
'No triggers have been created yet. Add one using the form above.',
);
});
});
});
export const triggers = [
{
hasTokenExposed: true,
token: '0000',
description: 'My trigger',
owner: {
name: 'My User',
username: 'user1',
path: '/user1',
},
lastUsed: null,
canAccessProject: true,
editProjectTriggerPath: '/triggers/1/edit',
projectTriggerPath: '/trigger/1',
},
{
hasTokenExposed: false,
token: '1111',
description: "Anothe user's trigger",
owner: {
name: 'Someone else',
username: 'user2',
path: '/user2',
},
lastUsed: '2020-09-10T08:26:47.410Z',
canAccessProject: false,
editProjectTriggerPath: '/triggers/1/edit',
projectTriggerPath: '/trigger/1',
},
];
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ci::TriggerEntity do
let(:project) { create(:project) }
let(:trigger) { create(:ci_trigger, project: project, token: '237f3604900a4cd71ed06ef13e57b96d') }
let(:user) { create(:user) }
let(:entity) { described_class.new(trigger, current_user: user, project: project) }
describe '#as_json' do
let(:as_json) { entity.as_json }
let(:project_trigger_path) { "/#{project.full_path}/-/triggers/#{trigger.id}" }
it 'contains required fields' do
expect(as_json).to include(
:description, :owner, :last_used, :token, :has_token_exposed, :can_access_project
)
end
it 'contains user fields' do
expect(as_json[:owner].to_json).to match_schema('entities/user')
end
context 'when current user can manage triggers' do
before do
project.add_maintainer(user)
end
it 'returns short_token as token' do
expect(as_json[:token]).to eq(trigger.short_token)
end
it 'contains project_trigger_path' do
expect(as_json[:project_trigger_path]).to eq(project_trigger_path)
end
it 'does not contain edit_project_trigger_path' do
expect(as_json).not_to include(:edit_project_trigger_path)
end
it 'returns has_token_exposed' do
expect(as_json[:has_token_exposed]).to eq(false)
end
end
context 'when current user is the owner of the trigger' do
before do
project.add_maintainer(user)
trigger.update!(owner: user)
end
it 'returns token as token' do
expect(as_json[:token]).to eq(trigger.token)
end
it 'contains project_trigger_path' do
expect(as_json[:project_trigger_path]).to eq(project_trigger_path)
end
it 'contains edit_project_trigger_path' do
expect(as_json[:edit_project_trigger_path]).to eq("#{project_trigger_path}/edit")
end
it 'returns has_token_exposed' do
expect(as_json[:has_token_exposed]).to eq(true)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ci::TriggerSerializer do
describe '#represent' do
let(:represent) { described_class.new.represent(trigger) }
let(:trigger) { build(:ci_trigger) }
it 'matches schema' do
expect(represent.to_json).to match_schema('entities/trigger')
end
end
end
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