546 lines
14 KiB
TypeScript
546 lines
14 KiB
TypeScript
import React, { useRef, useState } from 'react';
|
|
|
|
import {
|
|
Popover,
|
|
Content,
|
|
Button,
|
|
Alert,
|
|
TextInput,
|
|
Select,
|
|
MenuToggleElement,
|
|
MenuToggle,
|
|
SelectList,
|
|
SelectOption,
|
|
} from '@patternfly/react-core';
|
|
import { HelpIcon, MinusCircleIcon } from '@patternfly/react-icons';
|
|
import styles from '@patternfly/react-styles/css/components/Table/table';
|
|
import {
|
|
Table,
|
|
Th,
|
|
Thead,
|
|
Tbody,
|
|
Tr,
|
|
TrProps,
|
|
TbodyProps,
|
|
Td,
|
|
} from '@patternfly/react-table';
|
|
|
|
import { UNIT_GIB, UNIT_KIB, UNIT_MIB } from '../../../../../constants';
|
|
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
|
|
import {
|
|
changePartitionMinSize,
|
|
changePartitionMountpoint,
|
|
changePartitionOrder,
|
|
changePartitionUnit,
|
|
removePartition,
|
|
selectPartitions,
|
|
} from '../../../../../store/wizardSlice';
|
|
import { useFilesystemValidation } from '../../../utilities/useValidation';
|
|
import { ValidatedInputAndTextArea } from '../../../ValidatedInput';
|
|
|
|
export const FileSystemContext = React.createContext<boolean>(true);
|
|
|
|
export const MinimumSizePopover = () => {
|
|
return (
|
|
<Popover
|
|
maxWidth="30rem"
|
|
bodyContent={
|
|
<Content>
|
|
<Content>
|
|
Image Builder may extend this size based on requirements, selected
|
|
packages, and configurations.
|
|
</Content>
|
|
</Content>
|
|
}
|
|
>
|
|
<Button
|
|
icon={<HelpIcon />}
|
|
variant="plain"
|
|
aria-label="File system configuration info"
|
|
aria-describedby="file-system-configuration-info"
|
|
className="popover-button pf-v6-u-p-0"
|
|
/>
|
|
</Popover>
|
|
);
|
|
};
|
|
|
|
export type Partition = {
|
|
id: string;
|
|
mountpoint: string;
|
|
min_size: string;
|
|
unit: Units;
|
|
};
|
|
|
|
type RowPropTypes = {
|
|
partition: Partition;
|
|
onDrop?: (event: React.DragEvent<HTMLTableRowElement>) => void;
|
|
onDragEnd?: (event: React.DragEvent<HTMLTableRowElement>) => void;
|
|
onDragStart?: (event: React.DragEvent<HTMLTableRowElement>) => void;
|
|
};
|
|
|
|
const normalizeSuffix = (rawSuffix: string) => {
|
|
const suffix = rawSuffix.replace(/^\/+/g, '');
|
|
return suffix.length > 0 ? '/' + suffix : '';
|
|
};
|
|
|
|
const getPrefix = (mountpoint: string) => {
|
|
return mountpoint.split('/')[1] ? '/' + mountpoint.split('/')[1] : '/';
|
|
};
|
|
const getSuffix = (mountpoint: string) => {
|
|
const prefix = getPrefix(mountpoint);
|
|
return normalizeSuffix(mountpoint.substring(prefix.length));
|
|
};
|
|
|
|
const Row = ({ partition, onDragEnd, onDragStart, onDrop }: RowPropTypes) => {
|
|
const dispatch = useAppDispatch();
|
|
const handleRemovePartition = (id: string) => {
|
|
dispatch(removePartition(id));
|
|
};
|
|
const stepValidation = useFilesystemValidation();
|
|
const isPristine = React.useContext(FileSystemContext);
|
|
|
|
return (
|
|
<Tr
|
|
draggable
|
|
id={partition.id}
|
|
onDrop={onDrop}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
>
|
|
<Td
|
|
draggableRow={{
|
|
id: `draggable-row-${partition.id}`,
|
|
}}
|
|
/>
|
|
<Td className="pf-m-width-20">
|
|
<MountpointPrefix partition={partition} />
|
|
{!isPristine && stepValidation.errors[`mountpoint-${partition.id}`] && (
|
|
<Alert
|
|
variant="danger"
|
|
isInline
|
|
isPlain
|
|
title={stepValidation.errors[`mountpoint-${partition.id}`]}
|
|
/>
|
|
)}
|
|
</Td>
|
|
{partition.mountpoint !== '/' &&
|
|
!partition.mountpoint.startsWith('/boot') &&
|
|
!partition.mountpoint.startsWith('/usr') ? (
|
|
<Td width={20}>
|
|
<MountpointSuffix partition={partition} />
|
|
</Td>
|
|
) : (
|
|
<Td width={20} />
|
|
)}
|
|
|
|
<Td width={20}>xfs</Td>
|
|
<Td width={20}>
|
|
<MinimumSize partition={partition} />
|
|
</Td>
|
|
<Td width={10}>
|
|
<SizeUnit partition={partition} />
|
|
</Td>
|
|
<Td width={10}>
|
|
<Button
|
|
variant="link"
|
|
icon={<MinusCircleIcon />}
|
|
onClick={() => handleRemovePartition(partition.id)}
|
|
isDisabled={partition.mountpoint === '/'}
|
|
/>
|
|
</Td>
|
|
</Tr>
|
|
);
|
|
};
|
|
|
|
export const mountpointPrefixes = [
|
|
'/app',
|
|
'/boot',
|
|
'/data',
|
|
'/home',
|
|
'/opt',
|
|
'/srv',
|
|
'/tmp',
|
|
'/usr',
|
|
'/var',
|
|
];
|
|
|
|
const units = ['GiB', 'MiB', 'KiB'];
|
|
|
|
type MountpointPrefixPropTypes = {
|
|
partition: Partition;
|
|
};
|
|
|
|
const MountpointPrefix = ({ partition }: MountpointPrefixPropTypes) => {
|
|
const dispatch = useAppDispatch();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const prefix = getPrefix(partition.mountpoint);
|
|
const suffix = getSuffix(partition.mountpoint);
|
|
|
|
const onSelect = (event: React.MouseEvent, selection: string) => {
|
|
setIsOpen(false);
|
|
const mountpoint = selection + (suffix.length > 0 ? '/' + suffix : '');
|
|
dispatch(
|
|
changePartitionMountpoint({ id: partition.id, mountpoint: mountpoint })
|
|
);
|
|
};
|
|
|
|
const onToggleClick = () => {
|
|
setIsOpen(!isOpen);
|
|
};
|
|
|
|
const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
|
|
<MenuToggle
|
|
ref={toggleRef}
|
|
onClick={onToggleClick}
|
|
isExpanded={isOpen}
|
|
isDisabled={prefix === '/'}
|
|
data-testid="prefix-select"
|
|
isFullWidth
|
|
>
|
|
{prefix}
|
|
</MenuToggle>
|
|
);
|
|
|
|
return (
|
|
<Select
|
|
isOpen={isOpen}
|
|
selected={prefix}
|
|
onSelect={onSelect}
|
|
onOpenChange={(isOpen) => setIsOpen(isOpen)}
|
|
toggle={toggle}
|
|
shouldFocusToggleOnSelect
|
|
>
|
|
<SelectList>
|
|
{mountpointPrefixes.map((prefix, index) => {
|
|
return (
|
|
<SelectOption key={index} value={prefix}>
|
|
{prefix}
|
|
</SelectOption>
|
|
);
|
|
})}
|
|
</SelectList>
|
|
</Select>
|
|
);
|
|
};
|
|
|
|
type MountpointSuffixPropTypes = {
|
|
partition: Partition;
|
|
};
|
|
|
|
const MountpointSuffix = ({ partition }: MountpointSuffixPropTypes) => {
|
|
const dispatch = useAppDispatch();
|
|
const prefix = getPrefix(partition.mountpoint);
|
|
const suffix = getSuffix(partition.mountpoint);
|
|
|
|
return (
|
|
<TextInput
|
|
value={suffix}
|
|
type="text"
|
|
onChange={(event: React.FormEvent, newValue) => {
|
|
const mountpoint = prefix + normalizeSuffix(newValue);
|
|
dispatch(
|
|
changePartitionMountpoint({
|
|
id: partition.id,
|
|
mountpoint: mountpoint,
|
|
})
|
|
);
|
|
}}
|
|
aria-label="mountpoint suffix"
|
|
/>
|
|
);
|
|
};
|
|
|
|
type MinimumSizePropTypes = {
|
|
partition: Partition;
|
|
};
|
|
|
|
export type Units = 'B' | 'KiB' | 'MiB' | 'GiB';
|
|
|
|
export const getConversionFactor = (units: Units) => {
|
|
switch (units) {
|
|
case 'B':
|
|
return 1;
|
|
case 'KiB':
|
|
return UNIT_KIB;
|
|
case 'MiB':
|
|
return UNIT_MIB;
|
|
case 'GiB':
|
|
return UNIT_GIB;
|
|
}
|
|
};
|
|
|
|
const MinimumSize = ({ partition }: MinimumSizePropTypes) => {
|
|
const dispatch = useAppDispatch();
|
|
const stepValidation = useFilesystemValidation();
|
|
|
|
return (
|
|
<ValidatedInputAndTextArea
|
|
ariaLabel="minimum partition size"
|
|
value={partition.min_size}
|
|
isDisabled={partition.unit === 'B'}
|
|
warning={
|
|
partition.unit === 'B'
|
|
? 'The Wizard only supports KiB, MiB, or GiB. Adjust or keep the current value.'
|
|
: ''
|
|
}
|
|
type="text"
|
|
stepValidation={stepValidation}
|
|
fieldName={`min-size-${partition.id}`}
|
|
placeholder="File system"
|
|
onChange={(event, minSize) => {
|
|
if (minSize === '' || /^\d+$/.test(minSize)) {
|
|
dispatch(
|
|
changePartitionMinSize({
|
|
id: partition.id,
|
|
min_size: minSize,
|
|
})
|
|
);
|
|
dispatch(
|
|
changePartitionUnit({ id: partition.id, unit: partition.unit })
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
type SizeUnitPropTypes = {
|
|
partition: Partition;
|
|
};
|
|
|
|
const SizeUnit = ({ partition }: SizeUnitPropTypes) => {
|
|
const dispatch = useAppDispatch();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
const initialValue = useRef(partition).current;
|
|
|
|
const onSelect = (event: React.MouseEvent, selection: Units) => {
|
|
if (initialValue.unit === 'B' && selection === 'B') {
|
|
dispatch(
|
|
changePartitionMinSize({
|
|
id: partition.id,
|
|
min_size: initialValue.min_size,
|
|
})
|
|
);
|
|
}
|
|
dispatch(changePartitionUnit({ id: partition.id, unit: selection }));
|
|
setIsOpen(false);
|
|
};
|
|
|
|
const onToggleClick = () => {
|
|
setIsOpen(!isOpen);
|
|
};
|
|
|
|
const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
|
|
<MenuToggle
|
|
ref={toggleRef}
|
|
onClick={onToggleClick}
|
|
isExpanded={isOpen}
|
|
data-testid="unit-select"
|
|
>
|
|
{partition.unit}
|
|
</MenuToggle>
|
|
);
|
|
|
|
return (
|
|
<Select
|
|
isOpen={isOpen}
|
|
selected={partition.unit}
|
|
onSelect={onSelect}
|
|
onOpenChange={(isOpen) => setIsOpen(isOpen)}
|
|
toggle={toggle}
|
|
shouldFocusToggleOnSelect
|
|
>
|
|
<SelectList>
|
|
{units.map((unit, index) => (
|
|
<SelectOption key={index} value={unit}>
|
|
{unit}
|
|
</SelectOption>
|
|
))}
|
|
<>
|
|
{initialValue.unit === 'B' && (
|
|
<SelectOption value={'B'}>B</SelectOption>
|
|
)}
|
|
</>
|
|
</SelectList>
|
|
</Select>
|
|
);
|
|
};
|
|
|
|
const FileSystemTable = () => {
|
|
const [draggedItemId, setDraggedItemId] = useState<string | null>(null);
|
|
const [draggingToItemIndex, setDraggingToItemIndex] = useState<number | null>(
|
|
null
|
|
);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [tempItemOrder, setTempItemOrder] = useState<string[]>([]);
|
|
|
|
const bodyRef = useRef<HTMLTableSectionElement>(null);
|
|
const partitions = useAppSelector(selectPartitions);
|
|
const itemOrder = partitions.map((partition) => partition.id);
|
|
const dispatch = useAppDispatch();
|
|
const isValidDrop = (
|
|
evt: React.DragEvent<HTMLTableSectionElement | HTMLTableRowElement>
|
|
) => {
|
|
const ulRect = bodyRef.current?.getBoundingClientRect();
|
|
if (!ulRect) return false;
|
|
return (
|
|
evt.clientX > ulRect.x &&
|
|
evt.clientX < ulRect.x + ulRect.width &&
|
|
evt.clientY > ulRect.y &&
|
|
evt.clientY < ulRect.y + ulRect.height
|
|
);
|
|
};
|
|
|
|
const onDragStart: TrProps['onDragStart'] = (evt) => {
|
|
evt.dataTransfer.effectAllowed = 'move';
|
|
evt.dataTransfer.setData('text/plain', evt.currentTarget.id);
|
|
const draggedItemId = evt.currentTarget.id;
|
|
|
|
evt.currentTarget.classList.add(styles.modifiers.ghostRow);
|
|
evt.currentTarget.setAttribute('aria-pressed', 'true');
|
|
|
|
setDraggedItemId(draggedItemId);
|
|
setIsDragging(true);
|
|
};
|
|
|
|
const onDragCancel = () => {
|
|
const children = bodyRef.current?.children;
|
|
if (children) {
|
|
Array.from(children).forEach((el) => {
|
|
el.classList.remove(styles.modifiers.ghostRow);
|
|
el.setAttribute('aria-pressed', 'false');
|
|
});
|
|
}
|
|
setDraggedItemId(null);
|
|
setDraggingToItemIndex(null);
|
|
setIsDragging(false);
|
|
};
|
|
|
|
const onDragLeave: TbodyProps['onDragLeave'] = (evt) => {
|
|
if (!isValidDrop(evt)) {
|
|
move(itemOrder);
|
|
setDraggingToItemIndex(null);
|
|
}
|
|
};
|
|
|
|
const onDrop: TrProps['onDrop'] = (evt) => {
|
|
if (isValidDrop(evt)) {
|
|
dispatch(changePartitionOrder(tempItemOrder));
|
|
} else {
|
|
onDragCancel();
|
|
}
|
|
};
|
|
|
|
const onDragOver: TbodyProps['onDragOver'] = (evt) => {
|
|
evt.preventDefault();
|
|
|
|
const curListItem = (evt.target as HTMLTableSectionElement).closest('tr');
|
|
if (
|
|
!curListItem ||
|
|
!bodyRef.current?.contains(curListItem) ||
|
|
curListItem.id === draggedItemId
|
|
) {
|
|
return null;
|
|
}
|
|
const dragId = curListItem.id;
|
|
const newDraggingToItemIndex = Array.from(
|
|
bodyRef.current.children
|
|
).findIndex((item) => item.id === dragId);
|
|
if (newDraggingToItemIndex !== draggingToItemIndex && draggedItemId) {
|
|
const tempItemOrder = moveItem(
|
|
[...itemOrder],
|
|
draggedItemId,
|
|
newDraggingToItemIndex
|
|
);
|
|
move(tempItemOrder);
|
|
setDraggingToItemIndex(newDraggingToItemIndex);
|
|
setTempItemOrder(tempItemOrder);
|
|
}
|
|
};
|
|
|
|
const onDragEnd: TrProps['onDragEnd'] = (evt) => {
|
|
const target = evt.target as HTMLTableRowElement;
|
|
target.classList.remove(styles.modifiers.ghostRow);
|
|
target.setAttribute('aria-pressed', 'false');
|
|
setDraggedItemId(null);
|
|
setDraggingToItemIndex(null);
|
|
setIsDragging(false);
|
|
};
|
|
|
|
const moveItem = (arr: string[], i1: string, toIndex: number) => {
|
|
const fromIndex = arr.indexOf(i1);
|
|
if (fromIndex === toIndex) {
|
|
return arr;
|
|
}
|
|
const temp = arr.splice(fromIndex, 1);
|
|
arr.splice(toIndex, 0, temp[0]);
|
|
|
|
return arr;
|
|
};
|
|
|
|
const move = (itemOrder: string[]) => {
|
|
const ulNode = bodyRef.current;
|
|
if (!ulNode) {
|
|
return;
|
|
}
|
|
const nodes = Array.from(ulNode.children);
|
|
if (nodes.map((node) => node.id).every((id, i) => id === itemOrder[i])) {
|
|
return;
|
|
}
|
|
while (ulNode.firstChild) {
|
|
ulNode.removeChild(ulNode.lastChild as Node);
|
|
}
|
|
|
|
itemOrder.forEach((id) => {
|
|
const node = nodes.find((n) => n.id === id);
|
|
if (node) {
|
|
ulNode.appendChild(node);
|
|
}
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Table
|
|
className={isDragging ? styles.modifiers.dragOver : ''}
|
|
aria-label="File system table"
|
|
variant="compact"
|
|
data-testid="fsc-table"
|
|
>
|
|
<Thead>
|
|
<Tr>
|
|
<Th aria-label="Drag mount point" />
|
|
<Th>Mount point</Th>
|
|
<Th aria-label="Suffix"></Th>
|
|
<Th>Type</Th>
|
|
<Th>
|
|
Minimum size <MinimumSizePopover />
|
|
</Th>
|
|
<Th aria-label="Unit" />
|
|
<Th aria-label="Remove mount point" />
|
|
</Tr>
|
|
</Thead>
|
|
|
|
<Tbody
|
|
onDragOver={onDragOver}
|
|
onDrop={onDragOver}
|
|
onDragLeave={onDragLeave}
|
|
ref={bodyRef}
|
|
data-testid="file-system-configuration-tbody"
|
|
>
|
|
{partitions &&
|
|
partitions.map((partition) => (
|
|
<Row
|
|
onDrop={onDrop}
|
|
onDragEnd={onDragEnd}
|
|
onDragStart={onDragStart}
|
|
key={partition.id}
|
|
partition={partition}
|
|
/>
|
|
))}
|
|
</Tbody>
|
|
</Table>
|
|
);
|
|
};
|
|
|
|
export default FileSystemTable;
|