n8n/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/TableHeaderControlsButton.vue

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

310 lines
7.3 KiB
Vue
Raw Normal View History

<script setup lang="ts" generic="ColumnType extends ColumnHeader">
import { computed, ref } from 'vue';
import { useI18n } from '@n8n/design-system/composables/useI18n';
import type { ButtonSize, IconSize } from '@n8n/design-system/types';
import N8nButton from '../N8nButton';
import N8nIcon from '../N8nIcon';
import N8nPopoverReka from '../N8nPopoverReka/N8nPopoverReka.vue';
export type ColumnHeader =
| {
key: string;
label: string;
visible: boolean;
disabled: false;
}
// Disabled state ensures current sort order is not lost if user resorts teh columns
// even if some columns are disabled / not available in the current run
| { key: string; disabled: true };
interface Props {
columns: ColumnType[];
buttonSize?: ButtonSize;
iconSize?: IconSize;
}
const props = defineProps<Props>();
const visibleColumns = computed(() =>
props.columns.filter(
(column): column is ColumnType & { disabled: false } => !column.disabled && column.visible,
),
);
const hiddenColumns = computed(() =>
props.columns.filter(
(column): column is ColumnType & { disabled: false } => !column.disabled && !column.visible,
),
);
const { t } = useI18n();
const draggedItem = ref<string | null>(null);
const dragOverItem = ref<string | null>(null);
const emit = defineEmits<{
'update:columnVisibility': [key: string, visibility: boolean];
'update:columnOrder': [newOrder: string[]];
}>();
const resetDragState = () => {
draggedItem.value = null;
dragOverItem.value = null;
};
const handleDragStart = (event: DragEvent, columnKey: string) => {
if (!event.dataTransfer) return;
draggedItem.value = columnKey;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', columnKey);
};
const handleDragOver = (event: DragEvent, columnKey: string) => {
event.preventDefault();
if (!event.dataTransfer) return;
event.dataTransfer.dropEffect = 'move';
dragOverItem.value = columnKey;
};
const handleDragLeave = () => {
dragOverItem.value = null;
};
const handleDrop = (event: DragEvent, targetColumnKey: string) => {
event.preventDefault();
const draggedColumnKey = draggedItem.value;
if (!draggedColumnKey || draggedColumnKey === targetColumnKey) {
resetDragState();
return;
}
// Get all column keys in their original order, including hidden and disabled
const allColumnKeys = props.columns.map((col) => col.key);
const draggedIndex = allColumnKeys.indexOf(draggedColumnKey);
if (draggedIndex === -1) {
resetDragState();
return;
}
let newOrder: string[];
if (targetColumnKey === 'END') {
// Move to end
newOrder = [...allColumnKeys];
newOrder.splice(draggedIndex, 1);
newOrder.push(draggedColumnKey);
} else {
// Move to specific position
const targetIndex = allColumnKeys.indexOf(targetColumnKey);
if (targetIndex === -1) {
resetDragState();
return;
}
newOrder = [...allColumnKeys];
newOrder.splice(draggedIndex, 1);
// When dragging onto a target, insert at the target's position
// The target will naturally shift due to the insertion
let insertIndex = targetIndex;
// If we removed an item before the target, the target's index has shifted left by 1
if (draggedIndex <= targetIndex) {
insertIndex = targetIndex - 1;
}
newOrder.splice(insertIndex, 0, draggedColumnKey);
}
emit('update:columnOrder', newOrder);
resetDragState();
};
const handleDragEnd = () => {
resetDragState();
};
</script>
<template>
<N8nPopoverReka :class="$style.container" width="260px" max-height="300px" scroll-type="auto">
<template #trigger>
<N8nButton
icon="sliders-horizontal"
type="secondary"
:icon-size="iconSize"
:size="buttonSize"
>
{{ t('tableControlsButton.display') }}
</N8nButton>
</template>
<template #content>
<div :class="$style.contentContainer">
<div
v-if="visibleColumns.length"
:style="{ display: 'flex', flexDirection: 'column', gap: 2 }"
data-testid="visible-columns-section"
>
<h5 :class="$style.header">
{{ t('tableControlsButton.shown') }}
</h5>
<div v-for="column in visibleColumns" :key="column.key" :class="$style.columnWrapper">
<div
v-if="dragOverItem === column.key"
:class="$style.dropIndicator"
data-testid="drop-indicator"
></div>
<fieldset
:class="[
$style.column,
$style.draggable,
{ [$style.dragging]: draggedItem === column.key },
]"
draggable="true"
data-testid="visible-column"
:data-column-key="column.key"
@dragstart="(event) => handleDragStart(event, column.key)"
@dragover="(event) => handleDragOver(event, column.key)"
@dragleave="handleDragLeave"
@drop="(event) => handleDrop(event, column.key)"
@dragend="handleDragEnd"
>
<N8nIcon icon="grip-vertical" :class="$style.grip" />
<label>{{ column.label }}</label>
<N8nIcon
:class="$style.visibilityToggle"
icon="eye"
data-testid="visibility-toggle-visible"
@click="() => emit('update:columnVisibility', column.key, false)"
/>
</fieldset>
</div>
<!-- Drop zone at the end -->
<div
:class="$style.endDropZone"
data-testid="end-drop-zone"
@dragover="(event) => handleDragOver(event, 'END')"
@dragleave="handleDragLeave"
@drop="(event) => handleDrop(event, 'END')"
>
<div
v-if="dragOverItem === 'END'"
:class="$style.dropIndicator"
data-testid="drop-indicator"
></div>
</div>
</div>
<div
v-if="hiddenColumns.length"
:style="{ display: 'flex', flexDirection: 'column', gap: 2 }"
data-testid="hidden-columns-section"
>
<h4 :class="$style.header">
{{ t('tableControlsButton.hidden') }}
</h4>
<fieldset
v-for="column in hiddenColumns"
:key="column.key"
:class="[$style.column, $style.hidden]"
data-testid="hidden-column"
:data-column-key="column.key"
>
<N8nIcon icon="grip-vertical" :class="[$style.grip, $style.hidden]" />
<label>{{ column.label }}</label>
<N8nIcon
:class="$style.visibilityToggle"
icon="eye-off"
data-testid="visibility-toggle-hidden"
@click="() => emit('update:columnVisibility', column.key, true)"
/>
</fieldset>
</div>
</div>
</template>
</N8nPopoverReka>
</template>
<style lang="scss" module>
.header {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
margin-bottom: var(--spacing-xs);
}
.grip {
color: var(--color-text-light);
cursor: move;
&.hidden {
cursor: default;
}
}
.contentContainer {
padding: var(--spacing-s);
}
.column {
display: flex;
gap: 12px;
color: var(--color-text-dark);
padding: 6px 0;
align-items: center;
label {
font-size: var(--font-size-xs);
flex-grow: 1;
}
}
.draggable {
cursor: grab;
transition: all 0.2s ease;
&:active {
cursor: grabbing;
}
}
.dragging {
opacity: 0.5;
transform: scale(0.95);
}
.columnWrapper {
position: relative;
}
.dropIndicator {
position: absolute;
top: -2px;
left: 0;
right: 0;
height: 3px;
background-color: var(--prim-color-secondary);
border-radius: 2px;
z-index: 10;
}
.endDropZone {
position: relative;
height: 8px;
width: 100%;
}
.hidden {
color: var(--color-text-lighter);
label {
color: var(--color-text-light);
}
}
.visibilityToggle {
cursor: pointer;
}
</style>