admin set/change proposal arbiter
This commit is contained in:
parent
40e73f9ee6
commit
649d4c220f
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
&-controls {
|
||||
&-control + &-control {
|
||||
margin-left: 0 !important;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue