feat(review): ready for testing

This commit is contained in:
haitao(lj) 2022-11-25 17:55:13 +08:00
parent 81ec786366
commit 079513e8b6
16 changed files with 358 additions and 72 deletions

View File

@ -1190,6 +1190,9 @@ ui:
answer_edit: Answer edit
tag_edit: Tag edit
edit_summary: Edit summary
edit_question: Edit question
edit_answer: Edit answer
edit_tag: Edit tag
empty: No review tasks left.
timeline:
undeleted: undeleted

View File

@ -157,6 +157,13 @@ export interface SetNoticeReq {
notice_switch: boolean;
}
export interface NotificationStatus {
inbox: number;
achievement: number;
revision: number;
can_revision: boolean;
}
export interface QuestionDetailRes {
id: string;
title: string;
@ -192,7 +199,6 @@ export interface AnswerItem {
create_time: string;
update_time: string;
user_info: UserInfoBase;
[prop: string]: any;
}
@ -406,3 +412,29 @@ export interface TimelineRes {
object_info: TimelineObject;
timeline: TimelineItem[];
}
export interface ReviewItem {
type: 'question' | 'answer' | 'tag';
info: {
object_id: string;
title: string;
content: string;
html: string;
tags: Tag[];
};
unreviewed_info: {
id: string;
use_id: string;
object_id: string;
title: string;
status: 0 | 1;
create_at: number;
user_info: UserInfoBase;
reason: string;
content: Tag | QuestionDetailRes | AnswerItem;
};
}
export interface ReviewResp {
count: number;
list: ReviewItem[];
}

View File

@ -4,13 +4,26 @@ import { Tag } from '@/components';
import { diffText } from '@/utils';
interface Props {
objectType: string;
objectType: string | 'question' | 'answer' | 'tag';
newData: Record<string, any>;
oldData?: Record<string, any>;
className?: string;
opts?: Partial<{
showTitle: boolean;
showTagUrlSlug: boolean;
}>;
}
const Index: FC<Props> = ({ objectType, newData, oldData, className = '' }) => {
const Index: FC<Props> = ({
objectType,
newData,
oldData,
className = '',
opts = {
showTitle: true,
showTagUrlSlug: true,
},
}) => {
if (!newData?.original_text) return null;
let tag = newData.tags;
@ -51,7 +64,7 @@ const Index: FC<Props> = ({ objectType, newData, oldData, className = '' }) => {
return (
<div className={className}>
{objectType !== 'answer' && (
{objectType !== 'answer' && opts?.showTitle && (
<h5
dangerouslySetInnerHTML={{
__html: diffText(newData.title, oldData?.title),
@ -73,7 +86,7 @@ const Index: FC<Props> = ({ objectType, newData, oldData, className = '' }) => {
})}
</div>
)}
{objectType === 'tag' && (
{objectType === 'tag' && opts?.showTagUrlSlug && (
<div className="mb-4">
{`/tags/${
newData?.main_tag_slug_name

View File

@ -3,10 +3,11 @@ import { Nav, Dropdown } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link, NavLink } from 'react-router-dom';
import type * as Type from '@/common/interface';
import { Avatar, Icon } from '@/components';
interface Props {
redDot;
redDot: Type.NotificationStatus | undefined;
userInfo;
logOut: () => void;
}
@ -56,10 +57,14 @@ const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
{userInfo?.is_admin ? (
<Dropdown.Item href="/admin">{t('header.nav.admin')}</Dropdown.Item>
) : null}
{/* TODO: use review permission */}
{userInfo?.is_admin ? (
<Dropdown.Item href="/review">
{redDot?.can_revision ? (
<Dropdown.Item href="/review" className="position-relative">
{t('header.nav.review')}
{redDot?.revision > 0 && (
<span className="position-absolute top-50 translate-middle-y end-0 me-3 p-2 bg-danger border border-light rounded-circle">
<span className="visually-hidden">New Review</span>
</span>
)}
</Dropdown.Item>
) : null}
<Dropdown.Divider />

View File

@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Modal } from '@/components';
import { useReportModal, useToast } from '@/hooks';
import Share from '../Share';
import { deleteQuestion, deleteAnswer } from '@/services';
import { deleteQuestion, deleteAnswer, editCheck } from '@/services';
import { tryNormalLogged } from '@/utils/guard';
interface IProps {
@ -96,6 +96,15 @@ const Index: FC<IProps> = ({
});
}
};
const handleEdit = async (evt) => {
let checkObjectId = qid;
if (type === 'answer') {
checkObjectId = aid;
}
editCheck(checkObjectId).catch(() => {
evt.preventDefault();
});
};
const handleAction = (action) => {
if (!tryNormalLogged(true)) {
@ -124,6 +133,7 @@ const Index: FC<IProps> = ({
key={item.action}
to={editUrl}
className="link-secondary p-0 fs-14 me-3"
onClick={handleEdit}
style={{ lineHeight: '23px' }}>
{item.name}
</Link>

View File

@ -3,6 +3,7 @@ import React, { memo, FC } from 'react';
import classNames from 'classnames';
import { Tag } from '@/common/interface';
import { pathFactory } from '@/router/pathFactory';
interface IProps {
data: Tag;
@ -17,9 +18,7 @@ const Index: FC<IProps> = ({
className = '',
textClassName = '',
}) => {
href ||= `/tags/${encodeURIComponent(
data.main_tag_slug_name || data.slug_name,
)}`.toLowerCase();
href ||= pathFactory.tagLanding(data);
return (
<a

View File

@ -1,4 +1,4 @@
import { FC } from 'react';
import { FC, useEffect, useState } from 'react';
import {
Container,
Row,
@ -11,61 +11,218 @@ import {
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { BaseUserCard, FormatTime, Empty } from '@/components';
import { loggedUserInfoStore } from '@/stores';
import { BaseUserCard, FormatTime, Empty, DiffContent } from '@/components';
import { useReviewList, revisionAudit } from '@/services';
import { pathFactory } from '@/router/pathFactory';
import type * as Type from '@/common/interface';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_review' });
const [isLoading, setIsLoading] = useState(false);
const [noTasks, setNoTasks] = useState(false);
const [page, setPage] = useState(1);
const { data: reviewResp, mutate: mutateList } = useReviewList(page);
const ro = reviewResp?.list[0];
const { info, type, unreviewed_info } = ro || {
info: null,
type: '',
unreviewed_info: null,
};
const reviewInfo = unreviewed_info?.content;
const mutateNextPage = () => {
const count = reviewResp?.count;
if (count && page < count) {
setPage(page + 1);
} else {
setNoTasks(true);
}
};
const handlingSkip = () => {
mutateNextPage();
};
const handlingApprove = () => {
if (!unreviewed_info) {
return;
}
setIsLoading(true);
revisionAudit(unreviewed_info.id, 'approve')
.then(() => {
mutateList();
})
.catch((ex) => {
console.log('ex: ', ex);
})
.finally(() => {
setIsLoading(false);
});
};
const handlingReject = () => {
if (!unreviewed_info) {
return;
}
setIsLoading(true);
revisionAudit(unreviewed_info.id, 'reject')
.then(() => {
mutateList();
})
.catch((ex) => {
console.log('ex: ', ex);
})
.finally(() => {
setIsLoading(false);
});
};
const { user } = loggedUserInfoStore.getState();
let itemLink = '';
let itemTitle = '';
let editBadge = '';
let editSummary = unreviewed_info?.reason;
const editor = unreviewed_info?.user_info;
const editTime = unreviewed_info?.create_at;
if (type === 'question') {
itemLink = pathFactory.questionLanding(info?.object_id);
itemTitle = info?.title;
editBadge = t('question_edit');
editSummary ||= t('edit_question');
} else if (type === 'answer') {
itemLink = pathFactory.answerLanding(
// @ts-ignore
unreviewed_info.content.question_id,
unreviewed_info.object_id,
);
itemTitle = info?.title;
editBadge = t('answer_edit');
editSummary ||= t('edit_answer');
} else if (type === 'tag') {
const tagInfo = unreviewed_info.content as Type.Tag;
itemLink = pathFactory.tagLanding(tagInfo);
itemTitle = tagInfo.display_name;
editBadge = t('tag_edit');
editSummary ||= t('edit_tag');
}
useEffect(() => {
if (!reviewResp) {
return;
}
window.scrollTo({ top: 0 });
if (!reviewResp.list || !reviewResp.list.length) {
setNoTasks(true);
}
}, [reviewResp]);
return (
<Container className="pt-2 mt-4 mb-5">
<Row>
<Col lg={{ span: 7, offset: 1 }}>
<h3 className="mb-4">{t('review')}</h3>
</Col>
{!noTasks && ro && (
<>
<Col lg={{ span: 7, offset: 1 }}>
<Alert variant="secondary">
<Stack className="align-items-start">
<Badge bg="secondary" className="mb-2">
{t('question_edit')}
{editBadge}
</Badge>
<Link to="/review">
How do I test whether variable against multiple
<Link to={itemLink} target="_blank">
{itemTitle}
</Link>
<p className="mb-0">
{t('edit_summary')}: Editing part of the code and correcting the
grammar.
{t('edit_summary')}: {editSummary}
</p>
</Stack>
<Stack
direction="horizontal"
gap={1}
className="align-items-baseline mt-2">
<BaseUserCard data={user} avatarSize="24" />
<BaseUserCard data={editor} avatarSize="24" />
{editTime && (
<FormatTime
time={Date.now()}
time={editTime}
className="small text-secondary"
preFix={t('proposed')}
/>
)}
</Stack>
</Alert>
</Col>
<Col lg={{ span: 7, offset: 1 }}>Content</Col>
<Col lg={{ span: 7, offset: 1 }}>
<Stack direction="horizontal" gap={2}>
<Button variant="outline-primary">
{type === 'question' &&
info &&
reviewInfo &&
'content' in reviewInfo && (
<DiffContent
className="mt-2"
objectType={type}
oldData={{
title: info.title,
original_text: info.content,
tags: info.tags,
}}
newData={{
title: reviewInfo.title,
original_text: reviewInfo.content,
tags: reviewInfo.tags,
}}
/>
)}
{type === 'answer' &&
info &&
reviewInfo &&
'content' in reviewInfo && (
<DiffContent
className="mt-2"
objectType={type}
newData={{
original_text: reviewInfo.content,
}}
oldData={{
original_text: info.content,
}}
/>
)}
{type === 'tag' && info && reviewInfo && (
<DiffContent
className="mt-2"
objectType={type}
newData={{
original_text: reviewInfo.original_text,
}}
oldData={{
original_text: info.content,
}}
opts={{ showTitle: false, showTagUrlSlug: false }}
/>
)}
</Col>
<Col lg={{ span: 7, offset: 1 }}>
<Stack direction="horizontal" gap={2} className="mt-4">
<Button
variant="outline-primary"
disabled={isLoading}
onClick={handlingApprove}>
{t('approve', { keyPrefix: 'btns' })}
</Button>
<Button variant="outline-primary">
<Button
variant="outline-primary"
disabled={isLoading}
onClick={handlingReject}>
{t('reject', { keyPrefix: 'btns' })}
</Button>
<Button variant="outline-primary">
<Button
variant="outline-primary"
disabled={isLoading}
onClick={handlingSkip}>
{t('skip', { keyPrefix: 'btns' })}
</Button>
</Stack>
</Col>
</>
)}
{noTasks && (
<Col lg={{ span: 7, offset: 1 }}>
<Empty>{t('empty')}</Empty>
</Col>
)}
</Row>
</Container>
);

View File

@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { following } from '@/services';
import { tryNormalLogged } from '@/utils/guard';
import { escapeRemove } from '@/utils';
import { pathFactory } from '@/router/pathFactory';
interface Props {
data;
@ -53,7 +54,9 @@ const Index: FC<Props> = ({ data }) => {
{data.excerpt && (
<p className="text-break">
{escapeRemove(data.excerpt)}
<Link to={`/tags/${data.slug_name}/info`}> [{t('more')}]</Link>
<Link className="ms-1" to={pathFactory.tagInfo(data.slug_name)}>
[{t('more')}]
</Link>
</p>
)}

View File

@ -9,6 +9,7 @@ import { useTagInfo, useFollow } from '@/services';
import QuestionList from '@/components/QuestionList';
import HotQuestions from '@/components/HotQuestions';
import { escapeRemove } from '@/utils';
import { pathFactory } from '@/router/pathFactory';
const Questions: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'tags' });
@ -30,7 +31,7 @@ const Questions: FC = () => {
if (tagResp) {
const info = { ...tagResp };
if (info.main_tag_slug_name) {
navigate(`/tags/${info.main_tag_slug_name}`, { replace: true });
navigate(pathFactory.tagLanding(info), { replace: true });
return;
}
if (followResp) {
@ -62,7 +63,7 @@ const Questions: FC = () => {
<div className="tag-box mb-5">
<h3 className="mb-3">
<Link
to={`/tags/${tagInfo?.slug_name}`}
to={pathFactory.tagLanding(tagInfo)}
replace
className="link-dark">
{tagInfo.display_name}
@ -71,9 +72,7 @@ const Questions: FC = () => {
<p className="text-break">
{escapeRemove(tagInfo.excerpt) || t('no_description')}
<Link
to={`/tags/${encodeURIComponent(curTagName)}/info`}
className="ms-1">
<Link to={pathFactory.tagInfo(curTagName)} className="ms-1">
[{t('more')}]
</Link>
</p>

View File

@ -175,7 +175,7 @@ const Ask = () => {
<Form.Control
value={formData.displayName.value}
isInvalid={formData.displayName.isInvalid}
disabled={is_admin}
disabled={!is_admin}
onChange={handleDisplayNameChange}
/>
@ -188,7 +188,7 @@ const Ask = () => {
<Form.Control
value={formData.slugName.value}
isInvalid={formData.slugName.isInvalid}
disabled={is_admin}
disabled={!is_admin}
onChange={handleSlugNameChange}
/>
<Form.Text as="div">

View File

@ -11,7 +11,9 @@ import {
useQuerySynonymsTags,
saveSynonymsTags,
deleteTag,
editCheck,
} from '@/services';
import { pathFactory } from '@/router/pathFactory';
import { loggedUserInfoStore } from '@/stores';
const TagIntroduction = () => {
@ -27,7 +29,9 @@ const TagIntroduction = () => {
return null;
}
if (tagInfo.main_tag_slug_name) {
navigate(`/tags/${tagInfo.main_tag_slug_name}/info`, { replace: true });
navigate(pathFactory.tagInfo(tagInfo.main_tag_slug_name), {
replace: true,
});
return null;
}
const handleEdit = () => {
@ -51,7 +55,9 @@ const TagIntroduction = () => {
};
const handleEditTag = () => {
navigate(`/tags/${tagInfo?.tag_id}/edit`);
editCheck(tagInfo?.tag_id).then(() => {
navigate(pathFactory.tagEdit(tagInfo?.tag_id));
});
};
const handleDeleteTag = () => {
if (synonymsTags && synonymsTags.length > 0) {
@ -93,7 +99,7 @@ const TagIntroduction = () => {
<Col xxl={7} lg={8} sm={12}>
<h3 className="mb-3">
<Link
to={`/tags/${tagInfo?.slug_name}`}
to={pathFactory.tagLanding(tagInfo)}
replace
className="link-dark">
{tagInfo.display_name}

View File

@ -0,0 +1,28 @@
import type * as Type from '@/common/interface';
const tagLanding = (tag: Type.Tag) => {
let slugName = tag.main_tag_slug_name || tag.slug_name || '';
slugName = slugName.toLowerCase();
return `/tags/${encodeURIComponent(slugName)}`;
};
const tagInfo = (slugName: string) => {
slugName = slugName.toLowerCase();
return `/tags/${encodeURIComponent(slugName)}/info`;
};
const tagEdit = (tagId: string) => {
return `/tags/${tagId}/edit`;
};
const questionLanding = (question_id: string) => {
return `/questions/${question_id}`;
};
const answerLanding = (question_id: string, answer_id: string) => {
return `/questions/${question_id}/${answer_id}`;
};
export const pathFactory = {
tagLanding,
tagInfo,
tagEdit,
questionLanding,
answerLanding,
};

View File

@ -192,7 +192,6 @@ const routes: RouteNode[] = [
{
path: '/users/confirm-new-email',
page: 'pages/Users/ConfirmNewEmail',
// TODO: guard this
},
{
path: '/users/account-suspended',

View File

@ -7,3 +7,4 @@ export * from './tag';
export * from './settings';
export * from './legal';
export * from './timeline';
export * from './revision';

View File

@ -32,7 +32,7 @@ export const readNotification = (id) => {
export const useQueryNotificationStatus = () => {
const apiUrl = '/answer/api/v1/notification/status';
return useSWR<{ inbox: number; achievement: number }>(
return useSWR<Type.NotificationStatus>(
tryLoggedAndActivated().ok ? apiUrl : null,
request.instance.get,
{

View File

@ -0,0 +1,31 @@
import useSWR from 'swr';
import request from '@/utils/request';
import * as Type from '@/common/interface';
export const editCheck = (id: string) => {
const apiUrl = `/answer/api/v1/revisions/edit/check?id=${id}`;
return request.get(apiUrl);
};
export const revisionAudit = (id: string, operation: 'approve' | 'reject') => {
const apiUrl = `/answer/api/v1/revisions/audit`;
return request.put(apiUrl, {
id,
operation,
});
};
export const useReviewList = (page: number) => {
const apiUrl = `/answer/api/v1/revisions/unreviewed?page=${page}`;
const { data, error, mutate } = useSWR<Type.ReviewResp, Error>(
apiUrl,
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
mutate,
};
};