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:
parent
212400c283
commit
18252ab854
346 changed files with 642 additions and 644 deletions
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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> = {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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({});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
.boxselector_wrapper {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
.root {
|
||||
width: 100%;
|
||||
}
|
|
@ -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
|
|
@ -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);
|
||||
});
|
|
@ -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 };
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 />;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
14
app/portainer/components/BoxSelector/utils.ts
Normal file
14
app/portainer/components/BoxSelector/utils.ts
Normal 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 };
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
.add-button {
|
||||
border: none;
|
||||
}
|
|
@ -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!');
|
||||
},
|
||||
};
|
|
@ -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();
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
|
@ -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 '';
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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: '',
|
||||
};
|
|
@ -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();
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { CopyButton } from './CopyButton';
|
|
@ -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 };
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import { Button } from './Button';
|
||||
import { AddButton } from './AddButton';
|
||||
import { ButtonGroup } from './ButtonGroup';
|
||||
|
||||
export { Button, AddButton, ButtonGroup };
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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();
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { Code } from './Code';
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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} />);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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();
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 };
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,3 +1 @@
|
|||
export { InformationPanel } from './InformationPanel';
|
||||
|
||||
export { InformationPanelAngular } from './InformationPanelAngular';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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' },
|
||||
],
|
||||
};
|
|
@ -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} />
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
.breadcrumb-links {
|
||||
font-size: 10px;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { Breadcrumbs } from './Breadcrumbs';
|
|
@ -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*/
|
|
@ -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',
|
||||
};
|
4
app/portainer/components/PageHeader/HeaderContainer.ts
Normal file
4
app/portainer/components/PageHeader/HeaderContainer.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export const Header = {
|
||||
transclude: true,
|
||||
templateUrl: './HeaderContainer.html',
|
||||
};
|
|
@ -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',
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
});
|
8
app/portainer/components/PageHeader/HeaderContent.ts
Normal file
8
app/portainer/components/PageHeader/HeaderContent.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import controller from './HeaderContent.controller';
|
||||
|
||||
export const HeaderContent = {
|
||||
requires: '^rdHeader',
|
||||
transclude: true,
|
||||
templateUrl: './HeaderContent.html',
|
||||
controller,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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();
|
||||
});
|
11
app/portainer/components/PageHeader/HeaderTitle.ts
Normal file
11
app/portainer/components/PageHeader/HeaderTitle.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import controller from './HeaderTitle.controller';
|
||||
|
||||
export const HeaderTitle = {
|
||||
requires: '^rdHeader',
|
||||
bindings: {
|
||||
titleText: '@',
|
||||
},
|
||||
transclude: true,
|
||||
templateUrl: './HeaderTitle.html',
|
||||
controller,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -1,4 +0,0 @@
|
|||
.reloadButton {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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();
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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"`;
|
|
@ -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"`;
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
]);
|
|
@ -1,3 +0,0 @@
|
|||
.red-bg {
|
||||
background: red;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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']);
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { TeamsSelector } from './TeamsSelector';
|
|
@ -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',
|
||||
};
|
|
@ -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();
|
||||
});
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue