1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-21 22:39:41 +02:00

refactor(containers): migrate volumes tab to react [EE-5209] (#10284)

This commit is contained in:
Chaim Lev-Ari 2023-09-21 05:31:00 +03:00 committed by GitHub
parent 16ccf5871e
commit e92f067e42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 398 additions and 143 deletions

View file

@ -0,0 +1,98 @@
import _ from 'lodash';
import { ArrowRight } from 'lucide-react';
import { Icon } from '@@/Icon';
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
import { FormError } from '@@/form-components/FormError';
import { InputGroup } from '@@/form-components/InputGroup';
import { ItemProps } from '@@/form-components/InputList';
import { InputLabeled } from '@@/form-components/Input/InputLabeled';
import { Volume } from './types';
import { useInputContext } from './context';
import { VolumeSelector } from './VolumeSelector';
export function Item({
item: volume,
onChange,
error,
index,
}: ItemProps<Volume>) {
const allowBindMounts = useInputContext();
return (
<div>
<div className="col-sm-12 form-inline flex gap-1">
<InputLabeled
label="container"
placeholder="e.g. /path/in/container"
value={volume.containerPath}
onChange={(e) => setValue({ containerPath: e.target.value })}
size="small"
className="flex-1"
id={`container-path-${index}`}
/>
{allowBindMounts && (
<InputGroup size="small">
<ButtonSelector
value={volume.type}
onChange={(type) => {
onChange({ ...volume, type, name: '' });
}}
options={[
{ value: 'volume', label: 'Volume' },
{ value: 'bind', label: 'Bind' },
]}
aria-label="Volume type"
/>
</InputGroup>
)}
</div>
<div className="col-sm-12 form-inline mt-1 flex items-center gap-1">
<Icon icon={ArrowRight} />
{volume.type === 'volume' && (
<InputGroup size="small" className="flex-1">
<InputGroup.Addon as="label" htmlFor={`volume-${index}`}>
volume
</InputGroup.Addon>
<VolumeSelector
value={volume.name}
onChange={(name) => setValue({ name })}
inputId={`volume-${index}`}
/>
</InputGroup>
)}
{volume.type === 'bind' && (
<InputLabeled
size="small"
className="flex-1"
label="host"
placeholder="e.g. /path/on/host"
value={volume.name}
onChange={(e) => setValue({ name: e.target.value })}
id={`host-path-${index}`}
/>
)}
<InputGroup size="small">
<ButtonSelector<boolean>
aria-label="ReadWrite"
value={volume.readOnly}
onChange={(readOnly) => setValue({ readOnly })}
options={[
{ value: false, label: 'Writable' },
{ value: true, label: 'Read-only' },
]}
/>
</InputGroup>
</div>
{error && <FormError>{_.first(Object.values(error))}</FormError>}
</div>
);
function setValue(partial: Partial<Volume>) {
onChange({ ...volume, ...partial });
}
}

View file

@ -0,0 +1,45 @@
import { truncate } from '@/portainer/filters/filters';
import { useVolumes } from '@/react/docker/volumes/queries/useVolumes';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Select } from '@@/form-components/ReactSelect';
export function VolumeSelector({
value,
onChange,
inputId,
}: {
value: string;
onChange: (value?: string) => void;
inputId?: string;
}) {
const environmentId = useEnvironmentId();
const volumesQuery = useVolumes(environmentId, {
select(volumes) {
return volumes.sort((vol1, vol2) => vol1.Name.localeCompare(vol2.Name));
},
});
if (!volumesQuery.data) {
return null;
}
const volumes = volumesQuery.data;
const selectedValue = volumes.find((vol) => vol.Name === value);
return (
<Select
placeholder="Select a volume"
options={volumes}
getOptionLabel={(vol) =>
`${truncate(vol.Name, 30)} - ${truncate(vol.Driver, 30)}`
}
getOptionValue={(vol) => vol.Name}
isMulti={false}
value={selectedValue}
onChange={(vol) => onChange(vol?.Name)}
inputId={inputId}
/>
);
}

View file

@ -0,0 +1,46 @@
import { useState } from 'react';
import { FormikErrors } from 'formik';
import { InputList } from '@@/form-components/InputList';
import { Values, Volume } from './types';
import { InputContext } from './context';
import { Item } from './Item';
export function VolumesTab({
onChange,
values,
allowBindMounts,
errors,
}: {
onChange: (values: Values) => void;
values: Values;
allowBindMounts: boolean;
errors?: FormikErrors<Values>;
}) {
const [controlledValues, setControlledValues] = useState(values);
return (
<InputContext.Provider value={allowBindMounts}>
<InputList<Volume>
errors={Array.isArray(errors) ? errors : []}
label="Volume mapping"
onChange={(volumes) => handleChange(volumes)}
value={controlledValues}
addLabel="map additional volume"
item={Item}
itemBuilder={() => ({
containerPath: '',
type: 'volume',
name: '',
readOnly: false,
})}
/>
</InputContext.Provider>
);
function handleChange(newValues: Values) {
onChange(newValues);
setControlledValues(() => newValues);
}
}

View file

@ -0,0 +1,13 @@
import { createContext, useContext } from 'react';
export const InputContext = createContext<boolean | null>(null);
export function useInputContext() {
const value = useContext(InputContext);
if (value === null) {
throw new Error('useContext must be used within a Context.Provider');
}
return value;
}

View file

@ -0,0 +1,14 @@
import { validation } from './validation';
import { toRequest } from './toRequest';
import { toViewModel, getDefaultViewModel } from './toViewModel';
export { VolumesTab } from './VolumesTab';
export { type Values as VolumesTabValues } from './types';
export const volumesTabUtils = {
toRequest,
toViewModel,
validation,
getDefaultViewModel,
};

View file

@ -0,0 +1,33 @@
import { CreateContainerRequest } from '../types';
import { Values } from './types';
export function toRequest(
oldConfig: CreateContainerRequest,
values: Values
): CreateContainerRequest {
const validValues = values.filter(
(volume) => volume.containerPath && volume.name
);
const volumes = Object.fromEntries(
validValues.map((volume) => [volume.containerPath, {}])
);
const binds = validValues.map((volume) => {
let bind = `${volume.name}:${volume.containerPath}`;
if (volume.readOnly) {
bind += ':ro';
}
return bind;
});
return {
...oldConfig,
Volumes: volumes,
HostConfig: {
...oldConfig.HostConfig,
Binds: binds,
},
};
}

View file

@ -0,0 +1,16 @@
import { ContainerJSON } from '../../queries/container';
import { VolumeType, Values } from './types';
export function toViewModel(config: ContainerJSON): Values {
return Object.values(config.Mounts || {}).map((mount) => ({
type: (mount.Type || 'volume') as VolumeType,
name: mount.Name || mount.Source || '',
containerPath: mount.Destination || '',
readOnly: mount.RW === false,
}));
}
export function getDefaultViewModel(): Values {
return [];
}

View file

@ -0,0 +1,12 @@
export const volumeTypes = ['bind', 'volume'] as const;
export type VolumeType = (typeof volumeTypes)[number];
export interface Volume {
containerPath: string;
type: VolumeType;
name: string;
readOnly: boolean;
}
export type Values = Array<Volume>;

View file

@ -0,0 +1,16 @@
import { object, SchemaOf, array, string, mixed } from 'yup';
import { Values, VolumeType, volumeTypes } from './types';
export function validation(): SchemaOf<Values> {
return array(
object({
containerPath: string().required('Container path is required'),
type: mixed<VolumeType>()
.oneOf([...volumeTypes])
.default('volume'),
name: string().required('Volume name is required'),
readOnly: mixed<boolean>().default(false),
})
).default([]);
}