From cf20345edb1a79b7ed640b955847e02fa0ec4f05 Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor <info@fleon.org> Date: Mon, 30 Sep 2024 18:32:13 +0000 Subject: [PATCH] Allow resizing table columns in a GLQL table Introduce a new th_resizable component to allow columns in a GLQL table to be resizable. --- .../glql/components/common/th_resizable.vue | 75 +++++++++++++++++ .../glql/components/presenters/table.vue | 17 +++- .../stylesheets/framework/typography.scss | 5 +- .../components/common/th_resizable_spec.js | 83 +++++++++++++++++++ .../glql/components/presenters/table_spec.js | 22 ++--- 5 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 app/assets/javascripts/glql/components/common/th_resizable.vue create mode 100644 spec/frontend/glql/components/common/th_resizable_spec.js diff --git a/app/assets/javascripts/glql/components/common/th_resizable.vue b/app/assets/javascripts/glql/components/common/th_resizable.vue new file mode 100644 index 0000000000000..38f603ae7cb5d --- /dev/null +++ b/app/assets/javascripts/glql/components/common/th_resizable.vue @@ -0,0 +1,75 @@ +<script> +export default { + name: 'ThResizable', + props: { + table: { + required: true, + type: HTMLTableElement, + }, + }, + data() { + return { + initialX: 0, + initialColumnWidth: 0, + isResizing: false, + + columnWidth: 0, + tableHeight: 0, + }; + }, + computed: { + headerStyle() { + return ( + this.columnWidth && { + minWidth: `${this.columnWidth}px`, + maxWidth: `${this.columnWidth}px`, + } + ); + }, + }, + mounted() { + this.updateTableHeight(); + }, + methods: { + updateTableHeight() { + this.tableHeight = this.table.clientHeight; + }, + onDocumentMouseMove(e) { + this.columnWidth = this.initialColumnWidth + e.clientX - this.initialX; + + this.updateTableHeight(); + }, + onDocumentMouseUp() { + this.isResizing = false; + + document.removeEventListener('mousemove', this.onDocumentMouseMove); + document.removeEventListener('mouseup', this.onDocumentMouseUp); + + this.$emit('resize', this.columnWidth); + }, + onMouseDown(e) { + this.isResizing = true; + this.initialX = e.clientX; + + const styles = window.getComputedStyle(this.$el); + this.initialColumnWidth = parseInt(styles.width, 10); + + document.addEventListener('mousemove', this.onDocumentMouseMove); + document.addEventListener('mouseup', this.onDocumentMouseUp); + }, + }, +}; +</script> +<template> + <th :style="headerStyle" class="gl-relative"> + <slot></slot> + <div + class="gl-absolute gl-right-0 gl-top-0 gl-z-1 gl-w-2 gl-cursor-col-resize gl-select-none hover:gl-bg-gray-50" + data-testid="resize-handle" + :class="{ 'gl-bg-gray-50': isResizing }" + :style="{ height: `${tableHeight}px` }" + @mouseover="updateTableHeight" + @mousedown="onMouseDown" + ></div> + </th> +</template> diff --git a/app/assets/javascripts/glql/components/presenters/table.vue b/app/assets/javascripts/glql/components/presenters/table.vue index d2ced1d78b84c..0bcc0efa94459 100644 --- a/app/assets/javascripts/glql/components/presenters/table.vue +++ b/app/assets/javascripts/glql/components/presenters/table.vue @@ -1,11 +1,13 @@ <script> import { GlIcon } from '@gitlab/ui'; import Sorter from '../../core/sorter'; +import ThResizable from '../common/th_resizable.vue'; export default { name: 'TablePresenter', components: { GlIcon, + ThResizable, }, inject: ['presenter'], props: { @@ -27,16 +29,23 @@ export default { items, fields: this.config.fields, sorter: new Sorter(items), + + table: null, }; }, + async mounted() { + await this.$nextTick(); + + this.table = this.$refs.table; + }, }; </script> <template> <div class="!gl-my-4"> - <table class="!gl-mb-2 !gl-mt-0"> + <table ref="table" class="!gl-mb-2 !gl-mt-0"> <thead> - <tr> - <th v-for="(field, fieldIndex) in fields" :key="field.key"> + <tr v-if="table"> + <th-resizable v-for="(field, fieldIndex) in fields" :key="field.key" :table="table"> <div :data-testid="`column-${fieldIndex}`" class="gl-cursor-pointer" @@ -48,7 +57,7 @@ export default { :name="sorter.options.ascending ? 'arrow-up' : 'arrow-down'" /> </div> - </th> + </th-resizable> </tr> </thead> <tbody> diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index d4c9cf46bd46d..6ed30be7a9a7a 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -267,7 +267,6 @@ } } - &.grid-rows { > thead > tr > th, > tbody > tr > td { @@ -895,10 +894,10 @@ wbr { margin-block-end: 0; } } - + // prevent backref character from rendering as emoji .footnote-backref gl-emoji { - font-family: inherit; + font-family: inherit; font-size: 0.875em; pointer-events: none; } diff --git a/spec/frontend/glql/components/common/th_resizable_spec.js b/spec/frontend/glql/components/common/th_resizable_spec.js new file mode 100644 index 0000000000000..99dfa4e11003a --- /dev/null +++ b/spec/frontend/glql/components/common/th_resizable_spec.js @@ -0,0 +1,83 @@ +import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ThResizable from '~/glql/components/common/th_resizable.vue'; + +describe('ThResizable', () => { + let wrapper; + let table; + + const findResizeHandle = () => wrapper.findByTestId('resize-handle'); + + const createComponent = (props = {}) => { + table = document.createElement('table'); + table.innerHTML = '<thead><tr><th></th></tr></thead>'; + + document.body.appendChild(table); + + wrapper = mountExtended(ThResizable, { + propsData: { + table, + ...props, + }, + attachTo: table.querySelector('th'), + }); + + jest.spyOn(window, 'getComputedStyle').mockReturnValue({ width: '50px' }); + }; + + afterEach(() => { + table.remove(); + }); + + it('renders the th element with a resize handle', () => { + createComponent(); + + expect(wrapper.find('th').exists()).toBe(true); + expect(wrapper.findByTestId('resize-handle').exists()).toBe(true); + }); + + it('applies correct styles when resizing', async () => { + createComponent(); + const th = wrapper.find('th'); + + // Simulate start of resize + await findResizeHandle().trigger('mousedown', { clientX: 100 }); + + // Simulate mouse move + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150 })); + await nextTick(); + + // initial width = 50, deltaX = 50, expected = 50 + 50 = 100 + expect(th.element.style.minWidth).toBe('100px'); + expect(th.element.style.maxWidth).toBe('100px'); + }); + + it('applies correct styles when resizing ends', async () => { + createComponent(); + const th = wrapper.find('th'); + + // Start and end resize + await findResizeHandle().trigger('mousedown', { clientX: 100 }); + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150 })); + document.dispatchEvent(new MouseEvent('mouseup')); + await nextTick(); + + // initial width = 50, deltaX = 50, expected = 50 + 50 = 100 + expect(th.element.style.minWidth).toBe('100px'); + expect(th.element.style.maxWidth).toBe('100px'); + + expect(wrapper.emitted('resize')).toHaveLength(1); + expect(wrapper.emitted('resize')[0]).toEqual([100]); + }); + + it('updates resize handle height on mouseover', async () => { + createComponent(); + + // Set a fixed height for the table + Object.defineProperty(table, 'clientHeight', { value: 200, writable: true }); + + await findResizeHandle().trigger('mouseover'); + + expect(findResizeHandle().element.style.height).toBe('200px'); + }); +}); diff --git a/spec/frontend/glql/components/presenters/table_spec.js b/spec/frontend/glql/components/presenters/table_spec.js index 9289875cc3fc3..036822f1da99d 100644 --- a/spec/frontend/glql/components/presenters/table_spec.js +++ b/spec/frontend/glql/components/presenters/table_spec.js @@ -1,5 +1,6 @@ import { nextTick } from 'vue'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ThResizable from '~/glql/components/common/th_resizable.vue'; import IssuablePresenter from '~/glql/components/presenters/issuable.vue'; import StatePresenter from '~/glql/components/presenters/state.vue'; import TablePresenter from '~/glql/components/presenters/table.vue'; @@ -11,28 +12,29 @@ import { MOCK_FIELDS, MOCK_ISSUES } from '../../mock_data'; describe('TablePresenter', () => { let wrapper; - const createWrapper = ({ data, config, ...moreProps }, mountFn = shallowMountExtended) => { + const createWrapper = async ({ data, config, ...moreProps }, mountFn = shallowMountExtended) => { wrapper = mountFn(TablePresenter, { provide: { presenter: new Presenter().init({ data, config }), }, propsData: { data, config, ...moreProps }, }); + + await nextTick(); }; const getCells = (row) => row.findAll('td').wrappers.map((td) => td.text()); - it('renders header rows with sentence cased field names', () => { - createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } }); + it('renders header rows with sentence cased field names', async () => { + await createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } }); - const headerRow = wrapper.find('thead tr'); - const headerCells = headerRow.findAll('th').wrappers.map((th) => th.text()); + const headerCells = wrapper.findAllComponents(ThResizable).wrappers.map((th) => th.text()); expect(headerCells).toEqual(['Title', 'Author', 'State', 'Description']); }); - it('renders a row of items presented by appropriate presenters', () => { - createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } }, mountExtended); + it('renders a row of items presented by appropriate presenters', async () => { + await createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } }, mountExtended); const tableRow1 = wrapper.findByTestId('table-row-0'); const tableRow2 = wrapper.findByTestId('table-row-1'); @@ -69,8 +71,8 @@ describe('TablePresenter', () => { ]); }); - it('shows a "No data" message if the list of items provided is empty', () => { - createWrapper({ data: { nodes: [] }, config: { fields: MOCK_FIELDS } }); + it('shows a "No data" message if the list of items provided is empty', async () => { + await createWrapper({ data: { nodes: [] }, config: { fields: MOCK_FIELDS } }); expect(wrapper.text()).toContain('No data found for this query'); }); @@ -102,7 +104,7 @@ describe('TablePresenter', () => { }; beforeEach(async () => { - createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } }, mountExtended); + await createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } }, mountExtended); await triggerClick(); }); -- GitLab