From 313ca91904b092475e4d62ff90a90dbe17ca9c01 Mon Sep 17 00:00:00 2001 From: Sascha Eggenberger Date: Wed, 19 Jun 2024 08:31:38 +0200 Subject: [PATCH] Adds the CRUD Container component This new component will replace the GlCard with gl-new-card classes. Changelog: added --- .../components/crud_component.stories.js | 196 ++++++++++++++++++ .../vue_shared/components/crud_component.vue | 124 +++++++++++ app/assets/stylesheets/framework.scss | 1 + app/assets/stylesheets/framework/crud.scss | 21 ++ app/components/layouts/crud_component.haml | 34 +++ app/components/layouts/crud_component.rb | 29 +++ app/views/shared/web_hooks/_hook.html.haml | 19 +- app/views/shared/web_hooks/_index.html.haml | 25 +-- .../components/layouts/crud_component_spec.rb | 94 +++++++++ .../layouts/crud_component_preview.rb | 39 ++++ .../components/crud_component_spec.js | 93 +++++++++ 11 files changed, 651 insertions(+), 24 deletions(-) create mode 100644 app/assets/javascripts/vue_shared/components/crud_component.stories.js create mode 100644 app/assets/javascripts/vue_shared/components/crud_component.vue create mode 100644 app/assets/stylesheets/framework/crud.scss create mode 100644 app/components/layouts/crud_component.haml create mode 100644 app/components/layouts/crud_component.rb create mode 100644 spec/components/layouts/crud_component_spec.rb create mode 100644 spec/components/previews/layouts/crud_component_preview.rb create mode 100644 spec/frontend/vue_shared/components/crud_component_spec.js 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 00000000000000..465ebf56f434bb --- /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: ` + + + + + + #default 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: ` + + + + + + `, +}); + +const ContentListTemplate = (args, { argTypes }) => ({ + components: { CrudComponent, GlButton }, + props: Object.keys(argTypes), + template: ` + + + + + + `, +}); + +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 00000000000000..e24ca41a390593 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/crud_component.vue @@ -0,0 +1,124 @@ + + + diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index e049e8bf71c9a6..3d65d1babdba19 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 00000000000000..49533453b56f38 --- /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 00000000000000..fb25a552f7520f --- /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 00000000000000..cd2ada33fd7869 --- /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 6c6ff5f7fc859e..6df3d25f369679 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 25b9f114987c18..2384f98e24ea74 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 00000000000000..9a715ceb0389cb --- /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 00000000000000..df88aa1f20c652 --- /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 00000000000000..fa3669e47977e5 --- /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'); + }); +}); -- GitLab