1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +02:00

refactor(ui/box-selector): replace all selectors [EE-3856] (#7902)

This commit is contained in:
Chaim Lev-Ari 2023-02-07 09:03:57 +05:30 committed by GitHub
parent c9253319d9
commit 2dddc1c6b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 1267 additions and 1011 deletions

View file

@ -0,0 +1,52 @@
.root input {
display: none;
}
.root label {
@apply border border-solid;
@apply bg-gray-2 border-gray-5 text-black;
@apply th-dark:bg-gray-iron-10 th-dark:border-gray-neutral-8 th-dark:text-white;
@apply th-highcontrast:text-white;
font-weight: normal;
font-size: 12px;
display: block;
border-radius: 8px;
padding: 15px;
text-align: left;
box-shadow: var(--shadow-boxselector-color);
position: relative;
text-align: left;
height: 100%;
}
/* not disabled */
.root input:not(:disabled) ~ label {
@apply bg-gray-2;
@apply th-dark:bg-gray-iron-10;
@apply th-highcontrast:bg-black;
box-shadow: none;
cursor: pointer;
}
/* disabled */
.root input:disabled + label {
@apply bg-white;
@apply th-dark:bg-gray-7;
@apply th-highcontrast:bg-black;
filter: opacity(0.3) grayscale(1);
cursor: not-allowed;
}
.root input:checked + label {
@apply bg-blue-2 border-blue-6;
@apply th-dark:bg-blue-10 th-dark:border-blue-7;
@apply th-highcontrast:bg-blue-10 th-highcontrast:border-blue-7;
border-radius: 8px;
padding: 15px;
box-shadow: none;
}

View file

@ -1,56 +1,73 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import type { Icon } from 'lucide-react';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import './BoxSelectorItem.css';
import styles from './BoxOption.module.css';
import { BoxSelectorOption, Value } from './types';
import { BoxSelectorOption } from './types';
interface Props<T extends number | string> {
interface Props<T extends Value> {
radioName: string;
option: BoxSelectorOption<T>;
onChange?(value: T): void;
selectedValue: T;
onSelect?(value: T): void;
isSelected(value: T): boolean;
disabled?: boolean;
tooltip?: string;
className?: string;
type?: 'radio' | 'checkbox';
checkIcon: Icon;
}
export function BoxOption<T extends number | string>({
export function BoxOption<T extends Value>({
radioName,
option,
onChange = () => {},
selectedValue,
onSelect = () => {},
isSelected,
disabled,
tooltip,
className,
type = 'radio',
children,
checkIcon: Check,
}: PropsWithChildren<Props<T>>) {
const BoxOption = (
<div className={clsx('box-selector-item', className)}>
const selected = isSelected(option.value);
const item = (
<div className={clsx(styles.root, className)}>
<input
type={type}
name={radioName}
id={option.id}
checked={option.value === selectedValue}
value={option.value}
checked={selected}
value={option.value.toString()}
disabled={disabled}
onChange={() => onChange(option.value)}
onChange={() => onSelect(option.value)}
/>
<label htmlFor={option.id} data-cy={`${radioName}_${option.value}`}>
{children}
{!disabled && (
<div
className={clsx(
'absolute top-4 right-4 h-4 w-4 rounded-full border border-solid border-blue-8 text-white font-bold flex items-center justify-center',
{
'bg-white': !selected,
'bg-blue-8': selected,
}
)}
>
{selected && <Check className="lucide" strokeWidth={3} />}
</div>
)}
</label>
</div>
);
if (tooltip) {
return (
<TooltipWithChildren message={tooltip}>{BoxOption}</TooltipWithChildren>
);
return <TooltipWithChildren message={tooltip}>{item}</TooltipWithChildren>;
}
return BoxOption;
return item;
}

View file

@ -1,14 +0,0 @@
.boxselector_wrapper {
display: flex;
flex-flow: row wrap;
gap: 10px;
overflow: hidden !important;
margin-bottom: 5px;
margin-top: 5px;
}
@media only screen and (max-width: 700px) {
.boxselector_wrapper {
flex-direction: column;
}
}

View file

@ -1,4 +1,16 @@
.root {
width: 100%;
overflow: auto;
display: flex;
flex-flow: row wrap;
gap: 10px;
overflow: hidden !important;
margin-bottom: 5px;
margin-top: 5px;
}
@media only screen and (max-width: 700px) {
.root {
flex-direction: column;
}
}

View file

@ -1,7 +1,8 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { User } from 'lucide-react';
import { Anchor, Briefcase } from 'lucide-react';
import Docker from '@/assets/ico/vendor/docker.svg?c';
import { init as initFeatureService } from '@/react/portainer/feature-flags/feature-flags.service';
import { Edition, FeatureId } from '@/react/portainer/feature-flags/enums';
@ -22,14 +23,16 @@ function Example() {
const options: BoxSelectorOption<number>[] = [
{
description: 'description 1',
icon: User,
icon: Anchor,
iconType: 'badge',
id: '1',
value: 3,
label: 'option 1',
},
{
description: 'description 2',
icon: User,
icon: Briefcase,
iconType: 'badge',
id: '2',
value: 4,
label: 'option 2',
@ -54,14 +57,16 @@ function LimitedFeature() {
const options: BoxSelectorOption<number>[] = [
{
description: 'description 1',
icon: User,
icon: Anchor,
iconType: 'badge',
id: '1',
value: 3,
label: 'option 1',
},
{
description: 'description 2',
icon: User,
icon: Briefcase,
iconType: 'badge',
id: '2',
value: 4,
label: 'option 2',
@ -81,6 +86,85 @@ function LimitedFeature() {
);
}
// regular example
export function MultiSelect() {
const [value, setValue] = useState([3]);
const options: BoxSelectorOption<number>[] = [
{
description: 'description 1',
icon: Anchor,
iconType: 'badge',
id: '1',
value: 1,
label: 'option 1',
},
{
description: 'description 2',
icon: Briefcase,
iconType: 'badge',
id: '2',
value: 2,
label: 'option 2',
},
{
description: 'description 3',
icon: Docker,
id: '3',
value: 3,
label: 'option 2',
},
];
// story with limited feature
return (
<BoxSelector
isMulti
radioName="name"
onChange={(value: number[]) => {
setValue(value);
}}
value={value}
options={options}
/>
);
}
export function SlimMultiSelect() {
const [value, setValue] = useState([3]);
const options: BoxSelectorOption<number>[] = [
{
description: 'description 1',
icon: Anchor,
iconType: 'badge',
id: '1',
value: 1,
label: 'option 1',
},
{
description: 'description 2',
icon: Briefcase,
iconType: 'badge',
id: '2',
value: 2,
label: 'option 2',
},
{
description: 'description 3',
icon: Docker,
id: '3',
value: 3,
label: 'option 2',
},
];
return (
<BoxSelector
isMulti
radioName="name"
onChange={(value: number[]) => {
setValue(value);
}}
value={value}
options={options}
slim
/>
);
}

View file

@ -2,21 +2,26 @@ import { Rocket } from 'lucide-react';
import { render, fireEvent } from '@/react-tools/test-utils';
import { BoxSelector, Props } from './BoxSelector';
import { BoxSelectorOption } from './types';
import { BoxSelector } from './BoxSelector';
import { BoxSelectorOption, Value } from './types';
function renderDefault<T extends string | number>({
function renderDefault<T extends Value>({
options = [],
onChange = () => {},
radioName = 'radio',
value,
}: Partial<Props<T>> = {}) {
}: {
options?: BoxSelectorOption<T>[];
onChange?: (value: T) => void;
radioName?: string;
value: T;
}) {
return render(
<BoxSelector
options={options}
onChange={onChange}
radioName={radioName}
value={value || 0}
value={value}
/>
);
}

View file

@ -1,30 +1,39 @@
import clsx from 'clsx';
import { Check, Minus } from 'lucide-react';
import './BoxSelector.css';
import styles from './BoxSelector.module.css';
import { BoxSelectorItem } from './BoxSelectorItem';
import { BoxSelectorOption } from './types';
import { BoxSelectorOption, Value } from './types';
export interface Props<T extends number | string> {
radioName: string;
value: T;
onChange(value: T, limitedToBE: boolean): void;
options: BoxSelectorOption<T>[];
interface IsMultiProps<T extends Value> {
isMulti: true;
value: T[];
onChange(value: T[], limitedToBE: boolean): void;
}
export function BoxSelector<T extends number | string>({
interface SingleProps<T extends Value> {
isMulti?: never;
value: T;
onChange(value: T, limitedToBE: boolean): void;
}
type Union<T extends Value> = IsMultiProps<T> | SingleProps<T>;
export type Props<T extends Value> = Union<T> & {
radioName: string;
options: ReadonlyArray<BoxSelectorOption<T>> | Array<BoxSelectorOption<T>>;
slim?: boolean;
};
export function BoxSelector<T extends Value>({
radioName,
value,
options,
onChange,
slim = false,
...props
}: Props<T>) {
return (
<div className="form-group">
<div className="col-sm-12">
<div
className={clsx('boxselector_wrapper', styles.root)}
role="radiogroup"
>
<div className={styles.root} role="radiogroup">
{options
.filter((option) => !option.hide)
.map((option) => (
@ -32,14 +41,41 @@ export function BoxSelector<T extends number | string>({
key={option.id}
radioName={radioName}
option={option}
onChange={onChange}
selectedValue={value}
onSelect={handleSelect}
disabled={option.disabled && option.disabled()}
tooltip={option.tooltip && option.tooltip()}
type={props.isMulti ? 'checkbox' : 'radio'}
isSelected={isSelected}
slim={slim}
checkIcon={props.isMulti ? Minus : Check}
/>
))}
</div>
</div>
</div>
);
function handleSelect(optionValue: T, limitedToBE: boolean) {
if (props.isMulti) {
const newValue = isSelected(optionValue)
? props.value.filter((v) => v !== optionValue)
: [...props.value, optionValue];
props.onChange(newValue, limitedToBE);
return;
}
if (isSelected(optionValue)) {
return;
}
props.onChange(optionValue, limitedToBE);
}
function isSelected(optionValue: T) {
if (props.isMulti) {
return props.value.includes(optionValue);
}
return props.value === optionValue;
}
}

View file

@ -1,129 +0,0 @@
.boxselector_wrapper > div,
.box-selector-item {
flex: 1;
}
.boxselector_wrapper .boxselector_header,
.box-selector-item .boxselector_header {
font-size: 18px;
margin-right: 20px;
margin-bottom: 5px;
font-weight: bold;
user-select: none;
color: var(--text-boxselector-header);
}
.boxselector_wrapper input[type='radio'],
.box-selector-item input[type='radio'] {
display: none;
}
.boxselector_wrapper label,
.box-selector-item label {
@apply border border-solid;
@apply bg-gray-2 border-gray-5 text-black;
@apply th-dark:bg-gray-iron-10 th-dark:border-gray-neutral-8 th-dark:text-white;
font-weight: normal;
font-size: 12px;
display: block;
border-radius: 8px;
padding: 15px;
text-align: left;
box-shadow: var(--shadow-boxselector-color);
position: relative;
text-align: left;
height: 100%;
}
/* not disabled */
.boxselector_wrapper input[type='radio']:not(:disabled) ~ label,
.box-selector-item input[type='radio']:not(:disabled) ~ label {
background-color: var(--bg-boxselector-color);
box-shadow: none;
cursor: pointer;
}
/* disabled */
.box-selector-item input:disabled + label,
.boxselector_wrapper label.boxselector_disabled {
@apply !bg-white;
@apply th-dark:!bg-gray-7;
@apply th-highcontrast:!bg-black;
filter: opacity(0.3) grayscale(1);
cursor: not-allowed;
pointer-events: none;
}
.boxselector_wrapper label.boxselector_disabled a {
cursor: pointer;
pointer-events: auto;
}
/* checked */
.boxselector_wrapper input[type='radio']:checked + label,
.box-selector-item input[type='radio']:checked + label {
@apply bg-blue-2 border-blue-6;
@apply th-dark:bg-blue-10 th-dark:border-blue-7;
@apply th-highcontrast:bg-blue-10 th-highcontrast:border-blue-7;
background-image: url(../../../assets/ico/checked.svg);
background-repeat: no-repeat;
background-position: right 15px top 15px;
border-radius: 8px;
padding: 15px;
box-shadow: none;
}
@media only screen and (max-width: 700px) {
.boxselector_wrapper {
flex-direction: column;
}
}
.box-selector-item.limited.business label,
.box-selector-item.limited.business input[type='radio'] + label {
@apply border-warning-7 bg-warning-1 text-black;
@apply th-dark:bg-warning-8 th-dark:bg-opacity-10;
@apply th-highcontrast:bg-warning-8 th-highcontrast:bg-opacity-10;
}
.boxselector_img_container {
width: 100%;
margin-bottom: 20px;
text-align: left;
line-height: 90px;
margin-bottom: 0;
}
.boxselector_icon,
.boxselector_icon img {
font-size: 90px;
}
.boxselector_icon > svg {
margin-left: -5px;
}
.boxselector_header pr-icon {
margin-right: 5px;
}
.boxselector_content {
padding-left: 20px;
}
.boxselector_img_container {
line-height: 90px;
margin-bottom: 0;
}
.box-selector-item p {
margin-bottom: 0;
color: var(--text-boxselector-header);
}

View file

@ -0,0 +1,50 @@
.box-selector-item {
flex: 1;
}
.box-selector-item .header {
@apply text-black;
@apply th-dark:text-white;
@apply th-highcontrast:text-white;
font-size: 18px;
font-weight: bold;
user-select: none;
}
.image-container {
margin-bottom: 20px;
text-align: left;
margin-bottom: 0;
}
.icon,
.icon img {
font-size: 90px;
}
.slim .icon {
font-size: 56px;
}
.icon > svg {
margin-left: -5px;
}
.header pr-icon {
margin-right: 5px;
}
.content {
padding-left: 20px;
}
.box-selector-item.limited.business label,
.box-selector-item.limited.business input:checked + label {
@apply border-warning-7 bg-warning-1 text-black;
@apply th-dark:bg-warning-8 th-dark:bg-opacity-10;
@apply th-highcontrast:bg-warning-8 th-highcontrast:bg-opacity-10;
filter: none;
}

View file

@ -1,8 +1,10 @@
import { Meta } from '@storybook/react';
import { User } from 'lucide-react';
import { ReactNode } from 'react';
import { Briefcase } from 'lucide-react';
import { init as initFeatureService } from '@/react/portainer/feature-flags/feature-flags.service';
import { Edition, FeatureId } from '@/react/portainer/feature-flags/enums';
import Docker from '@/assets/ico/vendor/docker.svg?c';
import { IconProps } from '@@/Icon';
@ -14,7 +16,7 @@ const meta: Meta = {
args: {
selected: false,
description: 'description',
icon: User,
icon: Briefcase,
label: 'label',
},
};
@ -30,7 +32,7 @@ interface ExampleProps {
}
function Template({
selected,
selected = false,
description = 'description',
icon,
label = 'label',
@ -48,10 +50,10 @@ function Template({
return (
<div className="boxselector_wrapper">
<BoxSelectorItem
onChange={() => {}}
onSelect={() => {}}
option={option}
radioName="radio"
selectedValue={selected ? option.value : 0}
isSelected={() => selected}
/>
</div>
);
@ -78,3 +80,36 @@ export function SelectedLimitedFeatureItem() {
return <Template feature={FeatureId.ACTIVITY_AUDIT} selected />;
}
function IconTemplate({
icon,
iconType,
}: {
icon: ReactNode;
iconType: 'raw' | 'logo' | 'badge';
}) {
return (
<BoxSelectorItem
onSelect={() => {}}
option={{
description: 'description',
icon,
iconType,
label: 'label',
id: 'id',
value: 'value',
}}
isSelected={() => false}
radioName="radio"
slim
/>
);
}
export function LogoItem() {
return <IconTemplate icon={Docker} iconType="logo" />;
}
export function BadgeItem() {
return <IconTemplate icon={Briefcase} iconType="badge" />;
}

View file

@ -1,46 +1,57 @@
import clsx from 'clsx';
import { Icon as ReactFeatherComponentType, Check } from 'lucide-react';
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { Icon } from '@/react/components/Icon';
import './BoxSelectorItem.css';
import { BadgeIcon } from '@@/BadgeIcon';
import { BoxSelectorOption } from './types';
import styles from './BoxSelectorItem.module.css';
import { BoxSelectorOption, Value } from './types';
import { LimitedToBeIndicator } from './LimitedToBeIndicator';
import { BoxOption } from './BoxOption';
import { LogoIcon } from './LogoIcon';
interface Props<T extends number | string> {
radioName: string;
type Props<T extends Value> = {
option: BoxSelectorOption<T>;
onChange(value: T, limitedToBE: boolean): void;
selectedValue: T;
radioName: string;
disabled?: boolean;
tooltip?: string;
}
onSelect(value: T, limitedToBE: boolean): void;
isSelected(value: T): boolean;
type?: 'radio' | 'checkbox';
slim?: boolean;
checkIcon?: ReactFeatherComponentType;
};
export function BoxSelectorItem<T extends number | string>({
export function BoxSelectorItem<T extends Value>({
radioName,
option,
onChange,
selectedValue,
onSelect = () => {},
disabled,
tooltip,
type = 'radio',
isSelected,
slim = false,
checkIcon = Check,
}: Props<T>) {
const limitedToBE = isLimitedToBE(option.feature);
const beIndicatorTooltipId = `box-selector-item-${radioName}-${option.id}-limited`;
return (
<BoxOption
className={clsx({
business: limitedToBE,
limited: limitedToBE,
className={clsx(styles.boxSelectorItem, {
[styles.business]: limitedToBE,
[styles.limited]: limitedToBE,
})}
radioName={radioName}
option={option}
selectedValue={selectedValue}
disabled={disabled}
onChange={(value) => onChange(value, limitedToBE)}
isSelected={isSelected}
disabled={isDisabled()}
onSelect={(value) => onSelect(value, limitedToBE)}
tooltip={tooltip}
type={type}
checkIcon={checkIcon}
>
<>
{limitedToBE && (
@ -49,19 +60,51 @@ export function BoxSelectorItem<T extends number | string>({
featureId={option.feature}
/>
)}
<div className={clsx({ 'opacity-30': limitedToBE })}>
<div className="boxselector_img_container">
{!!option.icon && (
<Icon
icon={option.icon}
className="boxselector_icon !flex items-center"
/>
)}
<div
className={clsx('flex gap-2', {
'opacity-30': limitedToBE,
'flex-col justify-between h-full': !slim,
'items-center slim': slim,
})}
>
<div
className={clsx(styles.imageContainer, 'flex items-center', {
'flex-1': !slim,
})}
>
{renderIcon()}
</div>
<div>
<div className={styles.header}>{option.label}</div>
<p>{option.description}</p>
</div>
<div className="boxselector_header">{option.label}</div>
<p className="box-selector-item-description">{option.description}</p>
</div>
</>
</BoxOption>
);
function isDisabled() {
return disabled || (limitedToBE && option.disabledWhenLimited);
}
function renderIcon() {
if (!option.icon) {
return null;
}
if (option.iconType === 'badge') {
return <BadgeIcon icon={option.icon} />;
}
if (option.iconType === 'logo') {
return <LogoIcon icon={option.icon} />;
}
return (
<Icon
icon={option.icon}
className={clsx(styles.icon, '!flex items-center')}
/>
);
}
}

View file

@ -1,44 +1,58 @@
import { Edit, FileText, Globe, Upload } from 'lucide-react';
import { Edit, FileText, Globe, UploadCloud } from 'lucide-react';
import GitIcon from '@/assets/ico/git.svg?c';
import { BadgeIcon } from '@@/BadgeIcon';
import { BoxSelectorOption } from '../types';
export const editor: BoxSelectorOption<'editor'> = {
id: 'method_editor',
icon: <BadgeIcon icon={Edit} />,
icon: Edit,
iconType: 'badge',
label: 'Web editor',
description: 'Use our Web editor',
value: 'editor',
};
export const upload: BoxSelectorOption<'upload'> = {
id: 'method_upload',
icon: <BadgeIcon icon={Upload} />,
icon: UploadCloud,
iconType: 'badge',
label: 'Upload',
description: 'Upload from your computer',
value: 'upload',
};
export const git: BoxSelectorOption<'repository'> = {
id: 'method_repository',
icon: <GitIcon />,
icon: GitIcon,
iconType: 'logo',
label: 'Repository',
description: 'Use a git repository',
value: 'repository',
};
export const template: BoxSelectorOption<'template'> = {
export const edgeStackTemplate: BoxSelectorOption<'template'> = {
id: 'method_template',
icon: <BadgeIcon icon={FileText} />,
icon: FileText,
iconType: 'badge',
label: 'Template',
description: 'Use an Edge stack template',
value: 'template',
};
export const customTemplate: BoxSelectorOption<'template'> = {
id: 'method_template',
icon: FileText,
iconType: 'badge',
label: 'Custom template',
description: 'Use a custom template',
value: 'template',
};
export const url: BoxSelectorOption<'url'> = {
id: 'method_url',
icon: <BadgeIcon icon={Globe} />,
icon: Globe,
iconType: 'badge',
label: 'URL',
description: 'Specify a URL to a file',
value: 'url',

View file

@ -1,14 +1,20 @@
import { ReactNode } from 'react';
import type { FeatureId } from '@/react/portainer/feature-flags/enums';
import { IconProps } from '@@/Icon';
export interface BoxSelectorOption<T> extends IconProps {
id: string;
label: string;
description: string;
value: T;
disabled?: () => boolean;
tooltip?: () => string;
feature?: FeatureId;
hide?: boolean;
export type Value = number | string | boolean;
export interface BoxSelectorOption<T extends Value> extends IconProps {
readonly id: string;
readonly label: string;
readonly description?: ReactNode;
readonly value: T;
readonly disabled?: () => boolean;
readonly tooltip?: () => string;
readonly feature?: FeatureId;
readonly disabledWhenLimited?: boolean;
readonly hide?: boolean;
readonly iconType?: 'raw' | 'badge' | 'logo';
}

View file

@ -16,7 +16,7 @@ interface Args {
function Template({ totalSteps = 5 }: Args) {
const steps: Step[] = Array.from({ length: totalSteps }).map((_, index) => ({
title: `step ${index + 1}`,
label: `step ${index + 1}`,
}));
const [currentStep, setCurrentStep] = useState(1);

View file

@ -3,7 +3,7 @@ import clsx from 'clsx';
import styles from './Stepper.module.css';
export interface Step {
title: string;
label: string;
}
interface Props {
@ -16,7 +16,7 @@ export function Stepper({ currentStep, steps }: Props) {
<div className={styles.stepperWrapper}>
{steps.map((step, index) => (
<div
key={step.title}
key={step.label}
className={clsx(styles.stepWrapper, {
[styles.active]: index + 1 === currentStep,
[styles.completed]: index + 1 < currentStep,
@ -24,7 +24,7 @@ export function Stepper({ currentStep, steps }: Props) {
>
<div className={styles.step}>
<div className={styles.stepCounter}>{index + 1}</div>
<div className={styles.stepName}>{step.title}</div>
<div className={styles.stepName}>{step.label}</div>
</div>
</div>
))}

View file

@ -6,7 +6,6 @@ import highcontrastmode from '@/assets/ico/theme/highcontrastmode.svg?c';
// general icons
import heartbeatup from '@/assets/ico/heartbeat-up.svg?c';
import heartbeatdown from '@/assets/ico/heartbeat-down.svg?c';
import checked from '@/assets/ico/checked.svg?c';
import dataflow from '@/assets/ico/dataflow-1.svg?c';
import git from '@/assets/ico/git.svg?c';
import kube from '@/assets/ico/kube.svg?c';
@ -53,7 +52,6 @@ export const SvgIcons = {
lightmode,
highcontrastmode,
dataflow,
checked,
dockericon,
git,
laptopcode,

View file

@ -0,0 +1,70 @@
import { Box, Boxes } from 'lucide-react';
import { KubernetesApplicationDataAccessPolicies } from '@/kubernetes/models/application/models';
import { BoxSelector, BoxSelectorOption } from '@@/BoxSelector';
interface Props {
isEdit: boolean;
persistedFoldersUseExistingVolumes: boolean;
value: number;
onChange(value: number): void;
}
export function KubeApplicationAccessPolicySelector({
isEdit,
persistedFoldersUseExistingVolumes,
value,
onChange,
}: Props) {
const options = getOptions(value, isEdit, persistedFoldersUseExistingVolumes);
return (
<BoxSelector
slim
options={options}
value={value}
onChange={onChange}
radioName="data_access_policy"
/>
);
}
function getOptions(
value: number,
isEdit: boolean,
persistedFoldersUseExistingVolumes: boolean
): ReadonlyArray<BoxSelectorOption<number>> {
return [
{
value: KubernetesApplicationDataAccessPolicies.ISOLATED,
id: 'data_access_isolated',
icon: Boxes,
iconType: 'badge',
label: 'Isolated',
description:
'Application will be deployed as a StatefulSet with each instantiating their own data',
tooltip: () =>
isEdit || persistedFoldersUseExistingVolumes
? 'Changing the data access policy is not allowed'
: '',
disabled: () =>
(isEdit &&
value !== KubernetesApplicationDataAccessPolicies.ISOLATED) ||
persistedFoldersUseExistingVolumes,
},
{
value: KubernetesApplicationDataAccessPolicies.SHARED,
id: 'data_access_shared',
icon: Box,
iconType: 'badge',
label: 'Shared',
description:
'Application will be deployed as a Deployment with a shared storage access',
tooltip: () =>
isEdit ? 'Changing the data access policy is not allowed' : '',
disabled: () =>
isEdit && value !== KubernetesApplicationDataAccessPolicies.SHARED,
},
] as const;
}

View file

@ -0,0 +1,27 @@
import { BoxSelector } from '@@/BoxSelector';
import { getDeploymentOptions } from './deploymentOptions';
interface Props {
value: number;
onChange(value: number): void;
supportGlobalDeployment: boolean;
}
export function KubeApplicationDeploymentTypeSelector({
supportGlobalDeployment,
value,
onChange,
}: Props) {
const options = getDeploymentOptions(supportGlobalDeployment);
return (
<BoxSelector
slim
options={options}
value={value}
onChange={onChange}
radioName="deploymentType"
/>
);
}

View file

@ -0,0 +1,34 @@
import { Boxes, Sliders } from 'lucide-react';
import { KubernetesApplicationDeploymentTypes } from '@/kubernetes/models/application/models';
import { BoxSelectorOption } from '@@/BoxSelector';
export function getDeploymentOptions(
supportGlobalDeployment: boolean
): ReadonlyArray<BoxSelectorOption<number>> {
return [
{
id: 'deployment_replicated',
label: 'Replicated',
value: KubernetesApplicationDeploymentTypes.REPLICATED,
icon: Sliders,
iconType: 'badge',
description: 'Run one or multiple instances of this container',
},
{
id: 'deployment_global',
disabled: () => !supportGlobalDeployment,
tooltip: () =>
!supportGlobalDeployment
? 'The storage or access policy used for persisted folders cannot be used with this option'
: '',
label: 'Global',
description:
'Application will be deployed as a DaemonSet with an instance on each node of the cluster',
value: KubernetesApplicationDeploymentTypes.GLOBAL,
icon: Boxes,
iconType: 'badge',
},
] as const;
}

View file

@ -0,0 +1,34 @@
import { BoxSelector } from '@@/BoxSelector';
import { Team } from '../../users/teams/types';
import { ResourceControlOwnership } from '../types';
import { useOptions } from './useOptions';
export function AccessTypeSelector({
name,
isAdmin,
isPublicVisible,
teams,
value,
onChange,
}: {
name: string;
isAdmin: boolean;
teams: Team[];
isPublicVisible: boolean;
value: ResourceControlOwnership;
onChange(value: ResourceControlOwnership): void;
}) {
const options = useOptions(isAdmin, teams, isPublicVisible);
return (
<BoxSelector
slim
radioName={name}
value={value}
options={options}
onChange={onChange}
/>
);
}

View file

@ -4,7 +4,6 @@ import { FormikErrors } from 'formik';
import { useUser } from '@/react/hooks/useUser';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { BoxSelector } from '@@/BoxSelector';
import { FormError } from '@@/form-components/FormError';
import { ResourceControlOwnership, AccessControlFormData } from '../types';
@ -12,7 +11,7 @@ import { ResourceControlOwnership, AccessControlFormData } from '../types';
import { UsersField } from './UsersField';
import { TeamsField } from './TeamsField';
import { useLoadState } from './useLoadState';
import { useOptions } from './useOptions';
import { AccessTypeSelector } from './AccessTypeSelector';
interface Props {
values: AccessControlFormData;
@ -34,7 +33,6 @@ export function EditDetails({
const { user, isAdmin } = useUser();
const { users, teams, isLoading } = useLoadState(environmentId);
const options = useOptions(isAdmin, teams, isPublicVisible);
const handleChange = useCallback(
(partialValues: Partial<typeof values>) => {
@ -50,11 +48,13 @@ export function EditDetails({
return (
<>
<BoxSelector
radioName={withNamespace('ownership')}
<AccessTypeSelector
onChange={handleChangeOwnership}
name={withNamespace('ownership')}
value={values.ownership}
options={options}
onChange={(ownership) => handleChangeOwnership(ownership)}
isAdmin={isAdmin}
isPublicVisible={isPublicVisible}
teams={teams}
/>
{values.ownership === ResourceControlOwnership.RESTRICTED && (

View file

@ -70,9 +70,17 @@ function nonAdminOptions(teams?: Team[]) {
'access_restricted',
<BadgeIcon icon={ownershipIcon('restricted')} />,
'Restricted',
teams.length === 1
? `I want any member of my team (${teams[0].Name}) to be able to manage this resource`
: 'I want to restrict the management of this resource to one or more of my teams',
teams.length === 1 ? (
<>
I want any member of my team (<b>{teams[0].Name}</b>) to be able to
manage this resource
</>
) : (
<>
I want to restrict the management of this resource to one or more of
my teams
</>
),
ResourceControlOwnership.RESTRICTED
),
]);

View file

@ -66,7 +66,7 @@ export function EnvironmentTypeSelectView() {
trackEvent('endpoint-wizard-endpoint-select', {
category: 'portainer',
metadata: {
environment: steps.map((step) => step.title).join('/'),
environment: steps.map((step) => step.label).join('/'),
},
});

View file

@ -1,7 +1,6 @@
import { BoxSelector } from '@@/BoxSelector';
import { FormSection } from '@@/form-components/FormSection';
import { Option } from '../components/Option';
import { environmentTypes } from './environment-types';
export type EnvironmentSelectorValue = typeof environmentTypes[number]['id'];
@ -23,40 +22,26 @@ export function EnvironmentSelector({
onChange,
createEdgeDevice,
}: Props) {
const options = filterEdgeDevicesIfNeed(environmentTypes, createEdgeDevice);
return (
<div className="row">
<div className="form-horizontal">
<FormSection title="Select your environment(s)">
<p className="text-muted small">
You can onboard different types of environments, select all that
apply.
</p>
<div className="flex gap-4 flex-wrap">
{filterEdgeDevicesIfNeed(environmentTypes, createEdgeDevice).map(
(eType) => (
<Option
key={eType.id}
featureId={eType.featureId}
title={eType.title}
description={eType.description}
icon={eType.icon}
active={value.includes(eType.id)}
onClick={() => handleClick(eType.id)}
/>
)
)}
</div>
<BoxSelector
options={options}
isMulti
value={value}
onChange={onChange}
radioName="type-selector"
/>
</FormSection>
</div>
);
function handleClick(eType: EnvironmentSelectorValue) {
if (value.includes(eType)) {
onChange(value.filter((v) => v !== eType));
return;
}
onChange([...value, eType]);
}
}
function filterEdgeDevicesIfNeed(
@ -64,8 +49,8 @@ function filterEdgeDevicesIfNeed(
createEdgeDevice?: boolean
) {
if (!createEdgeDevice) {
return types;
return [...types];
}
return types.filter((eType) => hasEdge.includes(eType.id));
return [...types.filter((eType) => hasEdge.includes(eType.id))];
}

View file

@ -1,52 +1,71 @@
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import DockerIcon from '@/assets/ico/vendor/docker-icon.svg?c';
import Kube from '@/assets/ico/kube.svg?c';
import MicrosoftIcon from '@/assets/ico/vendor/microsoft-icon.svg?c';
import NomadIcon from '@/assets/ico/vendor/nomad-icon.svg?c';
import Docker from '@/assets/ico/vendor/docker.svg?c';
import Kubernetes from '@/assets/ico/vendor/kubernetes.svg?c';
import Azure from '@/assets/ico/vendor/azure.svg?c';
import Nomad from '@/assets/ico/vendor/nomad.svg?c';
import KaaSIcon from './kaas-icon.svg?c';
export const environmentTypes = [
{
id: 'dockerStandalone',
title: 'Docker Standalone',
icon: DockerIcon,
value: 'dockerStandalone',
label: 'Docker Standalone',
icon: Docker,
iconType: 'logo',
description: 'Connect to Docker Standalone via URL/IP, API or Socket',
featureId: undefined,
},
{
id: 'dockerSwarm',
title: 'Docker Swarm',
icon: DockerIcon,
value: 'dockerSwarm',
label: 'Docker Swarm',
icon: Docker,
iconType: 'logo',
description: 'Connect to Docker Swarm via URL/IP, API or Socket',
featureId: undefined,
},
{
id: 'kubernetes',
title: 'Kubernetes',
icon: Kube,
value: 'kubernetes',
label: 'Kubernetes',
icon: Kubernetes,
iconType: 'logo',
description: 'Connect to a kubernetes environment via URL/IP',
featureId: undefined,
},
{
id: 'aci',
title: 'ACI',
value: 'aci',
label: 'ACI',
description: 'Connect to ACI environment via API',
icon: MicrosoftIcon,
featureId: undefined,
iconType: 'logo',
icon: Azure,
},
{
id: 'nomad',
title: 'Nomad',
value: 'nomad',
label: 'Nomad',
description: 'Connect to HashiCorp Nomad environment via API',
icon: NomadIcon,
featureId: FeatureId.NOMAD,
icon: Nomad,
iconType: 'logo',
feature: FeatureId.NOMAD,
disabledWhenLimited: true,
},
{
id: 'kaas',
title: 'KaaS',
value: 'kaas',
label: 'KaaS',
description: 'Provision a Kubernetes environment with a cloud provider',
icon: KaaSIcon,
featureId: FeatureId.KAAS_PROVISIONING,
iconType: 'logo',
feature: FeatureId.KAAS_PROVISIONING,
disabledWhenLimited: true,
},
] as const;
export const formTitles = {
dockerStandalone: 'Connect to your Docker Standalone environment',
dockerSwarm: 'Connect to your Docker Swarm environment',
kubernetes: 'Connect to your Kubernetes environment',
aci: 'Connect to your ACI environment',
nomad: 'Connect to your Nomad environment',
kaas: 'Provision a KaaS environment',
};

View file

@ -18,7 +18,10 @@ import { Button } from '@@/buttons';
import { FormSection } from '@@/form-components/FormSection';
import { Icon } from '@@/Icon';
import { environmentTypes } from '../EnvironmentTypeSelectView/environment-types';
import {
environmentTypes,
formTitles,
} from '../EnvironmentTypeSelectView/environment-types';
import { EnvironmentSelectorValue } from '../EnvironmentTypeSelectView/EnvironmentSelector';
import { WizardDocker } from './WizardDocker';
@ -77,10 +80,7 @@ export function EnvironmentCreationView() {
<Stepper steps={steps} currentStep={currentStepIndex + 1} />
<div className="mt-12">
<FormSection
title={`Connect to your ${currentStep.title}
environment`}
>
<FormSection title={formTitles[currentStep.id]}>
<Component
onCreate={handleCreateEnvironment}
isDockerStandalone={isDockerStandalone}

View file

@ -1,13 +1,12 @@
import { EnvironmentCreationTypes } from '@/react/portainer/environments/types';
import { BoxSelectorOption } from '@@/BoxSelector';
import { Value, BoxSelectorOption } from '@@/BoxSelector/types';
import { useCreateEdgeDeviceParam } from '../hooks/useCreateEdgeDeviceParam';
export function useFilterEdgeOptionsIfNeeded<T = EnvironmentCreationTypes>(
options: BoxSelectorOption<T>[],
edgeValue: T
) {
export function useFilterEdgeOptionsIfNeeded<
T extends Value = EnvironmentCreationTypes
>(options: BoxSelectorOption<T>[], edgeValue: T) {
const createEdgeDevice = useCreateEdgeDeviceParam();
if (!createEdgeDevice) {

View file

@ -20,6 +20,7 @@ export enum FeatureId {
REGISTRY_MANAGEMENT = 'registry-management',
K8S_SETUP_DEFAULT = 'k8s-setup-default',
S3_BACKUP_SETTING = 's3-backup-setting',
S3_RESTORE = 'restore-s3-form',
HIDE_INTERNAL_AUTHENTICATION_PROMPT = 'hide-internal-authentication-prompt',
TEAM_MEMBERSHIP = 'team-membership',
HIDE_INTERNAL_AUTH = 'hide-internal-auth',

View file

@ -28,6 +28,7 @@ export async function init(edition: Edition) {
[FeatureId.RBAC_ROLES]: Edition.BE,
[FeatureId.REGISTRY_MANAGEMENT]: Edition.BE,
[FeatureId.S3_BACKUP_SETTING]: Edition.BE,
[FeatureId.S3_RESTORE]: Edition.BE,
[FeatureId.TEAM_MEMBERSHIP]: Edition.BE,
[FeatureId.FORCE_REDEPLOYMENT]: Edition.BE,
[FeatureId.HIDE_AUTO_UPDATE_WINDOW]: Edition.BE,