diff --git a/src/components/base/block/block.md b/src/components/base/block/block.md new file mode 100644 index 0000000000000000000000000000000000000000..21d046787a5789d18c8728cff0ea481b30b18126 --- /dev/null +++ b/src/components/base/block/block.md @@ -0,0 +1,3 @@ +Blocks are a flexible component used to display content and actions in a variety of contexts. +They are generally restricted to a single list or table and it should be easy for users to scan +relevant and actionable information. diff --git a/src/components/base/block/block.scss b/src/components/base/block/block.scss new file mode 100644 index 0000000000000000000000000000000000000000..716e0173793bb81aeea36308956abc6d604d8c98 --- /dev/null +++ b/src/components/base/block/block.scss @@ -0,0 +1,148 @@ +.gl-block { + @include gl-display-flex; + @include gl-flex-direction-column; + @include gl-overflow-wrap-break; + @include gl-relative; + @include gl-font-base; + @include gl-mt-5; + @include gl-bg-gray-10; + @include gl-border-1; + @include gl-border-solid; + @include gl-border-gray-100; + @include gl-rounded-base; +} + +.gl-block-header { + @include gl-py-4; + @include gl-border-b-1; + @include gl-border-b-solid; + @include gl-border-b-gray-100; + @include gl-rounded-top-base; + @include gl-flex-wrap; + + @include media-breakpoint-up(sm) { + @include gl-flex-nowrap; + } +} + +.gl-block-title-wrapper { + @include gl-display-flex; + @include gl-flex-grow-1; + @include gl-flex-wrap; +} + +.gl-block-title { + @include gl-display-flex; + @include gl-font-base; + @include gl-font-weight-bold; + @include gl-line-height-24; + @include gl-text-gray-900; + @include gl-relative; + @include gl-m-0; + @include gl-mr-3; + + @include media-breakpoint-up(sm) { + @include gl-mr-0; + } +} + +.gl-block-count { + @include gl-mr-3; + @include gl-font-base; + @include gl-font-weight-bold; + @include gl-text-gray-500; + @include gl-display-inline-flex; + @include gl-align-items-center; + + @include media-breakpoint-up(sm) { + @include gl-ml-3; + } +} + +.gl-block-description { + @include gl-flex-basis-full; + @include gl-font-sm; + @include gl-text-gray-500; + @include gl-m-0; + @include gl-mb-2; + + @include media-breakpoint-up(sm) { + @include gl-mr-3; + @include gl-mb-0; + } +} + +.gl-block-actions { + @include gl-display-flex; + @include gl-align-items-flex-start; + @include gl-mr-n2; +} + +.gl-block-body { + @include gl-overflow-hidden; + @include gl-rounded-bottom-base; + @include gl-p-0; +} + +.gl-block-empty { + @include gl-px-5; + @include gl-py-4; + @include gl-mb-0; + @include gl-text-gray-500; +} + +.gl-block-footer { + @include gl-py-3; + @include gl-border-t-1; + @include gl-border-t-solid; + @include gl-border-t-gray-100; + @include gl-rounded-bottom-base; +} + +.gl-block-header, +.gl-block-footer { + @include gl-px-5; + @include gl-display-flex; + @include gl-justify-content-space-between; + @include gl-bg-white; +} + +.gl-block-add-form { + @include gl-m-3; + @include gl-p-4; + @include gl-bg-white; + @include gl-border-1; + @include gl-border-solid; + @include gl-border-gray-100; + @include gl-rounded-base; +} + +// Variants +.gl-block--alternate { + @include gl-bg-white; + + .gl-block-header, + .gl-block-footer { + @include gl-bg-gray-10; + } +} + +.gl-block { + // Table adjustments + // to avoid double borders + // and removes an unnecessary whitespace at the end + .gl-table { + margin: -1px; + width: calc(100% + 2px) !important; + + tbody > tr { + &::after { + @include gl-bg-white; + } + + &:last-of-type::after { + display: none !important; + } + } + } +} diff --git a/src/components/base/block/block.spec.js b/src/components/base/block/block.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..2d8a087cd84f8be4986701fe5aaa425a7f9dd5be --- /dev/null +++ b/src/components/base/block/block.spec.js @@ -0,0 +1,139 @@ +import { shallowMount } from '@vue/test-utils'; +import GlBlock from './block.vue'; + +describe('GlBlock', () => { + let wrapper; + + const HEADER_TEXT = 'In legal trouble?'; + const BODY_TEXT = 'Better call Saul!'; + const FOOTER_TEXT = '(505) 503-4455'; + + const HEADER_CLASS = '.bg-red .text-white'; + const BODY_CLASS = '.bg-yellow .font-script'; + const FOOTER_CLASS = '.bg-yellow .text-white'; + + const createWrapper = (options = {}) => { + wrapper = shallowMount(GlBlock, options); + }; + + const findBody = () => wrapper.find('.gl-block-body'); + const findHeader = () => wrapper.find('.gl-block-header'); + const findFooter = () => wrapper.find('.gl-block-footer'); + + describe('with just the body content', () => { + beforeEach(() => { + createWrapper({ + propsData: { + bodyClass: BODY_CLASS, + }, + slots: { + default: BODY_TEXT, + }, + }); + }); + + it('should render the body content', () => { + expect(findBody().exists()).toBe(true); + expect(findBody().text()).toEqual(BODY_TEXT); + }); + + it('should add the body class', () => { + expect(findBody().classes()).toContain(...BODY_CLASS.split(' ')); + }); + + it('should not render the header content', () => { + expect(findHeader().exists()).toBe(false); + }); + + it('should not render the footer content', () => { + expect(findFooter().exists()).toBe(false); + }); + }); + + describe('with additional header content', () => { + beforeEach(() => { + createWrapper({ + propsData: { + headerClass: HEADER_CLASS, + }, + slots: { + default: BODY_TEXT, + header: HEADER_TEXT, + }, + }); + }); + + it('should render the body content', () => { + expect(findBody().exists()).toBe(true); + expect(findBody().text()).toEqual(BODY_TEXT); + }); + + it('should render the header content', () => { + expect(findHeader().exists()).toBe(true); + expect(findHeader().text()).toEqual(HEADER_TEXT); + }); + + it('should add the header class', () => { + expect(findHeader().classes()).toContain(...HEADER_CLASS.split(' ')); + }); + + it('should not render the footer content', () => { + expect(findFooter().exists()).toBe(false); + }); + }); + + describe('with additional footer content', () => { + beforeEach(() => { + createWrapper({ + propsData: { + footerClass: FOOTER_CLASS, + }, + slots: { + default: BODY_TEXT, + footer: FOOTER_TEXT, + }, + }); + }); + + it('should render the body content', () => { + expect(findBody().exists()).toBe(true); + expect(findBody().text()).toEqual(BODY_TEXT); + }); + + it('should not render the header content', () => { + expect(findHeader().exists()).toBe(false); + }); + + it('should render the footer content', () => { + expect(findFooter().exists()).toBe(true); + expect(findFooter().text()).toEqual(FOOTER_TEXT); + }); + + it('should add the footer class', () => { + expect(findFooter().classes()).toContain(...FOOTER_CLASS.split(' ')); + }); + }); + + it.each` + prop | value | finder | expected + ${'bodyClass'} | ${{ 'applied-class': true, 'non-applied-class': false }} | ${findBody} | ${['gl-block-body', 'applied-class']} + ${'bodyClass'} | ${['applied-class']} | ${findBody} | ${['gl-block-body', 'applied-class']} + ${'headerClass'} | ${{ 'applied-class': true, 'non-applied-class': false }} | ${findHeader} | ${['gl-block-header', 'applied-class']} + ${'headerClass'} | ${['applied-class']} | ${findHeader} | ${['gl-block-header', 'applied-class']} + ${'footerClass'} | ${{ 'applied-class': true, 'non-applied-class': false }} | ${findFooter} | ${['gl-block-footer', 'applied-class']} + ${'footerClass'} | ${['applied-class']} | ${findFooter} | ${['gl-block-footer', 'applied-class']} + `('properly sets classes when $prop is $value', ({ prop, value, finder, expected }) => { + createWrapper({ + propsData: { + [prop]: value, + }, + slots: { + default: BODY_TEXT, + header: HEADER_TEXT, + footer: FOOTER_TEXT, + }, + }); + + expect(finder().classes()).toEqual(expected); + }); +}); diff --git a/src/components/base/block/block.stories.js b/src/components/base/block/block.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..aeb998252551965fbea7ccaa1c746888bfd3199e --- /dev/null +++ b/src/components/base/block/block.stories.js @@ -0,0 +1,163 @@ +import { blockVariants } from '../../../utils/constants'; +import GlButton from '../button/button.vue'; +import GlTable from '../table/table.vue'; +import GlForm from '../form/form.vue'; +import GlFormGroup from '../form/form_group/form_group.vue'; +import GlFormInput from '../form/form_input/form_input.vue'; +import GlFormSelect from '../form/form_select/form_select.vue'; +import readme from './block.md'; +import GlBlock from './block.vue'; + +const tableItems = [ + { + column_one: 'test', + col_2: 1234, + }, + { + column_one: 'test2', + col_2: 5678, + }, + { + column_one: 'test3', + col_2: 9101, + }, +]; + +const template = ` + + + + + + + +`; + +const generateProps = ({ + variant = 'default', + title = 'This is a block title', + icon = 'issues', + count = 99, + description = '', + actionLabel = 'Add item', + emptyMessage = '', +} = {}) => ({ + variant, + title, + icon, + count, + description, + actionLabel, + emptyMessage, +}); + +const Template = (args) => ({ + components: { GlBlock, GlButton, GlTable, GlForm, GlFormGroup, GlFormInput, GlFormSelect }, + data: () => ({ + blockVariants, + }), + props: Object.keys(args), + template, + items: tableItems, +}); + +export const Default = Template.bind({}); +Default.args = generateProps({ description: 'Block description' }); + +export const Slots = (args, { argTypes }) => ({ + components: { GlBlock }, + props: Object.keys(argTypes), + template: ` + + + + + + + + + + `, +}); +Slots.args = generateProps({}); + +export const Empty = (args, { argTypes }) => ({ + components: { GlBlock }, + props: Object.keys(argTypes), + template, +}); +Empty.args = generateProps({ emptyMessage: 'This block is empty.' }); + +export default { + title: 'base/block', + component: GlBlock, + parameters: { + docs: { + description: { + component: readme, + }, + }, + }, + argTypes: { + variant: { + options: blockVariants, + control: 'select', + }, + }, +}; diff --git a/src/components/base/block/block.vue b/src/components/base/block/block.vue new file mode 100644 index 0000000000000000000000000000000000000000..a29bbc83a4b89510ea281c8df62910ceb84da9c4 --- /dev/null +++ b/src/components/base/block/block.vue @@ -0,0 +1,158 @@ + + + + diff --git a/src/scss/components.scss b/src/scss/components.scss index d6a666b4b7f7ae7937088cd0857632cb21f9bf2c..6fdf6c5cfde79095f84c6bb706760d007b555a8b 100644 --- a/src/scss/components.scss +++ b/src/scss/components.scss @@ -7,6 +7,7 @@ @import '../components/base/keyset_pagination/keyset_pagination'; @import '../components/charts/gauge/gauge'; @import '../components/base/token_selector/token_selector'; +@import '../components/base/block/block'; @import '../components/base/card/card'; @import '../components/shared_components/clear_icon_button/clear_icon_button'; @import '../components/shared_components/close_button/close_button'; diff --git a/src/utils/constants.js b/src/utils/constants.js index f25c2c749119c6e4df7ac69e8914fa8dff3ff420..b69fb9b9eadf21844eb8845c0eeb20ed06d18c56 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -318,3 +318,8 @@ export const loadingIconVariants = { spinner: 'spinner', dots: 'dots', }; + +export const blockVariants = { + default: 'default', + alternate: 'alternate', +};