diff --git a/src/Components/Header/Components/AdminMenu.tsx b/src/Components/Header/Components/AdminMenu.tsx index e16cc858..c33acecf 100644 --- a/src/Components/Header/Components/AdminMenu.tsx +++ b/src/Components/Header/Components/AdminMenu.tsx @@ -35,6 +35,7 @@ export const AdminMenu: FC = () => { } > + All Teams Patterns Contacts Notifier diff --git a/src/Components/ModalError/ModalError.tsx b/src/Components/ModalError/ModalError.tsx index 35a79c4b..670b8b8e 100644 --- a/src/Components/ModalError/ModalError.tsx +++ b/src/Components/ModalError/ModalError.tsx @@ -10,15 +10,17 @@ type FooterErrorProps = { message?: string | null; maxWidth?: string; margin?: string | number; + padding?: string | number; }; export default function ModalError({ message, maxWidth, margin, + padding, }: FooterErrorProps): React.ReactElement | null { return message ? ( -
+
{message}
diff --git a/src/Components/Teams/Confirm.tsx b/src/Components/Teams/Confirm.tsx index e97b07ca..8b3894a7 100644 --- a/src/Components/Teams/Confirm.tsx +++ b/src/Components/Teams/Confirm.tsx @@ -23,7 +23,7 @@ export function Confirm({ active = true, }: ConfirmProps): ReactElement { const [opened, setOpened] = useState(false); - const [error, setError] = useState(""); + const [error, setError] = useState(""); const handleConfirm = async () => { try { diff --git a/src/Components/Teams/ConfirmFullTeamDeletion/ConfirmFullTeamDeletion.tsx b/src/Components/Teams/ConfirmFullTeamDeletion/ConfirmFullTeamDeletion.tsx index fe486faf..1be0cbe0 100644 --- a/src/Components/Teams/ConfirmFullTeamDeletion/ConfirmFullTeamDeletion.tsx +++ b/src/Components/Teams/ConfirmFullTeamDeletion/ConfirmFullTeamDeletion.tsx @@ -5,7 +5,7 @@ import { Hovered, HoveredShow } from "../Hovered/Hovered"; import { Team } from "../../../Domain/Team"; import { Confirm } from "../Confirm"; import DeleteIcon from "@skbkontur/react-icons/Delete"; -import { useFyllyDeleteTeam } from "../../../hooks/useFullyDeleteTeam"; +import { useFullyDeleteTeam } from "../../../hooks/useFullyDeleteTeam"; import { fullyDeleteTeamConfirmText } from "../../../helpers/teamOperationsConfirmMessages"; interface IConfirmFullTeamDeleteionProps { @@ -25,7 +25,7 @@ export const ConfirmFullTeamDeleteion: FC = ({ isDeletingSubscriptions, isDeletingUsers, isDeletingTeam, - } = useFyllyDeleteTeam(teamId, !isConfirmOpened); + } = useFullyDeleteTeam(teamId, !isConfirmOpened); const confirmMessage = fullyDeleteTeamConfirmText( isFetchingData, diff --git a/src/Components/Teams/TeamCard/TeamCard.less b/src/Components/Teams/TeamCard/TeamCard.less new file mode 100644 index 00000000..aa6a7d8b --- /dev/null +++ b/src/Components/Teams/TeamCard/TeamCard.less @@ -0,0 +1,42 @@ +@import "~styles/variables.less"; +@import "~styles/mixins.less"; + +.team-card { + border: 1px solid #ddd; + border-radius: 8px; + padding: 16px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + text-align: start; + color: #333; + position: relative; + + .team-name { + overflow: hidden; + max-width: 100%; + font-size: 18px; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; + max-width: calc(100% - 12px); + } + + .team-description { + overflow: hidden; + text-overflow: ellipsis; + max-height: 120px; + } + + .team-id { + font-size: 14px; + color: #999; + } + + .team-card-kebab { + position: absolute; + right: 4px; + } + + .team-users { + margin-left: -4px; + } +} diff --git a/src/Components/Teams/TeamCard/TeamCard.tsx b/src/Components/Teams/TeamCard/TeamCard.tsx new file mode 100644 index 00000000..96beed93 --- /dev/null +++ b/src/Components/Teams/TeamCard/TeamCard.tsx @@ -0,0 +1,126 @@ +import React, { FC, useState } from "react"; +import TrashIcon from "@skbkontur/react-icons/Trash"; +import EditIcon from "@skbkontur/react-icons/Edit"; +import { Button, Kebab } from "@skbkontur/react-ui"; +import { MenuItem } from "@skbkontur/react-ui/components/MenuItem"; +import { useFullyDeleteTeam } from "../../../hooks/useFullyDeleteTeam"; +import WarningIcon from "@skbkontur/react-icons/Warning"; +import { fullyDeleteTeamConfirmText } from "../../../helpers/teamOperationsConfirmMessages"; +import ModalError from "../../../Components/ModalError/ModalError"; +import { Users } from "../../../Components/Teams/Users"; +import { useModal } from "../../../hooks/useModal"; +import { TeamEditor } from "../../../Components/Teams/TeamEditor/TeamEditor"; +import RouterLink from "../../../Components/RouterLink/RouterLink"; +import { getPageLink } from "../../../Domain/Global"; +import { Markdown } from "../../../Components/Markdown/Markdown"; +import { Team } from "../../../Domain/Team"; +import { Flexbox } from "../../Flexbox/FlexBox"; +import classNames from "classnames/bind"; + +import styles from "./TeamCard.less"; + +const cn = classNames.bind(styles); + +interface ITeamCardProps { + team: Team; + isDeleting: boolean; + onOpenDelete: () => void; + onCloseDelete: () => void; +} + +export const TeamCard: FC = ({ team, isDeleting, onOpenDelete, onCloseDelete }) => { + const { name, description, id } = team; + const [error, setError] = useState(""); + const { isModalOpen, openModal, closeModal } = useModal(); + + const handleConfirm = async () => { + try { + await handleFullyDeleteTeam(); + onCloseDelete(); + } catch (error) { + setError(error); + } + }; + + const { + handleFullyDeleteTeam, + isFetchingData, + isDeletingContacts, + isDeletingSubscriptions, + isDeletingUsers, + isDeletingTeam, + } = useFullyDeleteTeam(id, !isDeleting); + + const confirmMessage = fullyDeleteTeamConfirmText( + isFetchingData, + isDeletingContacts, + isDeletingSubscriptions, + isDeletingUsers, + isDeletingTeam, + name + ); + + const isLoading = + isFetchingData || + isDeletingContacts || + isDeletingSubscriptions || + isDeletingUsers || + isDeletingTeam; + + return ( + <> +
+ + + } onClick={openModal}> + Edit + + } onClick={onOpenDelete}> + Delete + + + {isDeleting ? ( + <> + + + {confirmMessage + + " If you are not a member, add yourself before deleting."} + + + + + + + + ) : ( + <> +
{name}
+
{`id: ${id}`}
+
+ {description && } +
+ +
+ +
+ + Team settings + +
+ + )} +
+
+ {isModalOpen && } + + ); +}; diff --git a/src/Components/Teams/TeamEditor/TeamEditor.tsx b/src/Components/Teams/TeamEditor/TeamEditor.tsx index 6139f81e..d9d49c65 100644 --- a/src/Components/Teams/TeamEditor/TeamEditor.tsx +++ b/src/Components/Teams/TeamEditor/TeamEditor.tsx @@ -18,7 +18,7 @@ import styles from "./TeamEditor.less"; const cn = classNames.bind(styles); interface ITeamEditorProps { - team?: Team; + team?: Team | null; onClose: () => void; } @@ -30,11 +30,11 @@ export const TeamEditor: FC = ({ team, onClose }: ITeamEditorP const [error, setError] = useState(null); const { handleUpdateTeam, isUpdatingTeam } = useUpdateTeam( validationRef, - team, name, description, setError, - onClose + onClose, + team ); const { handleAddTeam, isAddingTeam } = useAddTeam( validationRef, diff --git a/src/Components/Teams/Users.tsx b/src/Components/Teams/Users.tsx index 00cc8b80..2d1f8c52 100644 --- a/src/Components/Teams/Users.tsx +++ b/src/Components/Teams/Users.tsx @@ -51,7 +51,7 @@ export function Users({ team }: UsersProps): ReactElement { onCollapse={handleCollapse} > {users?.length ? ( - + {users.map((userName) => ( = [ + ["asc", "Name: A-Z"], + ["desc", "Name: Z-A"], +]; + +const AllTeamsContainer: FC = () => { + const { error, isLoading } = useAppSelector(UIState); + const [deletingTeam, setDeletingTeam] = useState(null); + const [searchValue, setSearchValue] = useState(""); + const [activePage, setActivePage] = useState(1); + const [sortDirection, setSortDirection] = useState("asc"); + const debouncedSearchMetric = useDebounce(searchValue, 500); + const dispatch = useAppDispatch(); + + const { data: teams } = useGetAllTeamsQuery({ + page: transformPageFromHumanToProgrammer(activePage), + searchText: debouncedSearchMetric, + sort: sortDirection, + }); + + const pageCount = Math.ceil((teams?.total ?? 0) / (teams?.size ?? 1)); + + const handleSetSearchValue = (value: string) => { + dispatch(setError(null)); + setSearchValue(value); + }; + + useEffect(() => { + setDocumentTitle("All Teams"); + }, []); + + return ( + + + Teams + + + setSearchValue("")} + /> + + placeholder="Sort" + value={sortDirection} + renderItem={(_v, item) => item} + renderValue={(_v, item) => item} + onValueChange={setSortDirection} + items={SORT_OPTIONS} + /> + + {teams?.list.length ? ( +
+ {teams?.list.map((team) => ( + setDeletingTeam(team)} + onCloseDelete={() => setDeletingTeam(null)} + /> + ))} +
+ ) : ( + + )} + + +
+
+
+ ); +}; + +export default AllTeamsContainer; diff --git a/src/Domain/Global.ts b/src/Domain/Global.ts index 06564e99..3c274973 100644 --- a/src/Domain/Global.ts +++ b/src/Domain/Global.ts @@ -12,6 +12,7 @@ export const PagesPaths = { teams: "/teams", team: "/teams/:teamId?", contacts: "/contacts", + allTeams: "/teams/all", }; export const PagesLinks = { @@ -29,6 +30,7 @@ export const PagesLinks = { team: "/teams/%id%", docs: "//moira.readthedocs.org/", contacts: "/contacts", + allTeams: "/teams/all", }; export type PagePath = keyof typeof PagesPaths; diff --git a/src/Domain/Team.ts b/src/Domain/Team.ts index eb39d0ee..e54ad53e 100644 --- a/src/Domain/Team.ts +++ b/src/Domain/Team.ts @@ -3,3 +3,10 @@ export interface Team { name: string; description?: string; } + +export interface ITeamList { + list: Team[]; + page: number; + size: number; + total: number; +} diff --git a/src/desktop.bundle.tsx b/src/desktop.bundle.tsx index 8155ab39..8c603df3 100644 --- a/src/desktop.bundle.tsx +++ b/src/desktop.bundle.tsx @@ -24,6 +24,7 @@ import { AdminRoute } from "./PrivateRoutes/AdminRoute"; import TeamsContainer from "./Containers/TeamsContainer"; import { TeamContainer } from "./Containers/TeamContainer"; import { TeamSettingsPrivateRoute } from "./PrivateRoutes/TeamSettingsPrivateRoute"; +import AllTeamsContainer from "./Containers/AllTeamsContainer/AllTeamsContainer"; import styles from "./desktop.less"; @@ -74,6 +75,7 @@ function Desktop() { /> + { +export const useFullyDeleteTeam = (teamId: string, skip?: boolean) => { const { data: teamSettings, isLoading: isGettingSettings } = useGetTeamSettingsQuery( { teamId, handleLoadingLocally: true }, { skip } @@ -61,7 +61,7 @@ export const useFyllyDeleteTeam = (teamId: string, skip?: boolean) => { teamId, handleLoadingLocally: true, handleErrorLocally: true, - }); + }).unwrap(); }; const isFetchingData = isGettingTeamUsers || isGettingSettings; diff --git a/src/hooks/useUpdateTeam.tsx b/src/hooks/useUpdateTeam.tsx index 90d1a16d..05a6b276 100644 --- a/src/hooks/useUpdateTeam.tsx +++ b/src/hooks/useUpdateTeam.tsx @@ -5,11 +5,11 @@ import { validateForm } from "../helpers/validations"; export const useUpdateTeam = ( validationRef: React.RefObject, - team: Team | undefined, name: string, description: string, setError: (error: string) => void, - onClose: () => void + onClose: () => void, + team?: Team | null ) => { const [updateTeam, { isLoading: isUpdatingTeam }] = useUpdateTeamMutation(); diff --git a/src/services/BaseApi.ts b/src/services/BaseApi.ts index 27984ad2..56bdf69c 100644 --- a/src/services/BaseApi.ts +++ b/src/services/BaseApi.ts @@ -70,7 +70,8 @@ export type TApiInvalidateTags = | "UserTeams" | "Team" | "TriggerState" - | "Trigger"; + | "Trigger" + | "AllTeams"; export const BaseApi = createApi({ reducerPath: "BaseApi", @@ -85,6 +86,7 @@ export const BaseApi = createApi({ "Team", "TriggerState", "Trigger", + "AllTeams", ], baseQuery: customFetchBaseQuery, endpoints: (builder) => ({ diff --git a/src/services/TeamsApi.ts b/src/services/TeamsApi.ts index 254b579c..21fbccb1 100644 --- a/src/services/TeamsApi.ts +++ b/src/services/TeamsApi.ts @@ -2,8 +2,11 @@ import { SubscriptionCreateInfo } from "../Api/MoiraApi"; import { Contact, TeamContactCreateInfo } from "../Domain/Contact"; import { Settings } from "../Domain/Settings"; import { Subscription } from "../Domain/Subscription"; -import { Team } from "../Domain/Team"; +import { ITeamList, Team } from "../Domain/Team"; import { BaseApi, CustomBaseQueryArgs, TApiInvalidateTags } from "./BaseApi"; +import qs from "qs"; + +const ALL_TEAMS_LIST_SIZE = 30; export const TeamsApi = BaseApi.injectEndpoints({ endpoints: (builder) => ({ @@ -81,7 +84,7 @@ export const TeamsApi = BaseApi.injectEndpoints({ if (error) { return []; } - return ["UserTeams", { type: "Team", id }]; + return ["UserTeams", { type: "Team", id }, "AllTeams"]; }, }), deleteTeam: builder.mutation>({ @@ -94,7 +97,7 @@ export const TeamsApi = BaseApi.injectEndpoints({ if (error) { return []; } - return ["UserTeams"]; + return ["UserTeams", "AllTeams"]; }, }), getTeamUsers: builder.query>({ @@ -143,6 +146,37 @@ export const TeamsApi = BaseApi.injectEndpoints({ return tagsToInvalidate; }, }), + getAllTeams: builder.query< + ITeamList, + CustomBaseQueryArgs<{ + page: number; + searchText?: string | null; + sort?: "asc" | "desc" | null; + }> + >({ + query: ({ page, searchText, sort }) => { + const params = qs.stringify( + { + p: page, + size: ALL_TEAMS_LIST_SIZE, + searchText, + sort, + }, + { arrayFormat: "comma", skipNulls: true } + ); + return { + url: `teams/all?${params}`, + method: "GET", + credentials: "same-origin", + }; + }, + providesTags: (_result, error) => { + if (error) { + return []; + } + return ["AllTeams"]; + }, + }), }), }); @@ -159,4 +193,5 @@ export const { useGetTeamUsersQuery, useAddUserToTeamMutation, useDeleteUserFromTeamMutation, + useGetAllTeamsQuery, } = TeamsApi;