mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-11-03 12:08:31 +00:00
Task improvements (#12361)
* chore(#6741): use antlr parser for fqn and entityLink * chore: use this for class context * chore: address comments * chore: convert js to ts * fix: typescript errors * fix: entity link split method issue * fix: split listener * chore(ui): task UI improvements * feat: add support for edit task assignees * fix: validation issue * minor improvements * chore: address comments * chore: show dropdown button on task action button --------- Co-authored-by: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com>
This commit is contained in:
parent
e200a42926
commit
a47e80baf0
@ -27,6 +27,7 @@ interface ActivityFeedListV1Props {
|
||||
onFeedClick?: (feed: Thread) => void;
|
||||
activeFeedId?: string;
|
||||
hidePopover: boolean;
|
||||
isForFeedTab?: boolean;
|
||||
emptyPlaceholderText: string;
|
||||
}
|
||||
|
||||
@ -37,6 +38,7 @@ const ActivityFeedListV1 = ({
|
||||
onFeedClick,
|
||||
activeFeedId,
|
||||
hidePopover = false,
|
||||
isForFeedTab = false,
|
||||
emptyPlaceholderText,
|
||||
}: ActivityFeedListV1Props) => {
|
||||
const [entityThread, setEntityThread] = useState<Thread[]>([]);
|
||||
@ -76,6 +78,7 @@ const ActivityFeedListV1 = ({
|
||||
feed={feed}
|
||||
hidePopover={hidePopover}
|
||||
isActive={activeFeedId === feed.id}
|
||||
isForFeedTab={isForFeedTab}
|
||||
key={feed.id}
|
||||
showThread={showThread}
|
||||
onFeedClick={onFeedClick}
|
||||
|
||||
@ -26,6 +26,7 @@ interface FeedPanelBodyPropV1 {
|
||||
isOpenInDrawer?: boolean;
|
||||
onFeedClick?: (feed: Thread) => void;
|
||||
isActive?: boolean;
|
||||
isForFeedTab?: boolean;
|
||||
hidePopover: boolean;
|
||||
}
|
||||
|
||||
@ -37,6 +38,7 @@ const FeedPanelBodyV1: FC<FeedPanelBodyPropV1> = ({
|
||||
onFeedClick,
|
||||
isActive,
|
||||
hidePopover = false,
|
||||
isForFeedTab = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const mainFeed = {
|
||||
@ -64,6 +66,7 @@ const FeedPanelBodyV1: FC<FeedPanelBodyPropV1> = ({
|
||||
feed={feed}
|
||||
hidePopover={hidePopover}
|
||||
isActive={isActive}
|
||||
isForFeedTab={isForFeedTab}
|
||||
isOpenInDrawer={isOpenInDrawer}
|
||||
key={feed.id}
|
||||
post={mainFeed}
|
||||
|
||||
@ -350,6 +350,7 @@ export const ActivityFeedTab = ({
|
||||
)}
|
||||
<ActivityFeedListV1
|
||||
hidePopover
|
||||
isForFeedTab
|
||||
activeFeedId={selectedThread?.id}
|
||||
emptyPlaceholderText={placeholderText}
|
||||
feedList={threads}
|
||||
@ -383,6 +384,7 @@ export const ActivityFeedTab = ({
|
||||
/>
|
||||
</div>
|
||||
<FeedPanelBodyV1
|
||||
isForFeedTab
|
||||
isOpenInDrawer
|
||||
showThread
|
||||
feed={selectedThread}
|
||||
|
||||
@ -11,24 +11,26 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import Icon from '@ant-design/icons';
|
||||
import { Col, Row, Tooltip, Typography } from 'antd';
|
||||
import { Button, Col, Row, Tooltip, Typography } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import AssigneeList from 'components/common/AssigneeList/AssigneeList';
|
||||
import EntityPopOverCard from 'components/common/PopOverCard/EntityPopOverCard';
|
||||
import UserPopOverCard from 'components/common/PopOverCard/UserPopOverCard';
|
||||
import ProfilePicture from 'components/common/ProfilePicture/ProfilePicture';
|
||||
import { Post, Thread, ThreadTaskStatus } from 'generated/entity/feed/thread';
|
||||
import { isUndefined, noop } from 'lodash';
|
||||
import React, { useState } from 'react';
|
||||
import { isEmpty, isUndefined, noop } from 'lodash';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import { getNameFromFQN } from 'utils/CommonUtils';
|
||||
import EntityLink from 'utils/EntityLink';
|
||||
import {
|
||||
getEntityFieldDisplay,
|
||||
getEntityFQN,
|
||||
getEntityType,
|
||||
prepareFeedLink,
|
||||
} from 'utils/FeedUtils';
|
||||
import { getTaskDetailPath } from 'utils/TasksUtils';
|
||||
import {
|
||||
getDateTimeFromMilliSeconds,
|
||||
getDayTimeByTimeStamp,
|
||||
@ -48,6 +50,7 @@ interface TaskFeedCardProps {
|
||||
isEntityFeed?: boolean;
|
||||
isOpenInDrawer?: boolean;
|
||||
isActive?: boolean;
|
||||
isForFeedTab?: boolean;
|
||||
hidePopover: boolean;
|
||||
}
|
||||
|
||||
@ -59,19 +62,33 @@ const TaskFeedCard = ({
|
||||
showThread = true,
|
||||
isActive,
|
||||
hidePopover = false,
|
||||
isForFeedTab = false,
|
||||
}: TaskFeedCardProps) => {
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const timeStamp = feed.threadTs;
|
||||
const taskDetails = feed.task;
|
||||
const postLength = feed?.postsCount ?? 0;
|
||||
const entityType = getEntityType(feed.about) ?? '';
|
||||
const entityFQN = getEntityFQN(feed.about) ?? '';
|
||||
|
||||
const entityCheck = !isUndefined(entityFQN) && !isUndefined(entityType);
|
||||
const [isEditPost, setIsEditPost] = useState(false);
|
||||
const repliedUsers = [...new Set((feed?.posts ?? []).map((f) => f.from))];
|
||||
const repliedUniqueUsersList = repliedUsers.slice(0, postLength >= 3 ? 2 : 1);
|
||||
|
||||
const { showDrawer } = useActivityFeedProvider();
|
||||
const { showDrawer, setActiveThread } = useActivityFeedProvider();
|
||||
|
||||
const taskField = useMemo(() => {
|
||||
const entityField = EntityLink.getEntityField(feed.about) ?? '';
|
||||
const columnName = EntityLink.getTableColumnName(feed.about) ?? '';
|
||||
|
||||
if (columnName) {
|
||||
return `${entityField}/${columnName}`;
|
||||
}
|
||||
|
||||
return entityField;
|
||||
}, [feed]);
|
||||
|
||||
const showReplies = () => {
|
||||
showDrawer?.(feed);
|
||||
@ -81,11 +98,21 @@ const TaskFeedCard = ({
|
||||
setIsEditPost(!isEditPost);
|
||||
};
|
||||
|
||||
const handleTaskLinkClick = () => {
|
||||
history.push({
|
||||
pathname: getTaskDetailPath(feed),
|
||||
});
|
||||
setActiveThread(feed);
|
||||
};
|
||||
|
||||
const getTaskLinkElement = entityCheck && (
|
||||
<Typography.Text>
|
||||
<span>{`#${taskDetails?.id} `}</span>
|
||||
<Button
|
||||
className="p-0"
|
||||
type="link"
|
||||
onClick={handleTaskLinkClick}>{`#${taskDetails?.id} `}</Button>
|
||||
|
||||
<Typography.Text>{taskDetails?.type}</Typography.Text>
|
||||
<Typography.Text className="p-l-xss">{taskDetails?.type}</Typography.Text>
|
||||
<span className="m-x-xss">{t('label.for-lowercase')}</span>
|
||||
{isEntityFeed ? (
|
||||
<span className="tw-heading" data-testid="headerText-entityField">
|
||||
@ -93,16 +120,26 @@ const TaskFeedCard = ({
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="p-r-xss">{entityType}</span>
|
||||
<EntityPopOverCard entityFQN={entityFQN} entityType={entityType}>
|
||||
<Link
|
||||
className="break-all"
|
||||
data-testid="entitylink"
|
||||
to={prepareFeedLink(entityType, entityFQN)}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{getNameFromFQN(entityFQN)}
|
||||
</Link>
|
||||
</EntityPopOverCard>
|
||||
{isForFeedTab ? null : (
|
||||
<>
|
||||
<span className="p-r-xss">{entityType}</span>
|
||||
<EntityPopOverCard entityFQN={entityFQN} entityType={entityType}>
|
||||
<Link
|
||||
className="break-all"
|
||||
data-testid="entitylink"
|
||||
to={prepareFeedLink(entityType, entityFQN)}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{getNameFromFQN(entityFQN)}
|
||||
</Link>
|
||||
</EntityPopOverCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isEmpty(taskField) ? (
|
||||
<span className={classNames({ 'p-l-xss': !isForFeedTab })}>
|
||||
{taskField}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Typography.Text>
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
*/
|
||||
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { Button, Space } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { InlineEditProps } from './InlineEdit.interface';
|
||||
|
||||
@ -20,10 +21,11 @@ const InlineEdit = ({
|
||||
onCancel,
|
||||
onSave,
|
||||
direction,
|
||||
className,
|
||||
}: InlineEditProps) => {
|
||||
return (
|
||||
<Space
|
||||
className="w-full"
|
||||
className={classNames(className, 'w-full')}
|
||||
data-testid="inline-edit-container"
|
||||
direction={direction}>
|
||||
{children}
|
||||
|
||||
@ -14,6 +14,7 @@ import { SpaceProps } from 'antd';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export interface InlineEditProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
onCancel: () => void;
|
||||
// onSave it can be API call or normal function
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import Icon from '@ant-design/icons';
|
||||
import Icon, { DownOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
@ -21,40 +21,51 @@ import {
|
||||
Space,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useForm } from 'antd/lib/form/Form';
|
||||
import Modal from 'antd/lib/modal/Modal';
|
||||
import AppState from 'AppState';
|
||||
import { ReactComponent as EditIcon } from 'assets/svg/edit-new.svg';
|
||||
import { AxiosError } from 'axios';
|
||||
import classNames from 'classnames';
|
||||
import ActivityFeedCardV1 from 'components/ActivityFeed/ActivityFeedCard/ActivityFeedCardV1';
|
||||
import ActivityFeedEditor from 'components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor';
|
||||
import { useActivityFeedProvider } from 'components/ActivityFeed/ActivityFeedProvider/ActivityFeedProvider';
|
||||
import AssigneeList from 'components/common/AssigneeList/AssigneeList';
|
||||
import { OwnerLabel } from 'components/common/OwnerLabel/OwnerLabel.component';
|
||||
import EntityPopOverCard from 'components/common/PopOverCard/EntityPopOverCard';
|
||||
import InlineEdit from 'components/InlineEdit/InlineEdit.component';
|
||||
import { DE_ACTIVE_COLOR } from 'constants/constants';
|
||||
import { TaskOperation } from 'constants/Feeds.constants';
|
||||
import { compare } from 'fast-json-patch';
|
||||
import { TaskType } from 'generated/api/feed/createThread';
|
||||
import { TaskDetails, ThreadTaskStatus } from 'generated/entity/feed/thread';
|
||||
import { TagLabel } from 'generated/type/tagLabel';
|
||||
import { useAuth } from 'hooks/authHooks';
|
||||
import { isEmpty, isEqual, isUndefined, noop } from 'lodash';
|
||||
import Assignees from 'pages/TasksPage/shared/Assignees';
|
||||
import DescriptionTask from 'pages/TasksPage/shared/DescriptionTask';
|
||||
import TagsTask from 'pages/TasksPage/shared/TagsTask';
|
||||
import {
|
||||
Option,
|
||||
TaskAction,
|
||||
TaskActionMode,
|
||||
} from 'pages/TasksPage/TasksPage.interface';
|
||||
import { MenuInfo } from 'rc-menu/lib/interface';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import { updateTask } from 'rest/feedsAPI';
|
||||
import { getNameFromFQN } from 'utils/CommonUtils';
|
||||
import { getEntityFQN, prepareFeedLink } from 'utils/FeedUtils';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { updateTask, updateThread } from 'rest/feedsAPI';
|
||||
import EntityLink from 'utils/EntityLink';
|
||||
import { getEntityName } from 'utils/EntityUtils';
|
||||
import { getEntityFQN } from 'utils/FeedUtils';
|
||||
import { getEntityLink } from 'utils/TableUtils';
|
||||
import {
|
||||
fetchOptions,
|
||||
isDescriptionTask,
|
||||
isTagsTask,
|
||||
TASK_ACTION_LIST,
|
||||
} from 'utils/TasksUtils';
|
||||
import { showErrorToast, showSuccessToast } from 'utils/ToastUtils';
|
||||
import './task-tab.less';
|
||||
import { TaskTabProps } from './TaskTab.interface';
|
||||
import { ReactComponent as TaskCloseIcon } from '/assets/svg/ic-close-task.svg';
|
||||
import { ReactComponent as TaskOpenIcon } from '/assets/svg/ic-open-task.svg';
|
||||
@ -65,6 +76,9 @@ export const TaskTab = ({
|
||||
entityType,
|
||||
...rest
|
||||
}: TaskTabProps) => {
|
||||
const [assigneesForm] = useForm();
|
||||
const updatedAssignees = Form.useWatch('assignees', assigneesForm);
|
||||
|
||||
const { task: taskDetails } = taskThread;
|
||||
const entityFQN = getEntityFQN(taskThread.about) ?? '';
|
||||
const entityCheck = !isUndefined(entityFQN) && !isUndefined(entityType);
|
||||
@ -72,12 +86,24 @@ export const TaskTab = ({
|
||||
const [form] = Form.useForm();
|
||||
const history = useHistory();
|
||||
const { isAdminUser } = useAuth();
|
||||
const { postFeed } = useActivityFeedProvider();
|
||||
const { postFeed, setActiveThread } = useActivityFeedProvider();
|
||||
const [taskAction, setTaskAction] = useState<TaskAction>(TASK_ACTION_LIST[0]);
|
||||
|
||||
const isTaskClosed = isEqual(taskDetails?.status, ThreadTaskStatus.Closed);
|
||||
const [showEditTaskModel, setShowEditTaskModel] = useState(false);
|
||||
const [comment, setComment] = useState('');
|
||||
const [isEditAssignee, setIsEditAssignee] = useState<boolean>(false);
|
||||
const [options, setOptions] = useState<Option[]>([]);
|
||||
|
||||
const initialAssignees = useMemo(
|
||||
() =>
|
||||
taskDetails?.assignees.map((assignee) => ({
|
||||
label: getEntityName(assignee),
|
||||
value: assignee.id || '',
|
||||
type: assignee.type,
|
||||
})) ?? [],
|
||||
[taskDetails]
|
||||
);
|
||||
|
||||
// get current user details
|
||||
const currentUser = useMemo(
|
||||
@ -85,6 +111,17 @@ export const TaskTab = ({
|
||||
[AppState.userDetails, AppState.nonSecureUserDetails]
|
||||
);
|
||||
|
||||
const taskField = useMemo(() => {
|
||||
const entityField = EntityLink.getEntityField(taskThread.about) ?? '';
|
||||
const columnName = EntityLink.getTableColumnName(taskThread.about) ?? '';
|
||||
|
||||
if (columnName) {
|
||||
return `${entityField}/${columnName}`;
|
||||
}
|
||||
|
||||
return entityField;
|
||||
}, [taskThread]);
|
||||
|
||||
const isOwner = isEqual(owner?.id, currentUser?.id);
|
||||
const isCreator = isEqual(taskThread.createdBy, currentUser?.name);
|
||||
|
||||
@ -113,18 +150,8 @@ export const TaskTab = ({
|
||||
|
||||
<Typography.Text>{taskDetails?.type}</Typography.Text>
|
||||
<span className="m-x-xss">{t('label.for-lowercase')}</span>
|
||||
<>
|
||||
<span className="p-r-xss">{entityType}</span>
|
||||
<EntityPopOverCard entityFQN={entityFQN} entityType={entityType}>
|
||||
<Link
|
||||
className="break-all"
|
||||
data-testid="entitylink"
|
||||
to={prepareFeedLink(entityType, entityFQN)}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{getNameFromFQN(entityFQN)}
|
||||
</Link>
|
||||
</EntityPopOverCard>
|
||||
</>
|
||||
|
||||
{!isEmpty(taskField) ? <span>{taskField}</span> : null}
|
||||
</Typography.Text>
|
||||
);
|
||||
|
||||
@ -259,6 +286,7 @@ export const TaskTab = ({
|
||||
</Button>
|
||||
) : (
|
||||
<Dropdown.Button
|
||||
icon={<DownOutlined />}
|
||||
menu={{
|
||||
items: TASK_ACTION_LIST,
|
||||
selectable: true,
|
||||
@ -304,6 +332,32 @@ export const TaskTab = ({
|
||||
}
|
||||
}, [taskDetails, isTaskDescription]);
|
||||
|
||||
const handleAssigneeUpdate = async () => {
|
||||
const updatedTaskThread = {
|
||||
...taskThread,
|
||||
task: {
|
||||
...taskThread.task,
|
||||
assignees: updatedAssignees.map((assignee: Option) => ({
|
||||
id: assignee.value,
|
||||
type: assignee.type,
|
||||
})),
|
||||
},
|
||||
};
|
||||
try {
|
||||
const patch = compare(taskThread, updatedTaskThread);
|
||||
const data = await updateThread(taskThread.id, patch);
|
||||
setIsEditAssignee(false);
|
||||
setActiveThread(data);
|
||||
} catch (error) {
|
||||
showErrorToast(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
assigneesForm.setFieldValue('assignees', initialAssignees);
|
||||
setOptions(initialAssignees);
|
||||
}, [initialAssignees]);
|
||||
|
||||
return (
|
||||
<Row className="p-y-sm p-x-md" gutter={[0, 24]}>
|
||||
<Col className="d-flex items-center" span={24}>
|
||||
@ -320,19 +374,78 @@ export const TaskTab = ({
|
||||
{getTaskLinkElement}
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<div className="d-flex justify-between">
|
||||
<div className="flex-center gap-2">
|
||||
<Typography.Text className="text-grey-muted">
|
||||
{t('label.assignee-plural')}:{' '}
|
||||
</Typography.Text>
|
||||
|
||||
<OwnerLabel
|
||||
hasPermission={false}
|
||||
owner={taskDetails?.assignees[0]}
|
||||
onUpdate={noop}
|
||||
/>
|
||||
<div
|
||||
className={classNames('d-flex justify-between', {
|
||||
'flex-column': isEditAssignee,
|
||||
})}>
|
||||
<div
|
||||
className={classNames('gap-2', { 'flex-center': !isEditAssignee })}>
|
||||
{isEditAssignee ? (
|
||||
<Form
|
||||
form={assigneesForm}
|
||||
layout="vertical"
|
||||
onFinish={handleAssigneeUpdate}>
|
||||
<Form.Item
|
||||
data-testid="assignees"
|
||||
label={`${t('label.assignee-plural')}:`}
|
||||
name="assignees"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('message.field-text-is-required', {
|
||||
fieldText: t('label.assignee-plural'),
|
||||
}),
|
||||
},
|
||||
]}>
|
||||
<InlineEdit
|
||||
className="assignees-edit-input"
|
||||
direction="horizontal"
|
||||
onCancel={() => {
|
||||
setIsEditAssignee(false);
|
||||
assigneesForm.setFieldValue(
|
||||
'assignees',
|
||||
initialAssignees
|
||||
);
|
||||
}}
|
||||
onSave={() => assigneesForm.submit()}>
|
||||
<Assignees
|
||||
options={options}
|
||||
value={updatedAssignees}
|
||||
onChange={(values) =>
|
||||
assigneesForm.setFieldValue('assignees', values)
|
||||
}
|
||||
onSearch={(query) => fetchOptions(query, setOptions)}
|
||||
/>
|
||||
</InlineEdit>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : (
|
||||
<>
|
||||
<Typography.Text className="text-grey-muted">
|
||||
{t('label.assignee-plural')}:{' '}
|
||||
</Typography.Text>
|
||||
<AssigneeList
|
||||
assignees={taskDetails?.assignees ?? []}
|
||||
className="d-flex gap-1"
|
||||
profilePicType="circle"
|
||||
profileWidth="24"
|
||||
showUserName={false}
|
||||
/>
|
||||
{isCreator || hasTaskUpdateAccess() ? (
|
||||
<Button
|
||||
className="flex-center p-0"
|
||||
data-testid="edit-assignees"
|
||||
icon={<EditIcon color={DE_ACTIVE_COLOR} width="14px" />}
|
||||
size="small"
|
||||
type="text"
|
||||
onClick={() => setIsEditAssignee(true)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-center gap-2">
|
||||
<div
|
||||
className={classNames('gap-2', { 'flex-center': !isEditAssignee })}>
|
||||
<Typography.Text className="text-grey-muted">
|
||||
{t('label.created-by')}:{' '}
|
||||
</Typography.Text>
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
.assignees-edit-input {
|
||||
.ant-space-item:first-child {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@ -142,6 +142,9 @@
|
||||
.flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
.flex-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// Justify Items
|
||||
.justify-center {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user