admin set/change proposal arbiter

This commit is contained in:
Aaron 2019-02-06 12:38:07 -06:00
parent 40e73f9ee6
commit 649d4c220f
No known key found for this signature in database
GPG Key ID: 3B5B7597106F0A0E
5 changed files with 260 additions and 12 deletions

View File

@ -0,0 +1,14 @@
.ArbiterControl {
&-results {
margin: 1rem 0 0 0;
&.no-results {
padding: 2rem 0;
font-weight: bold;
color: rgba(0, 0, 0, 0.5);
text-align: center;
border: 1px dashed rgba(0, 0, 0, 0.2);
border-radius: 0.2rem;
}
}
}

View File

@ -0,0 +1,161 @@
import { debounce } from 'lodash';
import React from 'react';
import { view } from 'react-easy-state';
import { Button, Modal, Input, Icon, List, Avatar, message } from 'antd';
import store from 'src/store';
import { Proposal, User } from 'src/types';
import Search from 'antd/lib/input/Search';
import { ButtonProps } from 'antd/lib/button';
import './index.less';
interface OwnProps {
buttonProps?: ButtonProps;
}
type Props = OwnProps & Proposal;
const STATE = {
showSearch: false,
searching: false,
};
type State = typeof STATE;
class ArbiterControlNaked extends React.Component<Props, State> {
state = STATE;
searchInput: null | Search = null;
private searchArbiter = debounce(async search => {
await store.searchArbiters(search);
this.setState({ searching: false });
}, 1000);
render() {
const { arbiter } = this.props;
const { showSearch, searching } = this.state;
const { results, search, error } = store.arbitersSearch;
const showEmpty = !results.length && !searching;
return (
<>
{/* CONTROL */}
<Button
className="ArbiterControl-control"
loading={store.proposalDetailApproving}
icon="crown"
type="primary"
onClick={this.handleShowSearch}
{...this.props.buttonProps}
>
{arbiter ? 'Change arbiter' : 'Set arbiter'}
</Button>
{/* SEARCH MODAL */}
{showSearch && (
<Modal
title={
<>
<Icon type="crown" /> Select an arbiter
</>
}
visible={true}
footer={null}
onCancel={this.handleCloseSearch}
>
<>
<Input.Search
ref={x => (this.searchInput = x)}
placeholder="name or email"
onChange={this.handleSearchInputChange}
/>
{/* EMPTY RESULTS */}
{showEmpty && (
<div className={`ArbiterControl-results no-results`}>
{(!error && (
<>
no arbiters found {search && ` for "${search}"`}, please type search
query
</>
)) || (
<>
<Icon type="exclamation-circle" /> {error}
</>
)}
</div>
)}
{/* RESULTS */}
{!showEmpty && (
<div className="ArbiterControl-results">
<List
size="small"
loading={searching}
bordered
dataSource={results}
renderItem={(u: User) => (
<List.Item
actions={[
<Button
type="primary"
key="select"
onClick={() => this.handleSelect(u)}
>
Select
</Button>,
]}
>
<List.Item.Meta
avatar={
<Avatar
icon="user"
src={(u.avatar && u.avatar.imageUrl) || undefined}
/>
}
title={u.displayName}
description={u.emailAddress}
/>
</List.Item>
)}
/>
</div>
)}
</>
</Modal>
)}
</>
);
}
private handleShowSearch = () => {
this.setState({ showSearch: true });
// hacky way of waiting for modal to render in before focus
setTimeout(() => {
if (this.searchInput) this.searchInput.focus();
}, 200);
};
private handleSearchInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ searching: true });
const search = ev.currentTarget.value;
this.searchArbiter(search);
};
private handleSelect = async (user: User) => {
this.setState({ showSearch: false });
store.searchArbitersClear();
try {
await store.setArbiter(this.props.proposalId, user.userid);
message.success(
<>
Arbiter set for <b>{this.props.title}</b>
</>,
);
} catch (e) {
message.error(`Could not set arbiter: ${e}`);
}
};
private handleCloseSearch = () => {
this.setState({ showSearch: false });
store.searchArbitersClear();
};
}
const ArbiterControl = view(ArbiterControlNaked);
export default ArbiterControl;

View File

@ -5,6 +5,7 @@
&-controls {
&-control + &-control {
margin-left: 0 !important;
margin-top: 0.8rem;
}
}

View File

@ -21,6 +21,7 @@ import { Link } from 'react-router-dom';
import Back from 'components/Back';
import Info from 'components/Info';
import Markdown from 'components/Markdown';
import ArbiterControl from 'components/ArbiterControl';
import './index.less';
type Props = RouteComponentProps<any>;
@ -47,7 +48,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
return 'loading proposal...';
}
const renderDelete = () => (
const renderDeleteControl = () => (
<Popconfirm
onConfirm={this.handleDelete}
title="Delete proposal?"
@ -60,7 +61,18 @@ class ProposalDetailNaked extends React.Component<Props, State> {
</Popconfirm>
);
const renderMatching = () => (
const renderArbiterControl = () => (
<ArbiterControl
{...p}
buttonProps={{
type: 'default',
className: 'ProposalDetail-controls-control',
block: true,
}}
/>
);
const renderMatchingControl = () => (
<div className="ProposalDetail-controls-control">
<Popconfirm
overlayClassName="ProposalDetail-popover-overlay"
@ -207,14 +219,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
description={
<div>
<p>An arbiter is required to review milestone payout requests.</p>
<Button
loading={store.proposalDetailApproving}
icon="crown"
type="primary"
onClick={this.handleApprove}
>
Set arbiter
</Button>
<ArbiterControl {...p} />
</div>
}
/>
@ -258,8 +263,9 @@ class ProposalDetailNaked extends React.Component<Props, State> {
<Col span={6}>
{/* ACTIONS */}
<Card size="small" className="ProposalDetail-controls">
{renderDelete()}
{renderMatching()}
{renderDeleteControl()}
{renderArbiterControl()}
{renderMatchingControl()}
{/* TODO - other actions */}
</Card>
@ -273,6 +279,11 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderDeetItem('contributed', p.contributed)}
{renderDeetItem('funded (inc. matching)', p.funded)}
{renderDeetItem('matching', p.contributionMatching)}
{p.arbiter &&
renderDeetItem(
'arbiter',
<Link to={`/users/${p.arbiter.userid}`}>{p.arbiter.displayName}</Link>,
)}
{p.rfp &&
renderDeetItem(
'rfp',

View File

@ -55,6 +55,16 @@ async function deleteUser(id: number) {
return data;
}
async function fetchArbiters(search: string) {
const { data } = await api.get(`/admin/arbiters`, { params: { search } });
return data;
}
async function setArbiter(proposalId: number, userId: number) {
const { data } = await api.put(`/admin/arbiters`, { proposalId, userId });
return data;
}
async function fetchProposals(params: Partial<PageQuery>) {
const { data } = await api.get('/admin/proposals', {
params,
@ -132,6 +142,13 @@ const app = store({
userDeleting: false,
userDeleted: false,
arbitersSearch: {
search: '',
results: [] as User[],
fetching: false,
error: null as string | null,
},
proposals: {
page: {
page: 1,
@ -177,6 +194,16 @@ const app = store({
}
},
updateUserInStore(u: User) {
const index = app.users.findIndex(x => x.userid === u.userid);
if (index > -1) {
app.users[index] = u;
}
if (app.userDetail && app.userDetail.userid === u.userid) {
app.userDetail = u;
}
},
async checkLogin() {
app.isLoggedIn = await checkLogin();
app.hasCheckedLogin = true;
@ -244,6 +271,40 @@ const app = store({
app.userDeleting = false;
},
async searchArbiters(search: string) {
app.arbitersSearch = {
...app.arbitersSearch,
search,
fetching: true,
};
try {
const data = await fetchArbiters(search);
app.arbitersSearch = {
...app.arbitersSearch,
...data,
};
} catch (e) {
handleApiError(e);
}
app.arbitersSearch.fetching = false;
},
async searchArbitersClear() {
app.arbitersSearch = {
search: '',
results: [] as User[],
fetching: false,
error: null,
};
},
async setArbiter(proposalId: number, userId: number) {
// let component handle errors for this one
const { proposal, user } = await setArbiter(proposalId, userId);
this.updateProposalInStore(proposal);
this.updateUserInStore(user);
},
async fetchProposals() {
app.proposals.page.fetching = true;
try {