mirror of
https://github.com/portainer/portainer.git
synced 2025-07-19 13:29:41 +02:00
refactor(app): move react components to react codebase [EE-3179] (#6971)
This commit is contained in:
parent
212400c283
commit
18252ab854
346 changed files with 642 additions and 644 deletions
4
app/react/components/BoxSelector/BoxSelector.css
Normal file
4
app/react/components/BoxSelector/BoxSelector.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
.boxselector_wrapper {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
}
|
3
app/react/components/BoxSelector/BoxSelector.module.css
Normal file
3
app/react/components/BoxSelector/BoxSelector.module.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.root {
|
||||
width: 100%;
|
||||
}
|
85
app/react/components/BoxSelector/BoxSelector.stories.tsx
Normal file
85
app/react/components/BoxSelector/BoxSelector.stories.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { Edition, FeatureId } from '@/portainer/feature-flags/enums';
|
||||
|
||||
import { BoxSelector } from './BoxSelector';
|
||||
import { BoxSelectorOption } from './types';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'BoxSelector',
|
||||
component: BoxSelector,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export { Example, LimitedFeature };
|
||||
|
||||
function Example() {
|
||||
const [value, setValue] = useState(3);
|
||||
const options: BoxSelectorOption<number>[] = [
|
||||
{
|
||||
description: 'description 1',
|
||||
icon: 'fa fa-rocket',
|
||||
id: '1',
|
||||
value: 3,
|
||||
label: 'option 1',
|
||||
},
|
||||
{
|
||||
description: 'description 2',
|
||||
icon: 'fa fa-rocket',
|
||||
id: '2',
|
||||
value: 4,
|
||||
label: 'option 2',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<BoxSelector
|
||||
radioName="name"
|
||||
onChange={(value: number) => {
|
||||
setValue(value);
|
||||
}}
|
||||
value={value}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LimitedFeature() {
|
||||
initFeatureService(Edition.CE);
|
||||
const [value, setValue] = useState(3);
|
||||
const options: BoxSelectorOption<number>[] = [
|
||||
{
|
||||
description: 'description 1',
|
||||
icon: 'fa fa-rocket',
|
||||
id: '1',
|
||||
value: 3,
|
||||
label: 'option 1',
|
||||
},
|
||||
{
|
||||
description: 'description 2',
|
||||
icon: 'fa fa-rocket',
|
||||
id: '2',
|
||||
value: 4,
|
||||
label: 'option 2',
|
||||
feature: FeatureId.ACTIVITY_AUDIT,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<BoxSelector
|
||||
radioName="name"
|
||||
onChange={(value: number) => {
|
||||
setValue(value);
|
||||
}}
|
||||
value={value}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// regular example
|
||||
|
||||
// story with limited feature
|
59
app/react/components/BoxSelector/BoxSelector.test.tsx
Normal file
59
app/react/components/BoxSelector/BoxSelector.test.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { render, fireEvent } from '@/react-tools/test-utils';
|
||||
|
||||
import { BoxSelector, Props } from './BoxSelector';
|
||||
import { BoxSelectorOption } from './types';
|
||||
|
||||
function renderDefault<T extends string | number>({
|
||||
options = [],
|
||||
onChange = () => {},
|
||||
radioName = 'radio',
|
||||
value,
|
||||
}: Partial<Props<T>> = {}) {
|
||||
return render(
|
||||
<BoxSelector
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
radioName={radioName}
|
||||
value={value || 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
test('should render with the initial value selected and call onChange when clicking a different value', async () => {
|
||||
const options: BoxSelectorOption<number>[] = [
|
||||
{
|
||||
description: 'description 1',
|
||||
icon: 'fa fa-rocket',
|
||||
id: '1',
|
||||
value: 3,
|
||||
label: 'option 1',
|
||||
},
|
||||
{
|
||||
description: 'description 2',
|
||||
icon: 'fa fa-rocket',
|
||||
id: '2',
|
||||
value: 4,
|
||||
label: 'option 2',
|
||||
},
|
||||
];
|
||||
|
||||
const onChange = jest.fn();
|
||||
const { getByLabelText } = renderDefault({
|
||||
options,
|
||||
onChange,
|
||||
value: options[0].value,
|
||||
});
|
||||
|
||||
const item1 = getByLabelText(options[0].label, {
|
||||
exact: false,
|
||||
}) as HTMLInputElement;
|
||||
expect(item1.checked).toBeTruthy();
|
||||
|
||||
const item2 = getByLabelText(options[1].label, {
|
||||
exact: false,
|
||||
}) as HTMLInputElement;
|
||||
expect(item2.checked).toBeFalsy();
|
||||
|
||||
fireEvent.click(item2);
|
||||
expect(onChange).toHaveBeenCalledWith(options[1].value, false);
|
||||
});
|
36
app/react/components/BoxSelector/BoxSelector.tsx
Normal file
36
app/react/components/BoxSelector/BoxSelector.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import './BoxSelector.css';
|
||||
import styles from './BoxSelector.module.css';
|
||||
import { BoxSelectorItem } from './BoxSelectorItem';
|
||||
import { BoxSelectorOption } from './types';
|
||||
|
||||
export interface Props<T extends number | string> {
|
||||
radioName: string;
|
||||
value: T;
|
||||
onChange(value: T, limitedToBE: boolean): void;
|
||||
options: BoxSelectorOption<T>[];
|
||||
}
|
||||
|
||||
export function BoxSelector<T extends number | string>({
|
||||
radioName,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div className={clsx('boxselector_wrapper', styles.root)} role="radiogroup">
|
||||
{options.map((option) => (
|
||||
<BoxSelectorItem
|
||||
key={option.id}
|
||||
radioName={radioName}
|
||||
option={option}
|
||||
onChange={onChange}
|
||||
selectedValue={value}
|
||||
disabled={option.disabled && option.disabled()}
|
||||
tooltip={option.tooltip && option.tooltip()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
112
app/react/components/BoxSelector/BoxSelectorItem.css
Normal file
112
app/react/components/BoxSelector/BoxSelectorItem.css
Normal file
|
@ -0,0 +1,112 @@
|
|||
.boxselector_wrapper > div,
|
||||
.boxselector_wrapper box-selector-item {
|
||||
--selected-item-color: var(--blue-2);
|
||||
flex: 1;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.boxselector_wrapper .boxselector_header {
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.boxselector_header .fa,
|
||||
.fab {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.boxselector_wrapper input[type='radio'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.boxselector_wrapper input[type='radio']:not(:disabled) ~ label {
|
||||
cursor: pointer;
|
||||
background-color: var(--bg-boxselector-wrapper-disabled-color);
|
||||
}
|
||||
|
||||
.boxselector_wrapper input[type='radio']:not(:disabled):hover ~ label:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.boxselector_wrapper label {
|
||||
font-weight: normal;
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
background: var(--bg-boxselector-color);
|
||||
border: 1px solid var(--border-boxselector-color);
|
||||
border-radius: 2px;
|
||||
padding: 10px 10px 0 10px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-boxselector-color);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.box-selector-item input:disabled + label,
|
||||
.boxselector_wrapper label.boxselector_disabled {
|
||||
background: var(--bg-boxselector-disabled-color) !important;
|
||||
border-color: #787878;
|
||||
color: #787878;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.boxselector_wrapper input[type='radio']:checked + label {
|
||||
background: var(--selected-item-color);
|
||||
color: white;
|
||||
padding-top: 20px;
|
||||
border-color: var(--selected-item-color);
|
||||
}
|
||||
|
||||
.boxselector_wrapper input[type='radio']:checked + label::after {
|
||||
color: var(--selected-item-color);
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
border: 2px solid var(--selected-item-color);
|
||||
content: '\f00c';
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
line-height: 26px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
box-shadow: 0 2px 5px -2px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 700px) {
|
||||
.boxselector_wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.box-selector-item.limited.business {
|
||||
--selected-item-color: var(--BE-only);
|
||||
}
|
||||
|
||||
.box-selector-item.limited.business label {
|
||||
border-color: var(--BE-only);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.box-selector-item .limited-icon {
|
||||
position: absolute;
|
||||
left: 1em;
|
||||
top: calc(50% - 0.5em);
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.box-selector-item .limited-icon {
|
||||
left: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.box-selector-item.limited.business :checked + label {
|
||||
color: initial;
|
||||
}
|
77
app/react/components/BoxSelector/BoxSelectorItem.stories.tsx
Normal file
77
app/react/components/BoxSelector/BoxSelectorItem.stories.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
|
||||
import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { Edition, FeatureId } from '@/portainer/feature-flags/enums';
|
||||
|
||||
import { BoxSelectorItem } from './BoxSelectorItem';
|
||||
import { BoxSelectorOption } from './types';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'BoxSelector/Item',
|
||||
args: {
|
||||
selected: false,
|
||||
description: 'description',
|
||||
icon: 'fa-rocket',
|
||||
label: 'label',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
interface ExampleProps {
|
||||
selected?: boolean;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
label?: string;
|
||||
feature?: FeatureId;
|
||||
}
|
||||
|
||||
function Template({
|
||||
selected,
|
||||
description = 'description',
|
||||
icon,
|
||||
label = 'label',
|
||||
feature,
|
||||
}: ExampleProps) {
|
||||
const option: BoxSelectorOption<number> = {
|
||||
description,
|
||||
icon: `fa ${icon}`,
|
||||
id: 'id',
|
||||
label,
|
||||
value: 1,
|
||||
feature,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="boxselector_wrapper">
|
||||
<BoxSelectorItem
|
||||
onChange={() => {}}
|
||||
option={option}
|
||||
radioName="radio"
|
||||
selectedValue={selected ? option.value : 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Example = Template.bind({});
|
||||
|
||||
export function SelectedItem() {
|
||||
return <Template selected />;
|
||||
}
|
||||
|
||||
SelectedItem.args = {
|
||||
selected: true,
|
||||
};
|
||||
|
||||
export function LimitedFeatureItem() {
|
||||
initFeatureService(Edition.CE);
|
||||
|
||||
return <Template feature={FeatureId.ACTIVITY_AUDIT} />;
|
||||
}
|
||||
|
||||
export function SelectedLimitedFeatureItem() {
|
||||
initFeatureService(Edition.CE);
|
||||
|
||||
return <Template feature={FeatureId.ACTIVITY_AUDIT} selected />;
|
||||
}
|
77
app/react/components/BoxSelector/BoxSelectorItem.tsx
Normal file
77
app/react/components/BoxSelector/BoxSelectorItem.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import clsx from 'clsx';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
|
||||
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import './BoxSelectorItem.css';
|
||||
|
||||
import { BoxSelectorOption } from './types';
|
||||
|
||||
interface Props<T extends number | string> {
|
||||
radioName: string;
|
||||
option: BoxSelectorOption<T>;
|
||||
onChange(value: T, limitedToBE: boolean): void;
|
||||
selectedValue: T;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export function BoxSelectorItem<T extends number | string>({
|
||||
radioName,
|
||||
option,
|
||||
onChange,
|
||||
selectedValue,
|
||||
disabled,
|
||||
tooltip,
|
||||
}: Props<T>) {
|
||||
const limitedToBE = isLimitedToBE(option.feature);
|
||||
|
||||
const tooltipId = `box-selector-item-${radioName}-${option.id}`;
|
||||
return (
|
||||
<div
|
||||
className={clsx('box-selector-item', {
|
||||
business: limitedToBE,
|
||||
limited: limitedToBE,
|
||||
})}
|
||||
data-tip
|
||||
data-for={tooltipId}
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={radioName}
|
||||
id={option.id}
|
||||
checked={option.value === selectedValue}
|
||||
value={option.value}
|
||||
disabled={disabled}
|
||||
onChange={() => onChange(option.value, limitedToBE)}
|
||||
/>
|
||||
<label htmlFor={option.id} data-cy={`${radioName}_${option.value}`}>
|
||||
{limitedToBE && <i className="fas fa-briefcase limited-icon" />}
|
||||
|
||||
<div className="boxselector_header">
|
||||
{!!option.icon && (
|
||||
<i
|
||||
className={clsx(option.icon, 'space-right')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{option.label}
|
||||
</div>
|
||||
|
||||
<p className="box-selector-item-description">{option.description}</p>
|
||||
</label>
|
||||
{tooltip && (
|
||||
<ReactTooltip
|
||||
place="bottom"
|
||||
className="portainer-tooltip"
|
||||
id={tooltipId}
|
||||
>
|
||||
{tooltip}
|
||||
</ReactTooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
2
app/react/components/BoxSelector/index.ts
Normal file
2
app/react/components/BoxSelector/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { BoxSelector } from './BoxSelector';
|
||||
export type { BoxSelectorOption } from './types';
|
12
app/react/components/BoxSelector/types.ts
Normal file
12
app/react/components/BoxSelector/types.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import type { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
|
||||
export interface BoxSelectorOption<T> {
|
||||
id: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
description: string;
|
||||
value: T;
|
||||
disabled?: () => boolean;
|
||||
tooltip?: () => string;
|
||||
feature?: FeatureId;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue