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:
parent
212400c283
commit
18252ab854
346 changed files with 642 additions and 644 deletions
|
@ -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;
|
||||
}
|
|
@ -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({});
|
|
@ -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>
|
||||
);
|
||||
}
|
1
app/react/components/BEFeatureIndicator/index.ts
Normal file
1
app/react/components/BEFeatureIndicator/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { BEFeatureIndicator } from './BEFeatureIndicator';
|
15
app/react/components/BEFeatureIndicator/utils.ts
Normal file
15
app/react/components/BEFeatureIndicator/utils.ts
Normal 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 };
|
||||
}
|
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;
|
||||
}
|
19
app/react/components/Code/Code.module.css
Normal file
19
app/react/components/Code/Code.module.css
Normal 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;
|
||||
}
|
34
app/react/components/Code/Code.stories.tsx
Normal file
34
app/react/components/Code/Code.stories.tsx
Normal 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,
|
||||
};
|
11
app/react/components/Code/Code.test.tsx
Normal file
11
app/react/components/Code/Code.test.tsx
Normal 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();
|
||||
});
|
33
app/react/components/Code/Code.tsx
Normal file
33
app/react/components/Code/Code.tsx
Normal 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>
|
||||
);
|
||||
}
|
1
app/react/components/Code/index.ts
Normal file
1
app/react/components/Code/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Code } from './Code';
|
36
app/react/components/DashboardItem/DashboardItem.stories.tsx
Normal file
36
app/react/components/DashboardItem/DashboardItem.stories.tsx
Normal 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>
|
||||
);
|
||||
}
|
35
app/react/components/DashboardItem/DashboardItem.test.tsx
Normal file
35
app/react/components/DashboardItem/DashboardItem.test.tsx
Normal 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} />);
|
||||
}
|
27
app/react/components/DashboardItem/DashboardItem.tsx
Normal file
27
app/react/components/DashboardItem/DashboardItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
1
app/react/components/DashboardItem/index.ts
Normal file
1
app/react/components/DashboardItem/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { DashboardItem } from './DashboardItem';
|
15
app/react/components/DetailsTable/DetailsRow.tsx
Normal file
15
app/react/components/DetailsTable/DetailsRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
33
app/react/components/DetailsTable/DetailsTable.stories.tsx
Normal file
33
app/react/components/DetailsTable/DetailsTable.stories.tsx
Normal 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',
|
||||
};
|
24
app/react/components/DetailsTable/DetailsTable.test.tsx
Normal file
24
app/react/components/DetailsTable/DetailsTable.test.tsx
Normal 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();
|
||||
});
|
27
app/react/components/DetailsTable/DetailsTable.tsx
Normal file
27
app/react/components/DetailsTable/DetailsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
13
app/react/components/DetailsTable/index.tsx
Normal file
13
app/react/components/DetailsTable/index.tsx
Normal 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 };
|
47
app/react/components/InformationPanel.tsx
Normal file
47
app/react/components/InformationPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
20
app/react/components/Link.tsx
Normal file
20
app/react/components/Link.tsx
Normal 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>
|
||||
);
|
||||
}
|
32
app/react/components/NavTabs/NavTabs.module.css
Normal file
32
app/react/components/NavTabs/NavTabs.module.css
Normal 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;
|
||||
}
|
31
app/react/components/NavTabs/NavTabs.stories.tsx
Normal file
31
app/react/components/NavTabs/NavTabs.stories.tsx
Normal 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' },
|
||||
],
|
||||
};
|
58
app/react/components/NavTabs/NavTabs.test.tsx
Normal file
58
app/react/components/NavTabs/NavTabs.test.tsx
Normal 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} />
|
||||
);
|
||||
}
|
60
app/react/components/NavTabs/NavTabs.tsx
Normal file
60
app/react/components/NavTabs/NavTabs.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
1
app/react/components/NavTabs/index.ts
Normal file
1
app/react/components/NavTabs/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { NavTabs } from './NavTabs';
|
|
@ -0,0 +1,3 @@
|
|||
.breadcrumb-links {
|
||||
font-size: 10px;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
39
app/react/components/PageHeader/Breadcrumbs/Breadcrumbs.tsx
Normal file
39
app/react/components/PageHeader/Breadcrumbs/Breadcrumbs.tsx
Normal 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;
|
||||
}
|
1
app/react/components/PageHeader/Breadcrumbs/index.ts
Normal file
1
app/react/components/PageHeader/Breadcrumbs/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Breadcrumbs } from './Breadcrumbs';
|
103
app/react/components/PageHeader/HeaderContainer.css
Normal file
103
app/react/components/PageHeader/HeaderContainer.css
Normal 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*/
|
47
app/react/components/PageHeader/HeaderContainer.stories.tsx
Normal file
47
app/react/components/PageHeader/HeaderContainer.stories.tsx
Normal 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',
|
||||
};
|
26
app/react/components/PageHeader/HeaderContainer.tsx
Normal file
26
app/react/components/PageHeader/HeaderContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
19
app/react/components/PageHeader/HeaderContent.module.css
Normal file
19
app/react/components/PageHeader/HeaderContent.module.css
Normal 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;
|
||||
}
|
41
app/react/components/PageHeader/HeaderContent.test.tsx
Normal file
41
app/react/components/PageHeader/HeaderContent.test.tsx
Normal 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();
|
||||
});
|
43
app/react/components/PageHeader/HeaderContent.tsx
Normal file
43
app/react/components/PageHeader/HeaderContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
40
app/react/components/PageHeader/HeaderTitle.test.tsx
Normal file
40
app/react/components/PageHeader/HeaderTitle.test.tsx
Normal 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();
|
||||
});
|
27
app/react/components/PageHeader/HeaderTitle.tsx
Normal file
27
app/react/components/PageHeader/HeaderTitle.tsx
Normal 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>
|
||||
);
|
||||
}
|
4
app/react/components/PageHeader/PageHeader.module.css
Normal file
4
app/react/components/PageHeader/PageHeader.module.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
.reloadButton {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
42
app/react/components/PageHeader/PageHeader.stories.tsx
Normal file
42
app/react/components/PageHeader/PageHeader.stories.tsx
Normal 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',
|
||||
};
|
22
app/react/components/PageHeader/PageHeader.test.tsx
Normal file
22
app/react/components/PageHeader/PageHeader.test.tsx
Normal 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();
|
||||
});
|
39
app/react/components/PageHeader/PageHeader.tsx
Normal file
39
app/react/components/PageHeader/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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"`;
|
|
@ -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"`;
|
7
app/react/components/PageHeader/index.ts
Normal file
7
app/react/components/PageHeader/index.ts
Normal 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 };
|
|
@ -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>
|
||||
);
|
||||
}
|
30
app/react/components/PaginationControls/PageButton.tsx
Normal file
30
app/react/components/PaginationControls/PageButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
58
app/react/components/PaginationControls/PageInput.tsx
Normal file
58
app/react/components/PaginationControls/PageInput.tsx
Normal 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);
|
||||
}
|
||||
}
|
98
app/react/components/PaginationControls/PageSelector.tsx
Normal file
98
app/react/components/PaginationControls/PageSelector.tsx
Normal 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}
|
||||
>
|
||||
«
|
||||
</PageButton>
|
||||
) : null}
|
||||
{directionLinks ? (
|
||||
<PageButton
|
||||
onPageChange={onPageChange}
|
||||
page={currentPage - 1}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
‹
|
||||
</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}
|
||||
>
|
||||
›
|
||||
</PageButton>
|
||||
) : null}
|
||||
{boundaryLinks ? (
|
||||
<PageButton
|
||||
disabled={currentPage === last}
|
||||
onPageChange={onPageChange}
|
||||
page={last}
|
||||
>
|
||||
»
|
||||
</PageButton>
|
||||
) : null}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
3
app/react/components/PaginationControls/index.ts
Normal file
3
app/react/components/PaginationControls/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import './pagination-controls.css';
|
||||
|
||||
export { PaginationControls } from './PaginationControls';
|
|
@ -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);
|
||||
}
|
31
app/react/components/PasswordCheckHint.tsx
Normal file
31
app/react/components/PasswordCheckHint.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
28
app/react/components/TeamsSelector/TeamsSelector.stories.tsx
Normal file
28
app/react/components/TeamsSelector/TeamsSelector.stories.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
41
app/react/components/TeamsSelector/TeamsSelector.tsx
Normal file
41
app/react/components/TeamsSelector/TeamsSelector.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
1
app/react/components/TeamsSelector/index.ts
Normal file
1
app/react/components/TeamsSelector/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { TeamsSelector } from './TeamsSelector';
|
20
app/react/components/Tip/TextTip/TextTip.stories.tsx
Normal file
20
app/react/components/Tip/TextTip/TextTip.stories.tsx
Normal 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',
|
||||
};
|
11
app/react/components/Tip/TextTip/TextTip.test.tsx
Normal file
11
app/react/components/Tip/TextTip/TextTip.test.tsx
Normal 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();
|
||||
});
|
27
app/react/components/Tip/TextTip/TextTip.tsx
Normal file
27
app/react/components/Tip/TextTip/TextTip.tsx
Normal 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>
|
||||
);
|
||||
}
|
1
app/react/components/Tip/TextTip/index.ts
Normal file
1
app/react/components/Tip/TextTip/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { TextTip } from './TextTip';
|
23
app/react/components/Tip/Tooltip/Tooltip.module.css
Normal file
23
app/react/components/Tip/Tooltip/Tooltip.module.css
Normal 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;
|
||||
}
|
23
app/react/components/Tip/Tooltip/Tooltip.stories.tsx
Normal file
23
app/react/components/Tip/Tooltip/Tooltip.stories.tsx
Normal 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',
|
||||
};
|
31
app/react/components/Tip/Tooltip/Tooltip.tsx
Normal file
31
app/react/components/Tip/Tooltip/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
1
app/react/components/Tip/Tooltip/index.ts
Normal file
1
app/react/components/Tip/Tooltip/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Tooltip } from './Tooltip';
|
29
app/react/components/UsersSelector/UsersSelector.mocks.ts
Normal file
29
app/react/components/UsersSelector/UsersSelector.mocks.ts
Normal 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: '',
|
||||
};
|
||||
}
|
29
app/react/components/UsersSelector/UsersSelector.stories.tsx
Normal file
29
app/react/components/UsersSelector/UsersSelector.stories.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
41
app/react/components/UsersSelector/UsersSelector.tsx
Normal file
41
app/react/components/UsersSelector/UsersSelector.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
1
app/react/components/UsersSelector/index.ts
Normal file
1
app/react/components/UsersSelector/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { UsersSelector } from './UsersSelector';
|
13
app/react/components/ViewLoading/ViewLoading.module.css
Normal file
13
app/react/components/ViewLoading/ViewLoading.module.css
Normal 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;
|
||||
}
|
21
app/react/components/ViewLoading/ViewLoading.stories.tsx
Normal file
21
app/react/components/ViewLoading/ViewLoading.stories.tsx
Normal 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...',
|
||||
};
|
26
app/react/components/ViewLoading/ViewLoading.tsx
Normal file
26
app/react/components/ViewLoading/ViewLoading.tsx
Normal 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>
|
||||
);
|
||||
}
|
1
app/react/components/ViewLoading/index.ts
Normal file
1
app/react/components/ViewLoading/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ViewLoading } from './ViewLoading';
|
8
app/react/components/Widget/Loading.tsx
Normal file
8
app/react/components/Widget/Loading.tsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
export function Loading() {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="double-bounce1" />
|
||||
<div className="double-bounce2" />
|
||||
</div>
|
||||
);
|
||||
}
|
91
app/react/components/Widget/Widget.stories.tsx
Normal file
91
app/react/components/Widget/Widget.stories.tsx
Normal 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>
|
||||
);
|
||||
}
|
19
app/react/components/Widget/Widget.tsx
Normal file
19
app/react/components/Widget/Widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
24
app/react/components/Widget/WidgetBody.tsx
Normal file
24
app/react/components/Widget/WidgetBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
9
app/react/components/Widget/WidgetFooter.tsx
Normal file
9
app/react/components/Widget/WidgetFooter.tsx
Normal 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>;
|
||||
}
|
22
app/react/components/Widget/WidgetTaskbar.tsx
Normal file
22
app/react/components/Widget/WidgetTaskbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
31
app/react/components/Widget/WidgetTitle.tsx
Normal file
31
app/react/components/Widget/WidgetTitle.tsx
Normal 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>
|
||||
);
|
||||
}
|
31
app/react/components/Widget/index.ts
Normal file
31
app/react/components/Widget/index.ts
Normal 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,
|
||||
};
|
3
app/react/components/buttons/AddButton.module.css
Normal file
3
app/react/components/buttons/AddButton.module.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.add-button {
|
||||
border: none;
|
||||
}
|
20
app/react/components/buttons/AddButton.stories.tsx
Normal file
20
app/react/components/buttons/AddButton.stories.tsx
Normal 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!');
|
||||
},
|
||||
};
|
22
app/react/components/buttons/AddButton.test.tsx
Normal file
22
app/react/components/buttons/AddButton.test.tsx
Normal 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();
|
||||
});
|
27
app/react/components/buttons/AddButton.tsx
Normal file
27
app/react/components/buttons/AddButton.tsx
Normal 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
Loading…
Add table
Add a link
Reference in a new issue