mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
refactor(ui/box-selector): replace all selectors [EE-3856] (#7902)
This commit is contained in:
parent
c9253319d9
commit
2dddc1c6b9
80 changed files with 1267 additions and 1011 deletions
52
app/react/components/BoxSelector/BoxOption.module.css
Normal file
52
app/react/components/BoxSelector/BoxOption.module.css
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
50
app/react/components/BoxSelector/BoxSelectorItem.module.css
Normal file
50
app/react/components/BoxSelector/BoxSelectorItem.module.css
Normal 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;
|
||||
}
|
|
@ -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" />;
|
||||
}
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue