mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 20:35:25 +02:00
refactor(teams): migrate teams to react [EE-2273] (#6691)
closes [EE-2273]
This commit is contained in:
parent
9b02f575ef
commit
f9427c8fb2
97 changed files with 1929 additions and 938 deletions
|
@ -6,7 +6,7 @@ import {
|
|||
Subscription,
|
||||
} from '@/react/azure/types';
|
||||
import { parseAccessControlFormData } from '@/portainer/access-control/utils';
|
||||
import { useIsAdmin } from '@/portainer/hooks/useUser';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { useProvider } from '@/react/azure/queries/useProvider';
|
||||
import { useResourceGroups } from '@/react/azure/queries/useResourceGroups';
|
||||
import { useSubscriptions } from '@/react/azure/queries/useSubscriptions';
|
||||
|
@ -37,7 +37,7 @@ export function useFormState(
|
|||
resourceGroups: Record<string, ResourceGroup[]> = {},
|
||||
providers: Record<string, ProviderViewModel> = {}
|
||||
) {
|
||||
const isAdmin = useIsAdmin();
|
||||
const { isAdmin } = useUser();
|
||||
|
||||
const subscriptionOptions = subscriptions.map((s) => ({
|
||||
value: s.subscriptionId,
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import './pagination-controls.css';
|
||||
|
||||
import { generatePagesArray } from './generatePagesArray';
|
||||
import { PageButton } from './PageButton';
|
||||
import { PageInput } from './PageInput';
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Team, TeamId } from '@/portainer/teams/types';
|
||||
import { Team, TeamId } from '@/react/portainer/users/teams/types';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ export function WidgetTitle({
|
|||
return (
|
||||
<div className="widget-header">
|
||||
<div className="row">
|
||||
<span className={clsx('pull-left', className)}>
|
||||
<span className={clsx('pull-left vertical-center', className)}>
|
||||
<div className="widget-icon">
|
||||
<Icon
|
||||
icon={icon}
|
||||
|
|
|
@ -5,8 +5,6 @@ import { Columns } from 'react-feather';
|
|||
|
||||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
|
||||
import { useTableContext } from './TableContainer';
|
||||
|
||||
interface Props<D extends object> {
|
||||
columns: ColumnInstance<D>[];
|
||||
onChange: (value: string[]) => void;
|
||||
|
@ -18,8 +16,6 @@ export function ColumnVisibilityMenu<D extends object>({
|
|||
onChange,
|
||||
value,
|
||||
}: Props<D>) {
|
||||
useTableContext();
|
||||
|
||||
return (
|
||||
<Menu className="setting">
|
||||
{({ isExpanded }) => (
|
||||
|
|
30
app/react/components/datatables/NameCell.tsx
Normal file
30
app/react/components/datatables/NameCell.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
export function buildNameColumn<T extends Record<string, unknown>>(
|
||||
nameKey: string,
|
||||
idKey: string,
|
||||
path: string
|
||||
) {
|
||||
const name: Column<T> = {
|
||||
Header: 'Name',
|
||||
accessor: (row) => row[nameKey],
|
||||
id: 'name',
|
||||
Cell: NameCell,
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
canHide: false,
|
||||
sortType: 'string',
|
||||
};
|
||||
|
||||
return name;
|
||||
|
||||
function NameCell({ value: name, row }: CellProps<T, string>) {
|
||||
return (
|
||||
<Link to={path} params={{ id: row.original[idKey] }} title={name}>
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -2,15 +2,15 @@ import clsx from 'clsx';
|
|||
import { PropsWithChildren } from 'react';
|
||||
import { TableProps } from 'react-table';
|
||||
|
||||
import { useTableContext, TableContainer } from './TableContainer';
|
||||
import { TableContainer } from './TableContainer';
|
||||
import { TableActions } from './TableActions';
|
||||
import { TableTitleActions } from './TableTitleActions';
|
||||
import { TableContent } from './TableContent';
|
||||
import { TableHeaderCell } from './TableHeaderCell';
|
||||
import { TableSettingsMenu } from './TableSettingsMenu';
|
||||
import { TableTitle } from './TableTitle';
|
||||
import { TableHeaderRow } from './TableHeaderRow';
|
||||
import { TableRow } from './TableRow';
|
||||
import { TableContent } from './TableContent';
|
||||
import { TableFooter } from './TableFooter';
|
||||
|
||||
function MainComponent({
|
||||
|
@ -19,8 +19,6 @@ function MainComponent({
|
|||
role,
|
||||
style,
|
||||
}: PropsWithChildren<TableProps>) {
|
||||
useTableContext();
|
||||
|
||||
return (
|
||||
<div className="table-responsive">
|
||||
<table
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import clsx from 'clsx';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { useTableContext } from './TableContainer';
|
||||
import { Children, PropsWithChildren } from 'react';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
|
@ -11,7 +9,9 @@ export function TableActions({
|
|||
children,
|
||||
className,
|
||||
}: PropsWithChildren<Props>) {
|
||||
useTableContext();
|
||||
if (Children.count(children) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={clsx('actionBar', className)}>{children}</div>;
|
||||
}
|
||||
|
|
|
@ -1,25 +1,13 @@
|
|||
import { createContext, PropsWithChildren, useContext } from 'react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
|
||||
const Context = createContext<null | boolean>(null);
|
||||
|
||||
export function useTableContext() {
|
||||
const context = useContext(Context);
|
||||
|
||||
if (context == null) {
|
||||
throw new Error('Should be nested inside a TableContainer component');
|
||||
}
|
||||
}
|
||||
|
||||
export function TableContainer({ children }: PropsWithChildren<unknown>) {
|
||||
return (
|
||||
<Context.Provider value>
|
||||
<div className="datatable">
|
||||
<Widget>
|
||||
<WidgetBody className="no-padding">{children}</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</Context.Provider>
|
||||
<div className="datatable">
|
||||
<Widget>
|
||||
<WidgetBody className="no-padding">{children}</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { useTableContext } from './TableContainer';
|
||||
|
||||
export function TableFooter({ children }: PropsWithChildren<unknown>) {
|
||||
useTableContext();
|
||||
|
||||
return <footer className="footer">{children}</footer>;
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ import clsx from 'clsx';
|
|||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
import { TableHeaderProps } from 'react-table';
|
||||
|
||||
import { useTableContext } from './TableContainer';
|
||||
import { TableHeaderSortIcons } from './TableHeaderSortIcons';
|
||||
import styles from './TableHeaderCell.module.css';
|
||||
|
||||
|
@ -27,8 +26,6 @@ export function TableHeaderCell({
|
|||
canFilter,
|
||||
renderFilter,
|
||||
}: Props) {
|
||||
useTableContext();
|
||||
|
||||
return (
|
||||
<th role={role} style={style} className={className}>
|
||||
<div className="flex flex-row flex-nowrap h-full items-center gap-1">
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { HeaderGroup, TableHeaderProps } from 'react-table';
|
||||
|
||||
import { useTableContext } from './TableContainer';
|
||||
import { TableHeaderCell } from './TableHeaderCell';
|
||||
|
||||
interface Props<D extends Record<string, unknown> = Record<string, unknown>> {
|
||||
|
@ -17,8 +16,6 @@ export function TableHeaderRow<
|
|||
role,
|
||||
style,
|
||||
}: Props<D> & TableHeaderProps) {
|
||||
useTableContext();
|
||||
|
||||
return (
|
||||
<tr className={className} role={role} style={style}>
|
||||
{headers.map((column) => (
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import { Cell, TableRowProps } from 'react-table';
|
||||
|
||||
import { useTableContext } from './TableContainer';
|
||||
|
||||
interface Props<D extends Record<string, unknown> = Record<string, unknown>>
|
||||
extends Omit<TableRowProps, 'key'> {
|
||||
cells: Cell<D>[];
|
||||
|
@ -10,8 +8,6 @@ interface Props<D extends Record<string, unknown> = Record<string, unknown>>
|
|||
export function TableRow<
|
||||
D extends Record<string, unknown> = Record<string, unknown>
|
||||
>({ cells, className, role, style }: Props<D>) {
|
||||
useTableContext();
|
||||
|
||||
return (
|
||||
<tr className={className} role={role} style={style}>
|
||||
{cells.map((cell) => {
|
||||
|
|
|
@ -3,8 +3,6 @@ import { Menu, MenuButton, MenuList } from '@reach/menu-button';
|
|||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
import { MoreVertical } from 'react-feather';
|
||||
|
||||
import { useTableContext } from './TableContainer';
|
||||
|
||||
interface Props {
|
||||
quickActions?: ReactNode;
|
||||
}
|
||||
|
@ -13,8 +11,6 @@ export function TableSettingsMenu({
|
|||
quickActions,
|
||||
children,
|
||||
}: PropsWithChildren<Props>) {
|
||||
useTableContext();
|
||||
|
||||
return (
|
||||
<Menu className="setting">
|
||||
{({ isExpanded }) => (
|
||||
|
|
|
@ -2,8 +2,6 @@ import { ComponentType, PropsWithChildren, ReactNode } from 'react';
|
|||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { useTableContext } from './TableContainer';
|
||||
|
||||
interface Props {
|
||||
icon?: ReactNode | ComponentType<unknown>;
|
||||
featherIcon?: boolean;
|
||||
|
@ -16,8 +14,6 @@ export function TableTitle({
|
|||
label,
|
||||
children,
|
||||
}: PropsWithChildren<Props>) {
|
||||
useTableContext();
|
||||
|
||||
return (
|
||||
<div className="toolBar">
|
||||
<div className="toolBarTitle">
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { useTableContext } from './TableContainer';
|
||||
import { Children, PropsWithChildren } from 'react';
|
||||
|
||||
export function TableTitleActions({ children }: PropsWithChildren<unknown>) {
|
||||
useTableContext();
|
||||
if (Children.count(children) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className="settings">{children}</div>;
|
||||
}
|
||||
|
|
103
app/react/portainer/users/teams/ItemView/Details.tsx
Normal file
103
app/react/portainer/users/teams/ItemView/Details.tsx
Normal file
|
@ -0,0 +1,103 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { Trash2, Users } from 'react-feather';
|
||||
|
||||
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
|
||||
import { usePublicSettings } from '@/portainer/settings/queries';
|
||||
import {
|
||||
mutationOptions,
|
||||
withError,
|
||||
withInvalidate,
|
||||
} from '@/react-tools/react-query';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Widget } from '@@/Widget';
|
||||
|
||||
import { Team, TeamId, TeamMembership, TeamRole } from '../types';
|
||||
import { deleteTeam } from '../teams.service';
|
||||
|
||||
interface Props {
|
||||
team: Team;
|
||||
memberships: TeamMembership[];
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export function Details({ team, memberships, isAdmin }: Props) {
|
||||
const deleteMutation = useDeleteTeam();
|
||||
const router = useRouter();
|
||||
const teamSyncQuery = usePublicSettings<boolean>({
|
||||
select: (settings) => settings.TeamSync,
|
||||
});
|
||||
|
||||
const leaderCount = memberships.filter(
|
||||
(m) => m.Role === TeamRole.Leader
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-lg-12 col-md-12 col-xs-12">
|
||||
<Widget>
|
||||
<Widget.Title title="Team details" icon={Users} />
|
||||
|
||||
<Widget.Body className="no-padding">
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>
|
||||
{!teamSyncQuery.data && team.Name}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
color="danger"
|
||||
size="xsmall"
|
||||
onClick={handleDeleteClick}
|
||||
icon={Trash2}
|
||||
>
|
||||
Delete this team
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Leaders</td>
|
||||
<td>{!teamSyncQuery.data && leaderCount}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Total users in team</td>
|
||||
<td>{memberships.length}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
async function handleDeleteClick() {
|
||||
const confirmed = await confirmDeletionAsync(
|
||||
`Do you want to delete this team? Users in this team will not be deleted.`
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteMutation.mutate(team.Id, {
|
||||
onSuccess() {
|
||||
router.stateService.go('portainer.teams');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function useDeleteTeam() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(id: TeamId) => deleteTeam(id),
|
||||
|
||||
mutationOptions(
|
||||
withError('Unable to delete team'),
|
||||
withInvalidate(queryClient, [['teams']])
|
||||
)
|
||||
);
|
||||
}
|
72
app/react/portainer/users/teams/ItemView/ItemView.tsx
Normal file
72
app/react/portainer/users/teams/ItemView/ItemView.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { useUsers } from '@/portainer/users/queries';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { usePublicSettings } from '@/portainer/settings/queries';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { useTeam, useTeamMemberships } from '../queries';
|
||||
|
||||
import { Details } from './Details';
|
||||
import { TeamAssociationSelector } from './TeamAssociationSelector';
|
||||
import { useTeamIdParam } from './useTeamIdParam';
|
||||
|
||||
export function ItemView() {
|
||||
const teamId = useTeamIdParam();
|
||||
|
||||
const { isAdmin } = useUser();
|
||||
const router = useRouter();
|
||||
const teamQuery = useTeam(teamId, () =>
|
||||
router.stateService.go('portainer.teams')
|
||||
);
|
||||
const usersQuery = useUsers();
|
||||
const membershipsQuery = useTeamMemberships(teamId);
|
||||
const teamSyncQuery = usePublicSettings<boolean>({
|
||||
select: (settings) => settings.TeamSync,
|
||||
});
|
||||
|
||||
if (!teamQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const team = teamQuery.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Team details"
|
||||
breadcrumbs={[{ label: 'Teams' }, { label: team.Name }]}
|
||||
/>
|
||||
|
||||
{membershipsQuery.data && (
|
||||
<Details
|
||||
team={team}
|
||||
memberships={membershipsQuery.data}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{teamSyncQuery.data && (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<TextTip color="orange">
|
||||
The team leader feature is disabled as external authentication is
|
||||
currently enabled with team sync.
|
||||
</TextTip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usersQuery.data && membershipsQuery.data && (
|
||||
<TeamAssociationSelector
|
||||
teamId={teamId}
|
||||
memberships={membershipsQuery.data}
|
||||
users={usersQuery.data}
|
||||
disabled={teamSyncQuery.data}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
.root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
justify-content: space-around;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.root > * {
|
||||
flex: 1;
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { createMockUsers } from '@/react-tools/test-mocks';
|
||||
import { Role, User } from '@/portainer/users/types';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
|
||||
import { TeamMembership, TeamRole } from '../../types';
|
||||
|
||||
import { TeamAssociationSelector } from './TeamAssociationSelector';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'teams/TeamAssociationSelector',
|
||||
component: TeamAssociationSelector,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export { Example };
|
||||
|
||||
interface Args {
|
||||
userRole: Role;
|
||||
}
|
||||
|
||||
function Example({ userRole }: Args) {
|
||||
const userProviderState = useMemo(
|
||||
() => ({ user: new UserViewModel({ Role: userRole }) }),
|
||||
[userRole]
|
||||
);
|
||||
const [users] = useState(createMockUsers(20) as User[]);
|
||||
|
||||
const [memberships] = useState<Omit<TeamMembership, 'Id' | 'TeamID'>[]>(
|
||||
users
|
||||
.filter(() => Math.random() > 0.5)
|
||||
.map((u) => ({
|
||||
UserID: u.Id,
|
||||
Role: Math.random() > 0.5 ? TeamRole.Leader : TeamRole.Member,
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={userProviderState}>
|
||||
<TeamAssociationSelector
|
||||
teamId={3}
|
||||
users={users}
|
||||
memberships={memberships as TeamMembership[]}
|
||||
/>
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
Example.args = {
|
||||
userRole: TeamRole.Leader,
|
||||
};
|
|
@ -0,0 +1,21 @@
|
|||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
|
||||
import { TeamAssociationSelector } from './TeamAssociationSelector';
|
||||
|
||||
test('renders correctly', () => {
|
||||
const queries = renderComponent();
|
||||
|
||||
expect(queries).toBeTruthy();
|
||||
});
|
||||
|
||||
function renderComponent() {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
|
||||
return renderWithQueryClient(
|
||||
<UserContext.Provider value={{ user }}>
|
||||
<TeamAssociationSelector users={[]} memberships={[]} teamId={3} />
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { User } from '@/portainer/users/types';
|
||||
|
||||
import { TeamId, TeamMembership } from '../../types';
|
||||
|
||||
import { UsersList } from './UsersList';
|
||||
import { TeamMembersList } from './TeamMembersList';
|
||||
|
||||
interface Props {
|
||||
users: User[];
|
||||
memberships: TeamMembership[];
|
||||
disabled?: boolean;
|
||||
teamId: TeamId;
|
||||
}
|
||||
|
||||
export function TeamAssociationSelector({
|
||||
users,
|
||||
memberships,
|
||||
disabled,
|
||||
teamId,
|
||||
}: Props) {
|
||||
const teamUsers = _.compact(
|
||||
memberships.map((m) => users.find((user) => user.Id === m.UserID))
|
||||
);
|
||||
const usersNotInTeam = users.filter(
|
||||
(user) => !memberships.some((m) => m.UserID === user.Id)
|
||||
);
|
||||
const userRoles = Object.fromEntries(
|
||||
memberships.map((m) => [m.UserID, m.Role])
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-6">
|
||||
<UsersList users={usersNotInTeam} disabled={disabled} teamId={teamId} />
|
||||
</div>
|
||||
<div className="col-sm-6">
|
||||
<TeamMembersList
|
||||
teamId={teamId}
|
||||
disabled={disabled}
|
||||
users={teamUsers}
|
||||
roles={userRoles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { TeamRole, TeamId } from '@/react/portainer/users/teams/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
import { createRowContext } from '@@/datatables/RowContext';
|
||||
|
||||
export interface RowContext {
|
||||
getRole(userId: UserId): TeamRole;
|
||||
disabled?: boolean;
|
||||
teamId: TeamId;
|
||||
}
|
||||
|
||||
const { RowProvider, useRowContext } = createRowContext<RowContext>();
|
||||
|
||||
export { RowProvider, useRowContext };
|
|
@ -0,0 +1,2 @@
|
|||
.root {
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { createMockUsers } from '@/react-tools/test-mocks';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { Role } from '@/portainer/users/types';
|
||||
import { TeamRole } from '@/react/portainer/users/teams/types';
|
||||
|
||||
import { TeamMembersList } from './TeamMembersList';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Teams/TeamAssociationSelector/TeamMembersList',
|
||||
component: TeamMembersList,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export { Example };
|
||||
|
||||
interface Args {
|
||||
userRole: Role;
|
||||
}
|
||||
|
||||
function Example({ userRole }: Args) {
|
||||
const userProviderState = useMemo(
|
||||
() => ({ user: new UserViewModel({ Role: userRole }) }),
|
||||
[userRole]
|
||||
);
|
||||
|
||||
const [users] = useState(createMockUsers(20));
|
||||
const [roles] = useState(
|
||||
Object.fromEntries(
|
||||
users.map((user) => [
|
||||
user.Id,
|
||||
Math.random() > 0.5 ? TeamRole.Leader : TeamRole.Member,
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={userProviderState}>
|
||||
<TeamMembersList users={users} roles={roles} teamId={3} />
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
Example.args = {
|
||||
userRole: Role.Admin,
|
||||
};
|
|
@ -0,0 +1,24 @@
|
|||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
|
||||
import { TeamMembersList } from './TeamMembersList';
|
||||
|
||||
test('renders correctly', () => {
|
||||
const queries = renderComponent();
|
||||
|
||||
expect(queries).toBeTruthy();
|
||||
});
|
||||
|
||||
function renderComponent() {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
|
||||
return renderWithQueryClient(
|
||||
<UserContext.Provider value={{ user }}>
|
||||
<TeamMembersList users={[]} roles={{}} teamId={3} />
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
test.todo('when users list is empty, add all users button is disabled');
|
||||
test.todo('filter displays expected users');
|
|
@ -0,0 +1,218 @@
|
|||
import {
|
||||
useGlobalFilter,
|
||||
usePagination,
|
||||
useSortBy,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Users, UserX } from 'react-feather';
|
||||
|
||||
import { User, UserId } from '@/portainer/users/types';
|
||||
import { TeamId, TeamRole } from '@/react/portainer/users/teams/types';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import {
|
||||
useRemoveMemberMutation,
|
||||
useTeamMemberships,
|
||||
} from '@/react/portainer/users/teams/queries';
|
||||
|
||||
import { Widget } from '@@/Widget';
|
||||
import { PageSelector } from '@@/PaginationControls/PageSelector';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Table } from '@@/datatables';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
|
||||
import { name } from './name-column';
|
||||
import { RowContext, RowProvider } from './RowContext';
|
||||
import { teamRole } from './team-role-column';
|
||||
|
||||
const columns = [name, teamRole];
|
||||
|
||||
interface Props {
|
||||
users: User[];
|
||||
roles: Record<UserId, TeamRole>;
|
||||
disabled?: boolean;
|
||||
teamId: TeamId;
|
||||
}
|
||||
|
||||
export function TeamMembersList({ users, roles, disabled, teamId }: Props) {
|
||||
const membershipsQuery = useTeamMemberships(teamId);
|
||||
|
||||
const removeMemberMutation = useRemoveMemberMutation(
|
||||
teamId,
|
||||
membershipsQuery.data
|
||||
);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const { isAdmin } = useUser();
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
page,
|
||||
prepareRow,
|
||||
gotoPage,
|
||||
setPageSize: setPageSizeInternal,
|
||||
setGlobalFilter: setGlobalFilterInternal,
|
||||
state: { pageIndex },
|
||||
setSortBy,
|
||||
rows,
|
||||
} = useTable<User>(
|
||||
{
|
||||
defaultCanFilter: false,
|
||||
columns,
|
||||
data: users,
|
||||
initialState: {
|
||||
pageSize,
|
||||
|
||||
sortBy: [{ id: 'name', desc: false }],
|
||||
globalFilter: search,
|
||||
},
|
||||
},
|
||||
useGlobalFilter,
|
||||
useSortBy,
|
||||
usePagination
|
||||
);
|
||||
|
||||
const tableProps = getTableProps();
|
||||
const tbodyProps = getTableBodyProps();
|
||||
const rowContext = useMemo<RowContext>(
|
||||
() => ({
|
||||
getRole(userId: UserId) {
|
||||
return roles[userId];
|
||||
},
|
||||
disabled,
|
||||
teamId,
|
||||
}),
|
||||
[roles, disabled, teamId]
|
||||
);
|
||||
return (
|
||||
<Widget>
|
||||
<Widget.Title icon={Users} title="Team members">
|
||||
Items per page:
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={handlePageSizeChange}
|
||||
className="space-left"
|
||||
>
|
||||
<option value={Number.MAX_SAFE_INTEGER}>All</option>
|
||||
<option value={10}>10</option>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</Widget.Title>
|
||||
<Widget.Taskbar className="col-sm-12 nopadding">
|
||||
<div className="col-sm-12 col-md-6 nopadding">
|
||||
{isAdmin && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleRemoveMembers(rows.map(({ original }) => original.Id))
|
||||
}
|
||||
disabled={disabled || users.length === 0}
|
||||
icon={UserX}
|
||||
>
|
||||
Remove all users
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-sm-12 col-md-6 nopadding">
|
||||
<Input
|
||||
type="text"
|
||||
id="filter-users"
|
||||
value={search}
|
||||
onChange={handleSearchBarChange}
|
||||
placeholder="Filter..."
|
||||
className="input-sm"
|
||||
/>
|
||||
</div>
|
||||
</Widget.Taskbar>
|
||||
<Widget.Body className="nopadding">
|
||||
<Table
|
||||
className={tableProps.className}
|
||||
role={tableProps.role}
|
||||
style={tableProps.style}
|
||||
>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const { key, className, role, style } =
|
||||
headerGroup.getHeaderGroupProps();
|
||||
|
||||
return (
|
||||
<Table.HeaderRow<User>
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
headers={headerGroup.headers}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody
|
||||
className={tbodyProps.className}
|
||||
role={tbodyProps.role}
|
||||
style={tbodyProps.style}
|
||||
>
|
||||
<Table.Content
|
||||
emptyContent="No users."
|
||||
prepareRow={prepareRow}
|
||||
rows={page}
|
||||
renderRow={(row, { key, className, role, style }) => (
|
||||
<RowProvider context={rowContext} key={key}>
|
||||
<Table.Row<User>
|
||||
cells={row.cells}
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
/>
|
||||
</RowProvider>
|
||||
)}
|
||||
/>
|
||||
</tbody>
|
||||
</Table>
|
||||
<Table.Footer>
|
||||
{pageSize !== 0 && (
|
||||
<div className="pagination-controls">
|
||||
<PageSelector
|
||||
maxSize={5}
|
||||
onPageChange={(p) => gotoPage(p - 1)}
|
||||
currentPage={pageIndex + 1}
|
||||
itemsPerPage={pageSize}
|
||||
totalCount={rows.length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Table.Footer>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
);
|
||||
|
||||
function handlePageSizeChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const pageSize = parseInt(e.target.value, 10);
|
||||
setPageSize(pageSize);
|
||||
setPageSizeInternal(pageSize);
|
||||
}
|
||||
|
||||
function handleSearchBarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const { value } = e.target;
|
||||
setSearch(value);
|
||||
setGlobalFilterInternal(value);
|
||||
}
|
||||
|
||||
function handleSortChange(id: string, desc: boolean) {
|
||||
setSortBy([{ id, desc }]);
|
||||
}
|
||||
|
||||
function handleRemoveMembers(userIds: UserId[]) {
|
||||
removeMemberMutation.mutate(userIds, {
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'All users successfully removed');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { TeamMembersList } from './TeamMembersList';
|
|
@ -0,0 +1,62 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
import { MinusCircle } from 'react-feather';
|
||||
|
||||
import { User, UserId } from '@/portainer/users/types';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import {
|
||||
useRemoveMemberMutation,
|
||||
useTeamMemberships,
|
||||
} from '@/react/portainer/users/teams/queries';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { useRowContext } from './RowContext';
|
||||
|
||||
export const name: Column<User> = {
|
||||
Header: 'Name',
|
||||
accessor: (row) => row.Username,
|
||||
id: 'name',
|
||||
Cell: NameCell,
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
canHide: false,
|
||||
sortType: 'string',
|
||||
};
|
||||
|
||||
export function NameCell({
|
||||
value: name,
|
||||
row: { original: user },
|
||||
}: CellProps<User, string>) {
|
||||
const { disabled, teamId } = useRowContext();
|
||||
|
||||
const membershipsQuery = useTeamMemberships(teamId);
|
||||
|
||||
const removeMemberMutation = useRemoveMemberMutation(
|
||||
teamId,
|
||||
membershipsQuery.data
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{name}
|
||||
|
||||
<Button
|
||||
color="link"
|
||||
className="space-left nopadding"
|
||||
onClick={() => handleRemoveMember(user.Id)}
|
||||
disabled={disabled}
|
||||
icon={MinusCircle}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
function handleRemoveMember(userId: UserId) {
|
||||
removeMemberMutation.mutate([userId], {
|
||||
onSuccess() {
|
||||
notifySuccess('User removed from team', name);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
import { User as UserIcon, UserPlus, UserX } from 'react-feather';
|
||||
|
||||
import { User } from '@/portainer/users/types';
|
||||
import { useUser as useCurrentUser } from '@/portainer/hooks/useUser';
|
||||
import { TeamRole } from '@/react/portainer/users/teams/types';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import {
|
||||
useTeamMemberships,
|
||||
useUpdateRoleMutation,
|
||||
} from '@/react/portainer/users/teams/queries';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { useRowContext } from './RowContext';
|
||||
|
||||
export const teamRole: Column<User> = {
|
||||
Header: 'Team Role',
|
||||
accessor: 'Id',
|
||||
id: 'role',
|
||||
Cell: RoleCell,
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
canHide: false,
|
||||
sortType: 'string',
|
||||
};
|
||||
|
||||
export function RoleCell({ row: { original: user } }: CellProps<User>) {
|
||||
const { getRole, disabled, teamId } = useRowContext();
|
||||
const membershipsQuery = useTeamMemberships(teamId);
|
||||
const updateRoleMutation = useUpdateRoleMutation(
|
||||
teamId,
|
||||
membershipsQuery.data
|
||||
);
|
||||
|
||||
const role = getRole(user.Id);
|
||||
|
||||
const { isAdmin } = useCurrentUser();
|
||||
|
||||
const Cell = role === TeamRole.Leader ? LeaderCell : MemberCell;
|
||||
|
||||
return (
|
||||
<Cell isAdmin={isAdmin} onClick={handleUpdateRole} disabled={disabled} />
|
||||
);
|
||||
|
||||
function handleUpdateRole(role: TeamRole, onSuccessMessage: string) {
|
||||
updateRoleMutation.mutate(
|
||||
{ userId: user.Id, role },
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess(onSuccessMessage, user.Username);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface LeaderCellProps {
|
||||
isAdmin: boolean;
|
||||
onClick: (role: TeamRole, onSuccessMessage: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function LeaderCell({ isAdmin, onClick, disabled }: LeaderCellProps) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Icon
|
||||
className="space-right feather"
|
||||
icon={UserPlus}
|
||||
mode="secondary-alt"
|
||||
/>
|
||||
|
||||
{isAdmin && (
|
||||
<Button
|
||||
color="link"
|
||||
className="nopadding"
|
||||
onClick={() => onClick(TeamRole.Member, 'User is now team member')}
|
||||
disabled={disabled}
|
||||
icon={UserX}
|
||||
>
|
||||
Member
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MemberCellProps {
|
||||
onClick: (role: TeamRole, onSuccessMessage: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function MemberCell({ onClick, disabled }: MemberCellProps) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Icon
|
||||
className="space-right feather"
|
||||
icon={UserIcon}
|
||||
mode="secondary-alt"
|
||||
/>
|
||||
<Button
|
||||
color="link"
|
||||
className="nopadding"
|
||||
onClick={() => onClick(TeamRole.Leader, 'User is now team leader')}
|
||||
disabled={disabled}
|
||||
icon={UserPlus}
|
||||
>
|
||||
Leader
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||
|
||||
import { createRowContext } from '@@/datatables/RowContext';
|
||||
|
||||
interface RowContext {
|
||||
disabled?: boolean;
|
||||
teamId: TeamId;
|
||||
}
|
||||
|
||||
const { RowProvider, useRowContext } = createRowContext<RowContext>();
|
||||
|
||||
export { RowProvider, useRowContext };
|
|
@ -0,0 +1,2 @@
|
|||
.root {
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
|
||||
import { createMockUsers } from '@/react-tools/test-mocks';
|
||||
|
||||
import { UsersList } from './UsersList';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Teams/TeamAssociationSelector/UsersList',
|
||||
component: UsersList,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export { Example };
|
||||
|
||||
function Example() {
|
||||
const users = createMockUsers(20);
|
||||
|
||||
return <UsersList users={users} teamId={3} />;
|
||||
}
|
||||
|
||||
Example.args = {};
|
|
@ -0,0 +1,23 @@
|
|||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
|
||||
import { UsersList } from './UsersList';
|
||||
|
||||
test('renders correctly', () => {
|
||||
const queries = renderComponent();
|
||||
|
||||
expect(queries).toBeTruthy();
|
||||
});
|
||||
|
||||
function renderComponent() {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
return renderWithQueryClient(
|
||||
<UserContext.Provider value={{ user }}>
|
||||
<UsersList users={[]} teamId={3} />
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
test.todo('when users list is empty, add all users button is disabled');
|
||||
test.todo('filter displays expected users');
|
|
@ -0,0 +1,199 @@
|
|||
import {
|
||||
useGlobalFilter,
|
||||
usePagination,
|
||||
useSortBy,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { UserPlus, Users } from 'react-feather';
|
||||
|
||||
import { User, UserId } from '@/portainer/users/types';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useAddMemberMutation } from '@/react/portainer/users/teams/queries';
|
||||
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||
|
||||
import { Widget } from '@@/Widget';
|
||||
import { PageSelector } from '@@/PaginationControls/PageSelector';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Table } from '@@/datatables';
|
||||
import { TableFooter } from '@@/datatables/TableFooter';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
|
||||
import { name } from './name-column';
|
||||
import { RowProvider } from './RowContext';
|
||||
|
||||
const columns = [name];
|
||||
|
||||
interface Props {
|
||||
users: User[];
|
||||
disabled?: boolean;
|
||||
teamId: TeamId;
|
||||
}
|
||||
|
||||
export function UsersList({ users, disabled, teamId }: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const addMemberMutation = useAddMemberMutation(teamId);
|
||||
|
||||
const { isAdmin } = useUser();
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
page,
|
||||
prepareRow,
|
||||
gotoPage,
|
||||
setPageSize: setPageSizeInternal,
|
||||
setGlobalFilter: setGlobalFilterInternal,
|
||||
state: { pageIndex },
|
||||
setSortBy,
|
||||
rows,
|
||||
} = useTable(
|
||||
{
|
||||
defaultCanFilter: false,
|
||||
columns,
|
||||
data: users,
|
||||
initialState: {
|
||||
pageSize,
|
||||
|
||||
sortBy: [{ id: 'name', desc: false }],
|
||||
globalFilter: search,
|
||||
},
|
||||
},
|
||||
useGlobalFilter,
|
||||
useSortBy,
|
||||
usePagination
|
||||
);
|
||||
|
||||
const tableProps = getTableProps();
|
||||
const tbodyProps = getTableBodyProps();
|
||||
const rowContext = useMemo(() => ({ disabled, teamId }), [disabled, teamId]);
|
||||
return (
|
||||
<Widget>
|
||||
<Widget.Title icon={Users} title="Users">
|
||||
Items per page:
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={handlePageSizeChange}
|
||||
className="space-left"
|
||||
>
|
||||
<option value={Number.MAX_SAFE_INTEGER}>All</option>
|
||||
<option value={10}>10</option>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</Widget.Title>
|
||||
<Widget.Taskbar className="col-sm-12 nopadding">
|
||||
<div className="col-sm-12 col-md-6 nopadding">
|
||||
{isAdmin && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleAddAllMembers(rows.map((row) => row.original.Id))
|
||||
}
|
||||
disabled={disabled || rows.length === 0}
|
||||
icon={UserPlus}
|
||||
>
|
||||
Add all users
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-sm-12 col-md-6 nopadding">
|
||||
<Input
|
||||
type="text"
|
||||
id="filter-users"
|
||||
value={search}
|
||||
onChange={handleSearchBarChange}
|
||||
placeholder="Filter..."
|
||||
className="input-sm"
|
||||
/>
|
||||
</div>
|
||||
</Widget.Taskbar>
|
||||
<Widget.Body className="nopadding">
|
||||
<Table
|
||||
className={tableProps.className}
|
||||
role={tableProps.role}
|
||||
style={tableProps.style}
|
||||
>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const { key, className, role, style } =
|
||||
headerGroup.getHeaderGroupProps();
|
||||
|
||||
return (
|
||||
<Table.HeaderRow<User>
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
headers={headerGroup.headers}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody
|
||||
className={tbodyProps.className}
|
||||
role={tbodyProps.role}
|
||||
style={tbodyProps.style}
|
||||
>
|
||||
<Table.Content
|
||||
emptyContent="No users."
|
||||
prepareRow={prepareRow}
|
||||
rows={page}
|
||||
renderRow={(row, { key, className, role, style }) => (
|
||||
<RowProvider context={rowContext} key={key}>
|
||||
<Table.Row<User>
|
||||
cells={row.cells}
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
/>
|
||||
</RowProvider>
|
||||
)}
|
||||
/>
|
||||
</tbody>
|
||||
</Table>
|
||||
<TableFooter>
|
||||
{pageSize !== 0 && (
|
||||
<div className="pagination-controls">
|
||||
<PageSelector
|
||||
maxSize={5}
|
||||
onPageChange={(p) => gotoPage(p - 1)}
|
||||
currentPage={pageIndex + 1}
|
||||
itemsPerPage={pageSize}
|
||||
totalCount={rows.length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TableFooter>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
);
|
||||
|
||||
function handlePageSizeChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const pageSize = parseInt(e.target.value, 10);
|
||||
setPageSize(pageSize);
|
||||
setPageSizeInternal(pageSize);
|
||||
}
|
||||
|
||||
function handleSearchBarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const { value } = e.target;
|
||||
setSearch(value);
|
||||
setGlobalFilterInternal(value);
|
||||
}
|
||||
|
||||
function handleSortChange(id: string, desc: boolean) {
|
||||
setSortBy([{ id, desc }]);
|
||||
}
|
||||
|
||||
function handleAddAllMembers(userIds: UserId[]) {
|
||||
addMemberMutation.mutate(userIds, {
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'All users successfully added');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { UsersList } from './UsersList';
|
|
@ -0,0 +1,54 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
import { PlusCircle } from 'react-feather';
|
||||
|
||||
import { User } from '@/portainer/users/types';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useAddMemberMutation } from '@/react/portainer/users/teams/queries';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { useRowContext } from './RowContext';
|
||||
|
||||
export const name: Column<User> = {
|
||||
Header: 'Name',
|
||||
accessor: (row) => row.Username,
|
||||
id: 'name',
|
||||
Cell: NameCell,
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
canHide: false,
|
||||
sortType: 'string',
|
||||
};
|
||||
|
||||
export function NameCell({
|
||||
value: name,
|
||||
row: { original: user },
|
||||
}: CellProps<User, string>) {
|
||||
const { disabled, teamId } = useRowContext();
|
||||
|
||||
const addMemberMutation = useAddMemberMutation(teamId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{name}
|
||||
|
||||
<Button
|
||||
color="link"
|
||||
className="space-left nopadding"
|
||||
disabled={disabled}
|
||||
icon={PlusCircle}
|
||||
onClick={() => handleAddMember()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
function handleAddMember() {
|
||||
addMemberMutation.mutate([user.Id], {
|
||||
onSuccess() {
|
||||
notifySuccess('User added to team', name);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { TeamAssociationSelector } from './TeamAssociationSelector';
|
1
app/react/portainer/users/teams/ItemView/index.ts
Normal file
1
app/react/portainer/users/teams/ItemView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ItemView } from './ItemView';
|
14
app/react/portainer/users/teams/ItemView/useTeamIdParam.ts
Normal file
14
app/react/portainer/users/teams/ItemView/useTeamIdParam.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
export function useTeamIdParam() {
|
||||
const {
|
||||
params: { id: teamIdParam },
|
||||
} = useCurrentStateAndParams();
|
||||
const teamId = parseInt(teamIdParam, 10);
|
||||
|
||||
if (!teamIdParam || Number.isNaN(teamId)) {
|
||||
throw new Error('Team ID is missing');
|
||||
}
|
||||
|
||||
return teamId;
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
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,
|
||||
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,
|
||||
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,23 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
|
||||
import { CreateTeamForm } from './CreateTeamForm';
|
||||
import { mockExampleData } from './CreateTeamForm.mocks';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'teams/CreateTeamForm',
|
||||
component: CreateTeamForm,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export { Example };
|
||||
|
||||
function Example() {
|
||||
const { teams, users } = mockExampleData();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CreateTeamForm users={users} teams={teams} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { renderWithQueryClient, 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 } = renderWithQueryClient(
|
||||
<CreateTeamForm users={[]} teams={[]} />
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,151 @@
|
|||
import { Formik, Field, Form } from 'formik';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { useReducer } from 'react';
|
||||
|
||||
import { Icon } from '@/react/components/Icon';
|
||||
import { User } from '@/portainer/users/types';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Widget } from '@@/Widget';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { UsersSelector } from '@@/UsersSelector';
|
||||
import { LoadingButton } from '@@/buttons/LoadingButton';
|
||||
|
||||
import { createTeam } from '../../teams.service';
|
||||
import { Team } from '../../types';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { validationSchema } from './CreateTeamForm.validation';
|
||||
|
||||
interface Props {
|
||||
users: User[];
|
||||
teams: Team[];
|
||||
}
|
||||
|
||||
export function CreateTeamForm({ users, teams }: Props) {
|
||||
const addTeamMutation = useAddTeamMutation();
|
||||
const [formKey, incFormKey] = useReducer((state: number) => state + 1, 0);
|
||||
|
||||
const initialValues = {
|
||||
name: '',
|
||||
leaders: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-lg-12 col-md-12 col-xs-12">
|
||||
<Widget>
|
||||
<Widget.Title
|
||||
icon="plus"
|
||||
title="Add a new team"
|
||||
featherIcon
|
||||
className="vertical-center"
|
||||
/>
|
||||
<Widget.Body>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={() => validationSchema(teams)}
|
||||
onSubmit={handleAddTeamClick}
|
||||
validateOnMount
|
||||
key={formKey}
|
||||
>
|
||||
{({
|
||||
values,
|
||||
errors,
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => (
|
||||
<Form
|
||||
className="form-horizontal"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<FormControl
|
||||
inputId="team_name"
|
||||
label="Name"
|
||||
errors={errors.name}
|
||||
required
|
||||
>
|
||||
<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}
|
||||
data-cy="team-createTeamButton"
|
||||
isLoading={isSubmitting || addTeamMutation.isLoading}
|
||||
loadingText="Creating team..."
|
||||
>
|
||||
<Icon icon="plus" feather size="md" />
|
||||
Create team
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
async function handleAddTeamClick(values: FormValues) {
|
||||
addTeamMutation.mutate(values, {
|
||||
onSuccess() {
|
||||
incFormKey();
|
||||
notifySuccess('Team successfully added', '');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useAddTeamMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
(values: FormValues) => createTeam(values.name, values.leaders),
|
||||
{
|
||||
meta: {
|
||||
error: {
|
||||
title: 'Failure',
|
||||
message: 'Failed to create team',
|
||||
},
|
||||
},
|
||||
onSuccess() {
|
||||
return queryClient.invalidateQueries(['teams']);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import { object, string, array, number } from 'yup';
|
||||
|
||||
import { Team } from '@/react/portainer/users/teams/types';
|
||||
|
||||
export function validationSchema(teams: Team[]) {
|
||||
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()),
|
||||
});
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { CreateTeamForm } from './CreateTeamForm';
|
|
@ -0,0 +1,6 @@
|
|||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
export interface FormValues {
|
||||
name: string;
|
||||
leaders: UserId[];
|
||||
}
|
30
app/react/portainer/users/teams/ListView/ListView.tsx
Normal file
30
app/react/portainer/users/teams/ListView/ListView.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { useUsers } from '@/portainer/users/queries';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { useTeams } from '../queries';
|
||||
|
||||
import { CreateTeamForm } from './CreateTeamForm';
|
||||
import { TeamsDatatableContainer } from './TeamsDatatable/TeamsDatatable';
|
||||
|
||||
export function ListView() {
|
||||
const { isAdmin } = useUser();
|
||||
|
||||
const usersQuery = useUsers(false);
|
||||
const teamsQuery = useTeams(!isAdmin, { enabled: !!usersQuery.data });
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Teams" breadcrumbs={[{ label: 'Teams management' }]} />
|
||||
|
||||
{usersQuery.data && teamsQuery.data && (
|
||||
<CreateTeamForm users={usersQuery.data} teams={teamsQuery.data} />
|
||||
)}
|
||||
|
||||
{teamsQuery.data && (
|
||||
<TeamsDatatableContainer teams={teamsQuery.data} isAdmin={isAdmin} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,244 @@
|
|||
import { useRowSelectColumn } from '@lineup-lite/hooks';
|
||||
import {
|
||||
Column,
|
||||
useGlobalFilter,
|
||||
usePagination,
|
||||
useRowSelect,
|
||||
useSortBy,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { Trash2, Users } from 'react-feather';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||
import { Team, TeamId } from '@/react/portainer/users/teams/types';
|
||||
import { deleteTeam } from '@/react/portainer/users/teams/teams.service';
|
||||
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
|
||||
|
||||
import { PaginationControls } from '@@/PaginationControls';
|
||||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
import { Table } from '@@/datatables';
|
||||
import { Button } from '@@/buttons';
|
||||
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
|
||||
import { TableFooter } from '@@/datatables/TableFooter';
|
||||
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
|
||||
import {
|
||||
TableSettingsProvider,
|
||||
useTableSettings,
|
||||
} from '@@/datatables/useTableSettings';
|
||||
import { TableContent } from '@@/datatables/TableContent';
|
||||
import { buildNameColumn } from '@@/datatables/NameCell';
|
||||
|
||||
import { TableSettings } from './types';
|
||||
|
||||
const tableKey = 'teams';
|
||||
|
||||
const columns: readonly Column<Team>[] = [
|
||||
buildNameColumn('Name', 'Id', 'portainer.teams.team'),
|
||||
] as const;
|
||||
|
||||
interface Props {
|
||||
teams: Team[];
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export function TeamsDatatable({ teams, isAdmin }: Props) {
|
||||
const { handleRemove } = useRemoveMutation();
|
||||
|
||||
const [searchBarValue, setSearchBarValue] = useSearchBarState(tableKey);
|
||||
const { settings, setTableSettings } = useTableSettings<TableSettings>();
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
page,
|
||||
prepareRow,
|
||||
selectedFlatRows,
|
||||
gotoPage,
|
||||
setPageSize,
|
||||
setGlobalFilter,
|
||||
state: { pageIndex, pageSize },
|
||||
} = useTable<Team>(
|
||||
{
|
||||
defaultCanFilter: false,
|
||||
columns,
|
||||
data: teams,
|
||||
|
||||
initialState: {
|
||||
pageSize: settings.pageSize || 10,
|
||||
sortBy: [settings.sortBy],
|
||||
globalFilter: searchBarValue,
|
||||
},
|
||||
selectCheckboxComponent: Checkbox,
|
||||
},
|
||||
|
||||
useGlobalFilter,
|
||||
useSortBy,
|
||||
usePagination,
|
||||
useRowSelect,
|
||||
isAdmin ? useRowSelectColumn : emptyPlugin
|
||||
);
|
||||
|
||||
const tableProps = getTableProps();
|
||||
const tbodyProps = getTableBodyProps();
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Table.Container>
|
||||
<Table.Title icon={Users} label="Teams">
|
||||
<SearchBar
|
||||
value={searchBarValue}
|
||||
onChange={handleSearchBarChange}
|
||||
/>
|
||||
|
||||
{isAdmin && (
|
||||
<Table.Actions>
|
||||
<Button
|
||||
color="dangerlight"
|
||||
onClick={handleRemoveClick}
|
||||
disabled={selectedFlatRows.length === 0}
|
||||
icon={Trash2}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Table.Actions>
|
||||
)}
|
||||
</Table.Title>
|
||||
|
||||
<Table
|
||||
className={tableProps.className}
|
||||
role={tableProps.role}
|
||||
style={tableProps.style}
|
||||
>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const { key, className, role, style } =
|
||||
headerGroup.getHeaderGroupProps();
|
||||
|
||||
return (
|
||||
<Table.HeaderRow<Team>
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
headers={headerGroup.headers}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody
|
||||
className={tbodyProps.className}
|
||||
role={tbodyProps.role}
|
||||
style={tbodyProps.style}
|
||||
>
|
||||
<TableContent
|
||||
prepareRow={prepareRow}
|
||||
renderRow={(row, { key, className, role, style }) => (
|
||||
<Table.Row<Team>
|
||||
cells={row.cells}
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
/>
|
||||
)}
|
||||
rows={page}
|
||||
emptyContent="No teams found"
|
||||
/>
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<TableFooter>
|
||||
<SelectedRowsCount value={selectedFlatRows.length} />
|
||||
<PaginationControls
|
||||
showAll
|
||||
pageLimit={pageSize}
|
||||
page={pageIndex + 1}
|
||||
onPageChange={(p) => gotoPage(p - 1)}
|
||||
totalCount={teams.length}
|
||||
onPageLimitChange={handlePageSizeChange}
|
||||
/>
|
||||
</TableFooter>
|
||||
</Table.Container>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
setPageSize(pageSize);
|
||||
setTableSettings({ pageSize });
|
||||
}
|
||||
|
||||
function handleSearchBarChange(value: string) {
|
||||
setSearchBarValue(value);
|
||||
setGlobalFilter(value);
|
||||
}
|
||||
|
||||
function handleSortChange(id: string, desc: boolean) {
|
||||
setTableSettings({ sortBy: { id, desc } });
|
||||
}
|
||||
|
||||
function handleRemoveClick() {
|
||||
const ids = selectedFlatRows.map((row) => row.original.Id);
|
||||
handleRemove(ids);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSettings: TableSettings = {
|
||||
pageSize: 10,
|
||||
sortBy: { id: 'name', desc: false },
|
||||
};
|
||||
|
||||
export function TeamsDatatableContainer(props: Props) {
|
||||
return (
|
||||
<TableSettingsProvider<TableSettings>
|
||||
defaults={defaultSettings}
|
||||
storageKey={tableKey}
|
||||
>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<TeamsDatatable {...props} />
|
||||
</TableSettingsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function useRemoveMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const deleteMutation = useMutation(
|
||||
async (ids: TeamId[]) =>
|
||||
promiseSequence(ids.map((id) => () => deleteTeam(id))),
|
||||
{
|
||||
meta: {
|
||||
error: { title: 'Failure', message: 'Unable to remove team' },
|
||||
},
|
||||
onSuccess() {
|
||||
return queryClient.invalidateQueries(['teams']);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return { handleRemove };
|
||||
|
||||
async function handleRemove(teams: TeamId[]) {
|
||||
const confirmed = await confirmDeletionAsync(
|
||||
'Are you sure you want to remove the selected teams?'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteMutation.mutate(teams, {
|
||||
onSuccess: () => {
|
||||
notifySuccess('Teams successfully removed', '');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function emptyPlugin() {}
|
||||
emptyPlugin.pluginName = 'emptyPlugin';
|
|
@ -0,0 +1 @@
|
|||
export { TeamsDatatable } from './TeamsDatatable';
|
|
@ -0,0 +1,8 @@
|
|||
import {
|
||||
PaginationTableSettings,
|
||||
SortableTableSettings,
|
||||
} from '@@/datatables/types-old';
|
||||
|
||||
export interface TableSettings
|
||||
extends PaginationTableSettings,
|
||||
SortableTableSettings {}
|
1
app/react/portainer/users/teams/ListView/index.ts
Normal file
1
app/react/portainer/users/teams/ListView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ListView } from './ListView';
|
2
app/react/portainer/users/teams/index.ts
Normal file
2
app/react/portainer/users/teams/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { ItemView } from './ItemView';
|
||||
export { ListView } from './ListView';
|
134
app/react/portainer/users/teams/queries.ts
Normal file
134
app/react/portainer/users/teams/queries.ts
Normal file
|
@ -0,0 +1,134 @@
|
|||
import { useMutation, useQuery, useQueryClient } from 'react-query';
|
||||
|
||||
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
import {
|
||||
createTeamMembership,
|
||||
deleteTeamMembership,
|
||||
updateTeamMembership,
|
||||
} from './team-membership.service';
|
||||
import { getTeam, getTeamMemberships, getTeams } from './teams.service';
|
||||
import { Team, TeamId, TeamMembership, TeamRole } from './types';
|
||||
|
||||
export function useTeams<T = Team[]>(
|
||||
onlyLedTeams = false,
|
||||
{
|
||||
enabled = true,
|
||||
select = (data) => data as unknown as T,
|
||||
}: {
|
||||
enabled?: boolean;
|
||||
select?: (data: Team[]) => T;
|
||||
} = {}
|
||||
) {
|
||||
const teams = useQuery(
|
||||
['teams', { onlyLedTeams }],
|
||||
() => getTeams(onlyLedTeams),
|
||||
{
|
||||
meta: {
|
||||
error: { title: 'Failure', message: 'Unable to load teams' },
|
||||
},
|
||||
enabled,
|
||||
select,
|
||||
}
|
||||
);
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
export function useTeam(id: TeamId, onError?: (error: unknown) => void) {
|
||||
return useQuery(['teams', id], () => getTeam(id), {
|
||||
meta: {
|
||||
error: { title: 'Failure', message: 'Unable to load team' },
|
||||
},
|
||||
onError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTeamMemberships(id: TeamId) {
|
||||
return useQuery(['teams', id, 'memberships'], () => getTeamMemberships(id), {
|
||||
meta: {
|
||||
error: { title: 'Failure', message: 'Unable to load team memberships' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddMemberMutation(teamId: TeamId) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
(userIds: UserId[]) =>
|
||||
promiseSequence(
|
||||
userIds.map(
|
||||
(userId) => () =>
|
||||
createTeamMembership(userId, teamId, TeamRole.Member)
|
||||
)
|
||||
),
|
||||
{
|
||||
onError(error) {
|
||||
notifyError('Failure', error as Error, 'Failure to add membership');
|
||||
},
|
||||
onSuccess() {
|
||||
return queryClient.invalidateQueries(['teams', teamId, 'memberships']);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function useRemoveMemberMutation(
|
||||
teamId: TeamId,
|
||||
teamMemberships: TeamMembership[] = []
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
(userIds: UserId[]) =>
|
||||
promiseSequence(
|
||||
userIds.map((userId) => () => {
|
||||
const membership = teamMemberships.find(
|
||||
(membership) => membership.UserID === userId
|
||||
);
|
||||
if (!membership) {
|
||||
throw new Error('Membership not found');
|
||||
}
|
||||
return deleteTeamMembership(membership.Id);
|
||||
})
|
||||
),
|
||||
{
|
||||
onError(error) {
|
||||
notifyError('Failure', error as Error, 'Failure to add membership');
|
||||
},
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries(['teams', teamId, 'memberships']);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function useUpdateRoleMutation(
|
||||
teamId: TeamId,
|
||||
teamMemberships: TeamMembership[] = []
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
({ userId, role }: { userId: UserId; role: TeamRole }) => {
|
||||
const membership = teamMemberships.find(
|
||||
(membership) => membership.UserID === userId
|
||||
);
|
||||
if (!membership) {
|
||||
throw new Error('Membership not found');
|
||||
}
|
||||
return updateTeamMembership(membership.Id, userId, teamId, role);
|
||||
},
|
||||
{
|
||||
onError(error) {
|
||||
notifyError('Failure', error as Error, 'Failure to update membership');
|
||||
},
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries(['teams', teamId, 'memberships']);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
47
app/react/portainer/users/teams/team-membership.service.ts
Normal file
47
app/react/portainer/users/teams/team-membership.service.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { UserId } from '@/portainer/users/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { TeamId, TeamRole, TeamMembershipId } from './types';
|
||||
|
||||
export async function createTeamMembership(
|
||||
userId: UserId,
|
||||
teamId: TeamId,
|
||||
role: TeamRole
|
||||
) {
|
||||
try {
|
||||
await axios.post(buildUrl(), { userId, teamId, role });
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to create team membership');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTeamMembership(id: TeamMembershipId) {
|
||||
try {
|
||||
await axios.delete(buildUrl(id));
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to delete team membership');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTeamMembership(
|
||||
id: TeamMembershipId,
|
||||
userId: UserId,
|
||||
teamId: TeamId,
|
||||
role: TeamRole
|
||||
) {
|
||||
try {
|
||||
await axios.put(buildUrl(id), { userId, teamId, role });
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to update team membership');
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(id?: TeamMembershipId) {
|
||||
let url = '/team_memberships';
|
||||
|
||||
if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
71
app/react/portainer/users/teams/teams.service.ts
Normal file
71
app/react/portainer/users/teams/teams.service.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { type UserId } from '@/portainer/users/types';
|
||||
|
||||
import { createTeamMembership } from './team-membership.service';
|
||||
import { Team, TeamId, TeamMembership, TeamRole } from './types';
|
||||
|
||||
export async function getTeams(onlyLedTeams = false) {
|
||||
try {
|
||||
const { data } = await axios.get<Team[]>(buildUrl(), {
|
||||
params: { onlyLedTeams },
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTeam(id: TeamId) {
|
||||
try {
|
||||
const { data } = await axios.get<Team>(buildUrl(id));
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTeam(id: TeamId) {
|
||||
try {
|
||||
await axios.delete(buildUrl(id));
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTeam(name: string, leaders: UserId[]) {
|
||||
try {
|
||||
const { data: team } = await axios.post(buildUrl(), { name });
|
||||
await Promise.all(
|
||||
leaders.map((leaderId) =>
|
||||
createTeamMembership(leaderId, team.Id, TeamRole.Leader)
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to create team');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTeamMemberships(teamId: TeamId) {
|
||||
try {
|
||||
const { data } = await axios.get<TeamMembership[]>(
|
||||
buildUrl(teamId, 'memberships')
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to get team memberships');
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(id?: TeamId, action?: string) {
|
||||
let url = '/teams';
|
||||
|
||||
if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
if (action) {
|
||||
url += `/${action}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
22
app/react/portainer/users/teams/types.ts
Normal file
22
app/react/portainer/users/teams/types.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { type UserId } from '@/portainer/users/types';
|
||||
|
||||
export type TeamId = number;
|
||||
|
||||
export enum TeamRole {
|
||||
Leader = 1,
|
||||
Member,
|
||||
}
|
||||
|
||||
export type Team = {
|
||||
Id: TeamId;
|
||||
Name: string;
|
||||
};
|
||||
|
||||
export type TeamMembershipId = number;
|
||||
|
||||
export interface TeamMembership {
|
||||
Id: TeamMembershipId;
|
||||
Role: TeamRole;
|
||||
UserID: UserId;
|
||||
TeamID: TeamId;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue