Merge pull request #369 from answerdev/feat/1.1.0/ui

Feat/1.1.0/UI
This commit is contained in:
haitao.jarvis 2023-05-24 16:32:44 +08:00 committed by GitHub
commit 97f58483f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 356 additions and 11 deletions

View File

@ -482,6 +482,11 @@ ui:
all_read: Mark all as read
show_more: Show more
someone: Someone
inbox_type:
all: All
posts: Posts
invites: Invites
votes: Votes
suspended:
title: Your Account has been Suspended
until_time: "Your account was suspended until {{ time }}."
@ -971,6 +976,12 @@ ui:
title: Related Questions
btn: Add question
answers: answers
invite_to_answer:
title: People asked
desc: Invite people who you think might know the answer.
invite: Invite to answer
add: Add people
search: Search people
question_detail:
action: Action
Asked: Asked
@ -1037,6 +1048,7 @@ ui:
btns:
confirm: Confirm
cancel: Cancel
edit: Edit
save: Save
delete: Delete
login: Log in

View File

@ -122,7 +122,7 @@ export interface UserInfoBase {
*/
status?: string;
/** roles */
role_id: RoleId;
role_id?: RoleId;
}
export interface UserInfoRes extends UserInfoBase {

View File

@ -0,0 +1,95 @@
import { FC, memo, useEffect, useState } from 'react';
import { Dropdown, Form } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { loggedUserInfoStore } from '@/stores';
import { userSearchByName } from '@/services';
import { Avatar } from '@/components';
import * as Type from '@/common/interface';
interface Props {
selectedPeople: Type.UserInfoBase[] | undefined;
onSelect: (people: Type.UserInfoBase) => void;
}
const Index: FC<Props> = ({ selectedPeople = [], onSelect }) => {
const { user: currentUser } = loggedUserInfoStore();
const { t } = useTranslation('translation', {
keyPrefix: 'invite_to_answer',
});
const [toggleState, setToggleState] = useState(false);
const [peopleList, setPeopleList] = useState<Type.UserInfoBase[]>([]);
const filterAndSetPeople = (source) => {
const filteredPeople: Type.UserInfoBase[] = [];
source.forEach((p) => {
if (currentUser && currentUser.username === p.username) {
return;
}
if (selectedPeople.find((_) => _.username === p.username)) {
return;
}
filteredPeople.push(p);
});
setPeopleList(filteredPeople);
};
const searchPeople = (evt) => {
const name = evt.target.value;
if (!name) {
return;
}
userSearchByName(name).then((resp) => {
filterAndSetPeople(resp);
});
};
const handleSelect = (idx) => {
const people = peopleList[idx];
if (people) {
onSelect(people);
}
};
useEffect(() => {
filterAndSetPeople(peopleList);
}, [selectedPeople]);
return (
<Dropdown
className="d-inline-flex"
show={toggleState}
onSelect={handleSelect}
onToggle={setToggleState}>
<Dropdown.Toggle
className="m-1 no-toggle"
size="sm"
variant="outline-secondary">
{t('add')} +
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Header className="px-2 pt-0">
<Form.Control
autoFocus
placeholder={t('search')}
onChange={searchPeople}
/>
</Dropdown.Header>
{peopleList.map((p, idx) => {
return (
<Dropdown.Item key={p.username} eventKey={idx}>
<div className="d-flex align-items-center text-nowrap">
<Avatar avatar={p.avatar} size="24" />
<span className="mx-2">{p.display_name}</span>
<small className="text-secondary">@{p.username}</small>
</div>
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown>
);
};
export default memo(Index);

View File

@ -0,0 +1,154 @@
import { memo, FC, useState, useEffect } from 'react';
import { Card, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
import { Avatar } from '@/components';
import { getInviteUser, putInviteUser } from '@/services';
import type * as Type from '@/common/interface';
import PeopleDropdown from './PeopleDropdown';
interface Props {
questionId: string;
readOnly?: boolean;
}
const Index: FC<Props> = ({ questionId, readOnly = false }) => {
const { t } = useTranslation('translation', {
keyPrefix: 'invite_to_answer',
});
const MAX_ASK_NUMBER = 5;
const [editing, setEditing] = useState(false);
const [users, setUsers] = useState<Type.UserInfoBase[]>();
const initInviteUsers = () => {
if (!questionId) {
return;
}
getInviteUser(questionId)
.then((resp) => {
setUsers(resp);
})
.catch(() => {
if (!users) {
setUsers([]);
}
});
};
const updateInviteUsers = (user: Type.UserInfoBase) => {
let userList = [user];
if (users?.length) {
userList = [...users, user];
}
setUsers(userList);
};
const removeInviteUser = (user: Type.UserInfoBase) => {
const inviteUsers = users!.filter((_) => {
return _.username !== user.username;
});
setUsers(inviteUsers);
};
const saveInviteUsers = () => {
if (!users) {
return;
}
const names = users.map((_) => {
return _.username;
});
putInviteUser(questionId, names)
.then(() => {
setEditing(false);
})
.catch((ex) => {
console.log('ex: ', ex);
});
};
useEffect(() => {
initInviteUsers();
}, [questionId]);
const showAddButton = editing && (!users || users.length < MAX_ASK_NUMBER);
const showInviteDesc = !editing && users?.length === 0;
return (
<Card className="mt-4">
<Card.Header className="text-nowrap d-flex justify-content-between text-capitalize">
{t('title')}
{!readOnly && editing ? (
<Button onClick={saveInviteUsers} variant="link" className="p-0">
{t('save', { keyPrefix: 'btns' })}
</Button>
) : null}
{!readOnly && !editing ? (
<Button
onClick={() => setEditing(true)}
variant="link"
className="p-0">
{t('edit', { keyPrefix: 'btns' })}
</Button>
) : null}
</Card.Header>
<Card.Body>
<div
className={classNames(
'd-flex align-items-center flex-wrap',
editing ? 'm-n1' : ' mx-n2 my-n1',
)}>
{users?.map((user) => {
if (editing) {
return (
<Button
key={user.username}
className="m-1 d-inline-flex flex-nowrap"
size="sm"
variant="outline-secondary">
<Avatar avatar={user.avatar} size="20" />
<span className="text-nowrap ms-2">{user.display_name}</span>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
<span
className="ps-1 pe-1 me-n1"
onClick={() => removeInviteUser(user)}>
x
</span>
</Button>
);
}
return (
<Link
key={user.username}
to={`/users/${user.username}`}
className="mx-2 my-1 d-inline-flex flex-nowrap">
<Avatar avatar={user.avatar} size="24" />
<span className="text-nowrap ms-2">{user.display_name}</span>
</Link>
);
})}
{showAddButton ? (
<PeopleDropdown
selectedPeople={users}
onSelect={updateInviteUsers}
/>
) : null}
</div>
{showInviteDesc ? (
<>
<div className="text-muted mb-3">{t('desc')}</div>
<Button
size="sm"
variant="outline-primary"
onClick={() => setEditing(true)}>
{t('invite')}
</Button>
</>
) : null}
</Card.Body>
</Card>
);
};
export default memo(Index);

View File

@ -5,6 +5,7 @@ import RelatedQuestions from './RelatedQuestions';
import WriteAnswer from './WriteAnswer';
import Alert from './Alert';
import ContentLoader from './ContentLoader';
import InviteToAnswer from './InviteToAnswer';
export {
Question,
@ -14,4 +15,5 @@ export {
WriteAnswer,
Alert,
ContentLoader,
InviteToAnswer,
};

View File

@ -27,6 +27,7 @@ import {
WriteAnswer,
Alert,
ContentLoader,
InviteToAnswer,
} from './components';
import './index.scss';
@ -197,6 +198,18 @@ const Index = () => {
description: question?.description,
keywords: question?.tags.map((_) => _.slug_name).join(','),
});
const showInviteToAnswer = question?.id;
let canInvitePeople = false;
if (showInviteToAnswer && Array.isArray(question.extends_actions)) {
const inviteAct = question.extends_actions.find((op) => {
return op.action === 'invite_other_to_answer';
});
if (inviteAct) {
canInvitePeople = true;
}
}
return (
<Row className="questionDetailPage pt-4 mb-5">
<Col className="flex-auto">
@ -257,6 +270,12 @@ const Index = () => {
<Col className="page-right-side mt-4 mt-xl-0">
<CustomSidebar />
<RelatedQuestions id={question?.id || ''} />
{showInviteToAnswer ? (
<InviteToAnswer
questionId={question.id}
readOnly={!canInvitePeople}
/>
) : null}
</Col>
</Row>
);

View File

@ -0,0 +1,4 @@
.inbox-nav {
border-top: 1px solid rgba(0, 0, 0, .125);
padding: .5rem 0;
}

View File

@ -1,7 +1,9 @@
import { useState, useEffect } from 'react';
import { Row, Col, ButtonGroup, Button } from 'react-bootstrap';
import { Row, Col, ButtonGroup, Button, Nav } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate, Link } from 'react-router-dom';
import classNames from 'classnames';
import { usePageTags } from '@/hooks';
import {
@ -14,6 +16,7 @@ import { floppyNavigation } from '@/utils';
import Inbox from './components/Inbox';
import Achievements from './components/Achievements';
import './index.scss';
const PAGE_SIZE = 10;
@ -21,13 +24,23 @@ const Notifications = () => {
const [page, setPage] = useState(1);
const [notificationData, setNotificationData] = useState<any>([]);
const { t } = useTranslation('translation', { keyPrefix: 'notifications' });
const { type = 'inbox' } = useParams();
const inboxTypeNavs = ['all', 'posts', 'invites', 'votes'];
const { type = 'inbox', subType = inboxTypeNavs[0] } = useParams();
const { data, mutate } = useQueryNotifications({
const queryParams: {
type: string;
inbox_type?: string;
page: number;
page_size: number;
} = {
type,
page,
page_size: PAGE_SIZE,
});
};
if (type === 'inbox') {
queryParams.inbox_type = subType;
}
const { data, mutate } = useQueryNotifications(queryParams);
useEffect(() => {
clearNotificationStatus(type);
@ -104,10 +117,32 @@ const Notifications = () => {
</Button>
</div>
{type === 'inbox' && (
<Inbox
data={notificationData}
handleReadNotification={handleReadNotification}
/>
<>
<Nav className="inbox-nav">
{inboxTypeNavs.map((nav) => {
const navLinkHref = `/users/notifications/inbox/${nav}`;
const navLinkName = t(`inbox_type.${nav}`);
return (
<Nav.Item key={nav}>
<Link
to={navLinkHref}
onClick={() => {
setPage(1);
}}
className={classNames('nav-link', {
disabled: nav === subType,
})}>
{navLinkName}
</Link>
</Nav.Item>
);
})}
</Nav>
<Inbox
data={notificationData}
handleReadNotification={handleReadNotification}
/>
</>
)}
{type === 'achievement' && (
<Achievements

View File

@ -163,7 +163,7 @@ const routes: RouteNode[] = [
],
},
{
path: 'users/notifications/:type',
path: 'users/notifications/:type/:subType?',
page: 'pages/Users/Notifications',
},
{

View File

@ -53,3 +53,18 @@ export const useSimilarQuestion = (params: {
error,
};
};
export const getInviteUser = (questionId: string) => {
const apiUrl = '/answer/api/v1/question/invite';
return request.get<Type.UserInfoBase[]>(apiUrl, {
params: { id: questionId },
});
};
export const putInviteUser = (questionId: string, users: string[]) => {
const apiUrl = '/answer/api/v1/question/invite';
return request.put(apiUrl, {
id: questionId,
invite_user: users,
});
};

View File

@ -11,3 +11,12 @@ export const useQueryContributeUsers = () => {
staffs: Type.User[];
}>(apiUrl, request.instance.get);
};
export const userSearchByName = (name: string) => {
const apiUrl = '/answer/api/v1/user/info/search';
return request.get<Type.UserInfoBase[]>(apiUrl, {
params: {
username: name,
},
});
};