mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-04 01:18:20 +02:00
Feat: new projects list (#6873)
New card view for list of projects. Co-authored-by: Thomas Heartman <thomas@getunleash.io>
This commit is contained in:
parent
0572d37181
commit
fd4bcfffa5
@ -0,0 +1,77 @@
|
|||||||
|
import { styled } from '@mui/material';
|
||||||
|
import { Card, Box } from '@mui/material';
|
||||||
|
import Delete from '@mui/icons-material/Delete';
|
||||||
|
import Edit from '@mui/icons-material/Edit';
|
||||||
|
import { flexRow } from 'themes/themeStyles';
|
||||||
|
|
||||||
|
export const StyledProjectCard = styled(Card)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
height: '100%',
|
||||||
|
boxShadow: 'none',
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
transition: 'background-color 0.2s ease-in-out',
|
||||||
|
backgroundColor: theme.palette.neutral.light,
|
||||||
|
},
|
||||||
|
borderRadius: theme.shape.borderRadiusMedium,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const StyledProjectCardBody = styled(Box)(({ theme }) => ({
|
||||||
|
padding: theme.spacing(1, 2, 2, 2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const StyledDivHeader = styled('div')(({ theme }) => ({
|
||||||
|
...flexRow,
|
||||||
|
width: '100%',
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const StyledH2Title = styled('h2')(({ theme }) => ({
|
||||||
|
fontWeight: 'normal',
|
||||||
|
fontSize: theme.fontSizes.bodySize,
|
||||||
|
lineClamp: '2',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
lineHeight: '1.2',
|
||||||
|
display: '-webkit-box',
|
||||||
|
boxOrient: 'vertical',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflow: 'hidden',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const StyledBox = styled(Box)(() => ({
|
||||||
|
...flexRow,
|
||||||
|
marginRight: 'auto',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const StyledEditIcon = styled(Edit)(({ theme }) => ({
|
||||||
|
color: theme.palette.neutral.main,
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const StyledDeleteIcon = styled(Delete)(({ theme }) => ({
|
||||||
|
color: theme.palette.neutral.main,
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const StyledDivInfo = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: theme.fontSizes.smallerBody,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const StyledDivInfoContainer = styled('div')(() => ({
|
||||||
|
textAlign: 'center',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const StyledParagraphInfo = styled('p')(({ theme }) => ({
|
||||||
|
color: theme.palette.primary.dark,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
}));
|
143
frontend/src/component/project/NewProjectCard/NewProjectCard.tsx
Normal file
143
frontend/src/component/project/NewProjectCard/NewProjectCard.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
import { Menu, MenuItem } from '@mui/material';
|
||||||
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
|
import { type SyntheticEvent, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { getProjectEditPath } from 'utils/routePathHelpers';
|
||||||
|
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
|
||||||
|
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
|
||||||
|
import { DEFAULT_PROJECT_ID } from 'hooks/api/getters/useDefaultProject/useDefaultProjectId';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { DeleteProjectDialogue } from '../Project/DeleteProject/DeleteProjectDialogue';
|
||||||
|
import {
|
||||||
|
StyledProjectCard,
|
||||||
|
StyledDivHeader,
|
||||||
|
StyledBox,
|
||||||
|
StyledH2Title,
|
||||||
|
StyledEditIcon,
|
||||||
|
StyledDivInfo,
|
||||||
|
StyledDivInfoContainer,
|
||||||
|
StyledParagraphInfo,
|
||||||
|
StyledProjectCardBody,
|
||||||
|
} from './NewProjectCard.styles';
|
||||||
|
import { ProjectCardFooter } from './ProjectCardFooter/ProjectCardFooter';
|
||||||
|
import { ProjectCardIcon } from './ProjectCardIcon/ProjectCardIcon';
|
||||||
|
|
||||||
|
interface IProjectCardProps {
|
||||||
|
name: string;
|
||||||
|
featureCount: number;
|
||||||
|
health: number;
|
||||||
|
memberCount: number;
|
||||||
|
id: string;
|
||||||
|
onHover: () => void;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
mode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectCard = ({
|
||||||
|
name,
|
||||||
|
featureCount,
|
||||||
|
health,
|
||||||
|
memberCount,
|
||||||
|
onHover,
|
||||||
|
id,
|
||||||
|
mode,
|
||||||
|
isFavorite = false,
|
||||||
|
}: IProjectCardProps) => {
|
||||||
|
const { isOss } = useUiConfig();
|
||||||
|
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
|
||||||
|
const [showDelDialog, setShowDelDialog] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleClick = (event: React.SyntheticEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledProjectCard onMouseEnter={onHover}>
|
||||||
|
<StyledProjectCardBody>
|
||||||
|
<StyledDivHeader data-loading>
|
||||||
|
<ProjectCardIcon mode={mode} />
|
||||||
|
<StyledBox>
|
||||||
|
<StyledH2Title>{name}</StyledH2Title>
|
||||||
|
</StyledBox>
|
||||||
|
<PermissionIconButton
|
||||||
|
style={{ transform: 'translateX(7px)' }}
|
||||||
|
permission={UPDATE_PROJECT}
|
||||||
|
hidden={isOss()}
|
||||||
|
projectId={id}
|
||||||
|
data-loading
|
||||||
|
onClick={handleClick}
|
||||||
|
tooltipProps={{
|
||||||
|
title: 'Options',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreVertIcon />
|
||||||
|
</PermissionIconButton>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
id='project-card-menu'
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
style={{ top: 0, left: -100 }}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
onClose={(event: SyntheticEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setAnchorEl(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(getProjectEditPath(id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StyledEditIcon />
|
||||||
|
Edit project
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</StyledDivHeader>
|
||||||
|
<StyledDivInfo>
|
||||||
|
<StyledDivInfoContainer>
|
||||||
|
<StyledParagraphInfo data-loading>
|
||||||
|
{featureCount}
|
||||||
|
</StyledParagraphInfo>
|
||||||
|
<p data-loading>toggles</p>
|
||||||
|
</StyledDivInfoContainer>
|
||||||
|
<StyledDivInfoContainer>
|
||||||
|
<StyledParagraphInfo data-loading>
|
||||||
|
{health}%
|
||||||
|
</StyledParagraphInfo>
|
||||||
|
<p data-loading>health</p>
|
||||||
|
</StyledDivInfoContainer>
|
||||||
|
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={id !== DEFAULT_PROJECT_ID}
|
||||||
|
show={
|
||||||
|
<StyledDivInfoContainer>
|
||||||
|
<StyledParagraphInfo data-loading>
|
||||||
|
{memberCount}
|
||||||
|
</StyledParagraphInfo>
|
||||||
|
<p data-loading>members</p>
|
||||||
|
</StyledDivInfoContainer>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledDivInfo>
|
||||||
|
</StyledProjectCardBody>
|
||||||
|
<ProjectCardFooter id={id} isFavorite={isFavorite} />
|
||||||
|
<DeleteProjectDialogue
|
||||||
|
project={id}
|
||||||
|
open={showDelDialog}
|
||||||
|
onClose={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setAnchorEl(null);
|
||||||
|
setShowDelDialog(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</StyledProjectCard>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,58 @@
|
|||||||
|
import type { VFC } from 'react';
|
||||||
|
import { Box, styled } from '@mui/material';
|
||||||
|
import { FavoriteIconButton } from 'component/common/FavoriteIconButton/FavoriteIconButton';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import { useFavoriteProjectsApi } from 'hooks/api/actions/useFavoriteProjectsApi/useFavoriteProjectsApi';
|
||||||
|
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||||
|
|
||||||
|
interface IProjectCardFooterProps {
|
||||||
|
id: string;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledFooter = styled(Box)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: theme.spacing(1, 2),
|
||||||
|
borderTop: `1px solid ${theme.palette.grey[300]}`,
|
||||||
|
backgroundColor: theme.palette.grey[100],
|
||||||
|
boxShadow: 'inset 0px 2px 4px rgba(32, 32, 33, 0.05)', // FIXME: replace with variable
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledFavoriteIconButton = styled(FavoriteIconButton)(({ theme }) => ({
|
||||||
|
marginRight: theme.spacing(-1),
|
||||||
|
marginLeft: 'auto',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ProjectCardFooter: VFC<IProjectCardFooterProps> = ({
|
||||||
|
id,
|
||||||
|
isFavorite = false,
|
||||||
|
}) => {
|
||||||
|
const { setToastApiError } = useToast();
|
||||||
|
const { favorite, unfavorite } = useFavoriteProjectsApi();
|
||||||
|
const { refetch } = useProjects();
|
||||||
|
|
||||||
|
const onFavorite = async (e: React.SyntheticEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (isFavorite) {
|
||||||
|
await unfavorite(id);
|
||||||
|
} else {
|
||||||
|
await favorite(id);
|
||||||
|
}
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
setToastApiError('Something went wrong, could not update favorite');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<StyledFooter>
|
||||||
|
<StyledFavoriteIconButton
|
||||||
|
onClick={onFavorite}
|
||||||
|
isFavorite={isFavorite}
|
||||||
|
size='medium'
|
||||||
|
/>
|
||||||
|
</StyledFooter>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,68 @@
|
|||||||
|
import type { VFC } from 'react';
|
||||||
|
import { styled } from '@mui/material';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||||
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
|
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||||
|
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
|
||||||
|
|
||||||
|
interface IProjectCardIconProps {
|
||||||
|
mode: 'private' | 'protected' | 'public' | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledVisibilityIcon = styled(VisibilityOffIcon)(({ theme }) => ({
|
||||||
|
color: theme.palette.action.disabled,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledLockIcon = styled(LockIcon)(({ theme }) => ({
|
||||||
|
color: theme.palette.action.disabled,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledProjectIcon = styled(BarChartIcon)(({ theme }) => ({
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const StyledIconBox = styled(Box)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: '1px',
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderColor: theme.palette.neutral.border,
|
||||||
|
padding: theme.spacing(0.5),
|
||||||
|
marginRight: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ProjectCardIcon: VFC<IProjectCardIconProps> = ({ mode }) => {
|
||||||
|
if (mode === 'private') {
|
||||||
|
return (
|
||||||
|
<StyledIconBox data-loading>
|
||||||
|
<HtmlTooltip
|
||||||
|
title="This project's collaboration mode is set to private. The project and associated feature flags can only be seen by members of the project."
|
||||||
|
arrow
|
||||||
|
>
|
||||||
|
<StyledVisibilityIcon />
|
||||||
|
</HtmlTooltip>
|
||||||
|
</StyledIconBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'protected') {
|
||||||
|
return (
|
||||||
|
<StyledIconBox data-loading>
|
||||||
|
<HtmlTooltip
|
||||||
|
title="This project's collaboration mode is set to protected. Only admins and project members can submit change requests."
|
||||||
|
arrow
|
||||||
|
>
|
||||||
|
<StyledLockIcon />
|
||||||
|
</HtmlTooltip>
|
||||||
|
</StyledIconBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledIconBox data-loading>
|
||||||
|
<StyledProjectIcon />
|
||||||
|
</StyledIconBox>
|
||||||
|
);
|
||||||
|
};
|
@ -4,7 +4,8 @@ import { mutate } from 'swr';
|
|||||||
import { getProjectFetcher } from 'hooks/api/getters/useProject/getProjectFetcher';
|
import { getProjectFetcher } from 'hooks/api/getters/useProject/getProjectFetcher';
|
||||||
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { ProjectCard } from '../ProjectCard/ProjectCard';
|
import { ProjectCard as LegacyProjectCard } from '../ProjectCard/ProjectCard';
|
||||||
|
import { ProjectCard as NewProjectCard } from '../NewProjectCard/NewProjectCard';
|
||||||
import type { IProjectCard } from 'interfaces/project';
|
import type { IProjectCard } from 'interfaces/project';
|
||||||
import loadingData from './loadingData';
|
import loadingData from './loadingData';
|
||||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
@ -34,6 +35,9 @@ import { useUiFlag } from 'hooks/useUiFlag';
|
|||||||
import { useProfile } from 'hooks/api/getters/useProfile/useProfile';
|
import { useProfile } from 'hooks/api/getters/useProfile/useProfile';
|
||||||
import { shouldDisplayInMyProjects } from './should-display-in-my-projects';
|
import { shouldDisplayInMyProjects } from './should-display-in-my-projects';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Remove after with `projectsListNewCards` flag
|
||||||
|
*/
|
||||||
const StyledDivContainer = styled('div')(({ theme }) => ({
|
const StyledDivContainer = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
@ -42,6 +46,12 @@ const StyledDivContainer = styled('div')(({ theme }) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const StyledGridContainer = styled('div')(({ theme }) => ({
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
|
||||||
|
gap: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
const StyledApiError = styled(ApiError)(({ theme }) => ({
|
const StyledApiError = styled(ApiError)(({ theme }) => ({
|
||||||
maxWidth: '400px',
|
maxWidth: '400px',
|
||||||
marginBottom: theme.spacing(2),
|
marginBottom: theme.spacing(2),
|
||||||
@ -139,6 +149,7 @@ export const ProjectListNew = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const showProjectFilterButtons = useUiFlag('projectListFilterMyProjects');
|
const showProjectFilterButtons = useUiFlag('projectListFilterMyProjects');
|
||||||
|
const projectsListNewCards = useUiFlag('projectsListNewCards');
|
||||||
const filters = ['All projects', 'My projects'];
|
const filters = ['All projects', 'My projects'];
|
||||||
const [filter, setFilter] = useState(filters[0]);
|
const [filter, setFilter] = useState(filters[0]);
|
||||||
const myProjects = new Set(useProfile().profile?.projects || []);
|
const myProjects = new Set(useProfile().profile?.projects || []);
|
||||||
@ -204,6 +215,13 @@ export const ProjectListNew = () => {
|
|||||||
? `${filteredProjects.length} of ${projects.length}`
|
? `${filteredProjects.length} of ${projects.length}`
|
||||||
: projects.length;
|
: projects.length;
|
||||||
|
|
||||||
|
const StyledItemsContainer = projectsListNewCards
|
||||||
|
? StyledGridContainer
|
||||||
|
: StyledDivContainer;
|
||||||
|
const ProjectCard = projectsListNewCards
|
||||||
|
? NewProjectCard
|
||||||
|
: LegacyProjectCard;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent
|
<PageContent
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
@ -282,7 +300,7 @@ export const ProjectListNew = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ConditionallyRender condition={error} show={renderError()} />
|
<ConditionallyRender condition={error} show={renderError()} />
|
||||||
<StyledDivContainer>
|
<StyledItemsContainer>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={filteredProjects.length < 1 && !loading}
|
condition={filteredProjects.length < 1 && !loading}
|
||||||
show={
|
show={
|
||||||
@ -350,7 +368,7 @@ export const ProjectListNew = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</StyledDivContainer>
|
</StyledItemsContainer>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -84,6 +84,7 @@ export type UiFlags = {
|
|||||||
scimApi?: boolean;
|
scimApi?: boolean;
|
||||||
projectListFilterMyProjects?: boolean;
|
projectListFilterMyProjects?: boolean;
|
||||||
createProjectWithEnvironmentConfig?: boolean;
|
createProjectWithEnvironmentConfig?: boolean;
|
||||||
|
projectsListNewCards?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
@ -59,6 +59,7 @@ export type IFlagKey =
|
|||||||
| 'projectOverviewRefactorFeedback'
|
| 'projectOverviewRefactorFeedback'
|
||||||
| 'featureLifecycle'
|
| 'featureLifecycle'
|
||||||
| 'projectListFilterMyProjects'
|
| 'projectListFilterMyProjects'
|
||||||
|
| 'projectsListNewCards'
|
||||||
| 'parseProjectFromSession'
|
| 'parseProjectFromSession'
|
||||||
| 'createProjectWithEnvironmentConfig'
|
| 'createProjectWithEnvironmentConfig'
|
||||||
| 'applicationOverviewNewQuery';
|
| 'applicationOverviewNewQuery';
|
||||||
|
@ -55,6 +55,7 @@ process.nextTick(async () => {
|
|||||||
projectOverviewRefactorFeedback: true,
|
projectOverviewRefactorFeedback: true,
|
||||||
featureLifecycle: true,
|
featureLifecycle: true,
|
||||||
projectListFilterMyProjects: true,
|
projectListFilterMyProjects: true,
|
||||||
|
projectsListNewCards: true,
|
||||||
parseProjectFromSession: true,
|
parseProjectFromSession: true,
|
||||||
createProjectWithEnvironmentConfig: true,
|
createProjectWithEnvironmentConfig: true,
|
||||||
applicationOverviewNewQuery: true,
|
applicationOverviewNewQuery: true,
|
||||||
|
Loading…
Reference in New Issue
Block a user