mirror of
https://github.com/portainer/portainer.git
synced 2025-08-09 07:45:22 +02:00
feat(app): introduce form framework [EE-1946] (#6272)
This commit is contained in:
parent
c5fe994cd2
commit
4f7b432f44
39 changed files with 815 additions and 339 deletions
|
@ -6,6 +6,7 @@ import settingsModule from './settings';
|
|||
import featureFlagModule from './feature-flags';
|
||||
import userActivityModule from './user-activity';
|
||||
import servicesModule from './services';
|
||||
import teamsModule from './teams';
|
||||
|
||||
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
|
||||
authManager.checkAuthOnRefresh();
|
||||
|
@ -32,6 +33,7 @@ angular
|
|||
userActivityModule,
|
||||
'portainer.shared.datatable',
|
||||
servicesModule,
|
||||
teamsModule,
|
||||
])
|
||||
.config([
|
||||
'$stateRegistryProvider',
|
||||
|
|
|
@ -1,17 +1,19 @@
|
|||
import { PropsWithChildren } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type Type = 'submit' | 'reset' | 'button';
|
||||
type Type = 'submit' | 'button' | 'reset';
|
||||
type Color = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'link';
|
||||
type Size = 'xsmall' | 'small' | 'medium' | 'large';
|
||||
|
||||
export interface Props {
|
||||
type?: Type;
|
||||
color?: Color;
|
||||
size?: Size;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
dataCy?: string;
|
||||
type?: Type;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
|
@ -20,12 +22,14 @@ export function Button({
|
|||
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}
|
||||
|
|
36
app/portainer/components/Button/LoadingButton.stories.tsx
Normal file
36
app/portainer/components/Button/LoadingButton.stories.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
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>
|
||||
);
|
||||
}
|
43
app/portainer/components/Button/LoadingButton.test.tsx
Normal file
43
app/portainer/components/Button/LoadingButton.test.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
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();
|
||||
});
|
39
app/portainer/components/Button/LoadingButton.tsx
Normal file
39
app/portainer/components/Button/LoadingButton.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
export function createMockUser(id: number, username: string): UserViewModel {
|
||||
return {
|
||||
Id: id,
|
||||
Username: username,
|
||||
Role: 2,
|
||||
UserTheme: '',
|
||||
EndpointAuthorizations: {},
|
||||
PortainerAuthorizations: {
|
||||
PortainerDockerHubInspect: true,
|
||||
PortainerEndpointExtensionAdd: true,
|
||||
PortainerEndpointExtensionRemove: true,
|
||||
PortainerEndpointGroupInspect: true,
|
||||
PortainerEndpointGroupList: true,
|
||||
PortainerEndpointInspect: true,
|
||||
PortainerEndpointList: true,
|
||||
PortainerMOTD: true,
|
||||
PortainerRoleList: true,
|
||||
PortainerTeamList: true,
|
||||
PortainerTemplateInspect: true,
|
||||
PortainerTemplateList: true,
|
||||
PortainerUserInspect: true,
|
||||
PortainerUserList: true,
|
||||
PortainerUserMemberships: true,
|
||||
},
|
||||
RoleName: 'user',
|
||||
Checked: false,
|
||||
AuthenticationMethod: '',
|
||||
};
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { UsersSelector } from './UsersSelector';
|
||||
import { createMockUser } from './UsersSelector.mocks';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/UsersSelector',
|
||||
component: UsersSelector,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export function Example() {
|
||||
const [selectedUsers, setSelectedUsers] = useState([10]);
|
||||
|
||||
const users = [createMockUser(1, 'user1'), createMockUser(2, 'user2')];
|
||||
|
||||
return (
|
||||
<UsersSelector
|
||||
value={selectedUsers}
|
||||
onChange={setSelectedUsers}
|
||||
users={users}
|
||||
placeholder="Select one or more users"
|
||||
/>
|
||||
);
|
||||
}
|
40
app/portainer/components/UsersSelector/UsersSelector.tsx
Normal file
40
app/portainer/components/UsersSelector/UsersSelector.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import Select from 'react-select';
|
||||
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
type UserId = number;
|
||||
|
||||
interface Props {
|
||||
value: UserId[];
|
||||
onChange(value: UserId[]): void;
|
||||
users: UserViewModel[];
|
||||
dataCy?: string;
|
||||
inputId?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function UsersSelector({
|
||||
value,
|
||||
onChange,
|
||||
users,
|
||||
dataCy,
|
||||
inputId,
|
||||
placeholder,
|
||||
}: Props) {
|
||||
return (
|
||||
<Select
|
||||
isMulti
|
||||
getOptionLabel={(user) => user.Username}
|
||||
getOptionValue={(user) => user.Id}
|
||||
options={users}
|
||||
value={users.filter((user) => value.includes(user.Id))}
|
||||
closeMenuOnSelect={false}
|
||||
onChange={(selectedUsers) =>
|
||||
onChange(selectedUsers.map((user) => user.Id))
|
||||
}
|
||||
data-cy={dataCy}
|
||||
inputId={inputId}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
1
app/portainer/components/UsersSelector/index.ts
Normal file
1
app/portainer/components/UsersSelector/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { UsersSelector } from './UsersSelector';
|
|
@ -1,8 +1,5 @@
|
|||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.space-right {
|
||||
margin-right: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { TextInput, Select } from '../Input';
|
||||
import { Input, Select } from '../Input';
|
||||
|
||||
import { FormControl } from './FormControl';
|
||||
|
||||
|
@ -21,7 +21,12 @@ function TextField({ label, tooltip = '' }: TextFieldProps) {
|
|||
const inputId = 'input';
|
||||
return (
|
||||
<FormControl inputId={inputId} label={label} tooltip={tooltip}>
|
||||
<TextInput id={inputId} type="text" value={value} onChange={setValue} />
|
||||
<Input
|
||||
id={inputId}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
@ -43,7 +48,7 @@ function SelectField({ label, tooltip = '' }: TextFieldProps) {
|
|||
<Select
|
||||
className="form-control"
|
||||
value={value}
|
||||
onChange={(value) => setValue(value)}
|
||||
onChange={(e) => setValue(parseInt(e.target.value, 10))}
|
||||
options={options}
|
||||
/>
|
||||
</FormControl>
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import { HTMLInputTypeAttribute } from 'react';
|
||||
|
||||
import { InputProps } from './types';
|
||||
|
||||
interface Props extends InputProps {
|
||||
type?: HTMLInputTypeAttribute;
|
||||
onChange(value: string): void;
|
||||
value: number | string;
|
||||
component?: 'input' | 'textarea';
|
||||
rows?: number;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export function BaseInput({
|
||||
component = 'input',
|
||||
value,
|
||||
disabled,
|
||||
id,
|
||||
readonly,
|
||||
required,
|
||||
type,
|
||||
className,
|
||||
rows,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const Component = component;
|
||||
return (
|
||||
<Component
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
readOnly={readonly}
|
||||
required={required}
|
||||
type={type}
|
||||
className={clsx('form-control', className)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={rows}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
import { Meta, Story } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { TextInput } from './TextInput';
|
||||
import { Input } from './Input';
|
||||
|
||||
export default {
|
||||
title: 'Components/Form/TextInput',
|
||||
title: 'Components/Form/Input',
|
||||
args: {
|
||||
disabled: false,
|
||||
},
|
||||
|
@ -16,7 +16,14 @@ interface Args {
|
|||
|
||||
export function TextField({ disabled }: Args) {
|
||||
const [value, setValue] = useState('');
|
||||
return <TextInput value={value} onChange={setValue} disabled={disabled} />;
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const DisabledTextField: Story<Args> = TextField.bind({});
|
15
app/portainer/components/form-components/Input/Input.tsx
Normal file
15
app/portainer/components/form-components/Input/Input.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import clsx from 'clsx';
|
||||
import { InputHTMLAttributes } from 'react';
|
||||
|
||||
export function Input({
|
||||
className,
|
||||
...props
|
||||
}: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
className={clsx('form-control', className)}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import { Meta, Story } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { NumberInput } from './NumberInput';
|
||||
|
||||
export default {
|
||||
title: 'Components/Form/NumberInput',
|
||||
args: {
|
||||
disabled: false,
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
interface Args {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Example({ disabled }: Args) {
|
||||
const [value, setValue] = useState(0);
|
||||
return <NumberInput value={value} onChange={setValue} disabled={disabled} />;
|
||||
}
|
||||
|
||||
export const DisabledNumberInput: Story<Args> = Example.bind({});
|
||||
DisabledNumberInput.args = {
|
||||
disabled: true,
|
||||
};
|
|
@ -1,35 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import { BaseInput } from './BaseInput';
|
||||
import { InputProps } from './types';
|
||||
|
||||
interface Props extends InputProps {
|
||||
value: number;
|
||||
readonly?: boolean;
|
||||
onChange(value: number): void;
|
||||
}
|
||||
|
||||
export function NumberInput({
|
||||
disabled,
|
||||
required,
|
||||
id,
|
||||
value,
|
||||
className,
|
||||
readonly,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<BaseInput
|
||||
id={id}
|
||||
type="number"
|
||||
className={clsx(className, 'form-control')}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
onChange={(value) => onChange(parseFloat(value))}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -21,9 +21,9 @@ export function Example({ disabled }: Args) {
|
|||
{ value: 2, label: 'two' },
|
||||
];
|
||||
return (
|
||||
<Select<number>
|
||||
<Select
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onChange={(e) => setValue(parseInt(e.target.value, 10))}
|
||||
disabled={disabled}
|
||||
options={options}
|
||||
/>
|
||||
|
|
|
@ -1,36 +1,25 @@
|
|||
import clsx from 'clsx';
|
||||
import { FormEvent } from 'react';
|
||||
|
||||
import { ChangeProps, InputProps } from './types';
|
||||
import { SelectHTMLAttributes } from 'react';
|
||||
|
||||
interface Option<T extends string | number> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props<T extends string | number> extends InputProps, ChangeProps<T> {
|
||||
interface Props<T extends string | number> {
|
||||
options: Option<T>[];
|
||||
}
|
||||
|
||||
export function Select<T extends number | string>({
|
||||
options,
|
||||
onChange,
|
||||
value,
|
||||
className,
|
||||
disabled,
|
||||
id,
|
||||
required,
|
||||
placeholder,
|
||||
}: Props<T>) {
|
||||
...props
|
||||
}: Props<T> & SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
required={required}
|
||||
className={clsx(className, 'form-control')}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
className={clsx('form-control', className)}
|
||||
>
|
||||
{options.map((item) => (
|
||||
<option value={item.value} key={item.value}>
|
||||
|
@ -39,10 +28,4 @@ export function Select<T extends number | string>({
|
|||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
function handleChange(e: FormEvent<HTMLSelectElement>) {
|
||||
const { selectedIndex } = e.currentTarget;
|
||||
const option = options[selectedIndex];
|
||||
onChange(option.value);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,35 +0,0 @@
|
|||
import { HTMLInputTypeAttribute } from 'react';
|
||||
|
||||
import { BaseInput } from './BaseInput';
|
||||
import { ChangeProps, InputProps } from './types';
|
||||
|
||||
interface TextInputProps extends InputProps, ChangeProps<string> {
|
||||
type?: HTMLInputTypeAttribute;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export function TextInput({
|
||||
id,
|
||||
type = 'text',
|
||||
value,
|
||||
className,
|
||||
onChange,
|
||||
disabled,
|
||||
readonly,
|
||||
required,
|
||||
placeholder,
|
||||
}: TextInputProps) {
|
||||
return (
|
||||
<BaseInput
|
||||
id={id}
|
||||
type={type}
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,31 +1,15 @@
|
|||
import { BaseInput } from './BaseInput';
|
||||
import { ChangeProps, InputProps } from './types';
|
||||
import clsx from 'clsx';
|
||||
import { TextareaHTMLAttributes } from 'react';
|
||||
|
||||
interface Props extends InputProps, ChangeProps<string> {
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
rows,
|
||||
export function TextArea({
|
||||
className,
|
||||
onChange,
|
||||
value,
|
||||
id,
|
||||
placeholder,
|
||||
disabled,
|
||||
required,
|
||||
}: Props & InputProps) {
|
||||
...props
|
||||
}: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return (
|
||||
<BaseInput
|
||||
component="textarea"
|
||||
id={id}
|
||||
rows={rows}
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
<textarea
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
className={clsx('form-control', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
export { NumberInput } from './NumberInput';
|
||||
export { TextInput } from './TextInput';
|
||||
export { Input } from './Input';
|
||||
export { Select } from './Select';
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
export interface InputProps {
|
||||
id?: string;
|
||||
className?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface ChangeProps<T> {
|
||||
value: T;
|
||||
onChange(value: T): void;
|
||||
}
|
|
@ -20,7 +20,7 @@ function BasicExample() {
|
|||
<InputGroup.Addon>@</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
value={value1}
|
||||
onChange={setValue1}
|
||||
onChange={(e) => setValue1(e.target.value)}
|
||||
placeholder="Username"
|
||||
aria-describedby="basic-addon1"
|
||||
/>
|
||||
|
@ -29,7 +29,7 @@ function BasicExample() {
|
|||
<InputGroup>
|
||||
<InputGroup.Input
|
||||
value={value1}
|
||||
onChange={setValue1}
|
||||
onChange={(e) => setValue1(e.target.value)}
|
||||
placeholder="Recipient's username"
|
||||
aria-describedby="basic-addon2"
|
||||
/>
|
||||
|
@ -38,9 +38,10 @@ function BasicExample() {
|
|||
|
||||
<InputGroup>
|
||||
<InputGroup.Addon>$</InputGroup.Addon>
|
||||
<InputGroup.NumberInput
|
||||
<InputGroup.Input
|
||||
type="number"
|
||||
value={valueNumber}
|
||||
onChange={setValueNumber}
|
||||
onChange={(e) => setValueNumber(parseInt(e.target.value, 10))}
|
||||
aria-label="Amount (to the nearest dollar)"
|
||||
/>
|
||||
<InputGroup.Addon>.00</InputGroup.Addon>
|
||||
|
@ -51,7 +52,7 @@ function BasicExample() {
|
|||
<InputGroup.Addon>https://example.com/users/</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
value={value1}
|
||||
onChange={setValue1}
|
||||
onChange={(e) => setValue1(e.target.value)}
|
||||
id="basic-url"
|
||||
aria-describedby="basic-addon3"
|
||||
/>
|
||||
|
@ -72,12 +73,18 @@ function Addons() {
|
|||
Go!
|
||||
</button>
|
||||
</InputGroup.ButtonWrapper>
|
||||
<InputGroup.Input value={value1} onChange={setValue1} />
|
||||
<InputGroup.Input
|
||||
value={value1}
|
||||
onChange={(e) => setValue1(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<div className="col-lg-6">
|
||||
<InputGroup>
|
||||
<InputGroup.Input value={value2} onChange={setValue2} />
|
||||
<InputGroup.Input
|
||||
value={value2}
|
||||
onChange={(e) => setValue2(e.target.value)}
|
||||
/>
|
||||
<InputGroup.Addon>
|
||||
<input type="checkbox" />
|
||||
</InputGroup.Addon>
|
||||
|
@ -93,17 +100,26 @@ function Sizing() {
|
|||
<div className="space-y-8">
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon>Small</InputGroup.Addon>
|
||||
<InputGroup.Input value={value} onChange={setValue} />
|
||||
<InputGroup.Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<InputGroup.Addon>Default</InputGroup.Addon>
|
||||
<InputGroup.Input value={value} onChange={setValue} />
|
||||
<InputGroup.Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup size="large">
|
||||
<InputGroup.Addon>Large</InputGroup.Addon>
|
||||
<InputGroup.Input value={value} onChange={setValue} />
|
||||
<InputGroup.Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { NumberInput, TextInput } from '../Input';
|
||||
import { Input } from '../Input';
|
||||
|
||||
import { InputGroup as MainComponent } from './InputGroup';
|
||||
import { InputGroupAddon } from './InputGroupAddon';
|
||||
|
@ -7,15 +7,13 @@ import { InputGroupButtonWrapper } from './InputGroupButtonWrapper';
|
|||
interface InputGroupSubComponents {
|
||||
Addon: typeof InputGroupAddon;
|
||||
ButtonWrapper: typeof InputGroupButtonWrapper;
|
||||
Input: typeof TextInput;
|
||||
NumberInput: typeof NumberInput;
|
||||
Input: typeof Input;
|
||||
}
|
||||
|
||||
const InputGroup: typeof MainComponent & InputGroupSubComponents = MainComponent as typeof MainComponent & InputGroupSubComponents;
|
||||
|
||||
InputGroup.Addon = InputGroupAddon;
|
||||
InputGroup.ButtonWrapper = InputGroupButtonWrapper;
|
||||
InputGroup.Input = TextInput;
|
||||
InputGroup.NumberInput = NumberInput;
|
||||
InputGroup.Input = Input;
|
||||
|
||||
export { InputGroup };
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { NumberInput, Select } from '../Input';
|
||||
import { Input, Select } from '../Input';
|
||||
|
||||
import { DefaultType, InputList } from './InputList';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'InputList',
|
||||
title: 'Components/Form/InputList',
|
||||
component: InputList,
|
||||
};
|
||||
|
||||
|
@ -75,12 +75,15 @@ function SelectAndInputItem({
|
|||
}) {
|
||||
return (
|
||||
<div>
|
||||
<NumberInput
|
||||
<Input
|
||||
type="number"
|
||||
value={item.value}
|
||||
onChange={(value: number) => onChange({ ...item, value })}
|
||||
onChange={(e) =>
|
||||
onChange({ ...item, value: parseInt(e.target.value, 10) })
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
onChange={(select: string) => onChange({ ...item, select })}
|
||||
onChange={(e) => onChange({ ...item, select: e.target.value })}
|
||||
options={[
|
||||
{ label: 'option1', value: 'option1' },
|
||||
{ label: 'option2', value: 'option2' },
|
||||
|
|
|
@ -4,7 +4,7 @@ import clsx from 'clsx';
|
|||
import { AddButton, Button } from '@/portainer/components/Button';
|
||||
import { Tooltip } from '@/portainer/components/Tip/Tooltip';
|
||||
|
||||
import { TextInput } from '../Input';
|
||||
import { Input } from '../Input';
|
||||
|
||||
import styles from './InputList.module.css';
|
||||
import { arrayMove } from './utils';
|
||||
|
@ -174,9 +174,9 @@ function defaultItemBuilder(): DefaultType {
|
|||
|
||||
function DefaultItem({ item, onChange }: ItemProps<DefaultType>) {
|
||||
return (
|
||||
<TextInput
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(value: string) => onChange({ value })}
|
||||
onChange={(e) => onChange({ value: e.target.value })}
|
||||
className={styles.defaultItem}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -11,6 +11,7 @@ export function UserViewModel(data) {
|
|||
this.AuthenticationMethod = data.AuthenticationMethod;
|
||||
this.Checked = false;
|
||||
this.EndpointAuthorizations = null;
|
||||
this.PortainerAuthorizations = null;
|
||||
}
|
||||
|
||||
export function UserTokenModel(data) {
|
||||
|
|
76
app/portainer/teams/CreateTeamForm/CreateTeamForm.mocks.ts
Normal file
76
app/portainer/teams/CreateTeamForm/CreateTeamForm.mocks.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { TeamViewModel } from '@/portainer/models/team';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
export function mockExampleData() {
|
||||
const teams: TeamViewModel[] = [
|
||||
{
|
||||
Id: 3,
|
||||
Name: 'Team 1',
|
||||
Checked: false,
|
||||
},
|
||||
{
|
||||
Id: 4,
|
||||
Name: 'Team 2',
|
||||
Checked: false,
|
||||
},
|
||||
];
|
||||
|
||||
const users: UserViewModel[] = [
|
||||
{
|
||||
Id: 10,
|
||||
Username: 'user1',
|
||||
Role: 2,
|
||||
UserTheme: '',
|
||||
EndpointAuthorizations: {},
|
||||
PortainerAuthorizations: {
|
||||
PortainerDockerHubInspect: true,
|
||||
PortainerEndpointExtensionAdd: true,
|
||||
PortainerEndpointExtensionRemove: true,
|
||||
PortainerEndpointGroupInspect: true,
|
||||
PortainerEndpointGroupList: true,
|
||||
PortainerEndpointInspect: true,
|
||||
PortainerEndpointList: true,
|
||||
PortainerMOTD: true,
|
||||
PortainerRoleList: true,
|
||||
PortainerTeamList: true,
|
||||
PortainerTemplateInspect: true,
|
||||
PortainerTemplateList: true,
|
||||
PortainerUserInspect: true,
|
||||
PortainerUserList: true,
|
||||
PortainerUserMemberships: true,
|
||||
},
|
||||
RoleName: 'user',
|
||||
Checked: false,
|
||||
AuthenticationMethod: '',
|
||||
},
|
||||
{
|
||||
Id: 13,
|
||||
Username: 'user2',
|
||||
Role: 2,
|
||||
UserTheme: '',
|
||||
EndpointAuthorizations: {},
|
||||
PortainerAuthorizations: {
|
||||
PortainerDockerHubInspect: true,
|
||||
PortainerEndpointExtensionAdd: true,
|
||||
PortainerEndpointExtensionRemove: true,
|
||||
PortainerEndpointGroupInspect: true,
|
||||
PortainerEndpointGroupList: true,
|
||||
PortainerEndpointInspect: true,
|
||||
PortainerEndpointList: true,
|
||||
PortainerMOTD: true,
|
||||
PortainerRoleList: true,
|
||||
PortainerTeamList: true,
|
||||
PortainerTemplateInspect: true,
|
||||
PortainerTemplateList: true,
|
||||
PortainerUserInspect: true,
|
||||
PortainerUserList: true,
|
||||
PortainerUserMemberships: true,
|
||||
},
|
||||
RoleName: 'user',
|
||||
Checked: false,
|
||||
AuthenticationMethod: '',
|
||||
},
|
||||
];
|
||||
|
||||
return { users, teams };
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { CreateTeamForm, FormValues } from './CreateTeamForm';
|
||||
import { mockExampleData } from './CreateTeamForm.mocks';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'teams/CreateTeamForm',
|
||||
component: CreateTeamForm,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export function Example() {
|
||||
const [message, setMessage] = useState('');
|
||||
const { teams, users } = mockExampleData();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CreateTeamForm users={users} teams={teams} onSubmit={handleSubmit} />
|
||||
<div>{message}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
setMessage(
|
||||
`created team ${values.name} with ${values.leaders.length} leaders`
|
||||
);
|
||||
resolve();
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
}
|
28
app/portainer/teams/CreateTeamForm/CreateTeamForm.test.tsx
Normal file
28
app/portainer/teams/CreateTeamForm/CreateTeamForm.test.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { render, waitFor } from '@/react-tools/test-utils';
|
||||
|
||||
import { CreateTeamForm } from './CreateTeamForm';
|
||||
|
||||
test('filling the name should make the submit button clickable and emptying it should make it disabled', async () => {
|
||||
const { findByLabelText, findByText } = render(
|
||||
<CreateTeamForm users={[]} teams={[]} onSubmit={() => {}} />
|
||||
);
|
||||
|
||||
const button = await findByText('Create team');
|
||||
expect(button).toBeVisible();
|
||||
|
||||
const nameField = await findByLabelText('Name');
|
||||
expect(nameField).toBeVisible();
|
||||
expect(nameField).toHaveDisplayValue('');
|
||||
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
const newValue = 'name';
|
||||
userEvent.type(nameField, newValue);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(nameField).toHaveDisplayValue(newValue);
|
||||
expect(button).toBeEnabled();
|
||||
});
|
||||
});
|
114
app/portainer/teams/CreateTeamForm/CreateTeamForm.tsx
Normal file
114
app/portainer/teams/CreateTeamForm/CreateTeamForm.tsx
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { Formik, Field, Form } from 'formik';
|
||||
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { TeamViewModel } from '@/portainer/models/team';
|
||||
import { Input } from '@/portainer/components/form-components/Input';
|
||||
import { UsersSelector } from '@/portainer/components/UsersSelector';
|
||||
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
||||
|
||||
import { validationSchema } from './CreateTeamForm.validation';
|
||||
|
||||
export interface FormValues {
|
||||
name: string;
|
||||
leaders: number[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
users: UserViewModel[];
|
||||
teams: TeamViewModel[];
|
||||
onSubmit(values: FormValues): void;
|
||||
}
|
||||
|
||||
export function CreateTeamForm({ users, teams, onSubmit }: Props) {
|
||||
const initialValues = {
|
||||
name: '',
|
||||
leaders: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-lg-12 col-md-12 col-xs-12">
|
||||
<Widget>
|
||||
<WidgetTitle icon="fa-plus" title="Add a new team" />
|
||||
<WidgetBody>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={() => validationSchema(teams)}
|
||||
onSubmit={onSubmit}
|
||||
validateOnMount
|
||||
>
|
||||
{({
|
||||
values,
|
||||
errors,
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => (
|
||||
<Form
|
||||
className="form-horizontal"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<FormControl
|
||||
inputId="team_name"
|
||||
label="Name"
|
||||
errors={errors.name}
|
||||
>
|
||||
<Field
|
||||
as={Input}
|
||||
name="name"
|
||||
id="team_name"
|
||||
required
|
||||
placeholder="e.g. development"
|
||||
data-cy="team-teamNameInput"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{users.length > 0 && (
|
||||
<FormControl
|
||||
inputId="users-input"
|
||||
label="Select team leader(s)"
|
||||
tooltip="You can assign one or more leaders to this team. Team leaders can manage their teams users and resources."
|
||||
errors={errors.leaders}
|
||||
>
|
||||
<UsersSelector
|
||||
value={values.leaders}
|
||||
onChange={(leaders) =>
|
||||
setFieldValue('leaders', leaders)
|
||||
}
|
||||
users={users}
|
||||
dataCy="team-teamLeaderSelect"
|
||||
inputId="users-input"
|
||||
placeholder="Select one or more team leaders"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
disabled={!isValid}
|
||||
dataCy="team-createTeamButton"
|
||||
isLoading={isSubmitting}
|
||||
loadingText="Creating team..."
|
||||
>
|
||||
<i
|
||||
className="fa fa-plus space-right"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Create team
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { object, string, array, number } from 'yup';
|
||||
|
||||
import { TeamViewModel } from '@/portainer/models/team';
|
||||
|
||||
export function validationSchema(teams: TeamViewModel[]) {
|
||||
return object().shape({
|
||||
name: string()
|
||||
.required('This field is required.')
|
||||
.test('is-unique', 'This team already exists.', (name) => !!name && teams.every((team) => team.Name !== name)),
|
||||
leaders: array().of(number()),
|
||||
});
|
||||
}
|
7
app/portainer/teams/CreateTeamForm/index.ts
Normal file
7
app/portainer/teams/CreateTeamForm/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { r2a } from '@/react-tools/react2angular';
|
||||
|
||||
import { CreateTeamForm } from './CreateTeamForm';
|
||||
|
||||
export { CreateTeamForm };
|
||||
|
||||
export const CreateTeamFormAngular = r2a(CreateTeamForm, ['users', 'actionInProgress', 'onSubmit', 'teams']);
|
8
app/portainer/teams/index.ts
Normal file
8
app/portainer/teams/index.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { CreateTeamFormAngular } from './CreateTeamForm';
|
||||
|
||||
export default angular
|
||||
.module('portainer.app.teams', [])
|
||||
|
||||
.component('createTeamForm', CreateTeamFormAngular).name;
|
|
@ -3,12 +3,12 @@ import { useRouter } from '@uirouter/react';
|
|||
|
||||
import { Widget, WidgetBody } from '@/portainer/components/widget';
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { TextInput } from '@/portainer/components/form-components/Input';
|
||||
import { Button } from '@/portainer/components/Button';
|
||||
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||
import { TextTip } from '@/portainer/components/Tip/TextTip';
|
||||
import { Code } from '@/portainer/components/Code';
|
||||
import { CopyButton } from '@/portainer/components/Button/CopyButton';
|
||||
import { Input } from '@/portainer/components/form-components/Input';
|
||||
|
||||
import styles from './CreateAccessToken.module.css';
|
||||
|
||||
|
@ -64,16 +64,15 @@ export function CreateAccessToken({
|
|||
<WidgetBody>
|
||||
<div>
|
||||
<FormControl inputId="input" label="Description" errors={errorText}>
|
||||
<TextInput
|
||||
<Input
|
||||
id="input"
|
||||
onChange={(value) => setDescription(value)}
|
||||
type="text"
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
value={description}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
disabled={!!errorText || !!accessToken}
|
||||
onClick={generateAccessToken}
|
||||
onClick={() => generateAccessToken()}
|
||||
className={styles.addButton}
|
||||
>
|
||||
Add access token
|
||||
|
|
|
@ -7,87 +7,7 @@
|
|||
<rd-header-content>Teams management</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row" ng-if="isAdmin">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-plus" title-text="Add a new team"> </rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" name="teamCreationForm" ng-submit="addTeam()">
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="team_name" class="col-sm-2 control-label text-left">Name</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="team_name"
|
||||
name="team_name"
|
||||
ng-model="formValues.Name"
|
||||
ng-change="checkNameValidity(teamCreationForm)"
|
||||
placeholder="e.g. development"
|
||||
auto-focus
|
||||
required
|
||||
data-cy="team-teamNameInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="teamCreationForm.team_name.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="teamCreationForm.team_name.$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||
<p ng-message="validName"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This team already exists.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<!-- team-leaders -->
|
||||
<div class="form-group" ng-if="users.length > 0">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Select team leader(s)
|
||||
<portainer-tooltip
|
||||
position="bottom"
|
||||
message="You can assign one or more leaders to this team. Team leaders can manage their teams users and resources."
|
||||
></portainer-tooltip>
|
||||
</label>
|
||||
<span
|
||||
isteven-multi-select
|
||||
ng-if="users.length > 0"
|
||||
input-model="users"
|
||||
output-model="formValues.Leaders"
|
||||
button-label="Username"
|
||||
item-label="Username"
|
||||
tick-property="ticked"
|
||||
helper-elements="filter"
|
||||
search-property="Username"
|
||||
translation="{nothingSelected: 'Select one or more team leaders', search: 'Search...'}"
|
||||
style="margin-left: 20px;"
|
||||
data-cy="team-teamLeaderSelect"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !team-leaders -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="state.actionInProgress || !teamCreationForm.$valid"
|
||||
ng-click="addTeam()"
|
||||
button-spinner="state.actionInProgress"
|
||||
data-cy="team-createTeamButton"
|
||||
>
|
||||
<span ng-hide="state.actionInProgress"><i class="fa fa-plus" aria-hidden="true"></i> Create team</span>
|
||||
<span ng-show="state.actionInProgress">Creating team...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
<create-team-form ng-if="isAdmin && users" users="users" action-in-progress="state.actionInProgress" teams="teams" on-submit="(addTeam)"></create-team-form>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
|
|
|
@ -30,15 +30,11 @@ angular.module('portainer.app').controller('TeamsController', [
|
|||
form.team_name.$setValidity('validName', valid);
|
||||
};
|
||||
|
||||
$scope.addTeam = function () {
|
||||
var teamName = $scope.formValues.Name;
|
||||
var leaderIds = [];
|
||||
angular.forEach($scope.formValues.Leaders, function (user) {
|
||||
leaderIds.push(user.Id);
|
||||
});
|
||||
$scope.addTeam = function (formValues) {
|
||||
const teamName = formValues.name;
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
TeamService.createTeam(teamName, leaderIds)
|
||||
TeamService.createTeam(teamName, formValues.leaders)
|
||||
.then(function success() {
|
||||
Notifications.success('Team successfully created', teamName);
|
||||
$state.reload();
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue