diff --git a/app/assets/javascripts/vue_shared/components/crud_component.stories.js b/app/assets/javascripts/vue_shared/components/crud_component.stories.js
new file mode 100644
index 0000000000000000000000000000000000000000..465ebf56f434bbf48a7e036043f2e869418874de
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/crud_component.stories.js
@@ -0,0 +1,196 @@
+import { GlButton, GlTableLite } from '@gitlab/ui';
+import CrudComponent from './crud_component.vue';
+
+export default {
+ component: CrudComponent,
+ title: 'vue_shared/crud',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { CrudComponent, GlButton },
+ props: Object.keys(argTypes),
+ template: `
+
+
+ #actions slot
+
+
+
+ #description slot
+
+
+ #default slot
+
+
+ Add form
+
+ Add item
+ Cancel
+
+
+
+
+ #footer slot
+
+
+
+ #pagination slot
+
+
+ `,
+});
+
+const defaultArgs = {
+ descriptionEnabled: false,
+ customActions: false,
+};
+
+export const Default = Template.bind({});
+Default.args = {
+ ...defaultArgs,
+ title: 'CRUD Component title',
+ icon: 'rocket',
+ count: 99,
+ toggleText: 'Add action',
+};
+
+export const WithDescription = Template.bind({});
+WithDescription.args = {
+ ...defaultArgs,
+ title: 'CRUD Component title',
+ description: 'Description text',
+ icon: 'rocket',
+ count: 99,
+ toggleText: 'Add action',
+ descriptionEnabled: true,
+};
+
+export const WithFooter = Template.bind({});
+WithFooter.args = {
+ ...defaultArgs,
+ title: 'CRUD Component title',
+ description: 'Description text',
+ icon: 'rocket',
+ count: 99,
+ toggleText: 'Add action',
+ footer: true,
+};
+
+export const WithPagnation = Template.bind({});
+WithPagnation.args = {
+ ...defaultArgs,
+ title: 'CRUD Component title',
+ description: 'Description text',
+ icon: 'rocket',
+ count: 99,
+ toggleText: 'Add action',
+ pagination: true,
+};
+
+export const WithCustomActions = Template.bind({});
+WithCustomActions.args = {
+ ...defaultArgs,
+ title: 'CRUD Component title',
+ icon: 'rocket',
+ count: 99,
+ toggleText: 'Add action',
+ customActions: true,
+};
+
+const TableTemplate = (args, { argTypes }) => ({
+ components: { CrudComponent, GlButton, GlTableLite },
+ props: Object.keys(argTypes),
+ template: `
+
+
+
+
+ Add form
+
+ Add item
+ Cancel
+
+
+
+ `,
+});
+
+const ContentListTemplate = (args, { argTypes }) => ({
+ components: { CrudComponent, GlButton },
+ props: Object.keys(argTypes),
+ template: `
+
+
+
+
+ Add form
+
+ Add item
+ Cancel
+
+
+
+ `,
+});
+
+export const TableExample = TableTemplate.bind({});
+TableExample.args = {
+ title: 'Hooks',
+ icon: 'hook',
+ count: 3,
+ toggleText: 'Add new hook',
+ tableItems: [
+ {
+ column_one: 'test',
+ column_two: 1234,
+ },
+ {
+ column_one: 'test2',
+ column_two: 5678,
+ },
+ {
+ column_one: 'test3',
+ column_two: 9101,
+ },
+ ],
+ tableFields: [
+ {
+ key: 'column_one',
+ label: 'First column',
+ thClass: 'w-60p',
+ tdClass: 'table-col',
+ },
+ {
+ key: 'column_two',
+ label: 'Second column',
+ thClass: 'w-60p',
+ tdClass: 'table-col',
+ },
+ ],
+};
+
+export const ContentListExample = ContentListTemplate.bind({});
+ContentListExample.args = {
+ title: 'Branches',
+ icon: 'branch',
+ count: 4,
+ toggleText: 'Add new branch',
+ items: [
+ {
+ label: 'First item',
+ },
+ {
+ label: 'Second item',
+ },
+ {
+ label: 'Third item',
+ },
+ {
+ label: 'Fourth item',
+ },
+ ],
+};
diff --git a/app/assets/javascripts/vue_shared/components/crud_component.vue b/app/assets/javascripts/vue_shared/components/crud_component.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e24ca41a390593e3711c47bde0dbf52c8a20b76f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/crud_component.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ count }}
+
+
+
+
+ {{ description }}
+
+
+
+ {{ toggleText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index e049e8bf71c9a6625289416df626e4f766ed5099..3d65d1babdba19665fd66346c2e169a17a75c8e2 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -65,3 +65,4 @@
@import 'framework/diffs';
@import 'framework/new_card';
@import 'framework/tanuki_bot';
+@import 'framework/crud';
diff --git a/app/assets/stylesheets/framework/crud.scss b/app/assets/stylesheets/framework/crud.scss
new file mode 100644
index 0000000000000000000000000000000000000000..49533453b56f38059e782bfc380055fc5c98f80f
--- /dev/null
+++ b/app/assets/stylesheets/framework/crud.scss
@@ -0,0 +1,21 @@
+.crud-body:has(.gl-table) {
+ margin: 0;
+
+ .gl-table {
+ margin-top: -1px;
+ margin-bottom: 0;
+
+ tbody tr:last-of-type td {
+ border-bottom: 0;
+ }
+ }
+}
+
+.crud-body:has(.content-list) {
+ margin: 0;
+
+ .content-list > li {
+ padding-inline: $gl-spacing-scale-5;
+ border-bottom-color: var(--gl-border-color-default);
+ }
+}
diff --git a/app/components/layouts/crud_component.haml b/app/components/layouts/crud_component.haml
new file mode 100644
index 0000000000000000000000000000000000000000..fb25a552f7520f0f9c910219f9ac6795836232d7
--- /dev/null
+++ b/app/components/layouts/crud_component.haml
@@ -0,0 +1,34 @@
+%section
+ .crud.gl-bg-subtle.gl-border.gl-border-default.gl-rounded-base{ @options, class: ('js-toggle-container' if @toggle_text) }
+ %header.gl-flex.gl-flex-wrap.gl-justify-between.gl-gap-x-5.gl-gap-y-2.gl-px-5.gl-py-4.gl-bg-default.gl-border-b.gl-border-default.gl-rounded-t-base
+ .gl-flex.gl-flex-col.gl-self-center
+ %h2.gl-text-base.gl-font-bold.gl-leading-24.gl-inline-flex.gl-gap-3.gl-m-0{ data: { testid: 'crud-title' } }
+ = @title
+ - if @count
+ %span.gl-inline-flex.gl-items-center.gl-gap-2.gl-text-sm.gl-text-secondary{ data: { testid: 'crud-count' } }
+ - if @icon
+ = sprite_icon(@icon)
+ = @count
+ - if description? || @description
+ .gl-text-sm.gl-text-secondary.gl-mt-1.gl-mb-0{ data: { testid: 'crud-description' } }
+ = description || @description
+ .gl-flex.gl-gap-3.gl-items-baseline{ data: { testid: 'crud-actions' } }
+ - if @toggle_text
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content', data: { testid: 'crud-action-toggle' } }) do
+ = @toggle_text
+ = actions
+
+ - if form?
+ .gl-p-5.gl-pt-4.gl-bg-default.gl-border-b.gl-border-default{ class: ('gl-hidden js-toggle-content' if @toggle_text), data: { testid: 'crud-form' } }
+ = form
+
+ .crud-body.gl-mx-5.gl-my-4{ class: ('gl-rounded-b-base' unless footer), data: { testid: 'crud-body' } }
+ = body
+
+ - if footer?
+ .gl-px-5.gl-py-4.gl-bg-default.gl-border-t.gl-border-default.gl-rounded-b-base{ data: { testid: 'crud-footer' } }
+ = footer
+
+ - if pagination?
+ .gl-mt-5{ data: { testid: 'crud-pagination' } }
+ = pagination
diff --git a/app/components/layouts/crud_component.rb b/app/components/layouts/crud_component.rb
new file mode 100644
index 0000000000000000000000000000000000000000..cd2ada33fd7869a102ad43b6cc9b5283de4254b3
--- /dev/null
+++ b/app/components/layouts/crud_component.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Layouts
+ class CrudComponent < ViewComponent::Base
+ # @param [String] title
+ # @param [String] description
+ # @param [Number] count
+ # @param [String] icon
+ # @param [String] toggle_text
+ # @param [Hash] options
+ def initialize(title, description: nil, count: nil, icon: nil, toggle_text: nil, options: {})
+ @title = title
+ @description = description
+ @count = count
+ @icon = icon
+ @toggle_text = toggle_text
+ @options = options
+ end
+
+ renders_one :description
+ renders_one :actions
+ renders_one :body
+ renders_one :form
+ renders_one :footer
+ renders_one :pagination
+
+ delegate :sprite_icon, to: :helpers
+ end
+end
diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml
index 6c6ff5f7fc859e51aa0b3ef25e82c19cb6c97330..6df3d25f369679ce35796973ae91d39c2a1b5bbb 100644
--- a/app/views/shared/web_hooks/_hook.html.haml
+++ b/app/views/shared/web_hooks/_hook.html.haml
@@ -2,11 +2,12 @@
- sslBadgeText = _('SSL Verification:') + ' ' + sslStatus
%li.gl-border-b.gl-last-of-type-border-b-0
- .row.gl-mx-0
- .col-md-8.col-lg-5.gl-px-5
- .light-header.gl-mb-1
- = hook.name
- .gl-font-sm
+ .gl-flex.gl-flex-wrap.sm:gl-flex-nowrap.gl-justify-between.gl-gap-x-5.gl-gap-y-3
+ .gl-basis-full.gl-flex.gl-flex-col
+ - if hook.name?
+ %p.gl-font-semibold.gl-mb-0
+ = hook.name
+ %p.gl-mb-2{ class: hook.name ? 'gl-text-secondary' : 'gl-font-semibold' }
= hook.url
- if hook.rate_limited?
@@ -19,13 +20,13 @@
%div
- hook.class.triggers.each_value do |trigger|
- if hook.public_send(trigger)
- = gl_badge_tag(integration_webhook_event_human_name(trigger), size: :sm)
- = gl_badge_tag(sslBadgeText, size: :sm)
+ = gl_badge_tag(integration_webhook_event_human_name(trigger), variant: :neutral, size: :sm)
+ = gl_badge_tag(sslBadgeText, variant: :neutral, size: :sm)
- .col-md-2.col-lg-4.gl-px-5.gl-font-sm
+ .gl-font-sm
= truncate(hook.description, length: 200)
- .col-md-4.col-lg-3.gl-mt-2.gl-px-5.gl-gap-3.gl-display-flex.gl-md-justify-content-end.gl-align-items-baseline
+ .gl-flex.gl-items-baseline.gl-gap-3
= render 'shared/web_hooks/test_button', hook: hook, size: 'small'
= render Pajamas::ButtonComponent.new(href: edit_hook_path(hook), size: :small) do
= _('Edit')
diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml
index 25b9f114987c18fa5513cf4636f7fe77856a3598..2384f98e24ea74e78b7e7d79cfec00a86e9aca51 100644
--- a/app/views/shared/web_hooks/_index.html.haml
+++ b/app/views/shared/web_hooks/_index.html.haml
@@ -1,19 +1,8 @@
-= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header'}, body_options: { class: 'gl-new-card-body gl-px-0'}) do |c|
- - c.with_header do
- .gl-new-card-title-wrapper
- %h3.gl-new-card-title
- = hook_class.underscore.humanize.titleize.pluralize
- %span.gl-new-card-count
- = sprite_icon('hook', css_class: 'gl-mr-2')
- #{hooks.size}
- = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content' }) do
- = _('Add new webhook')
+= render ::Layouts::CrudComponent.new(hook_class.underscore.humanize.titleize.pluralize,
+ icon: 'hook',
+ count: hooks.size,
+ toggle_text: _('Add new webhook')) do |c|
- c.with_body do
- = gitlab_ui_form_for @hook, as: :hook, url: url, html: { class: 'js-webhook-form gl-new-card-add-form gl-m-3 gl-display-none js-toggle-content' } do |f|
- = render partial: partial, locals: { form: f, hook: @hook }
- = f.submit _('Add webhook'), pajamas_button: true
- = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'js-webhook-edit-close gl-ml-2 js-toggle-button' }) do
- = _('Cancel')
- if hooks.any?
%ul.content-list
- hooks.each do |hook|
@@ -21,3 +10,9 @@
- else
%p.gl-new-card-empty.gl-text-center
= _('No webhooks enabled. Select trigger events above.')
+ - c.with_form do
+ = gitlab_ui_form_for @hook, as: :hook, url: url, html: { class: 'js-webhook-form' } do |f|
+ = render partial: partial, locals: { form: f, hook: @hook }
+ = f.submit _('Add webhook'), pajamas_button: true
+ = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'js-webhook-edit-close gl-ml-2 js-toggle-button' }) do
+ = _('Cancel')
diff --git a/spec/components/layouts/crud_component_spec.rb b/spec/components/layouts/crud_component_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9a715ceb0389cbd642be6d09b786047c7f9d4ae8
--- /dev/null
+++ b/spec/components/layouts/crud_component_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Layouts::CrudComponent, type: :component, feature_category: :shared do
+ let(:title) { 'Title' }
+ let(:description) { 'Description' }
+ let(:count) { 99 }
+ let(:icon) { 'rocket' }
+ let(:toggle_text) { 'Toggle text' }
+ let(:actions) { 'Actions' }
+ let(:body) { 'Body' }
+ let(:form) { 'Form' }
+ let(:footer) { 'Footer' }
+ let(:pagination) { 'Pagination' }
+ let(:component_title) { described_class.new(title) }
+
+ describe 'slots' do
+ it 'renders title' do
+ render_inline component_title
+
+ expect(page).to have_css('[data-testid="crud-title"]', text: title)
+ end
+
+ it 'renders description' do
+ render_inline described_class.new(title, description: description)
+
+ expect(page).to have_css('[data-testid="crud-description"]', text: description)
+ end
+
+ it 'renders description slot' do
+ render_inline component_title do |c|
+ c.with_description { description }
+ end
+
+ expect(page).to have_css('[data-testid="crud-description"]', text: description)
+ end
+
+ it 'renders count and icon' do
+ render_inline described_class.new(title, count: count, icon: icon)
+
+ expect(page).to have_css('[data-testid="crud-count"]', text: count)
+ expect(page).to have_css('[data-testid="crud-count"] svg[data-testid="rocket-icon"]')
+ end
+
+ it 'renders action toggle' do
+ render_inline described_class.new(title, toggle_text: toggle_text)
+
+ expect(page).to have_css('[data-testid="crud-action-toggle"]', text: toggle_text)
+ expect(page).to have_css('.crud.js-toggle-container')
+ expect(page).to have_css('[data-testid="crud-action-toggle"].js-toggle-button.js-toggle-content')
+ end
+
+ it 'renders actions slot' do
+ render_inline component_title do |c|
+ c.with_actions { actions }
+ end
+
+ expect(page).to have_css('[data-testid="crud-actions"]', text: actions)
+ end
+
+ it 'renders form slot' do
+ render_inline component_title do |c|
+ c.with_form { form }
+ end
+
+ expect(page).to have_css('[data-testid="crud-form"]', text: form)
+ end
+
+ it 'renders body slot' do
+ render_inline component_title do |c|
+ c.with_body { body }
+ end
+
+ expect(page).to have_css('[data-testid="crud-body"]', text: body)
+ end
+
+ it 'renders footer slot' do
+ render_inline component_title do |c|
+ c.with_footer { footer }
+ end
+
+ expect(page).to have_css('[data-testid="crud-footer"]', text: footer)
+ end
+
+ it 'renders pagination slot' do
+ render_inline component_title do |c|
+ c.with_pagination { pagination }
+ end
+
+ expect(page).to have_css('[data-testid="crud-pagination"]', text: pagination)
+ end
+ end
+end
diff --git a/spec/components/previews/layouts/crud_component_preview.rb b/spec/components/previews/layouts/crud_component_preview.rb
new file mode 100644
index 0000000000000000000000000000000000000000..df88aa1f20c652832ede80a177e3727376333e30
--- /dev/null
+++ b/spec/components/previews/layouts/crud_component_preview.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Layouts
+ class CrudComponentPreview < ViewComponent::Preview
+ # @param title text
+ # @param description text
+ # @param count number
+ # @param icon text
+ # @param toggle_text text
+ # rubocop:disable Metrics/ParameterLists -- allow all params
+ def default(
+ title: 'CRUD Component title',
+ description: 'Description',
+ count: 99,
+ icon: 'rocket',
+ toggle_text: 'Add action',
+ actions: 'Custom actions',
+ body: 'Body slot',
+ form: 'Form slot',
+ footer: 'Footer slot',
+ pagination: 'Pagination slot'
+ )
+ render(::Layouts::CrudComponent.new(
+ title,
+ description: description,
+ count: count,
+ icon: icon,
+ toggle_text: toggle_text)) do |c|
+ c.with_description { description }
+ c.with_actions { actions }
+ c.with_body { body }
+ c.with_form { form }
+ c.with_footer { footer }
+ c.with_pagination { pagination }
+ end
+ end
+ # rubocop:enable Metrics/ParameterLists
+ end
+end
diff --git a/spec/frontend/vue_shared/components/crud_component_spec.js b/spec/frontend/vue_shared/components/crud_component_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..fa3669e47977e556cb9fcf353a725cd217e3d8a5
--- /dev/null
+++ b/spec/frontend/vue_shared/components/crud_component_spec.js
@@ -0,0 +1,93 @@
+import { nextTick } from 'vue';
+import { GlButton, GlIcon } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import CrudComponent from '~/vue_shared/components/crud_component.vue';
+
+describe('CRUD Component', () => {
+ let wrapper;
+
+ const createComponent = (propsData, slots = {}) => {
+ wrapper = shallowMountExtended(CrudComponent, {
+ propsData: {
+ title: 'CRUD Component title',
+ ...propsData,
+ },
+ scopedSlots: {
+ ...slots,
+ },
+ stubs: { GlButton, GlIcon },
+ });
+ };
+
+ const findTitle = () => wrapper.findByTestId('crud-title');
+ const findDescription = () => wrapper.findByTestId('crud-description');
+ const findCount = () => wrapper.findByTestId('crud-count');
+ const findIcon = () => wrapper.findComponent(GlIcon);
+ const findFormToggle = () => wrapper.findByTestId('crud-form-toggle');
+ const findActions = () => wrapper.findByTestId('crud-actions');
+ const findForm = () => wrapper.findByTestId('crud-form');
+ const findBody = () => wrapper.findByTestId('crud-body');
+ const findFooter = () => wrapper.findByTestId('crud-footer');
+ const findPagination = () => wrapper.findByTestId('crud-pagination');
+
+ it('renders title', () => {
+ createComponent();
+
+ expect(findTitle().text()).toBe('CRUD Component title');
+ });
+
+ it('renders description', () => {
+ createComponent({ description: 'Description' });
+
+ expect(findDescription().text()).toBe('Description');
+ });
+
+ it('renders `description` slot', () => {
+ createComponent({}, { description: '
Description slot
' });
+
+ expect(findDescription().text()).toBe('Description slot');
+ });
+
+ it('renders count and icon', () => {
+ createComponent({ count: 99, icon: 'rocket' });
+
+ expect(findCount().text()).toBe('99');
+ expect(findIcon().props('name')).toBe('rocket');
+ });
+
+ it('renders `actions` slot', () => {
+ createComponent({}, { actions: 'Actions slot
' });
+
+ expect(findActions().text()).toBe('Actions slot');
+ });
+
+ it('renders and shows `form` slot', async () => {
+ createComponent({ toggleText: 'Form action toggle' }, { form: 'Form slot
' });
+
+ expect(findForm().exists()).toBe(false);
+ expect(findFormToggle().text()).toBe('Form action toggle');
+
+ findFormToggle().vm.$emit('click');
+ await nextTick();
+
+ expect(findForm().text()).toBe('Form slot');
+ });
+
+ it('renders `body` slot', () => {
+ createComponent({}, { default: 'Body slot
' });
+
+ expect(findBody().text()).toBe('Body slot');
+ });
+
+ it('renders `footer` slot', () => {
+ createComponent({}, { footer: 'Footer slot
' });
+
+ expect(findFooter().text()).toBe('Footer slot');
+ });
+
+ it('renders `pagination` slot', () => {
+ createComponent({}, { pagination: 'Pagination slot
' });
+
+ expect(findPagination().text()).toBe('Pagination slot');
+ });
+});