1
0
Fork 0
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:
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';
}