1
0
Fork 0
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:
Chaim Lev-Ari 2021-12-20 19:21:19 +02:00 committed by GitHub
parent c5fe994cd2
commit 4f7b432f44
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 815 additions and 339 deletions

View file

@ -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',

View file

@ -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}

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

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

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

View file

@ -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: '',
};
}

View file

@ -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"
/>
);
}

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

View file

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

View file

@ -1,8 +1,5 @@
.container {
display: flex;
align-items: center;
}
.space-right {
margin-right: 1rem;
width: 100%;
}

View file

@ -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>

View file

@ -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}
/>
);
}

View file

@ -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({});

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

View file

@ -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,
};

View file

@ -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))}
/>
);
}

View file

@ -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}
/>

View file

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

View file

@ -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}
/>
);
}

View file

@ -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)}
/>
);
}

View file

@ -1,3 +1,2 @@
export { NumberInput } from './NumberInput';
export { TextInput } from './TextInput';
export { Input } from './Input';
export { Select } from './Select';

View file

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

View file

@ -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>
);

View file

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

View file

@ -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' },

View file

@ -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}
/>
);

View file

@ -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) {

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

View file

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

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

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

View file

@ -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()),
});
}

View 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']);

View file

@ -0,0 +1,8 @@
import angular from 'angular';
import { CreateTeamFormAngular } from './CreateTeamForm';
export default angular
.module('portainer.app.teams', [])
.component('createTeamForm', CreateTeamFormAngular).name;

View file

@ -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

View file

@ -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">

View file

@ -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();