1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-10 00:05:24 +02:00

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

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

View file

@ -1,10 +1,11 @@
import { FormikErrors } from 'formik';
import { useUser } from '@/portainer/hooks/useUser';
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
import { EditDetails } from '@/portainer/access-control/EditDetails/EditDetails';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { SwitchField } from '@@/form-components/SwitchField';
import { ResourceControlOwnership, AccessControlFormData } from '../types';
export interface Props {

View file

@ -1,12 +1,13 @@
import { useReducer } from 'react';
import { Button } from '@/portainer/components/Button';
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
import { useUser } from '@/portainer/hooks/useUser';
import { r2a } from '@/react-tools/react2angular';
import { TeamMembership, Role } from '@/portainer/teams/types';
import { useUserMembership } from '@/portainer/users/queries';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { Button } from '@@/buttons';
import { ResourceControlType, ResourceId } from '../types';
import { ResourceControlViewModel } from '../models/ResourceControlViewModel';

View file

@ -3,13 +3,14 @@ import { PropsWithChildren } from 'react';
import _ from 'lodash';
import { ownershipIcon, truncate } from '@/portainer/filters/filters';
import { Tooltip } from '@/portainer/components/Tip/Tooltip';
import { Link } from '@/portainer/components/Link';
import { UserId } from '@/portainer/users/types';
import { TeamId } from '@/portainer/teams/types';
import { useTeams } from '@/portainer/teams/queries';
import { useUsers } from '@/portainer/users/queries';
import { Link } from '@@/Link';
import { Tooltip } from '@@/Tip/Tooltip';
import {
ResourceControlOwnership,
ResourceControlType,

View file

@ -4,11 +4,12 @@ import { useMutation } from 'react-query';
import { object } from 'yup';
import { useUser } from '@/portainer/hooks/useUser';
import { Button } from '@/portainer/components/Button';
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
import { notifySuccess } from '@/portainer/services/notifications';
import { Button } from '@@/buttons';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { EditDetails } from '../EditDetails';
import { parseAccessControlFormData } from '../utils';
import { validationSchema } from '../AccessControlForm/AccessControlForm.validation';

View file

@ -1,9 +1,10 @@
import { useCallback } from 'react';
import { FormikErrors } from 'formik';
import { BoxSelector } from '@/portainer/components/BoxSelector';
import { useUser } from '@/portainer/hooks/useUser';
import { FormError } from '@/portainer/components/form-components/FormError';
import { BoxSelector } from '@@/BoxSelector';
import { FormError } from '@@/form-components/FormError';
import { ResourceControlOwnership, AccessControlFormData } from '../types';

View file

@ -1,8 +1,9 @@
import { TeamsSelector } from '@/portainer/components/TeamsSelector';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { Link } from '@/portainer/components/Link';
import { Team } from '@/portainer/teams/types';
import { TeamsSelector } from '@@/TeamsSelector';
import { FormControl } from '@@/form-components/FormControl';
import { Link } from '@@/Link';
interface Props {
name: string;
teams: Team[];

View file

@ -1,8 +1,9 @@
import { UsersSelector } from '@/portainer/components/UsersSelector';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { Link } from '@/portainer/components/Link';
import { User } from '@/portainer/users/types';
import { UsersSelector } from '@@/UsersSelector';
import { FormControl } from '@@/form-components/FormControl';
import { Link } from '@@/Link';
interface Props {
name: string;
users: User[];

View file

@ -2,10 +2,11 @@ import _ from 'lodash';
import { useEffect, useState } from 'react';
import { buildOption } from '@/portainer/components/BoxSelector';
import { BoxSelectorOption } from '@/portainer/components/BoxSelector/types';
import { ownershipIcon } from '@/portainer/filters/filters';
import { Team } from '@/portainer/teams/types';
import { BoxSelectorOption } from '@@/BoxSelector/types';
import { ResourceControlOwnership } from '../types';
const publicOption: BoxSelectorOption<ResourceControlOwnership> = {

View file

@ -1,6 +1,6 @@
import { FeatureId } from '@/portainer/feature-flags/enums';
import { getFeatureDetails } from './utils';
import { getFeatureDetails } from '@@/BEFeatureIndicator/utils';
export default class BeIndicatorController {
limitedToBE?: boolean;

View file

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

View file

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

View file

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

View file

@ -1,8 +1,6 @@
import controller from './BEFeatureIndicator.controller';
import './BEFeatureIndicator.css';
export const beFeatureIndicatorAngular = {
export const beFeatureIndicator = {
templateUrl: './BEFeatureIndicator.html',
controller,
bindings: {
@ -10,5 +8,3 @@ export const beFeatureIndicatorAngular = {
},
transclude: true,
};
export { BEFeatureIndicator } from './BEFeatureIndicator';

View file

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

View file

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

View file

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

View file

@ -1,85 +0,0 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service';
import { Edition, FeatureId } from '@/portainer/feature-flags/enums';
import { BoxSelector } from './BoxSelector';
import { BoxSelectorOption } from './types';
const meta: Meta = {
title: 'BoxSelector',
component: BoxSelector,
};
export default meta;
export { Example, LimitedFeature };
function Example() {
const [value, setValue] = useState(3);
const options: BoxSelectorOption<number>[] = [
{
description: 'description 1',
icon: 'fa fa-rocket',
id: '1',
value: 3,
label: 'option 1',
},
{
description: 'description 2',
icon: 'fa fa-rocket',
id: '2',
value: 4,
label: 'option 2',
},
];
return (
<BoxSelector
radioName="name"
onChange={(value: number) => {
setValue(value);
}}
value={value}
options={options}
/>
);
}
function LimitedFeature() {
initFeatureService(Edition.CE);
const [value, setValue] = useState(3);
const options: BoxSelectorOption<number>[] = [
{
description: 'description 1',
icon: 'fa fa-rocket',
id: '1',
value: 3,
label: 'option 1',
},
{
description: 'description 2',
icon: 'fa fa-rocket',
id: '2',
value: 4,
label: 'option 2',
feature: FeatureId.ACTIVITY_AUDIT,
},
];
return (
<BoxSelector
radioName="name"
onChange={(value: number) => {
setValue(value);
}}
value={value}
options={options}
/>
);
}
// regular example
// story with limited feature

View file

@ -1,59 +0,0 @@
import { render, fireEvent } from '@/react-tools/test-utils';
import { BoxSelector, Props } from './BoxSelector';
import { BoxSelectorOption } from './types';
function renderDefault<T extends string | number>({
options = [],
onChange = () => {},
radioName = 'radio',
value,
}: Partial<Props<T>> = {}) {
return render(
<BoxSelector
options={options}
onChange={onChange}
radioName={radioName}
value={value || 0}
/>
);
}
test('should render with the initial value selected and call onChange when clicking a different value', async () => {
const options: BoxSelectorOption<number>[] = [
{
description: 'description 1',
icon: 'fa fa-rocket',
id: '1',
value: 3,
label: 'option 1',
},
{
description: 'description 2',
icon: 'fa fa-rocket',
id: '2',
value: 4,
label: 'option 2',
},
];
const onChange = jest.fn();
const { getByLabelText } = renderDefault({
options,
onChange,
value: options[0].value,
});
const item1 = getByLabelText(options[0].label, {
exact: false,
}) as HTMLInputElement;
expect(item1.checked).toBeTruthy();
const item2 = getByLabelText(options[1].label, {
exact: false,
}) as HTMLInputElement;
expect(item2.checked).toBeFalsy();
fireEvent.click(item2);
expect(onChange).toHaveBeenCalledWith(options[1].value, false);
});

View file

@ -1,49 +0,0 @@
import clsx from 'clsx';
import type { FeatureId } from '@/portainer/feature-flags/enums';
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>
);
}
export function buildOption<T extends number | string>(
id: string,
icon: string,
label: string,
description: string,
value: T,
feature?: FeatureId
): BoxSelectorOption<T> {
return { id, icon, label, description, value, feature };
}

View file

@ -1,112 +0,0 @@
.boxselector_wrapper > div,
.boxselector_wrapper box-selector-item {
--selected-item-color: var(--blue-2);
flex: 1;
padding: 5px;
}
.boxselector_wrapper .boxselector_header {
font-size: 14px;
margin-bottom: 5px;
font-weight: bold;
user-select: none;
}
.boxselector_header .fa,
.fab {
font-weight: normal;
}
.boxselector_wrapper input[type='radio'] {
display: none;
}
.boxselector_wrapper input[type='radio']:not(:disabled) ~ label {
cursor: pointer;
background-color: var(--bg-boxselector-wrapper-disabled-color);
}
.boxselector_wrapper input[type='radio']:not(:disabled):hover ~ label:hover {
cursor: pointer;
}
.boxselector_wrapper label {
font-weight: normal;
font-size: 12px;
display: block;
background: var(--bg-boxselector-color);
border: 1px solid var(--border-boxselector-color);
border-radius: 2px;
padding: 10px 10px 0 10px;
text-align: center;
box-shadow: var(--shadow-boxselector-color);
position: relative;
}
.box-selector-item input:disabled + label,
.boxselector_wrapper label.boxselector_disabled {
background: var(--bg-boxselector-disabled-color) !important;
border-color: #787878;
color: #787878;
cursor: not-allowed;
pointer-events: none;
}
.boxselector_wrapper input[type='radio']:checked + label {
background: var(--selected-item-color);
color: white;
padding-top: 20px;
border-color: var(--selected-item-color);
}
.boxselector_wrapper input[type='radio']:checked + label::after {
color: var(--selected-item-color);
font-family: 'Font Awesome 5 Free';
border: 2px solid var(--selected-item-color);
content: '\f00c';
font-size: 16px;
font-weight: bold;
position: absolute;
top: -15px;
left: 50%;
transform: translateX(-50%);
height: 30px;
width: 30px;
line-height: 26px;
text-align: center;
border-radius: 50%;
background: white;
box-shadow: 0 2px 5px -2px rgba(0, 0, 0, 0.25);
}
@media only screen and (max-width: 700px) {
.boxselector_wrapper {
flex-direction: column;
}
}
.box-selector-item.limited.business {
--selected-item-color: var(--BE-only);
}
.box-selector-item.limited.business label {
border-color: var(--BE-only);
border-width: 2px;
}
.box-selector-item .limited-icon {
position: absolute;
left: 1em;
top: calc(50% - 0.5em);
height: 1em;
}
@media (min-width: 992px) {
.box-selector-item .limited-icon {
left: 2em;
}
}
.box-selector-item.limited.business :checked + label {
color: initial;
}

View file

@ -1,77 +0,0 @@
import { Meta } from '@storybook/react';
import { init as initFeatureService } from '@/portainer/feature-flags/feature-flags.service';
import { Edition, FeatureId } from '@/portainer/feature-flags/enums';
import { BoxSelectorItem } from './BoxSelectorItem';
import { BoxSelectorOption } from './types';
const meta: Meta = {
title: 'BoxSelector/Item',
args: {
selected: false,
description: 'description',
icon: 'fa-rocket',
label: 'label',
},
};
export default meta;
interface ExampleProps {
selected?: boolean;
description?: string;
icon?: string;
label?: string;
feature?: FeatureId;
}
function Template({
selected,
description = 'description',
icon,
label = 'label',
feature,
}: ExampleProps) {
const option: BoxSelectorOption<number> = {
description,
icon: `fa ${icon}`,
id: 'id',
label,
value: 1,
feature,
};
return (
<div className="boxselector_wrapper">
<BoxSelectorItem
onChange={() => {}}
option={option}
radioName="radio"
selectedValue={selected ? option.value : 0}
/>
</div>
);
}
export const Example = Template.bind({});
export function SelectedItem() {
return <Template selected />;
}
SelectedItem.args = {
selected: true,
};
export function LimitedFeatureItem() {
initFeatureService(Edition.CE);
return <Template feature={FeatureId.ACTIVITY_AUDIT} />;
}
export function SelectedLimitedFeatureItem() {
initFeatureService(Edition.CE);
return <Template feature={FeatureId.ACTIVITY_AUDIT} selected />;
}

View file

@ -1,77 +0,0 @@
import clsx from 'clsx';
import ReactTooltip from 'react-tooltip';
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
import './BoxSelectorItem.css';
import { BoxSelectorOption } from './types';
interface Props<T extends number | string> {
radioName: string;
option: BoxSelectorOption<T>;
onChange(value: T, limitedToBE: boolean): void;
selectedValue: T;
disabled?: boolean;
tooltip?: string;
}
export function BoxSelectorItem<T extends number | string>({
radioName,
option,
onChange,
selectedValue,
disabled,
tooltip,
}: Props<T>) {
const limitedToBE = isLimitedToBE(option.feature);
const tooltipId = `box-selector-item-${radioName}-${option.id}`;
return (
<div
className={clsx('box-selector-item', {
business: limitedToBE,
limited: limitedToBE,
})}
data-tip
data-for={tooltipId}
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
>
<input
type="radio"
name={radioName}
id={option.id}
checked={option.value === selectedValue}
value={option.value}
disabled={disabled}
onChange={() => onChange(option.value, limitedToBE)}
/>
<label htmlFor={option.id} data-cy={`${radioName}_${option.value}`}>
{limitedToBE && <i className="fas fa-briefcase limited-icon" />}
<div className="boxselector_header">
{!!option.icon && (
<i
className={clsx(option.icon, 'space-right')}
aria-hidden="true"
/>
)}
{option.label}
</div>
<p className="box-selector-item-description">{option.description}</p>
</label>
{tooltip && (
<ReactTooltip
place="bottom"
className="portainer-tooltip"
id={tooltipId}
>
{tooltip}
</ReactTooltip>
)}
</div>
);
}

View file

@ -2,12 +2,11 @@ import angular from 'angular';
import { react2angular } from '@/react-tools/react2angular';
import { BoxSelector, buildOption } from './BoxSelector';
import { BoxSelector } from '@@/BoxSelector';
import { BoxSelectorAngular } from './BoxSelectorAngular';
export { type BoxSelectorOption } from './types';
export { BoxSelector, buildOption };
export { buildOption } from './utils';
const BoxSelectorReact = react2angular(BoxSelector, [
'value',
'onChange',
@ -15,7 +14,7 @@ const BoxSelectorReact = react2angular(BoxSelector, [
'radioName',
]);
export default angular
export const boxSelectorModule = angular
.module('app.portainer.component.box-selector', [])
.component('boxSelectorReact', BoxSelectorReact)
.component('boxSelector', BoxSelectorAngular).name;

View file

@ -1,12 +0,0 @@
import type { FeatureId } from '@/portainer/feature-flags/enums';
export interface BoxSelectorOption<T> {
id: string;
icon: string;
label: string;
description: string;
value: T;
disabled?: () => boolean;
tooltip?: () => string;
feature?: FeatureId;
}

View file

@ -0,0 +1,14 @@
import { FeatureId } from '@/portainer/feature-flags/enums';
import { BoxSelectorOption } from '@@/BoxSelector/types';
export function buildOption<T extends number | string>(
id: string,
icon: string,
label: string,
description: string,
value: T,
feature?: FeatureId
): BoxSelectorOption<T> {
return { id, icon, label, description, value, feature };
}

View file

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

View file

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

View file

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

View file

@ -1,27 +0,0 @@
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>
);
}

View file

@ -1,105 +0,0 @@
import { Meta, Story } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { Button, Props } from './Button';
export default {
component: Button,
title: 'Components/Buttons/Button',
} as Meta;
function Template({
onClick,
color,
size,
disabled,
}: JSX.IntrinsicAttributes & PropsWithChildren<Props>) {
return (
<Button onClick={onClick} color={color} size={size} disabled={disabled}>
<i className="fa fa-download" aria-hidden="true" /> Primary Button
</Button>
);
}
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
color: 'primary',
size: 'small',
disabled: false,
onClick: () => {
alert('Hello Button!');
},
};
export function Disabled() {
return (
<Button color="primary" onClick={() => {}} disabled>
Disabled Button
</Button>
);
}
export function Warning() {
return (
<Button color="warning" onClick={() => {}}>
Warning Button
</Button>
);
}
export function Success() {
return (
<Button color="success" onClick={() => {}}>
Success Button
</Button>
);
}
export function Danger() {
return (
<Button color="danger" onClick={() => {}}>
Danger Button
</Button>
);
}
export function Default() {
return (
<Button color="default" onClick={() => {}}>
<i className="fa fa-plus-circle" aria-hidden="true" /> Add an environment
variable
</Button>
);
}
export function Link() {
return (
<Button color="link" onClick={() => {}}>
Link Button
</Button>
);
}
export function XSmall() {
return (
<Button color="primary" onClick={() => {}} size="xsmall">
XSmall Button
</Button>
);
}
export function Small() {
return (
<Button color="primary" onClick={() => {}} size="small">
Small Button
</Button>
);
}
export function Large() {
return (
<Button color="primary" onClick={() => {}} size="large">
Large Button
</Button>
);
}

View file

@ -1,37 +0,0 @@
import { fireEvent, render } from '@testing-library/react';
import { PropsWithChildren } from 'react';
import { Button, Props } from './Button';
function renderDefault({
type = 'button',
color = 'primary',
size = 'small',
disabled = false,
onClick = () => {},
children = null,
}: Partial<PropsWithChildren<Props>> = {}) {
return render(
<Button
type={type}
color={color}
size={size}
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
);
}
test('should display a Button component and allow onClick', async () => {
const children = 'test label';
const onClick = jest.fn();
const { findByText } = renderDefault({ children, onClick });
const buttonLabel = await findByText(children);
expect(buttonLabel).toBeTruthy();
fireEvent.click(buttonLabel);
expect(onClick).toHaveBeenCalled();
});

View file

@ -1,56 +0,0 @@
import { MouseEventHandler, PropsWithChildren } from 'react';
import clsx from 'clsx';
type Type = 'submit' | 'button' | 'reset';
type Color = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'link';
type Size = 'xsmall' | 'small' | 'medium' | 'large';
export interface Props {
color?: Color;
size?: Size;
disabled?: boolean;
title?: string;
className?: string;
dataCy?: string;
type?: Type;
onClick?: MouseEventHandler<HTMLButtonElement>;
}
export function Button({
type = 'button',
color = 'primary',
size = 'small',
disabled = false,
className,
dataCy,
onClick,
title,
children,
}: PropsWithChildren<Props>) {
return (
<button
data-cy={dataCy}
/* eslint-disable-next-line react/button-has-type */
type={type}
disabled={disabled}
className={clsx('btn', `btn-${color}`, sizeClass(size), className)}
onClick={onClick}
title={title}
>
{children}
</button>
);
}
function sizeClass(size?: Size) {
switch (size) {
case 'large':
return 'btn-lg';
case 'medium':
return 'btn-md';
case 'xsmall':
return 'btn-xs';
default:
return 'btn-sm';
}
}

View file

@ -1,117 +0,0 @@
import { Meta, Story } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { Button } from './Button';
import { ButtonGroup, Props } from './ButtonGroup';
export default {
component: ButtonGroup,
title: 'Components/Buttons/ButtonGroup',
} as Meta;
function Template({
size,
}: JSX.IntrinsicAttributes & PropsWithChildren<Props>) {
return (
<ButtonGroup size={size}>
<Button color="success" onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Start
</Button>
<Button color="danger" onClick={() => {}}>
<i className="fa fa-stop space-right" aria-hidden="true" />
Stop
</Button>
<Button color="danger" onClick={() => {}}>
<i className="fa fa-bomb space-right" aria-hidden="true" />
Kill
</Button>
<Button color="primary" onClick={() => {}}>
<i className="fa fa-sync space-right" aria-hidden="true" />
Restart
</Button>
<Button color="primary" disabled onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Resume
</Button>
<Button color="danger" onClick={() => {}}>
<i className="fa fa-trash-alt space-right" aria-hidden="true" />
Remove
</Button>
</ButtonGroup>
);
}
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
size: 'small',
};
export function Xsmall() {
return (
<ButtonGroup size="xsmall">
<Button color="success" onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Start
</Button>
<Button color="danger" onClick={() => {}}>
<i className="fa fa-stop space-right" aria-hidden="true" />
Stop
</Button>
<Button color="success" onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Start
</Button>
<Button color="primary" onClick={() => {}}>
<i className="fa fa-sync space-right" aria-hidden="true" />
Restart
</Button>
</ButtonGroup>
);
}
export function Small() {
return (
<ButtonGroup size="small">
<Button color="success" onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Start
</Button>
<Button color="danger" onClick={() => {}}>
<i className="fa fa-stop space-right" aria-hidden="true" />
Stop
</Button>
<Button color="success" onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Start
</Button>
<Button color="primary" onClick={() => {}}>
<i className="fa fa-sync space-right" aria-hidden="true" />
Restart
</Button>
</ButtonGroup>
);
}
export function Large() {
return (
<ButtonGroup size="large">
<Button color="success" onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Start
</Button>
<Button color="danger" onClick={() => {}}>
<i className="fa fa-stop space-right" aria-hidden="true" />
Stop
</Button>
<Button color="success" onClick={() => {}}>
<i className="fa fa-play space-right" aria-hidden="true" />
Start
</Button>
<Button color="primary" onClick={() => {}}>
<i className="fa fa-sync space-right" aria-hidden="true" />
Restart
</Button>
</ButtonGroup>
);
}

View file

@ -1,18 +0,0 @@
import { render } from '@testing-library/react';
import { PropsWithChildren } from 'react';
import { ButtonGroup, Props } from './ButtonGroup';
function renderDefault({
size = 'small',
children = 'null',
}: Partial<PropsWithChildren<Props>> = {}) {
return render(<ButtonGroup size={size}>{children}</ButtonGroup>);
}
test('should display a ButtonGroup component', async () => {
const { findByRole } = renderDefault({});
const element = await findByRole('group');
expect(element).toBeTruthy();
});

View file

@ -1,31 +0,0 @@
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
export type Size = 'xsmall' | 'small' | 'large';
export interface Props {
size?: Size;
className?: string;
}
export function ButtonGroup({
size = 'small',
children,
className,
}: PropsWithChildren<Props>) {
return (
<div className={clsx('btn-group', sizeClass(size), className)} role="group">
{children}
</div>
);
}
function sizeClass(size: Size | undefined) {
switch (size) {
case 'xsmall':
return 'btn-group-xs';
case 'large':
return 'btn-group-lg';
default:
return '';
}
}

View file

@ -1,45 +0,0 @@
.fadeout {
animation: fadeOut 2.5s;
animation-fill-mode: forwards;
}
.container {
display: flex;
align-items: baseline;
margin-top: 10px;
}
.display-text {
opacity: 0;
margin-left: 7px;
color: #23ae89;
}
@-webkit-keyframes fadeOut {
0% {
opacity: 1;
}
50% {
opacity: 0.8;
}
99% {
opacity: 0.01;
}
100% {
opacity: 0;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
50% {
opacity: 0.8;
}
99% {
opacity: 0.01;
}
100% {
opacity: 0;
}
}

View file

@ -1,34 +0,0 @@
import { Meta, Story } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { CopyButton, Props } from './CopyButton';
export default {
component: CopyButton,
title: 'Components/Buttons/CopyButton',
} as Meta;
function Template({
copyText,
displayText,
children,
}: JSX.IntrinsicAttributes & PropsWithChildren<Props>) {
return (
<CopyButton copyText={copyText} displayText={displayText}>
{children}
</CopyButton>
);
}
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
children: 'Copy to clipboard',
copyText: 'this will be copied to clipboard',
};
export const NoCopyText: Story<PropsWithChildren<Props>> = Template.bind({});
NoCopyText.args = {
children: 'Copy to clipboard without copied text',
copyText: 'clipboard override',
displayText: '',
};

View file

@ -1,37 +0,0 @@
import { fireEvent, render } from '@testing-library/react';
import { CopyButton } from './CopyButton';
test('should display a CopyButton with children', async () => {
const children = 'test button children';
const { findByText } = render(
<CopyButton copyText="">{children}</CopyButton>
);
const button = await findByText(children);
expect(button).toBeTruthy();
});
test('CopyButton should copy text to clipboard', async () => {
// override navigator.clipboard.writeText (to test copy to clipboard functionality)
let clipboardText = '';
const writeText = jest.fn((text) => {
clipboardText = text;
});
Object.assign(navigator, {
clipboard: { writeText },
});
const children = 'button';
const copyText = 'text successfully copied to clipboard';
const { findByText } = render(
<CopyButton copyText={copyText}>{children}</CopyButton>
);
const button = await findByText(children);
expect(button).toBeTruthy();
fireEvent.click(button);
expect(clipboardText).toBe(copyText);
expect(writeText).toHaveBeenCalled();
});

View file

@ -1,49 +0,0 @@
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { Button } from '../Button';
import styles from './CopyButton.module.css';
import { useCopy } from './useCopy';
export interface Props {
copyText: string;
fadeDelay?: number;
displayText?: string;
className?: string;
}
export function CopyButton({
copyText,
fadeDelay = 1000,
displayText = 'copied',
className,
children,
}: PropsWithChildren<Props>) {
const { handleCopy, copiedSuccessfully } = useCopy(copyText, fadeDelay);
return (
<div className={styles.container}>
<Button
className={className}
size="small"
onClick={handleCopy}
title="Copy Value"
type="button"
>
<i className="fa fa-copy space-right" aria-hidden="true" /> {children}
</Button>
<span
className={clsx(
copiedSuccessfully && styles.fadeout,
styles.displayText,
'space-left'
)}
>
<i className="fa fa-check" aria-hidden="true" />
{displayText && <span className="space-left">{displayText}</span>}
</span>
</div>
);
}

View file

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

View file

@ -1,36 +0,0 @@
import { useEffect, useState } from 'react';
export function useCopy(copyText: string, fadeDelay = 1000) {
const [copiedSuccessfully, setCopiedSuccessfully] = useState(false);
useEffect(() => {
const fadeoutTime = setTimeout(
() => setCopiedSuccessfully(false),
fadeDelay
);
// clear timeout when component unmounts
return () => {
clearTimeout(fadeoutTime);
};
}, [copiedSuccessfully, fadeDelay]);
function handleCopy() {
// https://developer.mozilla.org/en-US/docs/Web/API/Clipboard
// https://caniuse.com/?search=clipboard
if (navigator.clipboard) {
navigator.clipboard.writeText(copyText);
} else {
// https://stackoverflow.com/a/57192718
const inputEl = document.createElement('textarea');
inputEl.value = copyText;
document.body.appendChild(inputEl);
inputEl.select();
document.execCommand('copy');
inputEl.hidden = true;
document.body.removeChild(inputEl);
}
setCopiedSuccessfully(true);
}
return { handleCopy, copiedSuccessfully };
}

View file

@ -1,36 +0,0 @@
import { Meta } from '@storybook/react';
import { LoadingButton } from './LoadingButton';
export default {
component: LoadingButton,
title: 'Components/Buttons/LoadingButton',
} as Meta;
interface Args {
loadingText: string;
isLoading: boolean;
}
function Template({ loadingText, isLoading }: Args) {
return (
<LoadingButton loadingText={loadingText} isLoading={isLoading}>
<i className="fa fa-download" aria-hidden="true" /> Download
</LoadingButton>
);
}
Template.args = {
loadingText: 'loading',
isLoading: false,
};
export const Example = Template.bind({});
export function IsLoading() {
return (
<LoadingButton loadingText="loading" isLoading>
<i className="fa fa-download" aria-hidden="true" /> Download
</LoadingButton>
);
}

View file

@ -1,43 +0,0 @@
import { render } from '@/react-tools/test-utils';
import { LoadingButton } from './LoadingButton';
test('when isLoading is true should show spinner and loading text', async () => {
const loadingText = 'loading';
const children = 'not visible';
const { findByLabelText, queryByText, findByText } = render(
<LoadingButton loadingText={loadingText} isLoading>
{children}
</LoadingButton>
);
const buttonLabel = queryByText(children);
expect(buttonLabel).toBeNull();
const spinner = await findByLabelText('loading');
expect(spinner).toBeVisible();
const loadingTextElem = await findByText(loadingText);
expect(loadingTextElem).toBeVisible();
});
test('should show children when false', async () => {
const loadingText = 'loading';
const children = 'visible';
const { queryByLabelText, queryByText } = render(
<LoadingButton loadingText={loadingText} isLoading={false}>
{children}
</LoadingButton>
);
const buttonLabel = queryByText(children);
expect(buttonLabel).toBeVisible();
const spinner = queryByLabelText('loading');
expect(spinner).toBeNull();
const loadingTextElem = queryByText(loadingText);
expect(loadingTextElem).toBeNull();
});

View file

@ -1,39 +0,0 @@
import { PropsWithChildren } from 'react';
import { type Props as ButtonProps, Button } from './Button';
interface Props extends ButtonProps {
loadingText: string;
isLoading: boolean;
}
export function LoadingButton({
loadingText,
isLoading,
disabled,
type = 'submit',
children,
...buttonProps
}: PropsWithChildren<Props>) {
return (
<Button
// eslint-disable-next-line react/jsx-props-no-spreading
{...buttonProps}
type={type}
disabled={disabled || isLoading}
>
{isLoading ? (
<>
<i
className="fa fa-circle-notch fa-spin space-right"
aria-label="loading"
aria-hidden="true"
/>
{loadingText}
</>
) : (
children
)}
</Button>
);
}

View file

@ -1,5 +0,0 @@
import { Button } from './Button';
import { AddButton } from './AddButton';
import { ButtonGroup } from './ButtonGroup';
export { Button, AddButton, ButtonGroup };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1 @@
export { InformationPanel } from './InformationPanel';
export { InformationPanelAngular } from './InformationPanelAngular';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,4 @@
export const Header = {
transclude: true,
templateUrl: './HeaderContainer.html',
};

View file

@ -1,31 +0,0 @@
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>
);
}
export const HeaderAngular = {
transclude: true,
templateUrl: './HeaderContainer.html',
};

View file

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

View file

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

View file

@ -0,0 +1,8 @@
import controller from './HeaderContent.controller';
export const HeaderContent = {
requires: '^rdHeader',
transclude: true,
templateUrl: './HeaderContent.html',
controller,
};

View file

@ -1,50 +0,0 @@
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { Link } from '@/portainer/components/Link';
import { useUser } from '@/portainer/hooks/useUser';
import controller from './HeaderContent.controller';
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>
);
}
export const HeaderContentAngular = {
requires: '^rdHeader',
transclude: true,
templateUrl: './HeaderContent.html',
controller,
};

View file

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

View file

@ -0,0 +1,11 @@
import controller from './HeaderTitle.controller';
export const HeaderTitle = {
requires: '^rdHeader',
bindings: {
titleText: '@',
},
transclude: true,
templateUrl: './HeaderTitle.html',
controller,
};

View file

@ -1,38 +0,0 @@
import { PropsWithChildren } from 'react';
import { useUser } from '@/portainer/hooks/useUser';
import { useHeaderContext } from './HeaderContainer';
import controller from './HeaderTitle.controller';
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>
);
}
export const HeaderTitleAngular = {
requires: '^rdHeader',
bindings: {
titleText: '@',
},
transclude: true,
templateUrl: './HeaderTitle.html',
controller,
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,16 +1,12 @@
import angular from 'angular';
import { Breadcrumbs } from './Breadcrumbs';
import { PageHeader } from './PageHeader';
import { HeaderContainer, HeaderAngular } from './HeaderContainer';
import { HeaderContent, HeaderContentAngular } from './HeaderContent';
import { HeaderTitle, HeaderTitleAngular } from './HeaderTitle';
export { PageHeader, Breadcrumbs, HeaderContainer, HeaderContent, HeaderTitle };
import { Header } from './HeaderContainer';
import { HeaderContent } from './HeaderContent';
import { HeaderTitle } from './HeaderTitle';
export const pageHeaderModule = angular
.module('portainer.app.components.header', [])
.component('rdHeader', HeaderAngular)
.component('rdHeaderContent', HeaderContentAngular)
.component('rdHeaderTitle', HeaderTitleAngular).name;
.component('rdHeader', Header)
.component('rdHeaderContent', HeaderContent)
.component('rdHeaderTitle', HeaderTitle).name;

View file

@ -1,37 +0,0 @@
import { react2angular } from '@/react-tools/react2angular';
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>
);
}
export const PasswordCheckHintAngular = react2angular(PasswordCheckHint, [
'passwordValid',
'forceChangePassword',
]);

View file

@ -1,3 +0,0 @@
.red-bg {
background: red;
}

View file

@ -1,24 +0,0 @@
import { UIRouter, pushStateLocationPlugin } from '@uirouter/react';
import { Meta } from '@storybook/react';
import { ReactExample } from './ReactExample';
const meta: Meta = {
title: 'ReactExample',
component: ReactExample,
};
export default meta;
export { Example };
interface Props {
text: string;
}
function Example({ text }: Props) {
return (
<UIRouter plugins={[pushStateLocationPlugin]}>
<ReactExample text={text} />
</UIRouter>
);
}

View file

@ -1,59 +0,0 @@
import { useSref } from '@uirouter/react';
import { Trans, useTranslation } from 'react-i18next';
import i18n from '@/i18n';
import { react2angular } from '@/react-tools/react2angular';
import { Link } from './Link';
import styles from './ReactExample.module.css';
export interface ReactExampleProps {
/**
* Example text to displayed in the component.
*/
text: string;
}
const lngs = {
en: { nativeName: 'English' },
de: { nativeName: 'Deutsch' },
he: { nativeName: 'Hebrew' },
};
export function ReactExample({ text }: ReactExampleProps) {
const route = 'portainer.registries';
const { onClick, href } = useSref(route);
const { t } = useTranslation();
return (
<div>
<div className={styles.redBg}>{text}</div>
<div>
<a href={href} onClick={onClick}>
{t('Registries useSref')}
</a>
</div>
<div>
<Link to={route}>
<Trans>
Registries <strong>Link</strong>
</Trans>
</Link>
</div>
{Object.entries(lngs).map(([lng, lngConfig]) => (
<button
key={lng}
style={{
fontWeight: i18n.resolvedLanguage === lng ? 'bold' : 'normal',
}}
type="submit"
onClick={() => i18n.changeLanguage(lng)}
>
{lngConfig.nativeName}
</button>
))}
</div>
);
}
export const ReactExampleAngular = react2angular(ReactExample, ['text']);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,27 +0,0 @@
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>
);
}

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