Skip to content
代码片段 群组 项目
未验证 提交 cf20345e 编辑于 作者: Himanshu Kapoor's avatar Himanshu Kapoor 提交者: GitLab
浏览文件

Allow resizing table columns in a GLQL table

Introduce a new th_resizable component to allow columns in
a GLQL table to be resizable.
上级 4563914b
No related branches found
No related tags found
无相关合并请求
<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>
<script> <script>
import { GlIcon } from '@gitlab/ui'; import { GlIcon } from '@gitlab/ui';
import Sorter from '../../core/sorter'; import Sorter from '../../core/sorter';
import ThResizable from '../common/th_resizable.vue';
export default { export default {
name: 'TablePresenter', name: 'TablePresenter',
components: { components: {
GlIcon, GlIcon,
ThResizable,
}, },
inject: ['presenter'], inject: ['presenter'],
props: { props: {
...@@ -27,16 +29,23 @@ export default { ...@@ -27,16 +29,23 @@ export default {
items, items,
fields: this.config.fields, fields: this.config.fields,
sorter: new Sorter(items), sorter: new Sorter(items),
table: null,
}; };
}, },
async mounted() {
await this.$nextTick();
this.table = this.$refs.table;
},
}; };
</script> </script>
<template> <template>
<div class="!gl-my-4"> <div class="!gl-my-4">
<table class="!gl-mb-2 !gl-mt-0"> <table ref="table" class="!gl-mb-2 !gl-mt-0">
<thead> <thead>
<tr> <tr v-if="table">
<th v-for="(field, fieldIndex) in fields" :key="field.key"> <th-resizable v-for="(field, fieldIndex) in fields" :key="field.key" :table="table">
<div <div
:data-testid="`column-${fieldIndex}`" :data-testid="`column-${fieldIndex}`"
class="gl-cursor-pointer" class="gl-cursor-pointer"
...@@ -48,7 +57,7 @@ export default { ...@@ -48,7 +57,7 @@ export default {
:name="sorter.options.ascending ? 'arrow-up' : 'arrow-down'" :name="sorter.options.ascending ? 'arrow-up' : 'arrow-down'"
/> />
</div> </div>
</th> </th-resizable>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
......
...@@ -267,7 +267,6 @@ ...@@ -267,7 +267,6 @@
} }
} }
&.grid-rows { &.grid-rows {
> thead > tr > th, > thead > tr > th,
> tbody > tr > td { > tbody > tr > td {
...@@ -895,10 +894,10 @@ wbr { ...@@ -895,10 +894,10 @@ wbr {
margin-block-end: 0; margin-block-end: 0;
} }
} }
// prevent backref character from rendering as emoji // prevent backref character from rendering as emoji
.footnote-backref gl-emoji { .footnote-backref gl-emoji {
font-family: inherit; font-family: inherit;
font-size: 0.875em; font-size: 0.875em;
pointer-events: none; pointer-events: none;
} }
......
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');
});
});
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; 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 IssuablePresenter from '~/glql/components/presenters/issuable.vue';
import StatePresenter from '~/glql/components/presenters/state.vue'; import StatePresenter from '~/glql/components/presenters/state.vue';
import TablePresenter from '~/glql/components/presenters/table.vue'; import TablePresenter from '~/glql/components/presenters/table.vue';
...@@ -11,28 +12,29 @@ import { MOCK_FIELDS, MOCK_ISSUES } from '../../mock_data'; ...@@ -11,28 +12,29 @@ import { MOCK_FIELDS, MOCK_ISSUES } from '../../mock_data';
describe('TablePresenter', () => { describe('TablePresenter', () => {
let wrapper; let wrapper;
const createWrapper = ({ data, config, ...moreProps }, mountFn = shallowMountExtended) => { const createWrapper = async ({ data, config, ...moreProps }, mountFn = shallowMountExtended) => {
wrapper = mountFn(TablePresenter, { wrapper = mountFn(TablePresenter, {
provide: { provide: {
presenter: new Presenter().init({ data, config }), presenter: new Presenter().init({ data, config }),
}, },
propsData: { data, config, ...moreProps }, propsData: { data, config, ...moreProps },
}); });
await nextTick();
}; };
const getCells = (row) => row.findAll('td').wrappers.map((td) => td.text()); const getCells = (row) => row.findAll('td').wrappers.map((td) => td.text());
it('renders header rows with sentence cased field names', () => { it('renders header rows with sentence cased field names', async () => {
createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } }); await createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } });
const headerRow = wrapper.find('thead tr'); const headerCells = wrapper.findAllComponents(ThResizable).wrappers.map((th) => th.text());
const headerCells = headerRow.findAll('th').wrappers.map((th) => th.text());
expect(headerCells).toEqual(['Title', 'Author', 'State', 'Description']); expect(headerCells).toEqual(['Title', 'Author', 'State', 'Description']);
}); });
it('renders a row of items presented by appropriate presenters', () => { it('renders a row of items presented by appropriate presenters', async () => {
createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } }, mountExtended); await createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } }, mountExtended);
const tableRow1 = wrapper.findByTestId('table-row-0'); const tableRow1 = wrapper.findByTestId('table-row-0');
const tableRow2 = wrapper.findByTestId('table-row-1'); const tableRow2 = wrapper.findByTestId('table-row-1');
...@@ -69,8 +71,8 @@ describe('TablePresenter', () => { ...@@ -69,8 +71,8 @@ describe('TablePresenter', () => {
]); ]);
}); });
it('shows a "No data" message if the list of items provided is empty', () => { it('shows a "No data" message if the list of items provided is empty', async () => {
createWrapper({ data: { nodes: [] }, config: { fields: MOCK_FIELDS } }); await createWrapper({ data: { nodes: [] }, config: { fields: MOCK_FIELDS } });
expect(wrapper.text()).toContain('No data found for this query'); expect(wrapper.text()).toContain('No data found for this query');
}); });
...@@ -102,7 +104,7 @@ describe('TablePresenter', () => { ...@@ -102,7 +104,7 @@ describe('TablePresenter', () => {
}; };
beforeEach(async () => { beforeEach(async () => {
createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } }, mountExtended); await createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } }, mountExtended);
await triggerClick(); await triggerClick();
}); });
......
0% 加载中 .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册