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 = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+ Submit
+
+
+
+
+
+ {{ emptyMessage }}
+
+
+`;
+
+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: `
+
+
+
+
+ #header
+
+
+ #actions
+
+
+ #default (Body)
+
+
+ #form
+
+
+ #empty
+
+
+ #footer
+
+
+ `,
+});
+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',
+};