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:
parent
16ccf5871e
commit
e92f067e42
18 changed files with 398 additions and 143 deletions
98
app/react/docker/containers/CreateView/VolumesTab/Item.tsx
Normal file
98
app/react/docker/containers/CreateView/VolumesTab/Item.tsx
Normal 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 });
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
13
app/react/docker/containers/CreateView/VolumesTab/context.ts
Normal file
13
app/react/docker/containers/CreateView/VolumesTab/context.ts
Normal 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;
|
||||
}
|
14
app/react/docker/containers/CreateView/VolumesTab/index.ts
Normal file
14
app/react/docker/containers/CreateView/VolumesTab/index.ts
Normal 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,
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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 [];
|
||||
}
|
12
app/react/docker/containers/CreateView/VolumesTab/types.ts
Normal file
12
app/react/docker/containers/CreateView/VolumesTab/types.ts
Normal 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>;
|
|
@ -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([]);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue