Commit 8633c996 authored by Miguel Rincon's avatar Miguel Rincon Committed by Kerri Miller

Migrate triggers settings table to Vue

So we can add more functionality to the triggers table we
would like to move the table from HAML to Vue.

As a temporary measure, this change loads the data from a JSON
string that gets added to the HTML of the page, instead of loading
the data via AJAX.
parent 8bf5e565
<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
......@@ -18627,6 +18627,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 ""
......@@ -18639,6 +18642,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 ""
......@@ -18654,15 +18666,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 ""
......@@ -18687,6 +18711,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