1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 15:59:41 +02:00

refactor(app): move react components to react codebase [EE-3179] (#6971)

This commit is contained in:
Chaim Lev-Ari 2022-06-17 19:18:42 +03:00 committed by GitHub
parent 212400c283
commit 18252ab854
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
346 changed files with 642 additions and 644 deletions

View file

@ -0,0 +1,26 @@
.be-indicator {
border: solid 1px var(--BE-only);
border-radius: 15px;
padding: 5px 10px;
font-weight: 400;
touch-action: all;
pointer-events: all;
white-space: nowrap;
}
.be-indicator .be-indicator-icon {
color: #000000;
}
.be-indicator:hover {
text-decoration: none;
}
.be-indicator:hover .be-indicator-label {
text-decoration: underline;
}
.be-indicator-container {
border: solid 1px var(--BE-only);
margin: 15px;
}

View file

@ -0,0 +1,25 @@
import { Meta } from '@storybook/react';
import { Edition, FeatureId } from '@/portainer/feature-flags/enums';
import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service';
import { BEFeatureIndicator, Props } from './BEFeatureIndicator';
export default {
component: BEFeatureIndicator,
title: 'Components/BEFeatureIndicator',
argTypes: {
featureId: {
control: { type: 'select', options: Object.values(FeatureId) },
},
},
} as Meta<Props>;
// : JSX.IntrinsicAttributes & PropsWithChildren<Props>
function Template({ featureId }: Props) {
initFeatureService(Edition.CE);
return <BEFeatureIndicator featureId={featureId} />;
}
export const Example = Template.bind({});

View file

@ -0,0 +1,42 @@
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { FeatureId } from '@/portainer/feature-flags/enums';
import { getFeatureDetails } from './utils';
export interface Props {
featureId?: FeatureId;
showIcon?: boolean;
className?: string;
}
export function BEFeatureIndicator({
featureId,
children,
showIcon = true,
className = '',
}: PropsWithChildren<Props>) {
const { url, limitedToBE } = getFeatureDetails(featureId);
if (!limitedToBE) {
return null;
}
return (
<a
className={clsx('be-indicator', className)}
href={url}
target="_blank"
rel="noopener noreferrer"
>
{children}
{showIcon && (
<i className="fas fa-briefcase space-right be-indicator-icon" />
)}
<span className="be-indicator-label break-words">
Business Edition Feature
</span>
</a>
);
}

View file

@ -0,0 +1 @@
export { BEFeatureIndicator } from './BEFeatureIndicator';

View file

@ -0,0 +1,15 @@
import { FeatureId } from '@/portainer/feature-flags/enums';
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
const BE_URL = 'https://www.portainer.io/business-upsell?from=';
export function getFeatureDetails(featureId?: FeatureId) {
if (!featureId) {
return {};
}
const url = `${BE_URL}${featureId}`;
const limitedToBE = isLimitedToBE(featureId);
return { url, limitedToBE };
}

View file

@ -0,0 +1,4 @@
.boxselector_wrapper {
display: flex;
flex-flow: row wrap;
}

View file

@ -0,0 +1,3 @@
.root {
width: 100%;
}

View 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

View 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);
});

View 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>
);
}

View 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;
}

View 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 />;
}

View 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>
);
}

View file

@ -0,0 +1,2 @@
export { BoxSelector } from './BoxSelector';
export type { BoxSelectorOption } from './types';

View 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;
}

View file

@ -0,0 +1,19 @@
.code {
display: block;
white-space: pre-wrap;
word-break: break-word;
padding: 20px;
}
.root {
position: relative;
}
.copy-button {
position: absolute;
top: min(20px, 25%);
right: 20px;
padding: 0;
outline: none !important;
margin: 0;
}

View file

@ -0,0 +1,34 @@
import { Meta, Story } from '@storybook/react';
import { Code } from './Code';
export default {
component: Code,
title: 'Components/Code',
} as Meta;
interface Args {
text: string;
showCopyButton?: boolean;
}
function Template({ text, showCopyButton }: Args) {
return <Code showCopyButton={showCopyButton}>{text}</Code>;
}
export const Primary: Story<Args> = Template.bind({});
Primary.args = {
text: 'curl -X GET http://ultra-sound-money.eth',
showCopyButton: true,
};
export const MultiLine: Story<Args> = Template.bind({});
MultiLine.args = {
text: 'curl -X\n GET http://example-with-children.crypto',
};
export const MultiLineWithIcon: Story<Args> = Template.bind({});
MultiLineWithIcon.args = {
text: 'curl -X\n GET http://example-with-children.crypto',
showCopyButton: true,
};

View file

@ -0,0 +1,11 @@
import { render } from '@testing-library/react';
import { Code } from './Code';
test('should display a Code with children', async () => {
const children = 'test text code component';
const { findByText } = render(<Code>{children}</Code>);
const heading = await findByText(children);
expect(heading).toBeTruthy();
});

View file

@ -0,0 +1,33 @@
import clsx from 'clsx';
import { Button } from '@@/buttons';
import { useCopy } from '@@/buttons/CopyButton/useCopy';
import styles from './Code.module.css';
interface Props {
showCopyButton?: boolean;
children: string;
}
export function Code({ children, showCopyButton }: Props) {
const { handleCopy, copiedSuccessfully } = useCopy(children);
return (
<div className={styles.root}>
<code className={styles.code}>{children}</code>
{showCopyButton && (
<Button color="link" className={styles.copyButton} onClick={handleCopy}>
<i
className={clsx(
'fa',
copiedSuccessfully ? 'fa-check green-icon' : 'fa-copy '
)}
aria-hidden="true"
/>
</Button>
)}
</div>
);
}

View file

@ -0,0 +1 @@
export { Code } from './Code';

View file

@ -0,0 +1,36 @@
import { Meta, Story } from '@storybook/react';
import { Link } from '@@/Link';
import { DashboardItem } from './DashboardItem';
const meta: Meta = {
title: 'Components/DashboardItem',
component: DashboardItem,
};
export default meta;
interface StoryProps {
value: number;
icon: string;
type: string;
}
function Template({ value, icon, type }: StoryProps) {
return <DashboardItem value={value} icon={icon} type={type} />;
}
export const Primary: Story<StoryProps> = Template.bind({});
Primary.args = {
value: 1,
icon: 'fa fa-th-list',
type: 'Example resource',
};
export function WithLink() {
return (
<Link to="example.page">
<DashboardItem value={1} icon="fa fa-th-list" type="Example resource" />
</Link>
);
}

View file

@ -0,0 +1,35 @@
import { render } from '@/react-tools/test-utils';
import { DashboardItem } from './DashboardItem';
test('should show provided resource value', async () => {
const { getByLabelText } = renderComponent(1);
const value = getByLabelText('value');
expect(value).toBeVisible();
expect(value).toHaveTextContent('1');
});
test('should show provided icon', async () => {
const { getByLabelText } = renderComponent(0, 'fa fa-th-list');
const icon = getByLabelText('icon');
expect(icon).toHaveClass('fa-th-list');
});
test('should show provided resource type', async () => {
const { getByLabelText } = renderComponent(0, '', 'Test');
const title = getByLabelText('resourceType');
expect(title).toBeVisible();
expect(title).toHaveTextContent('Test');
});
test('should have accessibility label created from the provided resource type', async () => {
const { getByLabelText } = renderComponent(0, '', 'testLabel');
expect(getByLabelText('testLabel')).toBeTruthy();
});
function renderComponent(value = 0, icon = '', type = '') {
return render(<DashboardItem value={value} icon={icon} type={type} />);
}

View file

@ -0,0 +1,27 @@
import { Widget, WidgetBody } from '@@/Widget';
interface Props {
value: number;
icon: string;
type: string;
}
export function DashboardItem({ value, icon, type }: Props) {
return (
<div className="col-sm-12 col-md-6" aria-label={type}>
<Widget>
<WidgetBody>
<div className="widget-icon blue pull-left">
<i className={icon} aria-hidden="true" aria-label="icon" />
</div>
<div className="title" aria-label="value">
{value}
</div>
<div className="comment" aria-label="resourceType">
{type}
</div>
</WidgetBody>
</Widget>
</div>
);
}

View file

@ -0,0 +1 @@
export { DashboardItem } from './DashboardItem';

View file

@ -0,0 +1,15 @@
import { ReactNode } from 'react';
interface Props {
children?: ReactNode;
label: string;
}
export function DetailsRow({ label, children }: Props) {
return (
<tr>
<td>{label}</td>
{children && <td data-cy={`detailsTable-${label}Value`}>{children}</td>}
</tr>
);
}

View file

@ -0,0 +1,33 @@
import { Meta, Story } from '@storybook/react';
import { DetailsTable } from './DetailsTable';
import { DetailsRow } from './DetailsRow';
type Args = {
key1: string;
val1: string;
key2: string;
val2: string;
};
export default {
component: DetailsTable,
title: 'Components/Tables/DetailsTable',
} as Meta;
function Template({ key1, val1, key2, val2 }: Args) {
return (
<DetailsTable>
<DetailsRow label={key1}>{val1}</DetailsRow>
<DetailsRow label={key2}>{val2}</DetailsRow>
</DetailsTable>
);
}
export const Default: Story<Args> = Template.bind({});
Default.args = {
key1: 'Name',
val1: 'My Cool App',
key2: 'Id',
val2: 'dmsjs1532',
};

View file

@ -0,0 +1,24 @@
import { render } from '@/react-tools/test-utils';
import { DetailsTable } from './index';
// should display child row elements
test('should display child row elements', () => {
const person = {
name: 'Bob',
id: 'dmsjs1532',
};
const { queryByText } = render(
<DetailsTable>
<DetailsTable.Row label="Name">{person.name}</DetailsTable.Row>
<DetailsTable.Row label="Id">{person.id}</DetailsTable.Row>
</DetailsTable>
);
const nameRow = queryByText(person.name);
expect(nameRow).toBeVisible();
const idRow = queryByText(person.id);
expect(idRow).toBeVisible();
});

View file

@ -0,0 +1,27 @@
import { PropsWithChildren } from 'react';
type Props = {
headers?: string[];
dataCy?: string;
};
export function DetailsTable({
headers = [],
dataCy,
children,
}: PropsWithChildren<Props>) {
return (
<table className="table" data-cy={dataCy}>
{headers.length > 0 && (
<thead>
<tr>
{headers.map((header) => (
<th key={header}>{header}</th>
))}
</tr>
</thead>
)}
<tbody>{children}</tbody>
</table>
);
}

View file

@ -0,0 +1,13 @@
import { DetailsTable as MainComponent } from './DetailsTable';
import { DetailsRow } from './DetailsRow';
interface DetailsTableSubcomponents {
Row: typeof DetailsRow;
}
const DetailsTable = MainComponent as typeof MainComponent &
DetailsTableSubcomponents;
DetailsTable.Row = DetailsRow;
export { DetailsTable };

View file

@ -0,0 +1,47 @@
import { PropsWithChildren } from 'react';
import { Widget, WidgetBody } from './Widget';
import { Button } from './buttons';
interface Props {
title: string;
onDismiss?(): void;
bodyClassName?: string;
wrapperStyle?: Record<string, string>;
}
export function InformationPanel({
title,
onDismiss,
wrapperStyle,
bodyClassName,
children,
}: PropsWithChildren<Props>) {
return (
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetBody className={bodyClassName}>
<div style={wrapperStyle}>
<div className="col-sm-12 form-section-title">
<span style={{ float: 'left' }}>{title}</span>
{!!onDismiss && (
<span
className="small"
style={{ float: 'right' }}
ng-if="dismissAction"
>
<Button color="link" onClick={() => onDismiss()}>
<i className="fa fa-times" /> dismiss
</Button>
</span>
)}
</div>
<div className="form-group">{children}</div>
</div>
</WidgetBody>
</Widget>
</div>
</div>
);
}

View file

@ -0,0 +1,20 @@
import { PropsWithChildren } from 'react';
import { UISref, UISrefProps } from '@uirouter/react';
interface Props {
title?: string;
}
export function Link({
title = '',
children,
...props
}: PropsWithChildren<Props> & UISrefProps) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<UISref {...props}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a title={title}>{children}</a>
</UISref>
);
}

View file

@ -0,0 +1,32 @@
.parent {
background: var(--blue-4);
border-radius: 4px 4px 0 0;
padding-top: 5px;
margin-top: -5px;
}
.parent a {
background-color: initial !important;
border: 1px solid transparent !important;
cursor: inherit !important;
}
.parent {
background-color: var(--blue-4);
}
:global(:root[theme='dark']) .parent {
background-color: var(--grey-40);
}
:global(:root[theme='highcontrast']) .parent {
background-color: var(--white-color);
}
.parent a {
color: var(--white-color) !important;
}
:global([theme='dark']) .parent a {
color: var(--black-color) !important;
}
:global([theme='highcontrast']) .parent a {
color: var(--black-color) !important;
}

View file

@ -0,0 +1,31 @@
import { ComponentMeta, Story } from '@storybook/react';
import { useState } from 'react';
import { NavTabs, type Option } from './NavTabs';
export default {
title: 'Components/NavTabs',
component: NavTabs,
} as ComponentMeta<typeof NavTabs>;
type Args = {
options: Option[];
};
function Template({ options = [] }: Args) {
const [selected, setSelected] = useState(
options.length ? options[0].id : undefined
);
return (
<NavTabs options={options} selectedId={selected} onSelect={setSelected} />
);
}
export const Example: Story<Args> = Template.bind({});
Example.args = {
options: [
{ children: 'Content 1', id: 'option1', label: 'Option 1' },
{ children: 'Content 2', id: 'option2', label: 'Option 2' },
],
};

View file

@ -0,0 +1,58 @@
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NavTabs, Option } from './NavTabs';
test('should show titles', async () => {
const options = [
{ children: 'Content 1', id: 'option1', label: 'Option 1' },
{ children: 'Content 2', id: 'option2', label: 'Option 2' },
];
const { findByText } = renderComponent(options);
const heading = await findByText(options[0].label);
expect(heading).toBeTruthy();
const heading2 = await findByText(options[1].label);
expect(heading2).toBeTruthy();
});
test('should show selected id content', async () => {
const options = [
{ children: 'Content 1', id: 'option1', label: 'Option 1' },
{ children: 'Content 2', id: 'option2', label: 'Option 2' },
];
const selected = options[1];
const { findByText } = renderComponent(options, selected.id);
const content = await findByText(selected.children);
expect(content).toBeTruthy();
});
test('should call onSelect when clicked with id', async () => {
const options = [
{ children: 'Content 1', id: 'option1', label: 'Option 1' },
{ children: 'Content 2', id: 'option2', label: 'Option 2' },
];
const onSelect = jest.fn();
const { findByText } = renderComponent(options, options[1].id, onSelect);
const heading = await findByText(options[0].label);
userEvent.click(heading);
expect(onSelect).toHaveBeenCalledWith(options[0].id);
});
function renderComponent(
options: Option[] = [],
selectedId?: string | number,
onSelect?: (id: string | number) => void
) {
return render(
<NavTabs options={options} selectedId={selectedId} onSelect={onSelect} />
);
}

View file

@ -0,0 +1,60 @@
import clsx from 'clsx';
import { ReactNode } from 'react';
import styles from './NavTabs.module.css';
export interface Option {
label: string | ReactNode;
children?: ReactNode;
id: string | number;
}
interface Props {
options: Option[];
selectedId?: string | number;
onSelect?(id: string | number): void;
}
export function NavTabs({ options, selectedId, onSelect = () => {} }: Props) {
const selected = options.find((option) => option.id === selectedId);
return (
<>
<ul className="nav nav-tabs">
{options.map((option) => (
<li
className={clsx({
active: option.id === selectedId,
[styles.parent]: !option.children,
})}
key={option.id}
>
{/* rule disabled because `nav-tabs` requires an anchor */}
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
onClick={() => handleSelect(option)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleSelect(option);
}
}}
role="button"
tabIndex={0}
>
{option.label}
</a>
</li>
))}
</ul>
{selected && selected.children && (
<div className="tab-content">{selected.children}</div>
)}
</>
);
function handleSelect(option: Option) {
if (option.children) {
onSelect(option.id);
}
}
}

View file

@ -0,0 +1 @@
export { NavTabs } from './NavTabs';

View file

@ -0,0 +1,3 @@
.breadcrumb-links {
font-size: 10px;
}

View file

@ -0,0 +1,31 @@
import { Meta } from '@storybook/react';
import { UIRouter, pushStateLocationPlugin } from '@uirouter/react';
import { Breadcrumbs } from './Breadcrumbs';
const meta: Meta = {
title: 'Components/PageHeader/Breadcrumbs',
component: Breadcrumbs,
};
export default meta;
export { Example };
function Example() {
return (
<UIRouter plugins={[pushStateLocationPlugin]}>
<Breadcrumbs
breadcrumbs={[
{ link: 'portainer.endpoints', label: 'Environments' },
{
label: 'endpointName',
link: 'portainer.endpoints.endpoint',
linkParams: { id: 5 },
},
{ label: 'String item' },
]}
/>
</UIRouter>
);
}

View file

@ -0,0 +1,15 @@
import { render } from '@/react-tools/test-utils';
import { Breadcrumbs } from './Breadcrumbs';
test('should display a Breadcrumbs, breadcrumbs should be separated by >', async () => {
const breadcrumbs = [
{ label: 'bread1' },
{ label: 'bread2' },
{ label: 'bread3' },
];
const { queryByText } = render(<Breadcrumbs breadcrumbs={breadcrumbs} />);
const heading = queryByText(breadcrumbs.map((b) => b.label).join(' > '));
expect(heading).toBeVisible();
});

View file

@ -0,0 +1,39 @@
import { Fragment } from 'react';
import { Link } from '@@/Link';
import './Breadcrumbs.css';
export interface Crumb {
label: string;
link?: string;
linkParams?: Record<string, unknown>;
}
interface Props {
breadcrumbs: Crumb[];
}
export function Breadcrumbs({ breadcrumbs }: Props) {
return (
<div className="breadcrumb-links">
{breadcrumbs.map((crumb, index) => (
<Fragment key={index}>
{renderCrumb(crumb)}
{index !== breadcrumbs.length - 1 ? ' > ' : ''}
</Fragment>
))}
</div>
);
}
function renderCrumb(crumb: Crumb) {
if (crumb.link) {
return (
<Link to={crumb.link} params={crumb.linkParams}>
{crumb.label}
</Link>
);
}
return crumb.label;
}

View file

@ -0,0 +1 @@
export { Breadcrumbs } from './Breadcrumbs';

View file

@ -0,0 +1,103 @@
.row.header .meta .page {
padding-top: 7px;
}
body.hamburg .row.header .meta {
margin-left: 70px;
}
.row.header {
min-height: 60px;
background: var(--bg-row-header-color);
margin-bottom: 15px;
}
.row.header > div:last-child {
padding-right: 0;
}
.row.header .meta .page {
font-size: 17px;
padding-top: 11px;
}
.row.header .meta div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row.header .login a {
padding: 18px;
display: block;
}
.row.header .user {
min-width: 130px;
}
.row.header .user > .item {
width: 65px;
height: 60px;
float: right;
display: inline-block;
text-align: center;
vertical-align: middle;
}
.row.header .user > .item a {
color: #919191;
display: block;
}
.row.header .user > .item i {
font-size: 20px;
line-height: 55px;
}
.row.header .user > .item img {
width: 40px;
height: 40px;
margin-top: 10px;
border-radius: 2px;
}
.row.header .user > .item ul.dropdown-menu {
border-radius: 2px;
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05);
}
.row.header .user > .item ul.dropdown-menu .dropdown-header {
text-align: center;
}
.row.header .user > .item ul.dropdown-menu li.link {
text-align: left;
}
.row.header .user > .item ul.dropdown-menu li.link a {
padding-left: 7px;
padding-right: 7px;
}
.row.header .user > .item ul.dropdown-menu:before {
position: absolute;
top: -7px;
right: 23px;
display: inline-block;
border-right: 7px solid transparent;
border-bottom: 7px solid rgba(0, 0, 0, 0.2);
border-left: 7px solid transparent;
content: '';
}
.row.header .user > .item ul.dropdown-menu:after {
position: absolute;
top: -6px;
right: 24px;
display: inline-block;
border-right: 6px solid transparent;
border-bottom: 6px solid #ffffff;
border-left: 6px solid transparent;
content: '';
}
/*angular-loading-bar override*/
#loadingbar-placeholder {
margin-bottom: 0;
height: 3px;
}
#loading-bar .bar {
position: relative;
height: 3px;
background: var(--blue-3);
}
/*!angular-loading-bar override*/

View file

@ -0,0 +1,47 @@
import { Meta, Story } from '@storybook/react';
import { useMemo } from 'react';
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { HeaderContainer } from './HeaderContainer';
import { Breadcrumbs } from './Breadcrumbs';
import { HeaderTitle } from './HeaderTitle';
import { HeaderContent } from './HeaderContent';
export default {
component: HeaderContainer,
title: 'Components/PageHeader/HeaderContainer',
} as Meta;
interface StoryProps {
title: string;
}
function Template({ title }: StoryProps) {
const state = useMemo(
() => ({ user: new UserViewModel({ Username: 'test' }) }),
[]
);
return (
<UserContext.Provider value={state}>
<HeaderContainer>
<HeaderTitle title={title} />
<HeaderContent>
<Breadcrumbs
breadcrumbs={[
{ link: 'example', label: 'crumb1' },
{ label: 'crumb2' },
]}
/>
</HeaderContent>
</HeaderContainer>
</UserContext.Provider>
);
}
export const Primary: Story<StoryProps> = Template.bind({});
Primary.args = {
title: 'Container details',
};

View file

@ -0,0 +1,26 @@
import { PropsWithChildren, createContext, useContext } from 'react';
import './HeaderContainer.css';
const Context = createContext<null | boolean>(null);
export function useHeaderContext() {
const context = useContext(Context);
if (context == null) {
throw new Error('Should be nested inside a HeaderContainer component');
}
}
export function HeaderContainer({ children }: PropsWithChildren<unknown>) {
return (
<Context.Provider value>
<div className="row header">
<div id="loadingbar-placeholder" />
<div className="col-xs-12">
<div className="meta">{children}</div>
</div>
</div>
</Context.Provider>
);
}

View file

@ -0,0 +1,19 @@
.user-links {
margin-right: 25px;
}
.user-links > * + * {
margin-left: 5px;
}
.link {
cursor: pointer;
}
.link .link-text {
text-decoration: underline;
}
.link .link-icon {
margin-right: 2px;
}

View file

@ -0,0 +1,41 @@
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { render } from '@/react-tools/test-utils';
import { HeaderContainer } from './HeaderContainer';
import { HeaderContent } from './HeaderContent';
test('should not render without a wrapping HeaderContainer', async () => {
const consoleErrorFn = jest
.spyOn(console, 'error')
.mockImplementation(() => jest.fn());
function renderComponent() {
return render(<HeaderContent />);
}
expect(renderComponent).toThrowErrorMatchingSnapshot();
consoleErrorFn.mockRestore();
});
test('should display a HeaderContent', async () => {
const username = 'username';
const user = new UserViewModel({ Username: username });
const userProviderState = { user };
const content = 'content';
const { queryByText } = render(
<UserContext.Provider value={userProviderState}>
<HeaderContainer>
<HeaderContent>{content}</HeaderContent>
</HeaderContainer>
</UserContext.Provider>
);
const contentElement = queryByText(content);
expect(contentElement).toBeVisible();
expect(queryByText('my account')).toBeVisible();
expect(queryByText('log out')).toBeVisible();
});

View file

@ -0,0 +1,43 @@
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { useUser } from '@/portainer/hooks/useUser';
import { Link } from '@@/Link';
import styles from './HeaderContent.module.css';
import { useHeaderContext } from './HeaderContainer';
export function HeaderContent({ children }: PropsWithChildren<unknown>) {
useHeaderContext();
const { user } = useUser();
return (
<div className="breadcrumb-links">
<div className="pull-left">{children}</div>
{user && !window.ddExtension && (
<div className={clsx('pull-right', styles.userLinks)}>
<Link to="portainer.account" className={styles.link}>
<i
className={clsx('fa fa-wrench', styles.linkIcon)}
aria-hidden="true"
/>
<span className={styles.linkText}>my account</span>
</Link>
<Link
to="portainer.logout"
params={{ performApiLogout: true }}
className={clsx('text-danger', styles.link)}
data-cy="template-logoutButton"
>
<i
className={clsx('fa fa-sign-out-alt', styles.linkIcon)}
aria-hidden="true"
/>
<span className={styles.linkText}>log out</span>
</Link>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,40 @@
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { render } from '@/react-tools/test-utils';
import { HeaderContainer } from './HeaderContainer';
import { HeaderTitle } from './HeaderTitle';
test('should not render without a wrapping HeaderContainer', async () => {
const consoleErrorFn = jest
.spyOn(console, 'error')
.mockImplementation(() => jest.fn());
const title = 'title';
function renderComponent() {
return render(<HeaderTitle title={title} />);
}
expect(renderComponent).toThrowErrorMatchingSnapshot();
consoleErrorFn.mockRestore();
});
test('should display a HeaderTitle', async () => {
const username = 'username';
const user = new UserViewModel({ Username: username });
const title = 'title';
const { queryByText } = render(
<UserContext.Provider value={{ user }}>
<HeaderContainer>
<HeaderTitle title={title} />
</HeaderContainer>
</UserContext.Provider>
);
const heading = queryByText(title);
expect(heading).toBeVisible();
expect(queryByText(username)).toBeVisible();
});

View file

@ -0,0 +1,27 @@
import { PropsWithChildren } from 'react';
import { useUser } from '@/portainer/hooks/useUser';
import { useHeaderContext } from './HeaderContainer';
interface Props {
title: string;
}
export function HeaderTitle({ title, children }: PropsWithChildren<Props>) {
useHeaderContext();
const { user } = useUser();
return (
<div className="page white-space-normal">
{title}
<span className="header_title_content">{children}</span>
{user && !window.ddExtension && (
<span className="pull-right user-box">
<i className="fa fa-user-circle" aria-hidden="true" />
{user.Username}
</span>
)}
</div>
);
}

View file

@ -0,0 +1,4 @@
.reloadButton {
padding: 0;
margin: 0;
}

View file

@ -0,0 +1,42 @@
import { Meta, Story } from '@storybook/react';
import { useMemo } from 'react';
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { PageHeader } from './PageHeader';
export default {
component: PageHeader,
title: 'Components/PageHeader',
} as Meta;
interface StoryProps {
title: string;
}
function Template({ title }: StoryProps) {
const state = useMemo(
() => ({ user: new UserViewModel({ Username: 'test' }) }),
[]
);
return (
<UserContext.Provider value={state}>
<PageHeader
title={title}
breadcrumbs={[
{ link: 'example', label: 'bread1' },
{ link: 'example2', label: 'bread2' },
{ label: 'bread3' },
{ label: 'bread4' },
]}
/>
</UserContext.Provider>
);
}
export const Primary: Story<StoryProps> = Template.bind({});
Primary.args = {
title: 'Container details',
};

View file

@ -0,0 +1,22 @@
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { render } from '@/react-tools/test-utils';
import { PageHeader } from './PageHeader';
test('should display a PageHeader', async () => {
const username = 'username';
const user = new UserViewModel({ Username: username });
const title = 'title';
const { queryByText } = render(
<UserContext.Provider value={{ user }}>
<PageHeader title={title} />
</UserContext.Provider>
);
const heading = queryByText(title);
expect(heading).toBeVisible();
expect(queryByText(username)).toBeVisible();
});

View file

@ -0,0 +1,39 @@
import { useRouter } from '@uirouter/react';
import { Button } from '../buttons';
import { Breadcrumbs } from './Breadcrumbs';
import { Crumb } from './Breadcrumbs/Breadcrumbs';
import { HeaderContainer } from './HeaderContainer';
import { HeaderContent } from './HeaderContent';
import { HeaderTitle } from './HeaderTitle';
import styles from './PageHeader.module.css';
interface Props {
reload?: boolean;
breadcrumbs?: Crumb[];
title: string;
}
export function PageHeader({ title, breadcrumbs = [], reload }: Props) {
const router = useRouter();
return (
<HeaderContainer>
<HeaderTitle title={title}>
{reload && (
<Button
color="link"
size="medium"
onClick={() => router.stateService.reload()}
className={styles.reloadButton}
>
<i className="fa fa-sync" aria-hidden="true" />
</Button>
)}
</HeaderTitle>
<HeaderContent>
<Breadcrumbs breadcrumbs={breadcrumbs} />
</HeaderContent>
</HeaderContainer>
);
}

View file

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should not render without a wrapping HeaderContainer 1`] = `"Should be nested inside a HeaderContainer component"`;

View file

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should not render without a wrapping HeaderContainer 1`] = `"Should be nested inside a HeaderContainer component"`;

View file

@ -0,0 +1,7 @@
import { Breadcrumbs } from './Breadcrumbs';
import { PageHeader } from './PageHeader';
import { HeaderContainer } from './HeaderContainer';
import { HeaderContent } from './HeaderContent';
import { HeaderTitle } from './HeaderTitle';
export { PageHeader, Breadcrumbs, HeaderContainer, HeaderContent, HeaderTitle };

View file

@ -0,0 +1,26 @@
interface Props {
value: number;
onChange(value: number): void;
showAll?: boolean;
}
export function ItemsPerPageSelector({ value, onChange, showAll }: Props) {
return (
<span className="limitSelector">
<span className="space-right">Items per page</span>
<select
className="form-control"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
data-cy="paginationSelect"
>
{showAll ? <option value={Number.MAX_SAFE_INTEGER}>All</option> : null}
{[10, 25, 50, 100].map((v) => (
<option value={v} key={v}>
{v}
</option>
))}
</select>
</span>
);
}

View file

@ -0,0 +1,30 @@
import clsx from 'clsx';
import { ReactNode } from 'react';
interface Props {
active?: boolean;
children: ReactNode;
disabled?: boolean;
onPageChange(page: number): void;
page: number | '...';
}
export function PageButton({
children,
page,
disabled,
active,
onPageChange,
}: Props) {
return (
<li className={clsx({ disabled, active })}>
<button
type="button"
onClick={() => typeof page === 'number' && onPageChange(page)}
disabled={disabled}
>
{children}
</button>
</li>
);
}

View file

@ -0,0 +1,58 @@
import { useFormik } from 'formik';
import { ChangeEvent, KeyboardEvent } from 'react';
import { object, number } from 'yup';
import { Button } from '@@/buttons';
import { Input } from '@@/form-components/Input';
interface Values {
page: number | '';
}
interface Props {
onChange(page: number): void;
totalPages: number;
}
export function PageInput({ onChange, totalPages }: Props) {
const { handleSubmit, setFieldValue, values, isValid } = useFormik<Values>({
initialValues: { page: '' },
onSubmit: async ({ page }) => page && onChange(page),
validateOnMount: true,
validationSchema: () =>
object({ page: number().required().max(totalPages).min(1) }),
});
return (
<form className="mx-3" onSubmit={handleSubmit}>
<label className="m-0 mr-2 font-normal small" htmlFor="go-to-page-input">
Go to page
</label>
<Input
id="go-to-page-input"
className="!w-32"
type="number"
value={values.page}
max={totalPages}
min={1}
step={1}
onChange={handleChange}
onKeyPress={preventNotNumber}
/>
<Button type="submit" disabled={!isValid}>
Go
</Button>
</form>
);
function preventNotNumber(e: KeyboardEvent<HTMLInputElement>) {
if (e.key.match(/^\D$/)) {
e.preventDefault();
}
}
function handleChange(e: ChangeEvent<HTMLInputElement>) {
const value = parseInt(e.target.value, 10);
setFieldValue('page', Number.isNaN(value) ? '' : value);
}
}

View file

@ -0,0 +1,98 @@
import { generatePagesArray } from './generatePagesArray';
import { PageButton } from './PageButton';
import { PageInput } from './PageInput';
interface Props {
boundaryLinks?: boolean;
currentPage: number;
directionLinks?: boolean;
itemsPerPage: number;
onPageChange(page: number): void;
totalCount: number;
maxSize: number;
isInputVisible?: boolean;
}
export function PageSelector({
currentPage,
totalCount,
itemsPerPage,
onPageChange,
maxSize = 5,
directionLinks = true,
boundaryLinks = false,
isInputVisible = false,
}: Props) {
const pages = generatePagesArray(
currentPage,
totalCount,
itemsPerPage,
maxSize
);
const last = pages[pages.length - 1];
if (pages.length <= 1) {
return null;
}
return (
<>
{isInputVisible && (
<PageInput
onChange={(page) => onPageChange(page)}
totalPages={Math.ceil(totalCount / itemsPerPage)}
/>
)}
<ul className="pagination">
{boundaryLinks ? (
<PageButton
onPageChange={onPageChange}
page={1}
disabled={currentPage === 1}
>
&laquo;
</PageButton>
) : null}
{directionLinks ? (
<PageButton
onPageChange={onPageChange}
page={currentPage - 1}
disabled={currentPage === 1}
>
&lsaquo;
</PageButton>
) : null}
{pages.map((pageNumber, index) => (
<PageButton
onPageChange={onPageChange}
page={pageNumber}
disabled={pageNumber === '...'}
active={currentPage === pageNumber}
key={index}
>
{pageNumber}
</PageButton>
))}
{directionLinks ? (
<PageButton
onPageChange={onPageChange}
page={currentPage + 1}
disabled={currentPage === last}
>
&rsaquo;
</PageButton>
) : null}
{boundaryLinks ? (
<PageButton
disabled={currentPage === last}
onPageChange={onPageChange}
page={last}
>
&raquo;
</PageButton>
) : null}
</ul>
</>
);
}

View file

@ -0,0 +1,50 @@
import { ItemsPerPageSelector } from './ItemsPerPageSelector';
import { PageSelector } from './PageSelector';
interface Props {
onPageChange(page: number): void;
onPageLimitChange(value: number): void;
page: number;
pageLimit: number;
showAll?: boolean;
totalCount: number;
isPageInputVisible?: boolean;
}
export function PaginationControls({
pageLimit,
page,
onPageLimitChange,
showAll,
onPageChange,
totalCount,
isPageInputVisible,
}: Props) {
return (
<div className="paginationControls">
<div className="form-inline flex">
<ItemsPerPageSelector
value={pageLimit}
onChange={handlePageLimitChange}
showAll={showAll}
/>
{pageLimit !== 0 && (
<PageSelector
maxSize={5}
onPageChange={onPageChange}
currentPage={page}
itemsPerPage={pageLimit}
totalCount={totalCount}
isInputVisible={isPageInputVisible}
/>
)}
</div>
</div>
);
function handlePageLimitChange(value: number) {
onPageLimitChange(value);
onPageChange(1);
}
}

View file

@ -0,0 +1,37 @@
/**
* Given the position in the sequence of pagination links, figure out what page number corresponds to that position.
*
* @param position
* @param currentPage
* @param paginationRange
* @param totalPages
*/
export function calculatePageNumber(
position: number,
currentPage: number,
paginationRange: number,
totalPages: number
) {
const halfWay = Math.ceil(paginationRange / 2);
if (position === paginationRange) {
return totalPages;
}
if (position === 1) {
return position;
}
if (paginationRange < totalPages) {
if (totalPages - halfWay < currentPage) {
return totalPages - paginationRange + position;
}
if (halfWay < currentPage) {
return currentPage - halfWay + position;
}
return position;
}
return position;
}

View file

@ -0,0 +1,55 @@
import { calculatePageNumber } from './calculatePageNumber';
export /**
* Generate an array of page numbers (or the '...' string) which is used in an ng-repeat to generate the
* links used in pagination
*
* @param currentPage
* @param rowsPerPage
* @param paginationRange
* @param collectionLength
* @returns {Array}
*/
function generatePagesArray(
currentPage: number,
collectionLength: number,
rowsPerPage: number,
paginationRange: number
): (number | '...')[] {
const pages: (number | '...')[] = [];
const totalPages = Math.ceil(collectionLength / rowsPerPage);
const halfWay = Math.ceil(paginationRange / 2);
let position;
if (currentPage <= halfWay) {
position = 'start';
} else if (totalPages - halfWay < currentPage) {
position = 'end';
} else {
position = 'middle';
}
const ellipsesNeeded = paginationRange < totalPages;
for (let i = 1; i <= totalPages && i <= paginationRange; i += 1) {
const pageNumber = calculatePageNumber(
i,
currentPage,
paginationRange,
totalPages
);
const openingEllipsesNeeded =
i === 2 && (position === 'middle' || position === 'end');
const closingEllipsesNeeded =
i === paginationRange - 1 &&
(position === 'middle' || position === 'start');
if (ellipsesNeeded && (openingEllipsesNeeded || closingEllipsesNeeded)) {
pages.push('...');
} else {
pages.push(pageNumber);
}
}
return pages;
}

View file

@ -0,0 +1,3 @@
import './pagination-controls.css';
export { PaginationControls } from './PaginationControls';

View file

@ -0,0 +1,72 @@
.pagination-controls {
margin-left: 10px;
}
.paginationControls form.form-inline {
display: flex;
}
.pagination > li:first-child > button {
margin-left: 0;
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
.pagination > .disabled > span,
.pagination > .disabled > span:hover,
.pagination > .disabled > span:focus,
.pagination > .disabled > button,
.pagination > .disabled > button:hover,
.pagination > .disabled > button:focus,
.pagination > .disabled > a,
.pagination > .disabled > a:hover,
.pagination > .disabled > a:focus {
color: var(--text-pagination-color);
background-color: var(--bg-pagination-color);
border-color: var(--border-pagination-color);
}
.pagination > li > button {
position: relative;
float: left;
padding: 6px 12px;
margin-left: -1px !important;
line-height: 1.42857143;
text-decoration: none;
border: 1px solid #ddd;
}
.pagination > li > a,
.pagination > li > button,
.pagination > li > span {
background-color: var(--bg-pagination-span-color);
border-color: var(--border-pagination-span-color);
color: var(--text-pagination-span-color);
}
.pagination > li > a:hover,
.pagination > li > button:hover,
.pagination > li > span:hover,
.pagination > li > a:focus,
.pagination > li > button:focus,
.pagination > li > span:focus {
background-color: var(--bg-pagination-hover-color);
border-color: var(--border-pagination-hover-color);
color: var(--text-pagination-span-hover-color);
}
.pagination > .active > a,
.pagination > .active > span,
.pagination > .active > button,
.pagination > .active > a:hover,
.pagination > .active > span:hover,
.pagination > .active > button:hover,
.pagination > .active > a:focus,
.pagination > .active > span:focus,
.pagination > .active > button:focus {
z-index: 3;
color: #fff;
cursor: default;
background-color: var(--text-pagination-span-color);
border-color: var(--text-pagination-span-color);
}

View file

@ -0,0 +1,31 @@
import { usePublicSettings } from '@/portainer/settings/queries';
interface Props {
passwordValid: boolean;
forceChangePassword?: boolean;
}
export function PasswordCheckHint({
passwordValid,
forceChangePassword,
}: Props) {
const settingsQuery = usePublicSettings();
const minPasswordLength = settingsQuery.data?.RequiredPasswordLength;
return (
<div>
<p className="text-muted">
<i
className="fa fa-exclamation-triangle orange-icon space-right"
aria-hidden="true"
/>
{forceChangePassword &&
'An administrator has changed your password requirements, '}
The password must be at least {minPasswordLength} characters long.
{passwordValid && (
<i className="fa fa-check green-icon space-left" aria-hidden="true" />
)}
</p>
</div>
);
}

View file

@ -1,7 +1,7 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { Button } from '@/portainer/components/Button';
import { Button } from '@@/buttons';
import { Step, Stepper } from './Stepper';

View file

@ -2,13 +2,11 @@ import clsx from 'clsx';
import _ from 'lodash';
import { TagId } from '@/portainer/tags/types';
import {
Creatable,
Select,
} from '@/portainer/components/form-components/ReactSelect';
import { useCreateTagMutation, useTags } from '@/portainer/tags/queries';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { Link } from '@/portainer/components/Link';
import { Creatable, Select } from '@@/form-components/ReactSelect';
import { FormControl } from '@@/form-components/FormControl';
import { Link } from '@@/Link';
import styles from './TagSelector.module.css';

View file

@ -0,0 +1,9 @@
import { TeamViewModel } from '@/portainer/models/team';
export function createMockTeam(id: number, name: string): TeamViewModel {
return {
Id: id,
Name: name,
Checked: false,
};
}

View file

@ -0,0 +1,28 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { TeamsSelector } from './TeamsSelector';
import { createMockTeam } from './TeamsSelector.mocks';
const meta: Meta = {
title: 'Components/TeamsSelector',
component: TeamsSelector,
};
export default meta;
export { Example };
function Example() {
const [selectedTeams, setSelectedTeams] = useState([1]);
const teams = [createMockTeam(1, 'team1'), createMockTeam(2, 'team2')];
return (
<TeamsSelector
value={selectedTeams}
onChange={setSelectedTeams}
teams={teams}
placeholder="Select one or more teams"
/>
);
}

View file

@ -0,0 +1,41 @@
import { Team, TeamId } from '@/portainer/teams/types';
import { Select } from '@@/form-components/ReactSelect';
interface Props {
name?: string;
value: TeamId[];
onChange(value: TeamId[]): void;
teams: Team[];
dataCy?: string;
inputId?: string;
placeholder?: string;
}
export function TeamsSelector({
name,
value,
onChange,
teams,
dataCy,
inputId,
placeholder,
}: Props) {
return (
<Select
name={name}
isMulti
getOptionLabel={(team) => team.Name}
getOptionValue={(team) => String(team.Id)}
options={teams}
value={teams.filter((team) => value.includes(team.Id))}
closeMenuOnSelect={false}
onChange={(selectedTeams) =>
onChange(selectedTeams.map((team) => team.Id))
}
data-cy={dataCy}
inputId={inputId}
placeholder={placeholder}
/>
);
}

View file

@ -0,0 +1 @@
export { TeamsSelector } from './TeamsSelector';

View file

@ -0,0 +1,20 @@
import { Meta, Story } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { TextTip } from './TextTip';
export default {
component: TextTip,
title: 'Components/Tip/TextTip',
} as Meta;
function Template({
children,
}: JSX.IntrinsicAttributes & PropsWithChildren<unknown>) {
return <TextTip>{children}</TextTip>;
}
export const Primary: Story<PropsWithChildren<unknown>> = Template.bind({});
Primary.args = {
children: 'This is a text tip with children',
};

View file

@ -0,0 +1,11 @@
import { render } from '@testing-library/react';
import { TextTip } from './TextTip';
test('should display a TextTip with children', async () => {
const children = 'test text tip';
const { findByText } = render(<TextTip>{children}</TextTip>);
const heading = await findByText(children);
expect(heading).toBeTruthy();
});

View file

@ -0,0 +1,27 @@
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
type Color = 'orange' | 'blue';
export interface Props {
color?: Color;
}
export function TextTip({
color = 'orange',
children,
}: PropsWithChildren<Props>) {
return (
<p className="text-muted small">
<i
aria-hidden="true"
className={clsx(
'fa fa-exclamation-circle',
`${color}-icon`,
'space-right'
)}
/>
{children}
</p>
);
}

View file

@ -0,0 +1 @@
export { TextTip } from './TextTip';

View file

@ -0,0 +1,23 @@
.tooltip-wrapper {
display: inline-block;
position: relative;
}
.tooltip {
font-family: Montserrat !important;
background-color: var(--bg-tooltip-color) !important;
padding: 0.833em 1em !important;
color: var(--text-tooltip-color) !important;
border: 1px solid var(--border-tooltip-color) !important;
border-radius: 0.14285714rem !important;
box-shadow: 0 2px 4px 0 rgba(34, 36, 38, 0.12), 0 2px 10px 0 rgba(34, 36, 38, 0.15) !important;
max-width: 200px;
text-align: center;
font-size: 12px !important;
font-weight: 400;
}
.icon {
margin-left: 5px;
font-size: 1.3em;
}

View file

@ -0,0 +1,23 @@
import { Meta, Story } from '@storybook/react';
import { Tooltip, Props } from './Tooltip';
export default {
component: Tooltip,
title: 'Components/Tip/Tooltip',
} as Meta;
function Template({ message, position }: JSX.IntrinsicAttributes & Props) {
return (
<div className="col-sm-3 col-lg-2">
Example tooltip
<Tooltip message={message} position={position} />
</div>
);
}
export const Primary: Story<Props> = Template.bind({});
Primary.args = {
message: 'Tooltip example',
position: 'bottom',
};

View file

@ -0,0 +1,31 @@
import ReactTooltip from 'react-tooltip';
import clsx from 'clsx';
import styles from './Tooltip.module.css';
type Place = 'top' | 'right' | 'bottom' | 'left';
export interface Props {
position?: Place;
message: string;
}
export function Tooltip({ message, position = 'bottom' }: Props) {
return (
<span className="interactive">
<i
className={clsx('fa fa-question-circle blue-icon', styles.icon)}
aria-hidden="true"
data-tip={message}
/>
<ReactTooltip
multiline
type="info"
place={position}
effect="solid"
className={styles.tooltip}
arrowColor="transparent"
/>
</span>
);
}

View file

@ -0,0 +1 @@
export { Tooltip } from './Tooltip';

View file

@ -0,0 +1,29 @@
import { UserViewModel } from '@/portainer/models/user';
export function createMockUser(id: number, username: string): UserViewModel {
return {
Id: id,
Username: username,
Role: 2,
UserTheme: '',
EndpointAuthorizations: {},
PortainerAuthorizations: {
PortainerDockerHubInspect: true,
PortainerEndpointGroupInspect: true,
PortainerEndpointGroupList: true,
PortainerEndpointInspect: true,
PortainerEndpointList: true,
PortainerMOTD: true,
PortainerRoleList: true,
PortainerTeamList: true,
PortainerTemplateInspect: true,
PortainerTemplateList: true,
PortainerUserInspect: true,
PortainerUserList: true,
PortainerUserMemberships: true,
},
RoleName: 'user',
Checked: false,
AuthenticationMethod: '',
};
}

View file

@ -0,0 +1,29 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { UsersSelector } from './UsersSelector';
import { createMockUser } from './UsersSelector.mocks';
const meta: Meta = {
title: 'Components/UsersSelector',
component: UsersSelector,
};
export default meta;
export { Example };
function Example() {
const [selectedUsers, setSelectedUsers] = useState([1]);
const users = [createMockUser(1, 'user1'), createMockUser(2, 'user2')];
return (
<UsersSelector
value={selectedUsers}
onChange={setSelectedUsers}
users={users}
placeholder="Select one or more users"
/>
);
}

View file

@ -0,0 +1,41 @@
import { User, UserId } from '@/portainer/users/types';
import { Select } from '@@/form-components/ReactSelect';
interface Props {
name?: string;
value: UserId[];
onChange(value: UserId[]): void;
users: User[];
dataCy?: string;
inputId?: string;
placeholder?: string;
}
export function UsersSelector({
name,
value,
onChange,
users,
dataCy,
inputId,
placeholder,
}: Props) {
return (
<Select
isMulti
name={name}
getOptionLabel={(user) => user.Username}
getOptionValue={(user) => `${user.Id}`}
options={users}
value={users.filter((user) => value.includes(user.Id))}
closeMenuOnSelect={false}
onChange={(selectedUsers) =>
onChange(selectedUsers.map((user) => user.Id))
}
data-cy={dataCy}
inputId={inputId}
placeholder={placeholder}
/>
);
}

View file

@ -0,0 +1 @@
export { UsersSelector } from './UsersSelector';

View file

@ -0,0 +1,13 @@
.root {
width: 100%;
height: 100%;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.message {
margin-top: 25px;
}

View file

@ -0,0 +1,21 @@
import { Meta, Story } from '@storybook/react';
import { ViewLoading } from './ViewLoading';
export default {
component: ViewLoading,
title: 'Components/ViewLoading',
} as Meta;
interface Args {
message: string;
}
function Template({ message }: Args) {
return <ViewLoading message={message} />;
}
export const Example: Story<Args> = Template.bind({});
Example.args = {
message: 'Loading...',
};

View file

@ -0,0 +1,26 @@
import clsx from 'clsx';
import styles from './ViewLoading.module.css';
interface Props {
message?: string;
}
export function ViewLoading({ message }: Props) {
return (
<div className={clsx('row', styles.root)}>
<div className="sk-fold">
<div className="sk-fold-cube" />
<div className="sk-fold-cube" />
<div className="sk-fold-cube" />
<div className="sk-fold-cube" />
</div>
{message && (
<span className={styles.message}>
{message}
<i className="fa fa-cog fa-spin space-left" />
</span>
)}
</div>
);
}

View file

@ -0,0 +1 @@
export { ViewLoading } from './ViewLoading';

View file

@ -0,0 +1,8 @@
export function Loading() {
return (
<div className="loading">
<div className="double-bounce1" />
<div className="double-bounce2" />
</div>
);
}

View file

@ -0,0 +1,91 @@
import type { Meta } from '@storybook/react';
import { Widget } from './Widget';
import { WidgetBody } from './WidgetBody';
import { WidgetTitle } from './WidgetTitle';
import { WidgetFooter } from './WidgetFooter';
import { WidgetTaskbar } from './WidgetTaskbar';
interface WidgetProps {
loading: boolean;
title: string;
icon: string;
bodyText: string;
footerText: string;
}
const meta: Meta<WidgetProps> = {
title: 'Widget',
component: Widget,
args: {
loading: false,
title: 'Title',
icon: 'fa-rocket',
bodyText: 'Body',
footerText: 'Footer',
},
};
export default meta;
export { Default, WidgetWithCustomImage, WidgetWithTaskBar };
function Default({ loading, bodyText, footerText, icon, title }: WidgetProps) {
return (
<Widget>
<WidgetTitle title={title} icon={icon} />
<WidgetBody loading={loading}>{bodyText}</WidgetBody>
<WidgetFooter>{footerText}</WidgetFooter>
</Widget>
);
}
function WidgetWithCustomImage({
loading,
bodyText,
footerText,
icon,
title,
}: WidgetProps) {
return (
<Widget>
<WidgetTitle
title={title}
icon={
<img
className="custom-header-ico space-right"
src={icon}
alt="header-icon"
/>
}
/>
<WidgetBody loading={loading}>{bodyText}</WidgetBody>
<WidgetFooter>{footerText}</WidgetFooter>
</Widget>
);
}
WidgetWithCustomImage.args = {
icon: 'https://via.placeholder.com/150',
};
function WidgetWithTaskBar({
loading,
bodyText,
footerText,
icon,
title,
}: WidgetProps) {
return (
<Widget>
<WidgetTitle title={title} icon={icon} />
<WidgetTaskbar>
<button type="button" className="btn btn-primary">
Button
</button>
</WidgetTaskbar>
<WidgetBody loading={loading}>{bodyText}</WidgetBody>
<WidgetFooter>{footerText}</WidgetFooter>
</Widget>
);
}

View file

@ -0,0 +1,19 @@
import { createContext, PropsWithChildren, useContext } from 'react';
const Context = createContext<null | boolean>(null);
export function useWidgetContext() {
const context = useContext(Context);
if (context == null) {
throw new Error('Should be inside a Widget component');
}
}
export function Widget({ children }: PropsWithChildren<unknown>) {
return (
<Context.Provider value>
<div className="widget">{children}</div>
</Context.Provider>
);
}

View file

@ -0,0 +1,24 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import { useWidgetContext } from './Widget';
import { Loading } from './Loading';
interface Props {
loading?: boolean;
className?: string;
}
export function WidgetBody({
loading,
className,
children,
}: PropsWithChildren<Props>) {
useWidgetContext();
return (
<div className={clsx(className, 'widget-body')}>
{loading ? <Loading /> : <div className="widget-content">{children}</div>}
</div>
);
}

View file

@ -0,0 +1,9 @@
import { PropsWithChildren } from 'react';
import { useWidgetContext } from './Widget';
export function WidgetFooter({ children }: PropsWithChildren<unknown>) {
useWidgetContext();
return <div className="widget-footer">{children}</div>;
}

View file

@ -0,0 +1,22 @@
import { PropsWithChildren } from 'react';
import { useWidgetContext } from './Widget';
interface Props {
className?: string;
}
export function WidgetTaskbar({
children,
className,
}: PropsWithChildren<Props>) {
useWidgetContext();
return (
<div className="widget-header">
<div className="row">
<div className={className}>{children}</div>
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
import clsx from 'clsx';
import { PropsWithChildren, ReactNode } from 'react';
import { useWidgetContext } from './Widget';
interface Props {
title: ReactNode;
icon: ReactNode;
className?: string;
}
export function WidgetTitle({
title,
icon,
className,
children,
}: PropsWithChildren<Props>) {
useWidgetContext();
return (
<div className="widget-header">
<div className="row">
<span className={clsx('pull-left', className)}>
{typeof icon === 'string' ? <i className={clsx('fa', icon)} /> : icon}
<span>{title}</span>
</span>
<span className={clsx('pull-right', className)}>{children}</span>
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
import { Widget as MainComponent } from './Widget';
import { WidgetBody } from './WidgetBody';
import { WidgetFooter } from './WidgetFooter';
import { WidgetTitle } from './WidgetTitle';
import { WidgetTaskbar } from './WidgetTaskbar';
import { Loading } from './Loading';
interface WithSubcomponents {
Body: typeof WidgetBody;
Footer: typeof WidgetFooter;
Title: typeof WidgetTitle;
Taskbar: typeof WidgetTaskbar;
Loading: typeof Loading;
}
const Widget = MainComponent as typeof MainComponent & WithSubcomponents;
Widget.Body = WidgetBody;
Widget.Footer = WidgetFooter;
Widget.Title = WidgetTitle;
Widget.Taskbar = WidgetTaskbar;
Widget.Loading = Loading;
export {
Widget,
WidgetBody,
WidgetFooter,
WidgetTitle,
WidgetTaskbar,
Loading,
};

View file

@ -0,0 +1,3 @@
.add-button {
border: none;
}

View file

@ -0,0 +1,20 @@
import { Meta, Story } from '@storybook/react';
import { AddButton, Props } from './AddButton';
export default {
component: AddButton,
title: 'Components/Buttons/AddButton',
} as Meta;
function Template({ label, onClick }: JSX.IntrinsicAttributes & Props) {
return <AddButton label={label} onClick={onClick} />;
}
export const Primary: Story<Props> = Template.bind({});
Primary.args = {
label: 'Create new container',
onClick: () => {
alert('Hello AddButton!');
},
};

View file

@ -0,0 +1,22 @@
import { fireEvent, render } from '@testing-library/react';
import { AddButton, Props } from './AddButton';
function renderDefault({
label = 'default label',
onClick = () => {},
}: Partial<Props> = {}) {
return render(<AddButton label={label} onClick={onClick} />);
}
test('should display a AddButton component and allow onClick', async () => {
const label = 'test label';
const onClick = jest.fn();
const { findByText } = renderDefault({ label, onClick });
const buttonLabel = await findByText(label);
expect(buttonLabel).toBeTruthy();
fireEvent.click(buttonLabel);
expect(onClick).toHaveBeenCalled();
});

View file

@ -0,0 +1,27 @@
import clsx from 'clsx';
import styles from './AddButton.module.css';
export interface Props {
className?: string;
label: string;
onClick: () => void;
}
export function AddButton({ label, onClick, className }: Props) {
return (
<button
className={clsx(
className,
'label',
'label-default',
'interactive',
styles.addButton
)}
type="button"
onClick={onClick}
>
<i className="fa fa-plus-circle space-right" aria-hidden="true" /> {label}
</button>
);
}

Some files were not shown because too many files have changed in this diff Show more