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