From 47c695f43b6828f3ad20aa23fd7b9c80bc8fed84 Mon Sep 17 00:00:00 2001 From: AMStrix Date: Wed, 9 Jan 2019 12:23:08 -0600 Subject: [PATCH] Proposal Approval Process (#39) * endpoints and model support for proposal approval * admin test + proposal approval tests * GET user/ withPending support * basic withPending suport for Profile * change create publish to sumbit for approval * admin proposal filter by status + some refactoring * admin: update antd * backend: admin get single proposal + populate date_approved * admin: rework Proposals + support approval * backend: approval process updates * admin: review count on home + cosmetic * frontend: proposal approval flow * Profile ZEC/ZAT adjustments * fix regression in formatUserFromGet + update error type in users/reducers * fix merge tsc issues * publish warning vebiage change * fix ssr fetchProposal 404 hang bug * proposals/ - limit status non-LIVE to team member, exclude DELETED * various adjustments to Proposal based on `status` * remove comments * Proposal statuses to banner style + fix up CreateFlow - Preview mode * Proposal tsc fix --- admin/package.json | 2 +- admin/src/Routes.tsx | 4 +- admin/src/components/Home/index.less | 9 +- admin/src/components/Home/index.tsx | 18 +- admin/src/components/Markdown/index.less | 145 ++++++ admin/src/components/Markdown/index.tsx | 24 + .../src/components/ProposalDetail/index.less | 26 ++ admin/src/components/ProposalDetail/index.tsx | 237 ++++++++++ .../components/Proposals/ProposalItem.less | 11 + .../src/components/Proposals/ProposalItem.tsx | 53 +++ admin/src/components/Proposals/STATUSES.ts | 65 +++ admin/src/components/Proposals/index.less | 110 +---- admin/src/components/Proposals/index.tsx | 268 +++++------ admin/src/components/Users/index.tsx | 9 +- admin/src/store.ts | 90 +++- admin/src/types.ts | 15 + admin/src/util/md.ts | 14 + admin/src/util/time.ts | 7 + admin/tsconfig.json | 3 +- admin/webpack.config.js | 1 + admin/yarn.lock | 440 +++++++++++------- backend/grant/admin/views.py | 54 ++- backend/grant/proposal/models.py | 105 ++++- backend/grant/proposal/views.py | 45 +- backend/grant/user/views.py | 17 +- backend/grant/utils/auth.py | 4 + backend/migrations/versions/2f30fb7d656e_.py | 32 ++ backend/tests/admin/__init__.py | 0 backend/tests/admin/test_api.py | 87 ++++ backend/tests/proposal/test_api.py | 39 +- frontend/client/api/api.ts | 16 +- .../client/components/CreateFlow/Final.tsx | 15 +- .../client/components/CreateFlow/Preview.less | 6 + .../client/components/CreateFlow/Preview.tsx | 13 +- .../CreateFlow/PublishWarningModal.tsx | 11 +- .../client/components/CreateFlow/index.tsx | 4 +- .../client/components/DraftList/index.tsx | 9 +- .../components/Profile/ProfilePending.less | 71 +++ .../components/Profile/ProfilePending.tsx | 154 ++++++ .../components/Profile/ProfilePendingList.tsx | 54 +++ .../components/Profile/ProfileProposal.tsx | 3 +- frontend/client/components/Profile/index.tsx | 29 +- .../Proposal/CampaignBlock/index.tsx | 15 +- .../Proposal/{style.less => index.less} | 12 + frontend/client/components/Proposal/index.tsx | 255 ++++++---- .../client/components/Template/index.less | 10 +- frontend/client/modules/create/actions.ts | 9 +- frontend/client/modules/create/reducers.ts | 12 +- frontend/client/modules/create/utils.ts | 8 +- frontend/client/modules/users/actions.ts | 24 + frontend/client/modules/users/reducers.ts | 62 ++- frontend/client/modules/users/types.ts | 6 + frontend/client/styles/variables.less | 21 +- frontend/client/utils/api.ts | 9 +- frontend/server/ssrAsync.ts | 3 +- frontend/stories/props.tsx | 7 +- frontend/types/proposal.ts | 27 +- 57 files changed, 2124 insertions(+), 675 deletions(-) create mode 100644 admin/src/components/Markdown/index.less create mode 100644 admin/src/components/Markdown/index.tsx create mode 100644 admin/src/components/ProposalDetail/index.less create mode 100644 admin/src/components/ProposalDetail/index.tsx create mode 100644 admin/src/components/Proposals/ProposalItem.less create mode 100644 admin/src/components/Proposals/ProposalItem.tsx create mode 100644 admin/src/components/Proposals/STATUSES.ts create mode 100644 admin/src/util/md.ts create mode 100644 admin/src/util/time.ts create mode 100644 backend/migrations/versions/2f30fb7d656e_.py create mode 100644 backend/tests/admin/__init__.py create mode 100644 backend/tests/admin/test_api.py create mode 100644 frontend/client/components/CreateFlow/Preview.less create mode 100644 frontend/client/components/Profile/ProfilePending.less create mode 100644 frontend/client/components/Profile/ProfilePending.tsx create mode 100644 frontend/client/components/Profile/ProfilePendingList.tsx rename frontend/client/components/Proposal/{style.less => index.less} (94%) diff --git a/admin/package.json b/admin/package.json index 1b7ad97e..c8c100e6 100644 --- a/admin/package.json +++ b/admin/package.json @@ -48,7 +48,7 @@ "@types/webpack": "4.4.17", "@types/webpack-env": "^1.13.6", "ant-design-pro": "2.0.0", - "antd": "3.9.3", + "antd": "3.12.1", "axios": "^0.18.0", "babel-core": "^7.0.0-bridge.0", "babel-loader": "^8.0.2", diff --git a/admin/src/Routes.tsx b/admin/src/Routes.tsx index 75df9fd6..aa582915 100644 --- a/admin/src/Routes.tsx +++ b/admin/src/Routes.tsx @@ -9,6 +9,7 @@ import Login from 'components/Login'; import Home from 'components/Home'; import Users from 'components/Users'; import Proposals from 'components/Proposals'; +import ProposalDetail from 'components/ProposalDetail'; import 'styles/style.less'; @@ -28,7 +29,8 @@ class Routes extends React.Component { - + + )} diff --git a/admin/src/components/Home/index.less b/admin/src/components/Home/index.less index 0cedb8c8..bb934882 100644 --- a/admin/src/components/Home/index.less +++ b/admin/src/components/Home/index.less @@ -3,7 +3,12 @@ font-size: 1.5rem; } - & > div { - margin-bottom: 0.5rem; + &-actionItems { + margin-bottom: 3rem; + font-size: 1rem; + + .anticon { + color: #ffaa00; + } } } diff --git a/admin/src/components/Home/index.tsx b/admin/src/components/Home/index.tsx index c9d75b1b..04617c6f 100644 --- a/admin/src/components/Home/index.tsx +++ b/admin/src/components/Home/index.tsx @@ -1,7 +1,9 @@ import React from 'react'; +import { Divider, Icon } from 'antd'; import { view } from 'react-easy-state'; import store from '../../store'; import './index.less'; +import { Link } from 'react-router-dom'; class Home extends React.Component { componentDidMount() { @@ -9,11 +11,21 @@ class Home extends React.Component { } render() { - const { userCount, proposalCount } = store.stats; + const { userCount, proposalCount, proposalPendingCount } = store.stats; return (
-

Home

-
isLoggedIn: {JSON.stringify(store.isLoggedIn)}
+ {!!proposalPendingCount && ( +
+ Action Items +
+ There are {proposalPendingCount}{' '} + proposals waiting for review.{' '} + Click here to view them. +
+
+ )} + + Stats
user count: {userCount}
proposal count: {proposalCount}
diff --git a/admin/src/components/Markdown/index.less b/admin/src/components/Markdown/index.less new file mode 100644 index 00000000..2cdf88f7 --- /dev/null +++ b/admin/src/components/Markdown/index.less @@ -0,0 +1,145 @@ +.Markdown { + line-height: 1.7; + font-family: 'Nunito Sans', 'Helvetica Neue', Arial, sans-serif; + + h1, + h2, + h3, + h4, + h5, + h6 { + display: block; + font-weight: bold; + margin-top: 0; + } + + h1 { + font-size: 2rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + } + h2 { + font-size: 1.8rem; + padding-bottom: 0.5rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + } + h3 { + font-size: 1.6rem; + margin-bottom: 1.5rem; + } + h4 { + font-size: 1.4rem; + margin-bottom: 1rem; + } + h5 { + font-size: 1.2rem; + margin-bottom: 0.5rem; + } + h6 { + font-size: 1.1rem; + margin-bottom: 0rem; + } + + ul, + ol { + padding-left: 30px; + font-size: 1.05rem; + } + + ul { + list-style: circle; + } + + ol { + list-style: decimal; + } + + dl { + dt { + text-decoration: underline; + } + + dd { + padding-left: 1rem; + margin-bottom: 1rem; + } + } + + img { + max-width: 100%; + } + + hr { + margin: 3rem 0; + border-bottom: 2px solid #eee; + } + + code, + pre { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; + } + + code { + padding: 0.2rem 0.25rem; + margin: 0; + font-size: 90%; + background: rgba(0, 0, 0, 0.02); + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 2px; + + &:before, + &:after { + display: none; + } + } + + pre { + padding: 0.5rem 0.75rem; + background: rgba(0, 0, 0, 0.02); + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 2px; + font-size: 1rem; + + code { + padding: 0; + font-size: inherit; + background: none; + border: none; + border-radius: 0; + } + } + + table { + display: block; + width: 100%; + border-spacing: 0; + border-collapse: collapse; + margin-bottom: 2rem; + + th, + td { + padding: 0.5rem 1rem; + border: 1px solid #ddd; + } + + th { + font-size: 1.1rem; + font-weight: bold; + } + + td { + font-size: 1rem; + } + } + + blockquote { + margin: 0 0 1rem; + padding: 0 0 0 1rem; + color: #777; + border-left: 4px solid rgba(0, 0, 0, 0.08); + + > :last-child { + margin: 0; + } + } +} diff --git a/admin/src/components/Markdown/index.tsx b/admin/src/components/Markdown/index.tsx new file mode 100644 index 00000000..bd4d3b5e --- /dev/null +++ b/admin/src/components/Markdown/index.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import classnames from 'classnames'; +import { mdToHtml } from 'util/md'; +import './index.less'; + +interface Props extends React.HTMLAttributes { + source: string; +} + +export default class Markdown extends React.PureComponent { + render() { + const { source, ...rest } = this.props; + const html = mdToHtml(source); + // TS types seem to be fighting over react prop defs for div + const divProps = rest as any; + return ( +
+ ); + } +} diff --git a/admin/src/components/ProposalDetail/index.less b/admin/src/components/ProposalDetail/index.less new file mode 100644 index 00000000..51e645e8 --- /dev/null +++ b/admin/src/components/ProposalDetail/index.less @@ -0,0 +1,26 @@ +.ProposalDetail { + h1 { + font-size: 1.5rem; + } + + &-deet { + position: relative; + margin-bottom: 0.6rem; + + & > span { + font-size: 0.7rem; + position: absolute; + top: 0.85rem; + } + } + + & .ant-card, + .ant-alert, + .ant-collapse { + margin-bottom: 16px; + + button + button { + margin-left: 0.5rem; + } + } +} diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx new file mode 100644 index 00000000..a8ac1026 --- /dev/null +++ b/admin/src/components/ProposalDetail/index.tsx @@ -0,0 +1,237 @@ +import React from 'react'; +import { view } from 'react-easy-state'; +import { RouteComponentProps, withRouter } from 'react-router'; +import { Row, Col, Card, Alert, Button, Collapse, Popconfirm, Modal, Input } from 'antd'; +import TextArea from 'antd/lib/input/TextArea'; +import store from 'src/store'; +import { formatDateSeconds } from 'util/time'; +import { PROPOSAL_STATUS } from 'src/types'; +import { Link } from 'react-router-dom'; +import './index.less'; +import Markdown from 'components/Markdown'; + +type Props = RouteComponentProps; + +const STATE = { + showRejectModal: false, + rejectReason: '', +}; + +type State = typeof STATE; + +class ProposalDetailNaked extends React.Component { + state = STATE; + rejectInput: null | TextArea = null; + componentDidMount() { + this.loadDetail(); + } + render() { + const id = this.getIdFromQuery(); + const { proposalDetail: p, proposalDetailFetching, proposalDetailApproving } = store; + const { rejectReason, showRejectModal } = this.state; + + if (!p || (p && p.proposalId !== id) || proposalDetailFetching) { + return 'loading proposal...'; + } + + const renderDelete = () => ( + + + + ); + + const renderApproved = () => + p.status === PROPOSAL_STATUS.APPROVED && ( + + ); + + const rejectModal = ( + this.setState({ showRejectModal: false })} + okButtonProps={{ + disabled: rejectReason.length === 0, + loading: proposalDetailApproving, + }} + cancelButtonProps={{ + loading: proposalDetailApproving, + }} + > + Please provide a reason ({!!rejectReason.length && `${rejectReason.length}/`} + 250 chars max): + (this.rejectInput = ta)} + rows={4} + maxLength={250} + required={true} + value={rejectReason} + onChange={e => { + this.setState({ rejectReason: e.target.value }); + }} + /> + + ); + + const renderReview = () => + p.status === PROPOSAL_STATUS.PENDING && ( + +

Please review this proposal and render your judgment.

+ + + {rejectModal} +
+ } + /> + ); + + const renderRejected = () => + p.status === PROPOSAL_STATUS.REJECTED && ( + +

+ This proposal has been rejected. The team will be able to re-submit it for + approval should they desire to do so. +

+ Reason: +
+ {p.rejectReason} + + } + /> + ); + + const renderDeetItem = (name: string, val: any) => ( +
+ {name} + {val} +
+ ); + + return ( +
+

{p.title}

+ + {/* MAIN */} + + {renderApproved()} + {renderReview()} + {renderRejected()} + + + {p.brief} + + + + + + + {/* TODO - comments, milestones, updates &etc. */} + +
{JSON.stringify(p, null, 4)}
+
+
+ + + {/* RIGHT SIDE */} + + {/* ACTIONS */} + + {renderDelete()} + {/* TODO - other actions */} + + + {/* DETAILS */} + + {renderDeetItem('id', p.proposalId)} + {renderDeetItem('created', formatDateSeconds(p.dateCreated))} + {renderDeetItem('status', p.status)} + {renderDeetItem('category', p.category)} + {renderDeetItem('target', p.target)} + + + {/* TEAM */} + + {p.team.map(t => ( + + {t.displayName} + + ))} + + {/* TODO: contributors here? */} + +
+
+ ); + } + + private getIdFromQuery = () => { + return Number(this.props.match.params.id); + }; + + private loadDetail = () => { + store.fetchProposalDetail(this.getIdFromQuery()); + }; + + private handleDelete = () => { + if (!store.proposalDetail) return; + store.deleteProposal(store.proposalDetail.proposalId); + }; + + private handleApprove = () => { + store.approveProposal(true); + }; + + private handleReject = async () => { + await store.approveProposal(false, this.state.rejectReason); + this.setState({ showRejectModal: false }); + }; +} + +const ProposalDetail = withRouter(view(ProposalDetailNaked)); +export default ProposalDetail; diff --git a/admin/src/components/Proposals/ProposalItem.less b/admin/src/components/Proposals/ProposalItem.less new file mode 100644 index 00000000..ac5aaa95 --- /dev/null +++ b/admin/src/components/Proposals/ProposalItem.less @@ -0,0 +1,11 @@ +.ProposalItem { + & h1 { + font-size: 1.4rem; + margin-bottom: 0; + + & .ant-tag { + vertical-align: text-top; + margin-top: 0.2rem; + } + } +} diff --git a/admin/src/components/Proposals/ProposalItem.tsx b/admin/src/components/Proposals/ProposalItem.tsx new file mode 100644 index 00000000..527b9679 --- /dev/null +++ b/admin/src/components/Proposals/ProposalItem.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { view } from 'react-easy-state'; +import { Popconfirm, Tag, Tooltip, List } from 'antd'; +import { Link } from 'react-router-dom'; +import store from 'src/store'; +import { Proposal } from 'src/types'; +import { getStatusById } from './STATUSES'; +import { formatDateSeconds } from 'src/util/time'; +import './ProposalItem.less'; + +class ProposalItemNaked extends React.Component { + state = { + showDelete: false, + }; + render() { + const p = this.props; + const status = getStatusById(p.status); + + const deleteAction = ( + +
delete
+
+ ); + const viewAction = view; + const actions = [viewAction, deleteAction]; + + return ( + +
+

+ {p.title || '(no title)'}{' '} + + {status.tagDisplay} + +

+
Created: {formatDateSeconds(p.dateCreated)}
+
{p.brief}
+
+
+ ); + } + private handleDelete = () => { + store.deleteProposal(this.props.proposalId); + }; +} + +const ProposalItem = view(ProposalItemNaked); +export default ProposalItem; diff --git a/admin/src/components/Proposals/STATUSES.ts b/admin/src/components/Proposals/STATUSES.ts new file mode 100644 index 00000000..b7c57477 --- /dev/null +++ b/admin/src/components/Proposals/STATUSES.ts @@ -0,0 +1,65 @@ +import { PROPOSAL_STATUS } from 'src/types'; + +export interface ProposalStatusSoT { + id: PROPOSAL_STATUS; + filterDisplay: string; + tagDisplay: string; + tagColor: string; + hint: string; +} + +const STATUSES: ProposalStatusSoT[] = [ + { + id: PROPOSAL_STATUS.APPROVED, + filterDisplay: 'Status: approved', + tagDisplay: 'Approved', + tagColor: '#afd500', + hint: 'Proposal has been approved and is awaiting being published by user.', + }, + { + id: PROPOSAL_STATUS.DELETED, + filterDisplay: 'Status: deleted', + tagDisplay: 'Deleted', + tagColor: '#bebebe', + hint: 'Proposal has been deleted and is not visible on the platform.', + }, + { + id: PROPOSAL_STATUS.DRAFT, + filterDisplay: 'Status: draft', + tagDisplay: 'Draft', + tagColor: '#8d8d8d', + hint: 'Proposal is being created by the user.', + }, + { + id: PROPOSAL_STATUS.LIVE, + filterDisplay: 'Status: live', + tagDisplay: 'Live', + tagColor: '#108ee9', + hint: 'Proposal is live on the platform.', + }, + { + id: PROPOSAL_STATUS.PENDING, + filterDisplay: 'Status: pending', + tagDisplay: 'Awaiting Approval', + tagColor: '#ffaa00', + hint: 'User is waiting for admin to approve or reject this Proposal.', + }, + { + id: PROPOSAL_STATUS.REJECTED, + filterDisplay: 'Status: rejected', + tagDisplay: 'Approval Rejected', + tagColor: '#eb4118', + hint: + 'Admin has rejected this proposal. User may adjust it and resubmit for approval.', + }, +]; + +export const getStatusById = (id: PROPOSAL_STATUS) => { + const result = STATUSES.find(s => s.id === id); + if (!result) { + throw Error(`getStatusById: could not find status for '${id}'`); + } + return result; +}; + +export default STATUSES; diff --git a/admin/src/components/Proposals/index.less b/admin/src/components/Proposals/index.less index 041a517f..d3e83340 100644 --- a/admin/src/components/Proposals/index.less +++ b/admin/src/components/Proposals/index.less @@ -1,108 +1,16 @@ -@controls-height: 40px; - .Proposals { - margin-top: @controls-height + 0.5rem; - - h1 { - font-size: 1.5rem; - } - &-controls { - height: @controls-height; - padding: 0.25rem 1rem; - margin-left: -1rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.1); - position: fixed; - top: 0; - right: 0; - left: 216px; - z-index: 5; - background: white; + margin-bottom: 0.5rem; + & > * { + margin-right: 0.5rem; + } } - &-proposal { - display: flex; - padding-bottom: 1rem; - border-bottom: 1px solid rgb(214, 214, 214); - margin-bottom: 1rem; + &-filters { + margin-bottom: 0.5rem; + } - &-controls { - margin: 0 0.5rem 0.2rem 0; - background: rgba(0, 0, 0, 0.1); - padding: 0.1rem; - border-radius: 0.5rem; - width: fit-content; - } - - &-img { - width: 100px; - height: 100px; - margin-right: 0.5rem; - background: rgba(0, 0, 0, 0.1); - - & img { - width: 100%; - } - } - - & button { - cursor: pointer; - margin: 0 0.3rem 0 0; - outline: none !important; - - &:hover { - color: #1890ff; - } - } - - &-body { - margin: 0; - border: 1px solid rgba(0, 0, 0, 0.1); - padding: 1rem; - max-height: 600px; - overflow-y: scroll; - - & img { - max-width: 100%; - } - - ul, - ol { - padding-left: 30px; - font-size: 1.05rem; - } - - ul { - list-style: circle; - } - - ol { - list-style: decimal; - } - } - - &-milestones { - display: flex; - flex-flow: wrap; - - & > div { - width: 316px; - margin: 0 1rem 1rem 0; - - & > div > span { - display: inline-block; - margin-left: 0.3rem; - font-size: 0.8rem; - opacity: 0.7; - } - } - } - - &-comments { - border-left: 1rem solid rgba(0, 0, 0, 0.1); - & > div { - margin: 0.2rem 0 0.2rem 0.4rem; - } - } + &-list { + margin-top: 1rem; } } diff --git a/admin/src/components/Proposals/index.tsx b/admin/src/components/Proposals/index.tsx index c70ca59c..1f50411a 100644 --- a/admin/src/components/Proposals/index.tsx +++ b/admin/src/components/Proposals/index.tsx @@ -1,34 +1,40 @@ import React from 'react'; -import { view } from 'react-easy-state'; -import { Icon, Button, Popover } from 'antd'; -import { RouteComponentProps, withRouter } from 'react-router'; -import Showdown from 'showdown'; -import moment from 'moment'; -import store from 'src/store'; -import { Proposal } from 'src/types'; -import './index.less'; -import Field from 'components/Field'; +import qs from 'query-string'; +import { uniq, without } from 'lodash'; import { Link } from 'react-router-dom'; +import { view } from 'react-easy-state'; +import { Icon, Button, Dropdown, Menu, Tag, List } from 'antd'; +import { RouteComponentProps, withRouter } from 'react-router'; +import store from 'src/store'; +import ProposalItem from './ProposalItem'; +import { PROPOSAL_STATUS, Proposal } from 'src/types'; +import STATUSES, { getStatusById } from './STATUSES'; +import { ClickParam } from 'antd/lib/menu'; +import './index.less'; -const showdownConverter = new Showdown.Converter({ - simplifiedAutoLink: true, - tables: true, - strikethrough: true, - disableForced4SpacesIndentedSublists: true, - openLinksInNewWindow: true, - excludeTrailingPunctuationFromURLs: true, -}); +interface Query { + status: PROPOSAL_STATUS[]; +} type Props = RouteComponentProps; -class ProposalsNaked extends React.Component { +const STATE = { + statusFilters: [] as PROPOSAL_STATUS[], +}; + +type State = typeof STATE; + +class ProposalsNaked extends React.Component { + state = STATE; componentDidMount() { - store.fetchProposals(); + this.setStateFromQueryString(); + this.fetchProposals(); } render() { const id = Number(this.props.match.params.id); - const { proposals, proposalsFetched } = store; + const { proposals, proposalsFetching, proposalsFetched } = store; + const { statusFilters } = this.state; if (!proposalsFetched) { return 'loading proposals...'; @@ -55,141 +61,113 @@ class ProposalsNaked extends React.Component { } } + const statusFilterMenu = ( + + {STATUSES.map(f => ( + {f.filterDisplay} + ))} + + ); + return (
- + +
- {proposals.length === 0 &&
no proposals
} - {proposals.length > 0 && - proposals.map(p => )} -
- ); - } -} - -// tslint:disable-next-line:max-classes-per-file -class ProposalItemNaked extends React.Component { - state = { - showDelete: false, - }; - render() { - const p = this.props; - const body = showdownConverter.makeHtml(p.content); - return ( -
-
-
- - {' '} - -
- } - title="Permanently delete proposal?" - trigger="click" - visible={this.state.showDelete} - onVisibleChange={showDelete => this.setState({ showDelete })} - > -
- } - /> - TODO: comments
} - /> - - } - /> - - {p.milestones.map((ms, idx) => ( -
-
- - {idx}. {ms.title} - - (title) -
-
- {moment(ms.dateCreated).format('YYYY/MM/DD h:mm a')} - (dateCreated) -
-
- {moment(ms.dateEstimated).format('YYYY/MM/DD h:mm a')} - (dateEstimated) -
-
- {ms.stage} - (stage) -
-
- {JSON.stringify(ms.immediatePayout)} - (immediatePayout) -
-
- {ms.payoutPercent} - (payoutPercent) -
-
- {ms.content} - (body) -
- {/* content -
{ms.content}
*/} -
- ))} - - } - /> - + )} + {proposalsFetching && 'Fetching proposals...'} + {proposalsFetched && + !proposalsFetching && ( + } + /> + )} ); } - private handleDelete = () => { - store.deleteProposal(this.props.proposalId); + + private fetchProposals = () => { + const statusFilters = this.getParsedQuery().status; + store.fetchProposals(statusFilters); + }; + + private getParsedQuery = () => { + const parsed = qs.parse(this.props.history.location.search) as Query; + let statusFilters = parsed.status || []; + // qs.parse returns non-array for single item + statusFilters = Array.isArray(statusFilters) ? statusFilters : [statusFilters]; + parsed.status = statusFilters; + return parsed; + }; + + private setStateFromQueryString = () => { + const statusFilters = this.getParsedQuery().status; + this.setState({ statusFilters }); + }; + + private updateHistoryStateAndProposals = (queryStringArgs: Query) => { + this.props.history.push(`${this.props.match.url}?${qs.stringify(queryStringArgs)}`); + this.setStateFromQueryString(); + this.fetchProposals(); + }; + + private addStatusFilter = (statusFilter: PROPOSAL_STATUS) => { + const parsed = this.getParsedQuery(); + parsed.status = uniq([statusFilter, ...parsed.status]); + this.updateHistoryStateAndProposals(parsed); + }; + + private removeStatusFilter = (statusFilter: PROPOSAL_STATUS) => { + const parsed = this.getParsedQuery(); + parsed.status = without(parsed.status, statusFilter); + this.updateHistoryStateAndProposals(parsed); + }; + + private clearStatusFilters = () => { + const parsed = this.getParsedQuery(); + parsed.status = []; + this.updateHistoryStateAndProposals(parsed); + }; + + private handleFilterClick = (e: ClickParam) => { + this.addStatusFilter(e.key as PROPOSAL_STATUS); + }; + + private handleFilterClose = (filter: PROPOSAL_STATUS) => { + this.removeStatusFilter(filter); + }; + + private handleFilterClear = () => { + this.clearStatusFilters(); }; } -const ProposalItem = view(ProposalItemNaked); const Proposals = withRouter(view(ProposalsNaked)); export default Proposals; diff --git a/admin/src/components/Users/index.tsx b/admin/src/components/Users/index.tsx index 8ba917f0..6d9f5d95 100644 --- a/admin/src/components/Users/index.tsx +++ b/admin/src/components/Users/index.tsx @@ -5,8 +5,8 @@ import { RouteComponentProps, withRouter } from 'react-router'; import { Link } from 'react-router-dom'; import store from 'src/store'; import { User } from 'src/types'; -import './index.less'; import Field from 'components/Field'; +import './index.less'; type Props = RouteComponentProps; @@ -16,7 +16,7 @@ class UsersNaked extends React.Component { } render() { - const id = this.props.match.params.id; + const id = parseInt(this.props.match.params.id, 10); const { users, usersFetched } = store; if (!usersFetched) { @@ -24,7 +24,7 @@ class UsersNaked extends React.Component { } if (id) { - const singleUser = users.find(u => u.accountAddress === id); + const singleUser = users.find(u => u.userid === id); if (singleUser) { return (
@@ -102,7 +102,6 @@ class UserItemNaked extends React.Component { - { ); } private handleDelete = () => { - store.deleteUser(this.props.accountAddress); + store.deleteUser(this.props.userid); }; } const UserItem = view(UserItemNaked); diff --git a/admin/src/store.ts b/admin/src/store.ts index 65f08654..34d340a0 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -1,6 +1,6 @@ import { store } from 'react-easy-state'; import axios, { AxiosError } from 'axios'; -import { User, Proposal } from './types'; +import { User, Proposal, PROPOSAL_STATUS } from './types'; // API const api = axios.create({ @@ -36,13 +36,20 @@ async function fetchUsers() { return data; } -async function deleteUser(id: string) { +async function deleteUser(id: number | string) { const { data } = await api.delete('/admin/users/' + id); return data; } -async function fetchProposals() { - const { data } = await api.get('/admin/proposals'); +async function fetchProposals(statusFilters?: PROPOSAL_STATUS[]) { + const { data } = await api.get('/admin/proposals', { + params: { statusFilters }, + }); + return data; +} + +async function fetchProposalDetail(id: number) { + const { data } = await api.get(`/admin/proposals/${id}`); return data; } @@ -51,25 +58,50 @@ async function deleteProposal(id: number) { return data; } +async function approveProposal(id: number, isApprove: boolean, rejectReason?: string) { + const { data } = await api.put(`/admin/proposals/${id}/approve`, { + isApprove, + rejectReason, + }); + return data; +} + // STORE const app = store({ hasCheckedLogin: false, isLoggedIn: false, loginError: '', generalError: [] as string[], + statsFetched: false, + statsFetching: false, stats: { - userCount: -1, - proposalCount: -1, + userCount: 0, + proposalCount: 0, + proposalPendingCount: 0, }, usersFetched: false, users: [] as User[], + proposalsFetching: false, proposalsFetched: false, proposals: [] as Proposal[], + proposalDetailFetching: false, + proposalDetail: null as null | Proposal, + proposalDetailApproving: false, removeGeneralError(i: number) { app.generalError.splice(i, 1); }, + updateProposalInStore(p: Proposal) { + const index = app.proposals.findIndex(x => x.proposalId === p.proposalId); + if (index > -1) { + app.proposals[index] = p; + } + if (app.proposalDetail && app.proposalDetail.proposalId === p.proposalId) { + app.proposalDetail = p; + } + }, + async checkLogin() { app.isLoggedIn = await checkLogin(); app.hasCheckedLogin = true; @@ -92,11 +124,14 @@ const app = store({ }, async fetchStats() { + app.statsFetching = true; try { app.stats = await fetchStats(); + app.statsFetched = true; } catch (e) { handleApiError(e); } + app.statsFetching = false; }, async fetchUsers() { @@ -108,26 +143,34 @@ const app = store({ } }, - async deleteUser(id: string) { + async deleteUser(id: string | number) { try { await deleteUser(id); - app.users = app.users.filter(u => u.accountAddress !== id && u.emailAddress !== id); + app.users = app.users.filter(u => u.userid !== id && u.emailAddress !== id); } catch (e) { handleApiError(e); } }, - async fetchProposals() { + async fetchProposals(statusFilters?: PROPOSAL_STATUS[]) { + app.proposalsFetching = true; try { - app.proposals = await fetchProposals(); + app.proposals = await fetchProposals(statusFilters); app.proposalsFetched = true; - // for (const p of app.proposals) { - // TODO: partial populate contributorList - // await app.populateProposalContract(p.proposalId); - // } } catch (e) { handleApiError(e); } + app.proposalsFetching = false; + }, + + async fetchProposalDetail(id: number) { + app.proposalDetailFetching = true; + try { + app.proposalDetail = await fetchProposalDetail(id); + } catch (e) { + handleApiError(e); + } + app.proposalDetailFetching = false; }, async deleteProposal(id: number) { @@ -138,6 +181,25 @@ const app = store({ handleApiError(e); } }, + + async approveProposal(isApprove: boolean, rejectReason?: string) { + if (!app.proposalDetail) { + (x => { + app.generalError.push(x); + console.error(x); + })('store.approveProposal(): Expected proposalDetail to be populated!'); + return; + } + app.proposalDetailApproving = true; + try { + const { proposalId } = app.proposalDetail; + const res = await approveProposal(proposalId, isApprove, rejectReason); + app.updateProposalInStore(res); + } catch (e) { + handleApiError(e); + } + app.proposalDetailApproving = false; + }, }); function handleApiError(e: AxiosError) { diff --git a/admin/src/types.ts b/admin/src/types.ts index 608c9209..c9aae106 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -11,10 +11,23 @@ export interface Milestone { stage: string; title: string; } +// NOTE: sync with backend/grant/proposal/models.py STATUSES +export enum PROPOSAL_STATUS { + DRAFT = 'DRAFT', + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + LIVE = 'LIVE', + DELETED = 'DELETED', +} export interface Proposal { proposalId: number; + brief: string; + status: PROPOSAL_STATUS; proposalAddress: string; dateCreated: number; + dateApproved: number; + datePublished: number; title: string; content: string; stage: string; @@ -23,6 +36,8 @@ export interface Proposal { team: User[]; comments: Comment[]; contractStatus: string; + target: string; + rejectReason: string; } export interface Comment { commentId: string; diff --git a/admin/src/util/md.ts b/admin/src/util/md.ts new file mode 100644 index 00000000..1ba2132b --- /dev/null +++ b/admin/src/util/md.ts @@ -0,0 +1,14 @@ +import Showdown from 'showdown'; + +const showdownConverter = new Showdown.Converter({ + simplifiedAutoLink: true, + tables: true, + strikethrough: true, + disableForced4SpacesIndentedSublists: true, + openLinksInNewWindow: true, + excludeTrailingPunctuationFromURLs: true, +}); + +export const mdToHtml = (text: string) => { + return showdownConverter.makeHtml(text); +}; diff --git a/admin/src/util/time.ts b/admin/src/util/time.ts new file mode 100644 index 00000000..dc84df25 --- /dev/null +++ b/admin/src/util/time.ts @@ -0,0 +1,7 @@ +import moment from 'moment'; + +const DATE_FMT_STRING = 'MM/DD/YYYY h:mm a'; + +export const formatDateSeconds = (s: number) => { + return moment(s * 1000).format(DATE_FMT_STRING); +}; diff --git a/admin/tsconfig.json b/admin/tsconfig.json index af600ca1..c70f3884 100644 --- a/admin/tsconfig.json +++ b/admin/tsconfig.json @@ -21,7 +21,8 @@ "paths": { "src/*": ["./*"], "components/*": ["./components/*"], - "styles/*": ["./styles/*"] + "styles/*": ["./styles/*"], + "util/*": ["./util/*"] } }, "include": ["./src/**/*"], diff --git a/admin/webpack.config.js b/admin/webpack.config.js index df9876b7..27f3d354 100644 --- a/admin/webpack.config.js +++ b/admin/webpack.config.js @@ -115,6 +115,7 @@ module.exports = { src: path.resolve(__dirname, 'src'), components: path.resolve(__dirname, 'src/components'), styles: path.resolve(__dirname, 'src/styles'), + util: path.resolve(__dirname, 'src/util'), }, }, plugins: [ diff --git a/admin/yarn.lock b/admin/yarn.lock index 6dc9c256..351a2880 100644 --- a/admin/yarn.lock +++ b/admin/yarn.lock @@ -2,16 +2,18 @@ # yarn lockfile v1 -"@ant-design/icons-react@~1.1.1": +"@ant-design/icons-react@~1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@ant-design/icons-react/-/icons-react-1.1.2.tgz#df25c4560864f8a3b687b305c3238daff048ed72" + integrity sha512-7Fgt9d8ABgxrhZxsFjHk/VpPcxodQJJhbJO8Lsh7u58pGN4NoxxW++92naeGTXCyqZsbDPBReP+SC0bdBtbsGQ== dependencies: ant-design-palettes "^1.1.3" babel-runtime "^6.26.0" -"@ant-design/icons@~1.1.5": - version "1.1.15" - resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-1.1.15.tgz#2ff689b87bb160c246a07adaa99cdb1c8dfd4412" +"@ant-design/icons@~1.1.16": + version "1.1.16" + resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-1.1.16.tgz#ac6426216934e3f4bc108f2f48f92ed66789235e" + integrity sha512-0zNVP5JYBJkfMi9HotN6QBQjF3SFmUlumJNJXZIH+pZWp/5EbrCczzlG3YTmBWoyRHAsuOGIjSFIy8v/76DTPg== "@antv/adjust@~0.0.7": version "0.0.7" @@ -1446,6 +1448,7 @@ acorn@^6.0.1, acorn@^6.0.2: add-dom-event-listener@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/add-dom-event-listener/-/add-dom-event-listener-1.1.0.tgz#6a92db3a0dd0abc254e095c0f1dc14acbbaae310" + integrity sha512-WCxx1ixHT0GQU9hb0KI/mhgRQhnU+U3GvwY6ZvVjYq8rsihIGoaIOUbY0yMPBxLH5MDtr0kz3fisWGNcbWW7Jw== dependencies: object-assign "4.x" @@ -1609,6 +1612,7 @@ ansi-styles@~1.0.0: ant-design-palettes@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/ant-design-palettes/-/ant-design-palettes-1.1.3.tgz#84119b1a4d86363adc52a38d587e65336a0a27dd" + integrity sha512-UpkkTp8egEN21KZNvY7sTcabLlkHvLvS71EVPk4CYi77Z9AaGGCaVn7i72tbOgWDrQp2wjIg8WgMbKBdK7GtWA== dependencies: tinycolor2 "^1.4.1" @@ -1641,60 +1645,61 @@ ant-design-pro@2.0.0: react-router-dom "^4.3.1" umi "^2.0.0-beta.10" -antd@3.9.3: - version "3.9.3" - resolved "https://registry.yarnpkg.com/antd/-/antd-3.9.3.tgz#bc3e6a93478f4fcfb57f9e7f683c3ce590069ddb" +antd@3.12.1: + version "3.12.1" + resolved "https://registry.yarnpkg.com/antd/-/antd-3.12.1.tgz#d1b3138ee907f7884f9eae2bfdee0b1b714bfe62" + integrity sha512-CExkSp+e1GxKYlfI6f9pTf2q+m6zuECvnBJblMiTx/sM29P2uEkJcmt1JysFGMS7MNa/HAel5SvvmB4lEEPMJw== dependencies: - "@ant-design/icons" "~1.1.5" - "@ant-design/icons-react" "~1.1.1" - array-tree-filter "^2.0.0" + "@ant-design/icons" "~1.1.16" + "@ant-design/icons-react" "~1.1.2" + array-tree-filter "^2.1.0" babel-runtime "6.x" - classnames "~2.2.0" - create-react-class "^15.6.0" + classnames "~2.2.6" + create-react-class "^15.6.3" create-react-context "0.2.2" - css-animation "^1.2.5" + css-animation "^1.5.0" dom-closest "^0.2.0" - enquire.js "^2.1.1" - intersperse "^1.0.0" - lodash "^4.17.5" - moment "^2.19.3" + enquire.js "^2.1.6" + lodash "^4.17.11" + moment "^2.22.2" omit.js "^1.0.0" - prop-types "^15.5.7" + prop-types "^15.6.2" raf "^3.4.0" - rc-animate "^2.4.1" - rc-calendar "~9.7.3" - rc-cascader "~0.16.0" + rc-animate "^2.5.4" + rc-calendar "~9.10.3" + rc-cascader "~0.17.0" rc-checkbox "~2.1.5" rc-collapse "~1.10.0" - rc-dialog "~7.2.0" - rc-drawer "~1.7.3" - rc-dropdown "~2.2.0" - rc-editor-mention "^1.0.2" - rc-form "^2.1.0" - rc-input-number "~4.0.0" - rc-menu "~7.4.1" - rc-notification "~3.2.0" - rc-pagination "~1.17.0" - rc-progress "~2.2.2" - rc-rate "~2.4.0" - rc-select "~8.2.6" - rc-slider "~8.6.0" + rc-dialog "~7.3.0" + rc-drawer "~1.7.6" + rc-dropdown "~2.4.1" + rc-editor-mention "^1.1.7" + rc-form "^2.4.0" + rc-input-number "~4.3.7" + rc-menu "~7.4.12" + rc-notification "~3.3.0" + rc-pagination "~1.17.7" + rc-progress "~2.2.6" + rc-rate "~2.5.0" + rc-select "^8.6.7" + rc-slider "~8.6.3" rc-steps "~3.3.0" - rc-switch "~1.7.0" - rc-table "~6.3.2" - rc-tabs "~9.4.0" - rc-time-picker "~3.4.0" - rc-tooltip "~3.7.0" - rc-tree "~1.14.5" - rc-tree-select "~2.2.0" - rc-trigger "^2.5.4" - rc-upload "~2.5.0" - rc-util "^4.0.4" - react-lazy-load "^3.0.12" - react-lifecycles-compat "^3.0.2" - react-slick "~0.23.1" - shallowequal "^1.0.1" - warning "~4.0.1" + rc-switch "~1.8.0" + rc-table "~6.4.0" + rc-tabs "~9.5.2" + rc-time-picker "~3.5.0" + rc-tooltip "~3.7.3" + rc-tree "~1.14.6" + rc-tree-select "~2.5.0" + rc-trigger "^2.6.2" + rc-upload "~2.6.0" + rc-util "^4.5.1" + react-lazy-load "^3.0.13" + react-lifecycles-compat "^3.0.4" + react-slick "~0.23.2" + resize-observer-polyfill "^1.5.0" + shallowequal "^1.1.0" + warning "~4.0.2" any-observable@^0.3.0: version "0.3.0" @@ -1795,13 +1800,10 @@ array-reduce@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/array-reduce/-/array-reduce-0.0.0.tgz#173899d3ffd1c7d9383e4479525dbe278cab5f2b" -array-tree-filter@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/array-tree-filter/-/array-tree-filter-1.0.1.tgz#0a8ad1eefd38ce88858632f9cc0423d7634e4d5d" - -array-tree-filter@^2.0.0: +array-tree-filter@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/array-tree-filter/-/array-tree-filter-2.1.0.tgz#873ac00fec83749f255ac8dd083814b4f6329190" + integrity sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw== array-union@^1.0.1: version "1.0.2" @@ -1836,6 +1838,7 @@ arrify@^1.0.0, arrify@^1.0.1: asap@~2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= asn1.js@^4.0.0: version "4.10.1" @@ -1888,6 +1891,7 @@ async-limiter@~1.0.0: async-validator@~1.8.5: version "1.8.5" resolved "https://registry.yarnpkg.com/async-validator/-/async-validator-1.8.5.tgz#dc3e08ec1fd0dddb67e60842f02c0cd1cec6d7f0" + integrity sha512-tXBM+1m056MAX0E8TL2iCjg8WvSyXu0Zc8LNtYqrVeyoL3+esHRZ4SieE9fKQyyU09uONjnMEjrNBMqT0mbvmA== dependencies: babel-runtime "6.x" @@ -2703,7 +2707,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@2.x, classnames@^2.2.0, classnames@^2.2.1, classnames@^2.2.3, classnames@^2.2.5, classnames@^2.2.6, classnames@~2.2.0: +classnames@2.x, classnames@^2.2.0, classnames@^2.2.1, classnames@^2.2.3, classnames@^2.2.5, classnames@^2.2.6, classnames@~2.2.6: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" @@ -2913,6 +2917,7 @@ commondir@^1.0.1: component-classes@1.x, component-classes@^1.2.5, component-classes@^1.2.6: version "1.2.6" resolved "https://registry.yarnpkg.com/component-classes/-/component-classes-1.2.6.tgz#c642394c3618a4d8b0b8919efccbbd930e5cd691" + integrity sha1-xkI5TDYYpNiwuJGe/Mu9kw5c1pE= dependencies: component-indexof "0.0.3" @@ -2923,6 +2928,7 @@ component-emitter@^1.2.0, component-emitter@^1.2.1: component-indexof@0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/component-indexof/-/component-indexof-0.0.3.tgz#11d091312239eb8f32c8f25ae9cb002ffe8d3c24" + integrity sha1-EdCRMSI5648yyPJa6csAL/6NPCQ= compressible@~2.0.14: version "2.0.15" @@ -3068,8 +3074,14 @@ copy-webpack-plugin@^4.2.0: core-js@^1.0.0: version "1.2.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" + integrity sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY= -core-js@^2.4.0, core-js@^2.5.0, core-js@^2.5.7: +core-js@^2.4.0: + version "2.6.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.1.tgz#87416ae817de957a3f249b3b5ca475d4aaed6042" + integrity sha512-L72mmmEayPJBejKIWe2pYtGis5r0tQ5NaJekdhyXgeMQTpJoBsH0NL4ElY2LfSoV15xeQWKQ+XTTOZdyero5Xg== + +core-js@^2.5.0, core-js@^2.5.7: version "2.5.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e" @@ -3128,9 +3140,10 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: safe-buffer "^5.0.1" sha.js "^2.4.8" -create-react-class@^15.5.2, create-react-class@^15.5.3, create-react-class@^15.6.0: +create-react-class@^15.5.3, create-react-class@^15.6.3: version "15.6.3" resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.6.3.tgz#2d73237fb3f970ae6ebe011a9e66f46dbca80036" + integrity sha512-M+/3Q6E6DLO6Yx3OwrWjwHBnvfXXYA7W+dFjt/ZDBemHO1DDZhsalX/NUtnTYclN6GfnBDRh4qRHjcDHmlJBJg== dependencies: fbjs "^0.8.9" loose-envify "^1.3.1" @@ -3139,6 +3152,7 @@ create-react-class@^15.5.2, create-react-class@^15.5.3, create-react-class@^15.6 create-react-context@0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.2.2.tgz#9836542f9aaa22868cd7d4a6f82667df38019dca" + integrity sha512-KkpaLARMhsTsgp0d2NA/R94F/eDLbhXERdIq3LvX2biCAXcDvHYoOqHfWCHf1+OLj+HKBotLG3KqaOOf+C1C+A== dependencies: fbjs "^0.8.0" gud "^1.0.0" @@ -3188,9 +3202,10 @@ crypto-random-string@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" -css-animation@1.x, css-animation@^1.2.5, css-animation@^1.3.2: - version "1.4.1" - resolved "https://registry.yarnpkg.com/css-animation/-/css-animation-1.4.1.tgz#5b8813125de0fbbbb0bbe1b472ae84221469b7a8" +css-animation@1.x, css-animation@^1.3.2, css-animation@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/css-animation/-/css-animation-1.5.0.tgz#c96b9097a5ef74a7be8480b45cc44e4ec6ca2bf5" + integrity sha512-hWYoWiOZ7Vr20etzLh3kpWgtC454tW5vn4I6rLANDgpzNSkO7UfOqyCEeaoBSG9CYWQpRkFWTWbWW8o3uZrNLw== dependencies: babel-runtime "6.x" component-classes "^1.2.5" @@ -3820,10 +3835,12 @@ doctrine@0.7.2: dom-align@^1.7.0: version "1.8.0" resolved "https://registry.yarnpkg.com/dom-align/-/dom-align-1.8.0.tgz#c0e89b5b674c6e836cd248c52c2992135f093654" + integrity sha512-B85D4ef2Gj5lw0rK0KM2+D5/pH7yqNxg2mB+E8uzFaolpm7RQmsxEfjyEuNiF8UBBkffumYDeKRzTzc3LePP+w== dom-closest@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/dom-closest/-/dom-closest-0.2.0.tgz#ebd9f91d1bf22e8d6f477876bbcd3ec90216c0cf" + integrity sha1-69n5HRvyLo1vR3h2u80+yQIWwM8= dependencies: dom-matches ">=1.0.1" @@ -3836,10 +3853,12 @@ dom-converter@~0.2: dom-matches@>=1.0.1: version "2.0.0" resolved "https://registry.yarnpkg.com/dom-matches/-/dom-matches-2.0.0.tgz#d2728b416a87533980eb089b848d253cf23a758c" + integrity sha1-0nKLQWqHUzmA6wibhI0lPPI6dYw= dom-scroll-into-view@1.x, dom-scroll-into-view@^1.2.0: version "1.2.1" resolved "https://registry.yarnpkg.com/dom-scroll-into-view/-/dom-scroll-into-view-1.2.1.tgz#e8f36732dd089b0201a88d7815dc3f88e6d66c7e" + integrity sha1-6PNnMt0ImwIBqI14Fdw/iObWbH4= dom-serializer@0, dom-serializer@~0.1.0: version "0.1.0" @@ -3919,6 +3938,7 @@ dotenv@^6.0.0: draft-js@^0.10.0, draft-js@~0.10.0: version "0.10.5" resolved "https://registry.yarnpkg.com/draft-js/-/draft-js-0.10.5.tgz#bfa9beb018fe0533dbb08d6675c371a6b08fa742" + integrity sha512-LE6jSCV9nkPhfVX2ggcRLA4FKs6zWq9ceuO/88BpXdNCS7mjRTgs0NsV6piUCJX9YxMsB9An33wnkMmU2sD2Zg== dependencies: fbjs "^0.8.15" immutable "~3.7.4" @@ -4014,6 +4034,7 @@ encodeurl@~1.0.2: encoding@^0.1.11: version "0.1.12" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" + integrity sha1-U4tm8+5izRq1HsMjgp0flIDHS+s= dependencies: iconv-lite "~0.4.13" @@ -4037,9 +4058,10 @@ enquire-js@^0.2.1: dependencies: enquire.js "^2.1.6" -enquire.js@^2.1.1, enquire.js@^2.1.6: +enquire.js@^2.1.6: version "2.1.6" resolved "https://registry.yarnpkg.com/enquire.js/-/enquire.js-2.1.6.tgz#3e8780c9b8b835084c3f60e166dbc3c2a3c89814" + integrity sha1-PoeAybi4NQhMP2DhZtvDwqPImBQ= entities@^1.1.1, entities@~1.1.1: version "1.1.2" @@ -4274,6 +4296,7 @@ eventemitter3@^3.0.0: eventlistener@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/eventlistener/-/eventlistener-0.0.1.tgz#ed2baabb852227af2bcf889152c72c63ca532eb8" + integrity sha1-7Suqu4UiJ68rz4iRUscsY8pTLrg= events@^1.0.0: version "1.1.1" @@ -4568,6 +4591,7 @@ fb-watchman@^2.0.0: fbjs@^0.8.0, fbjs@^0.8.15, fbjs@^0.8.16, fbjs@^0.8.9: version "0.8.17" resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" + integrity sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90= dependencies: core-js "^1.0.0" isomorphic-fetch "^2.1.1" @@ -5126,6 +5150,7 @@ growly@^1.3.0: gud@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" + integrity sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw== gzip-size@3.0.0: version "3.0.0" @@ -5180,6 +5205,7 @@ h2x-types@^1.1.0: hammerjs@^2.0.8: version "2.0.8" resolved "https://registry.yarnpkg.com/hammerjs/-/hammerjs-2.0.8.tgz#04ef77862cff2bb79d30f7692095930222bf60f1" + integrity sha1-BO93hiz/K7edMPdpIJWTAiK/YPE= handle-thing@^1.2.5: version "1.2.5" @@ -5569,10 +5595,12 @@ image-size@~0.5.0: immutable@^3.7.4: version "3.8.2" resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" + integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM= immutable@~3.7.4: version "3.7.6" - resolved "http://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz#13b4d3cb12befa15482a26fe1b2ebae640071e4b" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.7.6.tgz#13b4d3cb12befa15482a26fe1b2ebae640071e4b" + integrity sha1-E7TTyxK++hVIKib+Gy665kAHHks= import-cwd@^2.0.0: version "2.1.0" @@ -5699,10 +5727,6 @@ interpret@^1.0.0, interpret@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.1.0.tgz#7ed1b1410c6a0e0f78cf95d3b8440c63f78b8614" -intersperse@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/intersperse/-/intersperse-1.0.0.tgz#f2561fb1cfef9f5277cc3347a22886b4351a5181" - invariant@^2.2.0, invariant@^2.2.1, invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" @@ -5931,6 +5955,7 @@ is-installed-globally@^0.1.0: is-negative-zero@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461" + integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE= is-npm@^1.0.0: version "1.0.0" @@ -6102,6 +6127,11 @@ isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" +ismobilejs@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/ismobilejs/-/ismobilejs-0.5.1.tgz#0e3f825e29e32f84ad5ddbb60e9e04a894046488" + integrity sha512-QX4STsOcBYqlTjVGuAdP1MiRVxtiUbRHOKH0v7Gn1EvfUVIQnrSdgCM4zB4VCZuIejnb2NUMUx0Bwd3EIG6yyA== + isobject@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" @@ -6620,6 +6650,7 @@ json2module@^0.0.3: json2mq@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/json2mq/-/json2mq-0.2.0.tgz#b637bd3ba9eabe122c83e9720483aeb10d2c904a" + integrity sha1-tje9O6nqvhIsg+lyBIOusQ0skEo= dependencies: string-convert "^0.2.0" @@ -6910,6 +6941,7 @@ lodash-decorators@^6.0.0: lodash._getnative@^3.0.0: version "3.9.1" resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" + integrity sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U= lodash.camelcase@^4.3.0: version "4.3.0" @@ -6922,6 +6954,7 @@ lodash.clonedeep@^4.5.0: lodash.debounce@^4.0.0, lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= lodash.endswith@^4.2.1: version "4.2.1" @@ -6942,10 +6975,12 @@ lodash.flattendeep@^4.4.0: lodash.isarguments@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + integrity sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo= lodash.isarray@^3.0.0: version "3.0.4" resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" + integrity sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U= lodash.isequal@^4.5.0: version "4.5.0" @@ -6962,6 +6997,7 @@ lodash.isstring@^4.0.1: lodash.keys@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a" + integrity sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo= dependencies: lodash._getnative "^3.0.0" lodash.isarguments "^3.0.0" @@ -6986,12 +7022,13 @@ lodash.startswith@^4.2.1: lodash.throttle@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" + integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ= lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" -"lodash@>=3.5 <5", lodash@^4.11.1, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.16.5, lodash@^4.17.10, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.3.0: +"lodash@>=3.5 <5", lodash@^4.11.1, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.16.5, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.3.0: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" @@ -7040,6 +7077,7 @@ longest@^1.0.1: loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== dependencies: js-tokens "^3.0.0 || ^4.0.0" @@ -7309,6 +7347,7 @@ mini-css-extract-plugin@^0.4.1, mini-css-extract-plugin@^0.4.2: mini-store@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/mini-store/-/mini-store-2.0.0.tgz#0843c048d6942ce55e3e78b1b67fc063022b5488" + integrity sha512-EG0CuwpQmX+XL4QVS0kxNwHW5ftSbhygu1qxQH0pipugjnPkbvkalCdQbEihMwtQY6d3MTN+MS0q+aurs+RfLQ== dependencies: hoist-non-react-statics "^2.3.1" prop-types "^15.6.0" @@ -7395,7 +7434,12 @@ mkdirp@0.5.x, mkdirp@0.x, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0 dependencies: minimist "0.0.8" -moment@2.x, moment@^2.19.3, moment@^2.22.2: +moment@2.x: + version "2.23.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.23.0.tgz#759ea491ac97d54bac5ad776996e2a58cc1bc225" + integrity sha512-3IE39bHVqFbWWaPOMHZF98Q9c3LDKGTmypMiTM2QygGXXElkFWIH7GxfmlwmY2vwa+wmNsoYZmG2iusf1ZjJoA== + +moment@^2.22.2: version "2.22.2" resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66" @@ -7449,6 +7493,7 @@ mustache@^2.3.1: mutationobserver-shim@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/mutationobserver-shim/-/mutationobserver-shim-0.3.2.tgz#f4d5dae7a4971a2207914fb5a90ebd514b65acca" + integrity sha1-9NXa56SXGiIHkU+1qQ69UUtlrMo= mute-stream@0.0.7: version "0.0.7" @@ -7533,6 +7578,7 @@ no-case@^2.2.0: node-fetch@^1.0.1: version "1.7.3" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== dependencies: encoding "^0.1.11" is-stream "^1.0.1" @@ -7814,6 +7860,7 @@ obuf@^1.0.0, obuf@^1.1.1: omit.js@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/omit.js/-/omit.js-1.0.0.tgz#e013cb86a7517b9cf6f7cfb0ddb4297256a99288" + integrity sha512-O1rwbvEfAdhtonTv+v6IQeMOKTi/wlHcXpI3hehyPDlujkjSBQC6Vtzg0mdy+v2KVDmuPf7hAbHlTBM6q1bUHQ== dependencies: babel-runtime "^6.23.0" @@ -8186,6 +8233,7 @@ pbkdf2@^3.0.3: performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= pify@^2.0.0, pify@^2.3.0: version "2.3.0" @@ -8601,10 +8649,15 @@ prettier-package-json@^1.6.0: sort-object-keys "^1.1.2" sort-order "^1.0.1" -prettier@^1.13.4, prettier@^1.14.2, prettier@^1.14.3: +prettier@^1.13.4, prettier@^1.14.2: version "1.14.3" resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.14.3.tgz#90238dd4c0684b7edce5f83b0fb7328e48bd0895" +prettier@^1.14.3: + version "1.15.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.15.3.tgz#1feaac5bdd181237b54dbe65d874e02a1472786a" + integrity sha512-gAU9AGAPMaKb3NNSUUuhhFAS7SCO4ALTN4nRIn6PJ075Qd28Yn2Ig2ahEJWdJwJmlEBTUfC7mMUSFy8MwsOCfg== + pretty-bytes@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.1.0.tgz#6237ecfbdc6525beaef4de722cc60a58ae0e6c6d" @@ -8650,6 +8703,7 @@ promise-inflight@^1.0.1: promise@^7.1.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== dependencies: asap "~2.0.3" @@ -8790,9 +8844,10 @@ querystringify@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.1.0.tgz#7ded8dfbf7879dcc60d0a644ac6754b283ad17ef" -raf@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.0.tgz#a28876881b4bc2ca9117d4138163ddb80f781575" +raf@^3.4.0, raf@^3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" + integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== dependencies: performance-now "^2.1.0" @@ -8850,13 +8905,26 @@ raw-body@2.3.3, raw-body@^2.2.0: rc-align@^2.4.0, rc-align@^2.4.1: version "2.4.3" resolved "https://registry.yarnpkg.com/rc-align/-/rc-align-2.4.3.tgz#b9b3c2a6d68adae71a8e1d041cd5e3b2a655f99a" + integrity sha512-h5KgyB5IXYR7iKpYFcMr54cuQ2eozPCZ11kbXPG5+6CWvmyJ+c0R/yjndVndiNk2G3MKcTMbJNdDv5DIckLAxQ== dependencies: babel-runtime "^6.26.0" dom-align "^1.7.0" prop-types "^15.5.8" rc-util "^4.0.4" -rc-animate@2.x, rc-animate@^2.3.0, rc-animate@^2.4.1, rc-animate@^2.4.4: +rc-animate@2.x, rc-animate@^2.3.0, rc-animate@^2.5.4: + version "2.6.0" + resolved "https://registry.yarnpkg.com/rc-animate/-/rc-animate-2.6.0.tgz#ca8440d042781af7a1329d84f97ea94794c5ec15" + integrity sha512-JXDycchgbOI+7T/VKmFWnAIn042LLScK1fNkmNunb0jz5q5aPGCAybx2bTo7X5t31Jkj9OsxKNb/vZPDPWufCg== + dependencies: + babel-runtime "6.x" + classnames "^2.2.6" + css-animation "^1.3.2" + prop-types "15.x" + raf "^3.4.0" + react-lifecycles-compat "^3.0.4" + +rc-animate@^2.4.4: version "2.5.4" resolved "https://registry.yarnpkg.com/rc-animate/-/rc-animate-2.5.4.tgz#3b308c42e137a2e3fb578650fdb145c2100fcc35" dependencies: @@ -8870,6 +8938,7 @@ rc-animate@2.x, rc-animate@^2.3.0, rc-animate@^2.4.1, rc-animate@^2.4.4: rc-animate@^3.0.0-rc.1, rc-animate@^3.0.0-rc.4, rc-animate@^3.0.0-rc.5: version "3.0.0-rc.6" resolved "https://registry.yarnpkg.com/rc-animate/-/rc-animate-3.0.0-rc.6.tgz#04288eefa118e0cae214536c8a903ffaac1bc3fb" + integrity sha512-oBLPpiT6Q4t6YvD/pkLcmofBP1p01TX0Otse8Q4+Mxt8J+VSDflLZGIgf62EwkvRwsQUkLPjZVFBsldnPKLzjg== dependencies: babel-runtime "6.x" classnames "^2.2.5" @@ -8880,32 +8949,36 @@ rc-animate@^3.0.0-rc.1, rc-animate@^3.0.0-rc.4, rc-animate@^3.0.0-rc.5: rc-util "^4.5.0" react-lifecycles-compat "^3.0.4" -rc-calendar@~9.7.3: - version "9.7.10" - resolved "https://registry.yarnpkg.com/rc-calendar/-/rc-calendar-9.7.10.tgz#269393d9b5d4b5091cff65096bac7900ac809373" +rc-calendar@~9.10.3: + version "9.10.4" + resolved "https://registry.yarnpkg.com/rc-calendar/-/rc-calendar-9.10.4.tgz#2fe203b8fe875d2fd418693ea4176c65982cc072" + integrity sha512-+2K8aF+LPjtZ5SkWBpLkVeyYTDdoPOE8db3I+dS1XNKKQHn1ayaONIpbY0B+tUNpACu0Prj5pg+90VulnURwYA== dependencies: babel-runtime "6.x" classnames "2.x" - create-react-class "^15.5.2" moment "2.x" prop-types "^15.5.8" rc-trigger "^2.2.0" rc-util "^4.1.1" + react-lifecycles-compat "^3.0.4" -rc-cascader@~0.16.0: - version "0.16.0" - resolved "https://registry.yarnpkg.com/rc-cascader/-/rc-cascader-0.16.0.tgz#11baa854c2aaa2d6a8f601dec75dd136d59c5156" +rc-cascader@~0.17.0: + version "0.17.1" + resolved "https://registry.yarnpkg.com/rc-cascader/-/rc-cascader-0.17.1.tgz#914481c3370b5fd8f82e4f9df9b6596dfeda14d5" + integrity sha512-JED1iOLpj1+uob+0Asd4zwhhMRp3gLs2iYOY2/0OsdEsPc8Qj6TUwj8+isVtqyXiwGWG3vo8XgO6KCM/i7ZFqQ== dependencies: - array-tree-filter "^1.0.0" + array-tree-filter "^2.1.0" prop-types "^15.5.8" rc-trigger "^2.2.0" rc-util "^4.0.4" + react-lifecycles-compat "^3.0.4" shallow-equal "^1.0.0" warning "^4.0.1" rc-checkbox@~2.1.5: - version "2.1.5" - resolved "https://registry.yarnpkg.com/rc-checkbox/-/rc-checkbox-2.1.5.tgz#411858448c0ee2a797ef8544dac63bcaeef722ef" + version "2.1.6" + resolved "https://registry.yarnpkg.com/rc-checkbox/-/rc-checkbox-2.1.6.tgz#5dc00653e5277018c431fec55e38b91c1f976e90" + integrity sha512-+VxQbt2Cwe1PxCvwosrAYXT6EQeGwrbLJB2K+IPGCSRPCKnk9zcub/0eW8A4kxjyyfh60PkwsAUZ7qmB31OmRA== dependencies: babel-runtime "^6.23.0" classnames "2.x" @@ -8913,43 +8986,50 @@ rc-checkbox@~2.1.5: rc-util "^4.0.4" rc-collapse@~1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/rc-collapse/-/rc-collapse-1.10.0.tgz#b39578633a1e033391597776758763a10d3bc261" + version "1.10.2" + resolved "https://registry.yarnpkg.com/rc-collapse/-/rc-collapse-1.10.2.tgz#2136c9aca8f5f8e2af66d6454adde56693fa0f00" + integrity sha512-AtEE4rMXEBT05gScduc+NQf/257wqE0xk4tNX4N1DBq0qTx19xGcwX3EXDD+ZF8KuZ/A25pgmviXad/hthNQSg== dependencies: classnames "2.x" css-animation "1.x" prop-types "^15.5.6" rc-animate "2.x" + react-is "^16.7.0" -rc-dialog@~7.2.0: - version "7.2.1" - resolved "https://registry.yarnpkg.com/rc-dialog/-/rc-dialog-7.2.1.tgz#ac92fcffdf2a0eaa64b77f829336653d911a57be" +rc-dialog@~7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/rc-dialog/-/rc-dialog-7.3.0.tgz#d5b8c4bb4f4b2ac38bb5a839ab9e255b8a88b1ac" + integrity sha512-YLQHqZuU0cO02LUwhCsCCtvSw24SKLrT4DkNHCNGGcH9YpZP/IOFaH4zVUmXGEQiwyt0D1f3volHthMCKzLzMg== dependencies: babel-runtime "6.x" rc-animate "2.x" rc-util "^4.4.0" -rc-drawer@~1.7.3: - version "1.7.6" - resolved "https://registry.yarnpkg.com/rc-drawer/-/rc-drawer-1.7.6.tgz#925ce0768cf81ef5fa83eb22d90c603422b1b1b1" +rc-drawer@~1.7.6: + version "1.7.7" + resolved "https://registry.yarnpkg.com/rc-drawer/-/rc-drawer-1.7.7.tgz#b014d1de52457c85c4f3e1f0d647a54031dd260c" + integrity sha512-7dESNkClYdWGSdBdwcfeOz6DUCqzrW44QT013fsTBJIiWNLSLgDV5KoHKXG8VTJWU4mBn7M5Lqgyr94CRZcxGA== dependencies: babel-runtime "6.x" classnames "^2.2.5" prop-types "^15.5.0" rc-util "^4.5.1" -rc-dropdown@~2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/rc-dropdown/-/rc-dropdown-2.2.1.tgz#172b6e87f0909fe8ab983e375f62e2866f3250c3" +rc-dropdown@~2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/rc-dropdown/-/rc-dropdown-2.4.1.tgz#aaef6eb3a5152cdd9982895c2a78d9b5f046cdec" + integrity sha512-p0XYn0wrOpAZ2fUGE6YJ6U8JBNc5ASijznZ6dkojdaEfQJAeZtV9KMEewhxkVlxGSbbdXe10ptjBlTEW9vEwEg== dependencies: babel-runtime "^6.26.0" + classnames "^2.2.6" prop-types "^15.5.8" rc-trigger "^2.5.1" react-lifecycles-compat "^3.0.2" rc-editor-core@~0.8.3: - version "0.8.7" - resolved "https://registry.yarnpkg.com/rc-editor-core/-/rc-editor-core-0.8.7.tgz#0d0a46de772e48a1325c7c49d5ea6597b056b69e" + version "0.8.8" + resolved "https://registry.yarnpkg.com/rc-editor-core/-/rc-editor-core-0.8.8.tgz#331034cb8d50df218839fb399cdfb2a913e71630" + integrity sha512-4zT4Z8BtQSDcdh9mGXrsVCzUXmXKpe2U2VJSKOAErh5J4yTzJxSOfJon+nHxZyJZEKXg7rZvwrnhogXZzYNIng== dependencies: babel-runtime "^6.26.0" classnames "^2.2.5" @@ -8959,9 +9039,10 @@ rc-editor-core@~0.8.3: prop-types "^15.5.8" setimmediate "^1.0.5" -rc-editor-mention@^1.0.2: - version "1.1.8" - resolved "https://registry.yarnpkg.com/rc-editor-mention/-/rc-editor-mention-1.1.8.tgz#08dafc50b1ab9ad4d2355eedd6308373ea66473a" +rc-editor-mention@^1.1.7: + version "1.1.12" + resolved "https://registry.yarnpkg.com/rc-editor-mention/-/rc-editor-mention-1.1.12.tgz#896bcb172112f18812e96fdd33ba603c0fc7306a" + integrity sha512-cPm2rQ7P+hXaKMsO0ajVv08QlTDcSPVtw8/lVr9D+QzQKRPChCqLw9rVGOa4YGYTeS3gVe8lBfLr8a9JKFk3gA== dependencies: babel-runtime "^6.23.0" classnames "^2.2.5" @@ -8972,9 +9053,10 @@ rc-editor-mention@^1.0.2: rc-animate "^2.3.0" rc-editor-core "~0.8.3" -rc-form@^2.1.0: - version "2.2.6" - resolved "https://registry.yarnpkg.com/rc-form/-/rc-form-2.2.6.tgz#737bd1eb1f45c6ce821854e86248e145adb6230c" +rc-form@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/rc-form/-/rc-form-2.4.0.tgz#7488619a2ed73fc2f776afc8705e587539d16e19" + integrity sha512-NuXc+TXOkEmIgVcgvxCUNUNNBaGaPQRuB0WRIACAl4UGkiHTadkBmBUBw9+1WzwutaVIzJi/XVd7rWrI24OyFQ== dependencies: async-validator "~1.8.5" babel-runtime "6.x" @@ -8987,14 +9069,16 @@ rc-form@^2.1.0: rc-hammerjs@~0.6.0: version "0.6.9" resolved "https://registry.yarnpkg.com/rc-hammerjs/-/rc-hammerjs-0.6.9.tgz#9a4ddbda1b2ec8f9b9596091a6a989842a243907" + integrity sha512-4llgWO3RgLyVbEqUdGsDfzUDqklRlQW5VEhE3x35IvhV+w//VPRG34SBavK3D2mD/UaLKaohgU41V4agiftC8g== dependencies: babel-runtime "6.x" hammerjs "^2.0.8" prop-types "^15.5.9" -rc-input-number@~4.0.0: - version "4.0.13" - resolved "https://registry.yarnpkg.com/rc-input-number/-/rc-input-number-4.0.13.tgz#18ac305bf07b6771ad0e4edc97b1e1bbb9b71918" +rc-input-number@~4.3.7: + version "4.3.7" + resolved "https://registry.yarnpkg.com/rc-input-number/-/rc-input-number-4.3.7.tgz#5c11a2015812c414492d8f0408ef04bb48fd6e1c" + integrity sha512-uIrUSJ3mURDBIcoBE3Suq6hvO7OyfNJHLeai0xN2hXMxOkR9ePH4B/RbiqNjMPflmj6/fxbtK5odOXqVV7BemQ== dependencies: babel-runtime "6.x" classnames "^2.2.0" @@ -9003,13 +9087,15 @@ rc-input-number@~4.0.0: rc-util "^4.5.1" rmc-feedback "^2.0.0" -rc-menu@^7.3.0, rc-menu@~7.4.1: - version "7.4.17" - resolved "https://registry.yarnpkg.com/rc-menu/-/rc-menu-7.4.17.tgz#3d25b45716b1a594e5cf3e0e030139556abaaa24" +rc-menu@^7.3.0, rc-menu@~7.4.12: + version "7.4.21" + resolved "https://registry.yarnpkg.com/rc-menu/-/rc-menu-7.4.21.tgz#8a728afd8db81312c913511b6502d9de596d72fd" + integrity sha512-TfcwybKLuw2WhEkplYH7iFMGlDbH6KhPcd+gv5J2oLQcgiGeUECzyOWSVaFRRlkpB7g2eNzXbha/AXN/Xyzvnw== dependencies: babel-runtime "6.x" classnames "2.x" dom-scroll-into-view "1.x" + ismobilejs "^0.5.1" mini-store "^2.0.0" mutationobserver-shim "^0.3.2" prop-types "^15.5.6" @@ -9018,9 +9104,10 @@ rc-menu@^7.3.0, rc-menu@~7.4.1: rc-util "^4.1.0" resize-observer-polyfill "^1.5.0" -rc-notification@~3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/rc-notification/-/rc-notification-3.2.0.tgz#bbfb6a92c4e54c9eeb7ac51a7e8c64011ea12ab1" +rc-notification@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/rc-notification/-/rc-notification-3.3.0.tgz#af74fe7fc9ee4040eaa5e72dbd6d4088a3ecae2d" + integrity sha512-T7wUryaKTNTO9gsWPCwRyC9P4FcKFTrIRsiNVXJhjlRbHKT0xZF3ag/gxXxZzPBDAf0l1vfgIrT+11cfWtZW0g== dependencies: babel-runtime "6.x" classnames "2.x" @@ -9028,32 +9115,37 @@ rc-notification@~3.2.0: rc-animate "2.x" rc-util "^4.0.4" -rc-pagination@~1.17.0: - version "1.17.3" - resolved "https://registry.yarnpkg.com/rc-pagination/-/rc-pagination-1.17.3.tgz#4c5334adef607f7a80b90ca7501a1e962491aae5" +rc-pagination@~1.17.7: + version "1.17.8" + resolved "https://registry.yarnpkg.com/rc-pagination/-/rc-pagination-1.17.8.tgz#65583bebe13fffe4de7f418e1a6c86374ceabceb" + integrity sha512-duEV+K/b/nZNGr943+TMCEcY4xWkjAkpKW0Vr7fSR8wQk0DY7aTJC+k+vjl4X2EzEmPXqy85hibzpsO9vydKAw== dependencies: babel-runtime "6.x" prop-types "^15.5.7" + react-lifecycles-compat "^3.0.4" -rc-progress@~2.2.2: +rc-progress@~2.2.6: version "2.2.6" resolved "https://registry.yarnpkg.com/rc-progress/-/rc-progress-2.2.6.tgz#d5d07c07333b352a9ef13230c5940e13336c1e62" + integrity sha512-73Ul9WrWf474q0ze+XblpcR8q2No0tybHt+zdGXYyQ7fUZy4b+I5dUQcoxr9UXY6W5Ele9ZsPWJWHSDz/IAOUw== dependencies: babel-runtime "6.x" prop-types "^15.5.8" -rc-rate@~2.4.0: - version "2.4.2" - resolved "https://registry.yarnpkg.com/rc-rate/-/rc-rate-2.4.2.tgz#c097bfdba7a5783cec287c928b1461cc1621f836" +rc-rate@~2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/rc-rate/-/rc-rate-2.5.0.tgz#72d4984a03d0a7a0e6779c7a79efcea27626abf6" + integrity sha512-aXX5klRqbVZxvLghcKnLqqo7LvLVCHswEDteWsm5Gb7NBIPa1YKTcAbvb5SZ4Z4i4EeRoZaPwygRAWsQgGtbKw== dependencies: - babel-runtime "^6.26.0" classnames "^2.2.5" prop-types "^15.5.8" rc-util "^4.3.0" + react-lifecycles-compat "^3.0.4" -rc-select@~8.2.6: - version "8.2.9" - resolved "https://registry.yarnpkg.com/rc-select/-/rc-select-8.2.9.tgz#7862eb3b6e09d4942510e02be92f8e1e689fdfe2" +rc-select@^8.6.7: + version "8.7.0" + resolved "https://registry.yarnpkg.com/rc-select/-/rc-select-8.7.0.tgz#7a79b58c7f0a402dff4ad8bb782c54adb17a3bd5" + integrity sha512-YaNO4peulgvrqQCHGB2kdmS61enJUyQzxrpVKGDJkc+9wjnZ59X2O62QYyy8c/AcE/DNYawUmRVwN9xE3e0kVQ== dependencies: babel-runtime "^6.23.0" classnames "2.x" @@ -9068,9 +9160,10 @@ rc-select@~8.2.6: react-lifecycles-compat "^3.0.2" warning "^4.0.2" -rc-slider@~8.6.0: - version "8.6.3" - resolved "https://registry.yarnpkg.com/rc-slider/-/rc-slider-8.6.3.tgz#1ca0e0bd2863252741de75e7bf8c9f2cfcffccb7" +rc-slider@~8.6.3: + version "8.6.4" + resolved "https://registry.yarnpkg.com/rc-slider/-/rc-slider-8.6.4.tgz#b9d9000180f2b89bb71b58717753164b479fc75f" + integrity sha512-CV2i2Ww6ib0EjFuBKvgjw3PgT6QwvWKC93iEpqPtrztZrx5wO9Iw//AUri4KHRqptW13AuBvFdEHovqLi6XFTw== dependencies: babel-runtime "6.x" classnames "^2.2.5" @@ -9081,25 +9174,28 @@ rc-slider@~8.6.0: warning "^3.0.0" rc-steps@~3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/rc-steps/-/rc-steps-3.3.0.tgz#8817c438a6a5648997c7edb51bde727e6f32e132" + version "3.3.1" + resolved "https://registry.yarnpkg.com/rc-steps/-/rc-steps-3.3.1.tgz#4877e2897331e3bfdb6b789e88aea78f4f15f732" + integrity sha512-LGzmPYS9ETePo+6YbHlFukCdcKppeBZXO49ZxewaC7Cba00q0zrMXlexquZ4fm+9iz0IkpzwgmenvjsVWCmGOw== dependencies: babel-runtime "^6.23.0" classnames "^2.2.3" lodash "^4.17.5" prop-types "^15.5.7" -rc-switch@~1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/rc-switch/-/rc-switch-1.7.0.tgz#a655f08951d6db94d83f162da5b69506cb703b6f" +rc-switch@~1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/rc-switch/-/rc-switch-1.8.0.tgz#cff32fd04c406d8c0c0397e69bc36350a333e236" + integrity sha512-n4H+K2XJCqGwVQKwWOjbxl1kpdov0PVE9DGhzs/S20gk65s/nAOkpdO9tBD7IM/20KRNTBh0fEWkEedByrqh6w== dependencies: babel-runtime "^6.23.0" classnames "^2.2.1" prop-types "^15.5.6" -rc-table@~6.3.2: - version "6.3.7" - resolved "https://registry.yarnpkg.com/rc-table/-/rc-table-6.3.7.tgz#0de4f71415abe3a8d9f9937ab7ddd142ab6143b7" +rc-table@~6.4.0: + version "6.4.2" + resolved "https://registry.yarnpkg.com/rc-table/-/rc-table-6.4.2.tgz#97fdb52fc7817915178aead974d77c21d20236a6" + integrity sha512-kz1SnS7Xu2oUaZJ9fkpFWDTpUtMvzWDzs6dgKrFh4Dq4Rk/A/p7QKsscCHrsm8rIR/wIdF+m9wH4DOv9NtY97Q== dependencies: babel-runtime "6.x" classnames "^2.2.5" @@ -9112,39 +9208,44 @@ rc-table@~6.3.2: shallowequal "^1.0.2" warning "^3.0.0" -rc-tabs@~9.4.0: - version "9.4.7" - resolved "https://registry.yarnpkg.com/rc-tabs/-/rc-tabs-9.4.7.tgz#0277032d8f60dbaf1e60cb7e6df6faece4a1b96a" +rc-tabs@~9.5.2: + version "9.5.8" + resolved "https://registry.yarnpkg.com/rc-tabs/-/rc-tabs-9.5.8.tgz#c097e48999f704b5710307ca95db8bdfdf53c6a7" + integrity sha512-fvkM5FLa0Kq9jz7YNE72T9WeMEbF264FIhqRnKyvmKtaam2lI81qxbofMEfBGhNxcv1whWlZStHj9b1Wi3Q24w== dependencies: babel-runtime "6.x" classnames "2.x" + create-react-context "0.2.2" lodash "^4.17.5" prop-types "15.x" + raf "^3.4.1" rc-hammerjs "~0.6.0" rc-util "^4.0.4" warning "^3.0.0" -rc-time-picker@~3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/rc-time-picker/-/rc-time-picker-3.4.0.tgz#274e80122f885b37a4eace7393f3a25334fa141f" +rc-time-picker@~3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/rc-time-picker/-/rc-time-picker-3.5.0.tgz#bbe8d50a677eff1cd65cb645cb0fc29f83b91c35" + integrity sha512-swJyFZgR3P4UgFix5DP0fQQPKHP7WYEKlzbAWXd72TmFV80VCxmR+l0OWyCOjZgXfo9VJ/mEDzUnMSjP8/xyrg== dependencies: - babel-runtime "6.x" classnames "2.x" moment "2.x" prop-types "^15.5.8" rc-trigger "^2.2.0" -rc-tooltip@^3.7.0, rc-tooltip@~3.7.0: +rc-tooltip@^3.7.0, rc-tooltip@~3.7.3: version "3.7.3" resolved "https://registry.yarnpkg.com/rc-tooltip/-/rc-tooltip-3.7.3.tgz#280aec6afcaa44e8dff0480fbaff9e87fc00aecc" + integrity sha512-dE2ibukxxkrde7wH9W8ozHKUO4aQnPZ6qBHtrTH9LoO836PjDdiaWO73fgPB05VfJs9FbZdmGPVEbXCeOP99Ww== dependencies: babel-runtime "6.x" prop-types "^15.5.8" rc-trigger "^2.2.2" -rc-tree-select@~2.2.0: - version "2.2.6" - resolved "https://registry.yarnpkg.com/rc-tree-select/-/rc-tree-select-2.2.6.tgz#e045f83fb8d834848cc20d598d60452e56be4e53" +rc-tree-select@~2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/rc-tree-select/-/rc-tree-select-2.5.0.tgz#826df8239d08a071993982cba0b9569db78d4098" + integrity sha512-KpmJ30+WlX/2LHpfKmAFQ8+4WR/NL5LzYltIAFzp2ZaBlVXSSLo6LnBb33kYnIU0NngVbsS5/dpsOeiHhdV0TA== dependencies: babel-runtime "^6.23.0" classnames "^2.2.1" @@ -9158,9 +9259,10 @@ rc-tree-select@~2.2.0: shallowequal "^1.0.2" warning "^4.0.1" -rc-tree@~1.14.3, rc-tree@~1.14.5: +rc-tree@~1.14.3, rc-tree@~1.14.6: version "1.14.8" resolved "https://registry.yarnpkg.com/rc-tree/-/rc-tree-1.14.8.tgz#31a9652d71c015370d7b6c2109c865244deb9fde" + integrity sha512-jaI7D6q/Usvyaj2lIgRmOdyFD9M3GsxRdkVDtpCURJGuaBtH5eiAAYqj1xb1YR8gx9g6GiNZhaFd5srQcdM/aw== dependencies: babel-runtime "^6.23.0" classnames "2.x" @@ -9170,9 +9272,10 @@ rc-tree@~1.14.3, rc-tree@~1.14.5: react-lifecycles-compat "^3.0.4" warning "^3.0.0" -rc-trigger@^2.2.0, rc-trigger@^2.2.2, rc-trigger@^2.3.0, rc-trigger@^2.5.1, rc-trigger@^2.5.4: +rc-trigger@^2.2.0, rc-trigger@^2.2.2, rc-trigger@^2.3.0, rc-trigger@^2.5.1, rc-trigger@^2.5.4, rc-trigger@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/rc-trigger/-/rc-trigger-2.6.2.tgz#a9c09ba5fad63af3b2ec46349c7db6cb46657001" + integrity sha512-op4xCu95/gdHVaysyxxiYxbY+Z+UcIBSUY9nQfLqm1FlitdtnAN+owD5iMPfnnsRXntgcQ5+RdYKNUFQT5DjzA== dependencies: babel-runtime "6.x" classnames "^2.2.6" @@ -9184,6 +9287,7 @@ rc-trigger@^2.2.0, rc-trigger@^2.2.2, rc-trigger@^2.3.0, rc-trigger@^2.5.1, rc-t rc-trigger@^3.0.0-rc.2: version "3.0.0-rc.3" resolved "https://registry.yarnpkg.com/rc-trigger/-/rc-trigger-3.0.0-rc.3.tgz#35842df1674d25315e1426a44882a4c97652258b" + integrity sha512-4vB6cpxcUdm2qO5VtB9q1TZz0MoWm9BzFLvGknulphGrl1qI6uxUsPDCvqnmujdpDdAKGGfjxntFpA7RtAwkFQ== dependencies: babel-runtime "6.x" classnames "^2.2.6" @@ -9193,9 +9297,10 @@ rc-trigger@^3.0.0-rc.2: rc-animate "^3.0.0-rc.1" rc-util "^4.4.0" -rc-upload@~2.5.0: - version "2.5.1" - resolved "https://registry.yarnpkg.com/rc-upload/-/rc-upload-2.5.1.tgz#7ae0c9038d98ba8750e9466d8f969e1b4bc9f0e0" +rc-upload@~2.6.0: + version "2.6.1" + resolved "https://registry.yarnpkg.com/rc-upload/-/rc-upload-2.6.1.tgz#bf1a81a294e53920c8451b5752f90208d3ae2247" + integrity sha512-cYuHgy+wZZfQwwbuJuIBPdTmRYcfMddukZ9ayzuxlUJT77BUf6kgImfCj2CYTvpnTeIlDn8Wh79AAaC2PF1dIQ== dependencies: babel-runtime "6.x" classnames "^2.2.5" @@ -9205,6 +9310,7 @@ rc-upload@~2.5.0: rc-util@^4.0.4, rc-util@^4.1.0, rc-util@^4.1.1, rc-util@^4.3.0, rc-util@^4.4.0, rc-util@^4.5.0, rc-util@^4.5.1: version "4.6.0" resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-4.6.0.tgz#ba33721783192ec4f3afb259e182b04e55deb7f6" + integrity sha512-rbgrzm1/i8mgfwOI4t1CwWK7wGe+OwX+dNa7PVMgxZYPBADGh86eD4OcJO1UKGeajIMDUUKMluaZxvgraQIOmw== dependencies: add-dom-event-listener "^1.1.0" babel-runtime "6.x" @@ -9314,9 +9420,15 @@ react-is@^16.5.2, react-is@^16.6.0: version "16.6.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.6.0.tgz#456645144581a6e99f6816ae2bd24ee94bdd0c01" -react-lazy-load@^3.0.12: +react-is@^16.7.0: + version "16.7.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.7.0.tgz#c1bd21c64f1f1364c6f70695ec02d69392f41bfa" + integrity sha512-Z0VRQdF4NPDoI0tsXVMLkJLiwEBa+RP66g0xDHxgxysxSoCUccSten4RTF/UFvZF1dZvZ9Zu1sx+MDXwcOR34g== + +react-lazy-load@^3.0.13: version "3.0.13" resolved "https://registry.yarnpkg.com/react-lazy-load/-/react-lazy-load-3.0.13.tgz#3b0a92d336d43d3f0d73cbe6f35b17050b08b824" + integrity sha1-OwqS0zbUPT8Nc8vm81sXBQsIuCQ= dependencies: eventlistener "0.0.1" lodash.debounce "^4.0.0" @@ -9326,6 +9438,7 @@ react-lazy-load@^3.0.12: react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== react-loadable@^5.5.0: version "5.5.0" @@ -9367,9 +9480,10 @@ react-side-effect@^1.0.2: exenv "^1.2.1" shallowequal "^1.0.1" -react-slick@~0.23.1: +react-slick@~0.23.2: version "0.23.2" resolved "https://registry.yarnpkg.com/react-slick/-/react-slick-0.23.2.tgz#8d8bdbc77a6678e8ad36f50c32578c7c0f1c54f6" + integrity sha512-fM6DXX7+22eOcYE9cgaXUfioZL/Zw6fwS6aPMDBt0kLHl4H4fFNEbp4JsJQdEWMLUNFtUytNcvd9KRml22Tp5w== dependencies: classnames "^2.2.5" enquire.js "^2.1.6" @@ -9735,8 +9849,9 @@ resize-observer-lite@0.2.3: element-resize-detector "1.1.13" resize-observer-polyfill@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.0.tgz#660ff1d9712a2382baa2cad450a4716209f9ca69" + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== resolve-cwd@^2.0.0: version "2.0.0" @@ -9839,6 +9954,7 @@ rlp@^2.0.0: rmc-feedback@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/rmc-feedback/-/rmc-feedback-2.0.0.tgz#cbc6cb3ae63c7a635eef0e25e4fbaf5ac366eeaa" + integrity sha512-5PWOGOW7VXks/l3JzlOU9NIxRpuaSS8d9zA3UULUCuTKnpwBHNvv1jSJzxgbbCQeYzROWUpgKI4za3X4C/mKmQ== dependencies: babel-runtime "6.x" classnames "^2.2.5" @@ -10098,16 +10214,19 @@ shallow-clone@^0.1.2: shallow-equal@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.0.0.tgz#508d1838b3de590ab8757b011b25e430900945f7" + integrity sha1-UI0YOLPeWQq4dXsBGyXkMJAJRfc= shallowequal@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-0.2.2.tgz#1e32fd5bcab6ad688a4812cb0cc04efc75c7014e" + integrity sha1-HjL9W8q2rWiKSBLLDMBO/HXHAU4= dependencies: lodash.keys "^3.1.2" -shallowequal@^1.0.1, shallowequal@^1.0.2: +shallowequal@^1.0.1, shallowequal@^1.0.2, shallowequal@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" + integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== shebang-command@^1.2.0: version "1.2.0" @@ -10498,6 +10617,7 @@ string-argv@^0.0.2: string-convert@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/string-convert/-/string-convert-0.2.1.tgz#6982cc3049fbb4cd85f8b24568b9d9bf39eeff97" + integrity sha1-aYLMMEn7tM2F+LJFaLnZvznu/5c= string-length@^2.0.0: version "2.0.0" @@ -10813,6 +10933,7 @@ timsort@^0.3.0: tinycolor2@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.1.tgz#f4fad333447bc0b07d4dc8e9209d8f39a8ac77e8" + integrity sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g= tmp@^0.0.33: version "0.0.33" @@ -11067,8 +11188,9 @@ typescript@^3.0.1: resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.1.3.tgz#01b70247a6d3c2467f70c45795ef5ea18ce191d5" ua-parser-js@^0.7.18: - version "0.7.18" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz#a7bfd92f56edfb117083b69e31d2aa8882d4b1ed" + version "0.7.19" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.19.tgz#94151be4c0a7fb1d001af7022fdaca4642659e4b" + integrity sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ== uglify-es@^3.3.4: version "3.3.9" @@ -11567,18 +11689,21 @@ walker@~1.0.5: warning@2.x: version "2.1.0" resolved "https://registry.yarnpkg.com/warning/-/warning-2.1.0.tgz#21220d9c63afc77a8c92111e011af705ce0c6901" + integrity sha1-ISINnGOvx3qMkhEeARr3Bc4MaQE= dependencies: loose-envify "^1.0.0" warning@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c" + integrity sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w= dependencies: loose-envify "^1.0.0" -warning@^4.0.1, warning@^4.0.2, warning@~4.0.1: +warning@^4.0.1, warning@^4.0.2, warning@~4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.2.tgz#aa6876480872116fa3e11d434b0d0d8d91e44607" + integrity sha512-wbTp09q/9C+jJn4KKJfJfoS6VleK/Dti0yqWSm6KMvJ4MRCXFQNapHuJXutJIrWV0Cf4AhTdeIe4qdKHR1+Hug== dependencies: loose-envify "^1.0.0" @@ -11872,6 +11997,7 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3, whatwg-encoding@^1.0.5: whatwg-fetch@>=0.10.0: version "3.0.0" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb" + integrity sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q== whatwg-fetch@^2.0.4: version "2.0.4" diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 37e1e2f6..2044e3ed 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -1,14 +1,14 @@ from functools import wraps -from flask import Blueprint, g, session +from flask import Blueprint, g, session, request from flask_yoloapi import endpoint, parameter from hashlib import sha256 from uuid import uuid4 from flask_cors import CORS, cross_origin -from sqlalchemy import func +from sqlalchemy import func, or_ from grant.extensions import db from grant.user.models import User, users_schema -from grant.proposal.models import Proposal, proposals_schema +from grant.proposal.models import Proposal, proposals_schema, proposal_schema, PENDING from grant.comment.models import Comment, comments_schema @@ -34,7 +34,6 @@ def auth_required(f): @blueprint.route("/checklogin", methods=["GET"]) -@cross_origin(supports_credentials=True) @endpoint.api() def loggedin(): if 'username' in session: @@ -44,7 +43,6 @@ def loggedin(): @blueprint.route("/login", methods=["POST"]) -@cross_origin(supports_credentials=True) @endpoint.api( parameter('username', type=str, required=False), parameter('password', type=str, required=False), @@ -60,7 +58,6 @@ def login(username, password): @blueprint.route("/logout", methods=["GET"]) -@cross_origin(supports_credentials=True) @endpoint.api() def logout(): del session['username'] @@ -68,20 +65,22 @@ def logout(): @blueprint.route("/stats", methods=["GET"]) -@cross_origin(supports_credentials=True) @endpoint.api() @auth_required def stats(): user_count = db.session.query(func.count(User.id)).scalar() proposal_count = db.session.query(func.count(Proposal.id)).scalar() + proposal_pending_count = db.session.query(func.count(Proposal.id)) \ + .filter(Proposal.status == PENDING) \ + .scalar() return { "userCount": user_count, - "proposalCount": proposal_count + "proposalCount": proposal_count, + "proposalPendingCount": proposal_pending_count, } @blueprint.route('/users/', methods=['DELETE']) -@cross_origin(supports_credentials=True) @endpoint.api() @auth_required def delete_user(id): @@ -89,7 +88,6 @@ def delete_user(id): @blueprint.route("/users", methods=["GET"]) -@cross_origin(supports_credentials=True) @endpoint.api() @auth_required def get_users(): @@ -104,18 +102,48 @@ def get_users(): @blueprint.route("/proposals", methods=["GET"]) -@cross_origin(supports_credentials=True) @endpoint.api() @auth_required def get_proposals(): - proposals = Proposal.query.order_by(Proposal.date_created.desc()).all() + # endpoint.api doesn't seem to handle GET query array input + status_filters = request.args.getlist('statusFilters[]') + or_filter = or_(Proposal.status == v for v in status_filters) + proposals = Proposal.query.filter(or_filter) \ + .order_by(Proposal.date_created.desc()) \ + .all() + # TODO: return partial data for list dumped_proposals = proposals_schema.dump(proposals) return dumped_proposals +@blueprint.route('/proposals/', methods=['GET']) +@endpoint.api() +@auth_required +def get_proposal(id): + proposal = Proposal.query.filter(Proposal.id == id).first() + if proposal: + return proposal_schema.dump(proposal) + return {"message": "Could not find proposal with id %s" % id}, 404 + + @blueprint.route('/proposals/', methods=['DELETE']) -@cross_origin(supports_credentials=True) @endpoint.api() @auth_required def delete_proposal(id): return {"message": "Not implemented."}, 400 + + +@blueprint.route('/proposals//approve', methods=['PUT']) +@endpoint.api( + parameter('isApprove', type=bool, required=True), + parameter('rejectReason', type=str, required=False) +) +@auth_required +def approve_proposal(id, is_approve, reject_reason=None): + proposal = Proposal.query.filter_by(id=id).first() + if proposal: + proposal.approve_pending(is_approve, reject_reason) + db.session.commit() + return proposal_schema.dump(proposal) + + return {"message": "Not implemented."}, 400 diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 167a8491..a2bda87f 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -1,6 +1,6 @@ import datetime from typing import List -from sqlalchemy import func +from sqlalchemy import func, or_ from grant.comment.models import Comment from grant.extensions import ma, db @@ -9,9 +9,11 @@ from grant.utils.exceptions import ValidationException DRAFT = 'DRAFT' PENDING = 'PENDING' +APPROVED = 'APPROVED' +REJECTED = 'REJECTED' LIVE = 'LIVE' DELETED = 'DELETED' -STATUSES = [DRAFT, PENDING, LIVE, DELETED] +STATUSES = [DRAFT, PENDING, APPROVED, REJECTED, LIVE, DELETED] FUNDING_REQUIRED = 'FUNDING_REQUIRED' COMPLETED = 'COMPLETED' @@ -114,6 +116,9 @@ class Proposal(db.Model): stage = db.Column(db.String(255), nullable=False) content = db.Column(db.Text, nullable=False) category = db.Column(db.String(255), nullable=False) + date_approved = db.Column(db.DateTime) + date_published = db.Column(db.DateTime) + reject_reason = db.Column(db.String(255)) # Payment info target = db.Column(db.String(255), nullable=False) @@ -163,6 +168,24 @@ class Proposal(db.Model): if category and category not in CATEGORIES: raise ValidationException("Category {} not in {}".format(category, CATEGORIES)) + def validate_publishable(self): + # Require certain fields + # TODO: I'm an idiot, make this a loop. + if not self.title: + raise ValidationException("Proposal must have a title") + if not self.content: + raise ValidationException("Proposal must have content") + if not self.brief: + raise ValidationException("Proposal must have a brief") + if not self.category: + raise ValidationException("Proposal must have a category") + if not self.target: + raise ValidationException("Proposal must have a target amount") + if not self.payout_address: + raise ValidationException("Proposal must have a payout address") + # Then run through regular validation + Proposal.validate(vars(self)) + @staticmethod def create(**kwargs): Proposal.validate(kwargs) @@ -171,10 +194,12 @@ class Proposal(db.Model): ) @staticmethod - def get_by_user(user): + def get_by_user(user, statuses=[LIVE]): + status_filter = or_(Proposal.status == v for v in statuses) return Proposal.query \ .join(proposal_team) \ .filter(proposal_team.c.user_id == user.id) \ + .filter(status_filter) \ .all() @staticmethod @@ -204,25 +229,40 @@ class Proposal(db.Model): self.deadline_duration = deadline_duration Proposal.validate(vars(self)) - def publish(self): - # Require certain fields - # TODO: I'm an idiot, make this a loop. - if not self.title: - raise ValidationException("Proposal must have a title") - if not self.content: - raise ValidationException("Proposal must have content") - if not self.brief: - raise ValidationException("Proposal must have a brief") - if not self.category: - raise ValidationException("Proposal must have a category") - if not self.target: - raise ValidationException("Proposal must have a target amount") - if not self.payout_address: - raise ValidationException("Proposal must have a payout address") + def submit_for_approval(self): + self.validate_publishable() + allowed_statuses = [DRAFT, REJECTED] + # specific validation + if self.status not in allowed_statuses: + raise ValidationException("Proposal status must be {} or {} to submit for approval".format(DRAFT, REJECTED)) - # Then run through regular validation - Proposal.validate(vars(self)) - self.status = 'LIVE' + self.status = PENDING + + def approve_pending(self, is_approve, reject_reason=None): + self.validate_publishable() + # specific validation + if not self.status == PENDING: + raise ValidationException("Proposal status must be {} to approve or reject".format(PENDING)) + + if is_approve: + self.status = APPROVED + self.date_approved = datetime.datetime.now() + # TODO: send approval email + else: + if not reject_reason: + raise ValidationException("Please provide a reason for rejecting the proposal") + self.status = REJECTED + self.reject_reason = reject_reason + # TODO: send rejection email + + def publish(self): + self.validate_publishable() + # specific validation + if not self.status == APPROVED: + raise ValidationException("Proposal status must be {}".format(APPROVED)) + + self.date_published = datetime.datetime.now() + self.status = LIVE class ProposalSchema(ma.Schema): @@ -231,7 +271,11 @@ class ProposalSchema(ma.Schema): # Fields to expose fields = ( "stage", + "status", "date_created", + "date_approved", + "date_published", + "reject_reason", "title", "brief", "proposal_id", @@ -250,6 +294,8 @@ class ProposalSchema(ma.Schema): ) date_created = ma.Method("get_date_created") + date_approved = ma.Method("get_date_approved") + date_published = ma.Method("get_date_published") proposal_id = ma.Method("get_proposal_id") funded = ma.Method("get_funded") @@ -266,6 +312,12 @@ class ProposalSchema(ma.Schema): def get_date_created(self, obj): return dt_to_unix(obj.date_created) + def get_date_approved(self, obj): + return dt_to_unix(obj.date_approved) if obj.date_approved else None + + def get_date_published(self, obj): + return dt_to_unix(obj.date_published) if obj.date_published else None + def get_funded(self, obj): # TODO: Add up all contributions and return that return "0" @@ -382,13 +434,20 @@ class UserProposalSchema(ma.Schema): # Fields to expose fields = ( "proposal_id", + "status", "title", "brief", + "target", + "funded", "date_created", + "date_approved", + "date_published", + "reject_reason", "team", ) date_created = ma.Method("get_date_created") proposal_id = ma.Method("get_proposal_id") + funded = ma.Method("get_funded") team = ma.Nested("UserSchema", many=True) def get_proposal_id(self, obj): @@ -397,6 +456,10 @@ class UserProposalSchema(ma.Schema): def get_date_created(self, obj): return dt_to_unix(obj.date_created) * 1000 + def get_funded(self, obj): + # TODO: Add up all contributions and return that + return "0" + user_proposal_schema = UserProposalSchema() user_proposals_schema = UserProposalSchema(many=True) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 19395742..fca4d3ce 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -5,12 +5,13 @@ import ast from flask import Blueprint, g from flask_yoloapi import endpoint, parameter from sqlalchemy.exc import IntegrityError +from sqlalchemy import or_ from grant.comment.models import Comment, comment_schema, comments_schema from grant.milestone.models import Milestone from grant.user.models import User, SocialMedia, Avatar from grant.email.send import send_email -from grant.utils.auth import requires_auth, requires_team_member_auth +from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user from grant.utils.exceptions import ValidationException from grant.utils.misc import is_email, make_url from .models import( @@ -24,7 +25,13 @@ from .models import( proposal_team, ProposalTeamInvite, proposal_team_invite_schema, - db + db, + DRAFT, + PENDING, + APPROVED, + REJECTED, + LIVE, + DELETED ) import traceback @@ -36,6 +43,13 @@ blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals") def get_proposal(proposal_id): proposal = Proposal.query.filter_by(id=proposal_id).first() if proposal: + if proposal.status != LIVE: + if proposal.status == DELETED: + return {"message": "Proposal was deleted"}, 404 + authed_user = get_authed_user() + team_ids = list(x.id for x in proposal.team) + if not authed_user or authed_user.id not in team_ids: + return {"message": "User cannot view this proposal"}, 404 return proposal_schema.dump(proposal) else: return {"message": "No proposal matching id"}, 404 @@ -96,13 +110,13 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id): def get_proposals(stage): if stage: proposals = ( - Proposal.query.filter_by(status="LIVE", stage=stage) + Proposal.query.filter_by(status=LIVE, stage=stage) .order_by(Proposal.date_created.desc()) .all() ) else: proposals = ( - Proposal.query.filter_by(status="LIVE") + Proposal.query.filter_by(status=LIVE) .order_by(Proposal.date_created.desc()) .all() ) @@ -131,7 +145,7 @@ def make_proposal_draft(): def get_proposal_drafts(): proposals = ( Proposal.query - .filter_by(status="DRAFT") + .filter(or_(Proposal.status == DRAFT, Proposal.status == REJECTED)) .join(proposal_team) .filter(proposal_team.c.user_id == g.current_user.id) .order_by(Proposal.date_created.desc()) @@ -182,14 +196,29 @@ def update_proposal(milestones, proposal_id, **kwargs): @blueprint.route("/", methods=["DELETE"]) @requires_team_member_auth @endpoint.api() -def delete_proposal_draft(proposal_id): - if g.current_proposal.status != 'DRAFT': - return {"message": "Cannot delete non-draft proposals"}, 400 +def delete_proposal(proposal_id): + deleteable_statuses = [DRAFT, PENDING, APPROVED, REJECTED] + status = g.current_proposal.status + if status not in deleteable_statuses: + return {"message": "Cannot delete proposals with %s status" % status}, 400 db.session.delete(g.current_proposal) db.session.commit() return None, 202 +@blueprint.route("//submit_for_approval", methods=["PUT"]) +@requires_team_member_auth +@endpoint.api() +def submit_for_approval_proposal(proposal_id): + try: + g.current_proposal.submit_for_approval() + except ValidationException as e: + return {"message": "Invalid proposal parameters: {}".format(str(e))}, 400 + db.session.add(g.current_proposal) + db.session.commit() + return proposal_schema.dump(g.current_proposal), 200 + + @blueprint.route("//publish", methods=["PUT"]) @requires_team_member_auth @endpoint.api() diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index edb7f76a..932355b2 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -8,9 +8,12 @@ from grant.proposal.models import ( proposal_team, ProposalTeamInvite, invites_with_proposal_schema, - user_proposals_schema + user_proposals_schema, + PENDING, + APPROVED, + REJECTED ) -from grant.utils.auth import requires_auth, requires_same_user_auth +from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException from grant.utils.social import verify_social, get_social_login_url, VerifySocialException from grant.email.models import EmailRecovery @@ -52,9 +55,10 @@ def get_me(): @endpoint.api( parameter("withProposals", type=bool, required=False), parameter("withComments", type=bool, required=False), - parameter("withFunded", type=bool, required=False) + parameter("withFunded", type=bool, required=False), + parameter("withPending", type=bool, required=False) ) -def get_user(user_id, with_proposals, with_comments, with_funded): +def get_user(user_id, with_proposals, with_comments, with_funded, with_pending): user = User.get_by_id(user_id) if user: result = user_schema.dump(user) @@ -70,6 +74,11 @@ def get_user(user_id, with_proposals, with_comments, with_funded): comments = Comment.get_by_user(user) comments_dump = user_comments_schema.dump(comments) result["comments"] = comments_dump + authed_user = get_authed_user() + if with_pending and authed_user and authed_user.id == user.id: + pending = Proposal.get_by_user(user, [PENDING, APPROVED, REJECTED]) + pending_dump = user_proposals_schema.dump(pending) + result["pendingProposals"] = pending_dump return result else: message = "User with id matching {} not found".format(user_id) diff --git a/backend/grant/utils/auth.py b/backend/grant/utils/auth.py index bd823f5d..785213ce 100644 --- a/backend/grant/utils/auth.py +++ b/backend/grant/utils/auth.py @@ -12,6 +12,10 @@ from ..proposal.models import Proposal from ..user.models import User +def get_authed_user(): + return current_user if current_user.is_authenticated else None + + def requires_auth(f): @wraps(f) def decorated(*args, **kwargs): diff --git a/backend/migrations/versions/2f30fb7d656e_.py b/backend/migrations/versions/2f30fb7d656e_.py new file mode 100644 index 00000000..cdd03ac0 --- /dev/null +++ b/backend/migrations/versions/2f30fb7d656e_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: 2f30fb7d656e +Revises: 3ffceaeb996a +Create Date: 2019-01-04 13:31:32.851145 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2f30fb7d656e' +down_revision = '3ffceaeb996a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('proposal', sa.Column('date_approved', sa.DateTime(), nullable=True)) + op.add_column('proposal', sa.Column('date_published', sa.DateTime(), nullable=True)) + op.add_column('proposal', sa.Column('reject_reason', sa.String(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('proposal', 'reject_reason') + op.drop_column('proposal', 'date_published') + op.drop_column('proposal', 'date_approved') + # ### end Alembic commands ### diff --git a/backend/tests/admin/__init__.py b/backend/tests/admin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/admin/test_api.py b/backend/tests/admin/test_api.py new file mode 100644 index 00000000..8559992b --- /dev/null +++ b/backend/tests/admin/test_api.py @@ -0,0 +1,87 @@ +import json +from mock import patch + +from grant.proposal.models import Proposal, APPROVED, REJECTED, PENDING, DRAFT +from ..config import BaseProposalCreatorConfig +from ..test_data import test_proposal, test_user + +plaintext_mock_password = "p4ssw0rd" +mock_admin_auth = { + "username": "admin", + "password": "20cc8f433a1d6400aed9850504c33bfe51ace17ed15d62b0e046b9d7bc4b893b", + "salt": "s4lt" +} + + +class TestAdminAPI(BaseProposalCreatorConfig): + @patch.dict('grant.admin.views.admin_auth', mock_admin_auth) + def login_admin(self): + return self.app.post( + "/api/v1/admin/login", + data={ + "username": mock_admin_auth["username"], + "password": plaintext_mock_password + } + ) + + def test_login(self): + resp = self.login_admin() + self.assert200(resp) + + def test_checklogin_loggedin(self): + self.login_admin() + resp = self.app.get("/api/v1/admin/checklogin") + self.assert200(resp) + self.assertTrue(resp.json["isLoggedIn"]) + + def test_checklogin_loggedout(self): + resp = self.app.get("/api/v1/admin/checklogin") + self.assert200(resp) + self.assertFalse(resp.json["isLoggedIn"]) + + def test_logout(self): + self.login_admin() + resp = self.app.get("/api/v1/admin/logout") + self.assert200(resp) + self.assertFalse(resp.json["isLoggedIn"]) + cl_resp = self.app.get("/api/v1/admin/checklogin") + self.assertFalse(cl_resp.json["isLoggedIn"]) + + def test_get_users(self): + self.login_admin() + resp = self.app.get("/api/v1/admin/users") + self.assert200(resp) + # 2 users created by BaseProposalCreatorConfig + self.assertEqual(len(resp.json), 2) + + def test_get_proposals(self): + self.login_admin() + resp = self.app.get("/api/v1/admin/proposals") + self.assert200(resp) + # 2 proposals created by BaseProposalCreatorConfig + self.assertEqual(len(resp.json), 2) + + def test_approve_proposal(self): + self.login_admin() + # submit for approval (performed by end-user) + self.proposal.submit_for_approval() + # approve + resp = self.app.put( + "/api/v1/admin/proposals/{}/approve".format(self.proposal.id), + data={"isApprove": True} + ) + self.assert200(resp) + self.assertEqual(resp.json["status"], APPROVED) + + def test_reject_proposal(self): + self.login_admin() + # submit for approval (performed by end-user) + self.proposal.submit_for_approval() + # reject + resp = self.app.put( + "/api/v1/admin/proposals/{}/approve".format(self.proposal.id), + data={"isApprove": False, "rejectReason": "Funnzies."} + ) + self.assert200(resp) + self.assertEqual(resp.json["status"], REJECTED) + self.assertEqual(resp.json["rejectReason"], "Funnzies.") diff --git a/backend/tests/proposal/test_api.py b/backend/tests/proposal/test_api.py index a9f1e4a0..4f451f8c 100644 --- a/backend/tests/proposal/test_api.py +++ b/backend/tests/proposal/test_api.py @@ -1,7 +1,7 @@ import json from mock import patch -from grant.proposal.models import Proposal +from grant.proposal.models import Proposal, APPROVED, PENDING, DRAFT from ..config import BaseProposalCreatorConfig from ..test_data import test_proposal, test_user @@ -64,16 +64,47 @@ class TestProposalAPI(BaseProposalCreatorConfig): ) self.assert404(resp) - def test_publish_proposal_draft(self): + # /submit_for_approval + def test_proposal_draft_submit_for_approval(self): self.login_default_user() + resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id)) + self.assert200(resp) + + def test_no_auth_proposal_draft_submit_for_approval(self): + resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id)) + self.assert401(resp) + + def test_invalid_proposal_draft_submit_for_approval(self): + self.login_default_user() + resp = self.app.put("/api/v1/proposals/12345/submit_for_approval") + self.assert404(resp) + + def test_invalid_status_proposal_draft_submit_for_approval(self): + self.login_default_user() + self.proposal.status = PENDING # should be DRAFT + resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id)) + self.assert400(resp) + + # /publish + def test_publish_proposal_approved(self): + self.login_default_user() + # submit for approval, then approve + self.proposal.submit_for_approval() + self.proposal.approve_pending(True) # admin action resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id)) self.assert200(resp) - def test_no_auth_publish_proposal_draft(self): + def test_no_auth_publish_proposal(self): resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id)) self.assert401(resp) - def test_invalid_proposal_publish_proposal_draft(self): + def test_invalid_proposal_publish_proposal(self): self.login_default_user() resp = self.app.put("/api/v1/proposals/12345/publish") self.assert404(resp) + + def test_invalid_status_proposal_publish_proposal(self): + self.login_default_user() + self.proposal.status = PENDING # should be APPROVED + resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id)) + self.assert400(resp) diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index b673b601..2f8472bd 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -48,6 +48,7 @@ export function getUser(address: string): Promise<{ data: User }> { withProposals: true, withComments: true, withFunded: true, + withPending: true, }, }) .then(res => { @@ -156,10 +157,21 @@ export function putProposal(proposal: ProposalDraft): Promise<{ data: ProposalDr return axios.put(`/api/v1/proposals/${proposal.proposalId}`, rest); } -export async function putProposalPublish( +export async function putProposalSubmitForApproval( proposal: ProposalDraft, ): Promise<{ data: Proposal }> { - return axios.put(`/api/v1/proposals/${proposal.proposalId}/publish`).then(res => { + return axios + .put(`/api/v1/proposals/${proposal.proposalId}/submit_for_approval`) + .then(res => { + res.data = formatProposalFromGet(res.data); + return res; + }); +} + +export async function putProposalPublish( + proposalId: number, +): Promise<{ data: Proposal }> { + return axios.put(`/api/v1/proposals/${proposalId}/publish`).then(res => { res.data = formatProposalFromGet(res.data); return res; }); diff --git a/frontend/client/components/CreateFlow/Final.tsx b/frontend/client/components/CreateFlow/Final.tsx index f3f24fb4..782dc04d 100644 --- a/frontend/client/components/CreateFlow/Final.tsx +++ b/frontend/client/components/CreateFlow/Final.tsx @@ -36,27 +36,30 @@ class CreateFinal extends React.Component {
); - } else - if (submittedProposal) { + } else if (submittedProposal) { content = (
+ Your proposal has been submitted! Check your{' '} + profile's pending proposals tab to check it's + status. +
+ {/* TODO - remove or rework depending on design choices */} + {/*
Your proposal has been submitted!{' '} Click here {' '}to check it out. -
+
*/} ); } else { content = (
-
- Submitting your proposal... -
+
Submitting your proposal...
); } diff --git a/frontend/client/components/CreateFlow/Preview.less b/frontend/client/components/CreateFlow/Preview.less new file mode 100644 index 00000000..03d4fa61 --- /dev/null +++ b/frontend/client/components/CreateFlow/Preview.less @@ -0,0 +1,6 @@ +@import '~styles/variables.less'; + +// simulate non-fullscreen template margins +.Preview { + margin: @template-space-top @template-space-sides; +} diff --git a/frontend/client/components/CreateFlow/Preview.tsx b/frontend/client/components/CreateFlow/Preview.tsx index 182e9cb9..fdcb6a90 100644 --- a/frontend/client/components/CreateFlow/Preview.tsx +++ b/frontend/client/components/CreateFlow/Preview.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Alert } from 'antd'; import { ProposalDetail } from 'components/Proposal'; import { AppState } from 'store/reducers'; import { makeProposalPreviewFromDraft } from 'modules/create/utils'; import { ProposalDraft } from 'types'; +import './Preview.less'; interface StateProps { form: ProposalDraft; @@ -17,14 +17,7 @@ class CreateFlowPreview extends React.Component { const { form } = this.props; const proposal = makeProposalPreviewFromDraft(form); return ( - <> - +
{ proposal={proposal} isPreview /> - +
); } } diff --git a/frontend/client/components/CreateFlow/PublishWarningModal.tsx b/frontend/client/components/CreateFlow/PublishWarningModal.tsx index ee44d69b..786465c7 100644 --- a/frontend/client/components/CreateFlow/PublishWarningModal.tsx +++ b/frontend/client/components/CreateFlow/PublishWarningModal.tsx @@ -18,9 +18,9 @@ export default class PublishWarningModal extends React.Component { return ( Confirm publish} + title={<>Confirm submit for approval} visible={isVisible} - okText="Confirm publish" + okText="Confirm submit" cancelText="Never mind" onOk={handlePublish} onCancel={handleClose} @@ -38,15 +38,14 @@ export default class PublishWarningModal extends React.Component {
  • {w}
  • ))} -

    You can still publish, despite these warnings.

    +

    You can still submit, despite these warnings.

    } /> )}

    - Are you sure you’re ready to publish your proposal? Once you’ve done so, you - won't be able to change certain fields such as: target amount, payout address, - team, & deadline. + Are you sure you're ready to submit your proposal for approval? Once you’ve + done so, you won't be able to edit it.

    diff --git a/frontend/client/components/CreateFlow/index.tsx b/frontend/client/components/CreateFlow/index.tsx index 805b73f8..d2009d26 100644 --- a/frontend/client/components/CreateFlow/index.tsx +++ b/frontend/client/components/CreateFlow/index.tsx @@ -213,11 +213,11 @@ class CreateFlow extends React.Component { ) : ( diff --git a/frontend/client/components/DraftList/index.tsx b/frontend/client/components/DraftList/index.tsx index a1a22e7f..9e405041 100644 --- a/frontend/client/components/DraftList/index.tsx +++ b/frontend/client/components/DraftList/index.tsx @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; import { List, Button, Divider, Spin, Popconfirm, message } from 'antd'; import Placeholder from 'components/Placeholder'; -import { ProposalDraft } from 'types'; +import { ProposalDraft, STATUS } from 'types'; import { fetchDrafts, createDraft, deleteDraft } from 'modules/create/actions'; import { AppState } from 'store/reducers'; import './style.less'; @@ -96,7 +96,12 @@ class DraftList extends React.Component { Untitled proposal} + title={ + <> + {d.title || Untitled proposal} + {d.status === STATUS.REJECTED && (rejected)} + + } description={d.brief || No description} /> diff --git a/frontend/client/components/Profile/ProfilePending.less b/frontend/client/components/Profile/ProfilePending.less new file mode 100644 index 00000000..f88ec55a --- /dev/null +++ b/frontend/client/components/Profile/ProfilePending.less @@ -0,0 +1,71 @@ +@import '~styles/variables.less'; +@small-query: ~'(max-width: 640px)'; + +.ProfilePending { + display: flex; + padding-bottom: 1.2rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + margin-bottom: 1rem; + + &:last-child { + border-bottom: none; + padding-bottom: none; + } + + @media @small-query { + flex-direction: column; + padding-bottom: 0.6rem; + } + + &-title { + font-size: 1.2rem; + font-weight: 600; + color: inherit; + display: block; + margin-bottom: 0.1rem; + } + + &-block { + flex: 1 0 0%; + + &:last-child { + margin-left: 1.2rem; + flex: 0 0 0%; + min-width: 15rem; + + @media @small-query { + margin-left: 0; + margin-top: 0.6rem; + } + } + + &.is-actions { + display: flex; + justify-content: flex-end; + align-items: center; + + & button + button, + a + button { + margin-left: 0.5rem; + } + } + } + + .ant-tag { + vertical-align: text-top; + } + + &-status { + margin-bottom: 0.6rem; + + & q { + display: block; + margin: 0.5rem; + font-style: italic; + } + + & small { + opacity: 0.6; + } + } +} diff --git a/frontend/client/components/Profile/ProfilePending.tsx b/frontend/client/components/Profile/ProfilePending.tsx new file mode 100644 index 00000000..457aa2cc --- /dev/null +++ b/frontend/client/components/Profile/ProfilePending.tsx @@ -0,0 +1,154 @@ +import React, { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { Button, Popconfirm, message, Tag } from 'antd'; +import { UserProposal, STATUS } from 'types'; +import { deletePendingProposal, publishPendingProposal } from 'modules/users/actions'; +import './ProfilePending.less'; +import { connect } from 'react-redux'; +import { AppState } from 'store/reducers'; + +interface OwnProps { + proposal: UserProposal; + onPublish: (id: UserProposal['proposalId']) => void; +} + +interface StateProps { + user: AppState['auth']['user']; +} + +interface DispatchProps { + deletePendingProposal: typeof deletePendingProposal; + publishPendingProposal: typeof publishPendingProposal; +} + +type Props = OwnProps & StateProps & DispatchProps; + +const STATE = { + isDeleting: false, + isPublishing: false, +}; + +type State = typeof STATE; + +class ProfilePending extends React.Component { + state = STATE; + render() { + const { status, title, proposalId, rejectReason } = this.props.proposal; + const { isDeleting, isPublishing } = this.state; + + const isDisableActions = isDeleting || isPublishing; + + const st = { + [STATUS.APPROVED]: { + color: 'green', + tag: 'Approved', + blurb:
    You may publish this proposal when you are ready.
    , + }, + [STATUS.REJECTED]: { + color: 'red', + tag: 'Rejected', + blurb: ( + <> +
    This proposal was rejected for the following reason:
    + {rejectReason} +
    You may edit this proposal and re-submit it for approval.
    + + ), + }, + [STATUS.PENDING]: { + color: 'orange', + tag: 'Pending', + blurb: ( +
    + You will receive an email when this proposal has completed the review process. +
    + ), + }, + } as { [key in STATUS]: { color: string; tag: string; blurb: ReactNode } }; + + return ( +
    +
    + + {title} {st[status].tag} + +
    + {st[status].blurb} +
    +
    +
    + {STATUS.APPROVED === status && ( + + )} + {STATUS.REJECTED === status && ( + + + + )} + + this.handleDelete()} + > + + +
    +
    + ); + } + + private handlePublish = async () => { + const { + user, + proposal: { proposalId }, + onPublish, + } = this.props; + if (!user) return; + this.setState({ isPublishing: true }); + try { + await this.props.publishPendingProposal(user.userid, proposalId); + onPublish(proposalId); + } catch (e) { + message.error(e.message || e.toString()); + this.setState({ isPublishing: false }); + } + }; + + private handleDelete = async () => { + const { + user, + proposal: { proposalId }, + } = this.props; + if (!user) return; + this.setState({ isDeleting: true }); + try { + await this.props.deletePendingProposal(user.userid, proposalId); + message.success('Proposal deleted.'); + } catch (e) { + message.error(e.message || e.toString()); + this.setState({ isDeleting: false }); + } + }; +} + +export default connect( + state => ({ + user: state.auth.user, + }), + { + deletePendingProposal, + publishPendingProposal, + }, +)(ProfilePending); diff --git a/frontend/client/components/Profile/ProfilePendingList.tsx b/frontend/client/components/Profile/ProfilePendingList.tsx new file mode 100644 index 00000000..ca26ab47 --- /dev/null +++ b/frontend/client/components/Profile/ProfilePendingList.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Modal } from 'antd'; +import { UserProposal } from 'types'; +import ProfilePending from './ProfilePending'; + +interface OwnProps { + proposals: UserProposal[]; +} + +type Props = OwnProps; + +const STATE = { + publishedId: null as null | UserProposal['proposalId'], +}; + +type State = typeof STATE; + +class ProfilePendingList extends React.Component { + state = STATE; + render() { + const { proposals } = this.props; + const { publishedId } = this.state; + return ( + <> + {proposals.map(p => ( + + ))} + + this.setState({ publishedId: null })} + > +
    + Your proposal is live!{' '} + Click here to check it out. +
    +
    + + ); + } + + private handlePublish = (publishedId: UserProposal['proposalId']) => { + this.setState({ publishedId }); + }; +} + +export default ProfilePendingList; diff --git a/frontend/client/components/Profile/ProfileProposal.tsx b/frontend/client/components/Profile/ProfileProposal.tsx index 02b9dfab..68538d9f 100644 --- a/frontend/client/components/Profile/ProfileProposal.tsx +++ b/frontend/client/components/Profile/ProfileProposal.tsx @@ -11,8 +11,7 @@ interface OwnProps { export default class Profile extends React.Component { render() { - const { title, brief, team, funded, target, proposalId } = this.props.proposal; - + const { title, brief, team, proposalId, funded, target } = this.props.proposal; return (
    diff --git a/frontend/client/components/Profile/index.tsx b/frontend/client/components/Profile/index.tsx index 8b003178..e7b62fa6 100644 --- a/frontend/client/components/Profile/index.tsx +++ b/frontend/client/components/Profile/index.tsx @@ -9,12 +9,12 @@ import { import { connect } from 'react-redux'; import { compose } from 'recompose'; import { Spin, Tabs, Badge } from 'antd'; -import { UsersState } from 'modules/users/reducers'; import { usersActions } from 'modules/users'; import { AppState } from 'store/reducers'; import HeaderDetails from 'components/HeaderDetails'; import ProfileUser from './ProfileUser'; import ProfileEdit from './ProfileEdit'; +import ProfilePendingList from './ProfilePendingList'; import ProfileProposal from './ProfileProposal'; import ProfileComment from './ProfileComment'; import ProfileInvite from './ProfileInvite'; @@ -23,7 +23,7 @@ import Exception from 'pages/exception'; import './style.less'; interface StateProps { - usersMap: UsersState['map']; + usersMap: AppState['users']['map']; authUser: AppState['auth']['user']; } @@ -69,7 +69,14 @@ class Profile extends React.Component { return ; } - const { createdProposals, fundedProposals, comments, invites } = user; + const { + pendingProposals, + createdProposals, + fundedProposals, + comments, + invites, + } = user; + const nonePending = pendingProposals.length === 0; const noneCreated = createdProposals.length === 0; const noneFunded = fundedProposals.length === 0; const noneCommented = comments.length === 0; @@ -96,6 +103,22 @@ class Profile extends React.Component { /> + {isAuthedUser && ( + +
    + {nonePending && ( + + )} + +
    +
    + )}
    {noneCreated && ( diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index c780279a..d6a5b935 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import moment from 'moment'; import { Spin, Form, Input, Button, Icon } from 'antd'; -import { Proposal } from 'types'; +import { Proposal, STATUS } from 'types'; import classnames from 'classnames'; import { fromZat } from 'utils/units'; import { connect } from 'react-redux'; @@ -83,6 +83,7 @@ export class ProposalCampaignBlock extends React.Component { console.warn('TODO: Get deadline and isFrozen from proposal data'); const deadline = 0; const isFrozen = false; + const isLive = proposal.status === STATUS.LIVE; const isFundingOver = isRaiseGoalReached || deadline < Date.now() || isFrozen; const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview; @@ -90,12 +91,14 @@ export class ProposalCampaignBlock extends React.Component { content = ( -
    -
    Started
    -
    - {moment(proposal.dateCreated * 1000).fromNow()} + {isLive && ( +
    +
    Started
    +
    + {moment(proposal.datePublished * 1000).fromNow()} +
    -
    + )}
    Category
    diff --git a/frontend/client/components/Proposal/style.less b/frontend/client/components/Proposal/index.less similarity index 94% rename from frontend/client/components/Proposal/style.less rename to frontend/client/components/Proposal/index.less index 39cb97f2..eb662d01 100644 --- a/frontend/client/components/Proposal/style.less +++ b/frontend/client/components/Proposal/index.less @@ -7,6 +7,18 @@ max-width: 1280px; margin: 0 auto; + &-banner { + text-align: center; + margin-top: -@template-space-top; + margin-left: -@template-space-sides; + margin-right: -@template-space-sides; + margin-bottom: 1rem; + + .ant-alert { + padding-top: 1rem; + } + } + &-top { display: flex; width: 100%; diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index e8f31f5d..9fbaff20 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -1,13 +1,15 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { compose } from 'recompose'; import { connect } from 'react-redux'; +import { Link } from 'react-router-dom'; import Markdown from 'components/Markdown'; import { proposalActions } from 'modules/proposals'; import { bindActionCreators, Dispatch } from 'redux'; import { AppState } from 'store/reducers'; -import { Proposal } from 'types'; +import { Proposal, STATUS } from 'types'; import { getProposal } from 'modules/proposals/selectors'; -import { Spin, Tabs, Icon, Dropdown, Menu, Button } from 'antd'; +import { Spin, Tabs, Icon, Dropdown, Menu, Button, Alert } from 'antd'; +import { AlertProps } from 'antd/lib/alert'; import CampaignBlock from './CampaignBlock'; import TeamBlock from './TeamBlock'; import Milestones from './Milestones'; @@ -20,7 +22,7 @@ import CancelModal from './CancelModal'; import classnames from 'classnames'; import { withRouter } from 'react-router'; import SocialShare from 'components/SocialShare'; -import './style.less'; +import './index.less'; interface OwnProps { proposalId: number; @@ -56,9 +58,10 @@ export class ProposalDetail extends React.Component { }; componentDidMount() { - if (!this.props.proposal) { - this.props.fetchProposal(this.props.proposalId); - } else { + // always refresh from server + this.props.fetchProposal(this.props.proposalId); + + if (this.props.proposal) { this.checkBodyOverflow(); } if (typeof window !== 'undefined') { @@ -91,37 +94,85 @@ export class ProposalDetail extends React.Component { if (!proposal) { return ; - } else { - const deadline = 0; // TODO: Use actual date for deadline - // TODO: isTrustee - determine rework to isAdmin? - // for now: check if authed user in member of proposal team - const isTrustee = !!proposal.team.find(tm => tm.userid === (user && user.userid)); - const hasBeenFunded = false; // TODO: deterimne if proposal has reached funding - const isProposalActive = !hasBeenFunded && deadline > Date.now(); - const canCancel = false; // TODO: Allow canceling if proposal hasn't gone live yet + } - const adminMenu = isTrustee && ( - - Post an Update - alert('Sorry, not yet implemented!')} - disabled={!isProposalActive} - > - Edit proposal - - - Cancel proposal - - - ); + const deadline = 0; // TODO: Use actual date for deadline + // TODO: isTrustee - determine rework to isAdmin? + // for now: check if authed user in member of proposal team + const isTrustee = !!proposal.team.find(tm => tm.userid === (user && user.userid)); + const hasBeenFunded = false; // TODO: deterimne if proposal has reached funding + const isProposalActive = !hasBeenFunded && deadline > Date.now(); + const canCancel = false; // TODO: Allow canceling if proposal hasn't gone live yet + const isLive = proposal.status === STATUS.LIVE; - return ( -
    -
    + const adminMenu = ( + + + Post an Update + + alert('Sorry, not yet implemented!')} + disabled={!isProposalActive} + > + Edit proposal + + + Cancel proposal + + + ); + + // BANNER + const statusBanner = { + [STATUS.PENDING]: { + blurb: ( + <> + Your proposal is being reviewed and is only visible to the team. You will get + an email when it is complete. + + ), + type: 'warning', + }, + [STATUS.APPROVED]: { + blurb: ( + <> + Your proposal has been approved! It is currently only visible to the team. + Visit your profile - pending tab to publish. + + ), + type: 'success', + }, + [STATUS.REJECTED]: { + blurb: ( + <> + Your proposal was rejected and is only visible to the team. Visit your{' '} + profile - pending tab for more information. + + ), + type: 'error', + }, + } as { [key in STATUS]: { blurb: ReactNode; type: AlertProps['type'] } }; + let banner = statusBanner[proposal.status]; + if (isPreview) { + banner = { + blurb: 'This is a preview of your proposal. It has not yet been published.', + type: 'info', + }; + } + + return ( +
    + {banner && ( +
    + +
    + )} +
    + {isLive && (
    { } needs funding on Grant.io! Come help make this proposal a reality by funding it.`} />
    -
    -

    - {proposal ? proposal.title :  } -

    -
    -
    - {proposal ? ( - - ) : ( - - )} -
    - {showExpand && ( - + )} +
    +

    + {proposal ? proposal.title :  } +

    +
    +
    + {proposal ? ( + + ) : ( + )}
    - {isTrustee && ( + {showExpand && ( + + )} +
    + {isLive && + isTrustee && (
    {
    )} -
    -
    - - -
    - - {proposal && ( - - -
    - -
    -
    - - - - - - - - - -
    - )} - {isTrustee && ( - <> - - - - )} +
    + + +
    - ); - } + + + +
    + +
    +
    + + + + + + + + + +
    + + {isTrustee && ( + <> + + + + )} +
    + ); } private expandBody = () => { diff --git a/frontend/client/components/Template/index.less b/frontend/client/components/Template/index.less index 8d2f1aa4..a7289727 100644 --- a/frontend/client/components/Template/index.less +++ b/frontend/client/components/Template/index.less @@ -9,7 +9,7 @@ display: flex; justify-content: center; flex: 1; - padding: 0 2.5rem; + padding: 0 @template-space-sides; .is-fullscreen & { padding: 0; @@ -19,8 +19,8 @@ width: 100%; max-width: @max-content-width; margin: 0 auto; - padding-top: 2.5rem; - padding-bottom: 2.5rem; + padding-top: @template-space-top; + padding-bottom: @template-space-top; min-height: 280px; .is-fullscreen & { @@ -28,7 +28,7 @@ padding-bottom: 0; max-width: none; } - + .is-centered & { align-self: center; } @@ -41,4 +41,4 @@ } } } -} \ No newline at end of file +} diff --git a/frontend/client/modules/create/actions.ts b/frontend/client/modules/create/actions.ts index eef245b8..eb3a86d8 100644 --- a/frontend/client/modules/create/actions.ts +++ b/frontend/client/modules/create/actions.ts @@ -1,10 +1,7 @@ import { Dispatch } from 'redux'; import { ProposalDraft } from 'types'; -// import { AppState } from 'store/reducers'; import types, { CreateDraftOptions } from './types'; -import { putProposal, putProposalPublish } from 'api/api'; - -// type GetState = () => AppState; +import { putProposal, putProposalSubmitForApproval } from 'api/api'; export function initializeForm(proposalId: number) { return { @@ -50,12 +47,12 @@ export function submitProposal(form: ProposalDraft) { dispatch({ type: types.SUBMIT_PROPOSAL_PENDING }); try { await putProposal(form); - const res = await putProposalPublish(form); + const res = await putProposalSubmitForApproval(form); dispatch({ type: types.SUBMIT_PROPOSAL_FULFILLED, payload: res.data, }); - } catch(err) { + } catch (err) { dispatch({ type: types.SUBMIT_PROPOSAL_REJECTED, payload: err.message || err.toString(), diff --git a/frontend/client/modules/create/reducers.ts b/frontend/client/modules/create/reducers.ts index 4c764e3d..d2ce41f5 100644 --- a/frontend/client/modules/create/reducers.ts +++ b/frontend/client/modules/create/reducers.ts @@ -24,6 +24,10 @@ export interface CreateState { submittedProposal: Proposal | null; isSubmitting: boolean; submitError: string | null; + + publishedProposal: Proposal | null; + isPublishing: boolean; + publishError: string | null; } export const INITIAL_STATE: CreateState = { @@ -49,6 +53,10 @@ export const INITIAL_STATE: CreateState = { submittedProposal: null, isSubmitting: false, submitError: null, + + publishedProposal: null, + isPublishing: false, + publishError: null, }; export default function createReducer( @@ -162,7 +170,7 @@ export default function createReducer( isDeletingDraft: false, deleteDraftError: action.payload, }; - + case types.SUBMIT_PROPOSAL_PENDING: return { ...state, @@ -180,7 +188,7 @@ export default function createReducer( ...state, submitError: action.payload, isSubmitting: false, - } + }; } return state; } diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index d37df22e..ea6a2332 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -1,4 +1,4 @@ -import { ProposalDraft, CreateMilestone } from 'types'; +import { ProposalDraft, CreateMilestone, STATUS } from 'types'; import { User } from 'types'; import { getAmountError } from 'utils/validators'; import { MILESTONE_STATE, Proposal } from 'types'; @@ -170,17 +170,17 @@ export function proposalToContractData(form: ProposalDraft): any { } // This is kind of a disgusting function, sorry. -export function makeProposalPreviewFromDraft( - draft: ProposalDraft, -): Proposal { +export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal { const target = parseFloat(draft.target); return { proposalId: 0, + status: STATUS.DRAFT, proposalUrlId: '0-title', proposalAddress: '0x0', payoutAddress: '0x0', dateCreated: Date.now(), + datePublished: Date.now(), title: draft.title, brief: draft.brief, content: draft.content, diff --git a/frontend/client/modules/users/actions.ts b/frontend/client/modules/users/actions.ts index 4bf2e2ae..05009266 100644 --- a/frontend/client/modules/users/actions.ts +++ b/frontend/client/modules/users/actions.ts @@ -5,6 +5,8 @@ import { updateUser as apiUpdateUser, fetchUserInvites as apiFetchUserInvites, putInviteResponse, + deleteProposalDraft, + putProposalPublish, } from 'api/api'; import { Dispatch } from 'redux'; import { cleanClone } from 'utils/helpers'; @@ -89,3 +91,25 @@ export function respondToInvite( } }; } + +export function deletePendingProposal(userId: number, proposalId: number) { + return async (dispatch: Dispatch) => { + await dispatch({ + type: types.USER_DELETE_PROPOSAL, + payload: deleteProposalDraft(proposalId).then(_ => ({ userId, proposalId })), + }); + }; +} + +export function publishPendingProposal(userId: number, proposalId: number) { + return async (dispatch: Dispatch) => { + await dispatch({ + type: types.USER_PUBLISH_PROPOSAL, + payload: putProposalPublish(proposalId).then(res => ({ + userId, + proposalId, + proposal: res.data, + })), + }); + }; +} diff --git a/frontend/client/modules/users/reducers.ts b/frontend/client/modules/users/reducers.ts index 50614cb9..168f8529 100644 --- a/frontend/client/modules/users/reducers.ts +++ b/frontend/client/modules/users/reducers.ts @@ -5,21 +5,22 @@ import { User } from 'types'; export interface TeamInviteWithResponse extends TeamInviteWithProposal { isResponding: boolean; - respondError: number | null; + respondError: string | null; } export interface UserState extends User { isFetching: boolean; hasFetched: boolean; - fetchError: number | null; + fetchError: string | null; isUpdating: boolean; - updateError: number | null; + updateError: string | null; + pendingProposals: UserProposal[]; createdProposals: UserProposal[]; fundedProposals: UserProposal[]; comments: UserComment[]; isFetchingInvites: boolean; hasFetchedInvites: boolean; - fetchErrorInvites: number | null; + fetchErrorInvites: string | null; invites: TeamInviteWithResponse[]; } @@ -43,6 +44,7 @@ export const INITIAL_USER_STATE: UserState = { fetchError: null, isUpdating: false, updateError: null, + pendingProposals: [], createdProposals: [], fundedProposals: [], comments: [], @@ -60,12 +62,10 @@ export default (state = INITIAL_STATE, action: any) => { const { payload } = action; const userFetchId = payload && payload.userFetchId; const invites = payload && payload.invites; - const errorStatus = - (payload && - payload.error && - payload.error.response && - payload.error.response.status) || - 999; + const errorMessage = + (payload && payload.error && (payload.error.message || payload.error.toString())) || + null; + switch (action.type) { // fetch case types.FETCH_USER_PENDING: @@ -81,7 +81,7 @@ export default (state = INITIAL_STATE, action: any) => { return updateUserState(state, userFetchId, { isFetching: false, hasFetched: true, - fetchError: errorStatus, + fetchError: errorMessage, }); // update case types.UPDATE_USER_PENDING: @@ -99,7 +99,7 @@ export default (state = INITIAL_STATE, action: any) => { case types.UPDATE_USER_REJECTED: return updateUserState(state, payload.user.userid, { isUpdating: false, - updateError: errorStatus, + updateError: errorMessage, }); // invites case types.FETCH_USER_INVITES_PENDING: @@ -117,7 +117,7 @@ export default (state = INITIAL_STATE, action: any) => { return updateUserState(state, userFetchId, { isFetchingInvites: false, hasFetchedInvites: true, - fetchErrorInvites: errorStatus, + fetchErrorInvites: errorMessage, }); // invites case types.FETCH_USER_INVITES_PENDING: @@ -135,7 +135,7 @@ export default (state = INITIAL_STATE, action: any) => { return updateUserState(state, userFetchId, { isFetchingInvites: false, hasFetchedInvites: true, - fetchErrorInvites: errorStatus, + fetchErrorInvites: errorMessage, }); // invite response case types.RESPOND_TO_INVITE_PENDING: @@ -148,8 +148,14 @@ export default (state = INITIAL_STATE, action: any) => { case types.RESPOND_TO_INVITE_REJECTED: return updateTeamInvite(state, payload.userId, payload.inviteId, { isResponding: false, - respondError: errorStatus, + respondError: errorMessage, }); + // proposal delete + case types.USER_DELETE_PROPOSAL_FULFILLED: + return removePendingProposal(state, payload.userId, payload.proposalId); + // proposal publish + case types.USER_PUBLISH_PROPOSAL_FULFILLED: + return updatePublishedProposal(state, payload.userId, payload.proposal); // default default: return state; @@ -171,6 +177,32 @@ function updateUserState( }; } +function removePendingProposal( + state: UsersState, + userId: string | number, + proposalId: number, +) { + const pendingProposals = state.map[userId].pendingProposals.filter( + p => p.proposalId !== proposalId, + ); + const userUpdates = { + pendingProposals, + }; + return updateUserState(state, userId, userUpdates); +} + +function updatePublishedProposal( + state: UsersState, + userId: string | number, + proposal: UserProposal, +) { + const withoutPending = removePendingProposal(state, userId, proposal.proposalId); + const userUpdates = { + createdProposals: [proposal, ...state.map[userId].createdProposals], + }; + return updateUserState(withoutPending, userId, userUpdates); +} + function updateTeamInvite( state: UsersState, userid: string | number, diff --git a/frontend/client/modules/users/types.ts b/frontend/client/modules/users/types.ts index f8f5dbfb..fd25dd4f 100644 --- a/frontend/client/modules/users/types.ts +++ b/frontend/client/modules/users/types.ts @@ -18,6 +18,12 @@ enum UsersActions { RESPOND_TO_INVITE_PENDING = 'RESPOND_TO_INVITE_PENDING', RESPOND_TO_INVITE_FULFILLED = 'RESPOND_TO_INVITE_FULFILLED', RESPOND_TO_INVITE_REJECTED = 'RESPOND_TO_INVITE_REJECTED', + + USER_DELETE_PROPOSAL = 'USER_DELETE_PROPOSAL', + USER_DELETE_PROPOSAL_FULFILLED = 'USER_DELETE_PROPOSAL_FULFILLED', + + USER_PUBLISH_PROPOSAL = 'USER_PUBLISH_PROPOSAL', + USER_PUBLISH_PROPOSAL_FULFILLED = 'USER_PUBLISH_PROPOSAL_FULFILLED', } export default UsersActions; diff --git a/frontend/client/styles/variables.less b/frontend/client/styles/variables.less index c7d5dc45..37f98290 100644 --- a/frontend/client/styles/variables.less +++ b/frontend/client/styles/variables.less @@ -1,26 +1,29 @@ // Override ant less variables -@primary-color: #530EEC; -@link-color: rgba(@primary-color, 0.8); +@primary-color: #530eec; +@link-color: rgba(@primary-color, 0.8); @success-color: #52c41a; @warning-color: #faad14; @error-color: #f5222d; @info-color: #1890ff; @processing-color: @primary-color; -@normal-color: #DDD; -@heading-color: #221F1F; -@text-color: rgba(#221F1F, .8); +@normal-color: #ddd; +@heading-color: #221f1f; +@text-color: rgba(#221f1f, 0.8); @text-color-secondary : rgba(#221F1F, .6); @border-color: rgba(229, 231, 235, 100); @font-family: 'Nunito Sans', 'Helvetica Neue', Arial, sans-serif; -@code-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; +@code-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; // Custom variables -@primary-color-dark: #3F05BE; -@primary-color-light: #692CEC; +@primary-color-dark: #3f05be; +@primary-color-light: #692cec; @max-content-width: 1440px; @tablet-query: ~'(max-width: 900px)'; @mobile-query: ~'(max-width: 600px)'; -@tiny-query: ~'(max-width: 360px)'; \ No newline at end of file +@tiny-query: ~'(max-width: 360px)'; + +@template-space-top: 2.5rem; +@template-space-sides: 2.5rem; diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts index f54db75c..3d705132 100644 --- a/frontend/client/utils/api.ts +++ b/frontend/client/utils/api.ts @@ -12,11 +12,14 @@ export function formatUserForPost(user: User) { } export function formatUserFromGet(user: UserState) { - const bnUserProp = (p: UserProposal) => { - p.funded = new BN(p.funded); - p.target = new BN(p.target); + const bnUserProp = (p: any) => { + p.funded = toZat(p.funded); + p.target = toZat(p.target); return p; }; + if (user.pendingProposals) { + user.pendingProposals = user.pendingProposals.map(bnUserProp); + } user.createdProposals = user.createdProposals.map(bnUserProp); user.fundedProposals = user.fundedProposals.map(bnUserProp); return user; diff --git a/frontend/server/ssrAsync.ts b/frontend/server/ssrAsync.ts index bcba405e..03734a3e 100644 --- a/frontend/server/ssrAsync.ts +++ b/frontend/server/ssrAsync.ts @@ -15,7 +15,8 @@ const pathActions = [ action: (match: RegExpMatchArray, store: Store) => { const proposalId = extractProposalIdFromUrl(match[1]); if (proposalId) { - return store.dispatch(fetchProposal(proposalId)); + // return null for errors (404 most likely) + return store.dispatch(fetchProposal(proposalId)).catch(() => null); } }, }, diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index 41e04174..0fd71cb0 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -4,6 +4,7 @@ import { MILESTONE_STATE, Proposal, ProposalMilestone, + STATUS, } from 'types'; import { PROPOSAL_CATEGORY } from 'api/constants'; import BN from 'bn.js'; @@ -97,7 +98,9 @@ export function generateProposal({ return ts.slice(rand).join(''); }; - const genMilestone = (overrides: Partial = {}): ProposalMilestone => { + const genMilestone = ( + overrides: Partial = {}, + ): ProposalMilestone => { const now = new Date(); if (overrides.index) { const estimate = new Date(now.setMonth(now.getMonth() + overrides.index)); @@ -140,10 +143,12 @@ export function generateProposal({ const proposal: Proposal = { proposalId: 12345, + status: STATUS.DRAFT, proposalUrlId: '12345-crowdfund-title', proposalAddress: '0x033fDc6C01DC2385118C7bAAB88093e22B8F0710', payoutAddress: 'z123', dateCreated: created / 1000, + datePublished: created / 1000, deadlineDuration: 86400 * 60, target: amountBn, funded: fundedBn, diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index dd5e3275..ae53fafd 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -1,12 +1,7 @@ import BN from 'bn.js'; import { Zat } from 'utils/units'; import { PROPOSAL_CATEGORY } from 'api/constants'; -import { - CreateMilestone, - Update, - User, - Comment, -} from 'types'; +import { CreateMilestone, Update, User, Comment } from 'types'; import { ProposalMilestone } from './milestone'; export interface TeamInvite { @@ -39,9 +34,9 @@ export interface ProposalDraft { milestones: CreateMilestone[]; team: User[]; invites: TeamInvite[]; + status: STATUS; } - export interface Proposal extends Omit { proposalAddress: string; proposalUrlId: string; @@ -49,6 +44,7 @@ export interface Proposal extends Omit { funded: BN; percentFunded: number; milestones: ProposalMilestone[]; + datePublished: number; } export interface TeamInviteWithProposal extends TeamInvite { @@ -68,9 +64,24 @@ export interface ProposalUpdates { export interface UserProposal { proposalId: number; + status: STATUS; title: string; brief: string; - team: User[]; funded: BN; target: BN; + dateCreated: number; + dateApproved: number; + datePublished: number; + team: User[]; + rejectReason: string; +} + +// NOTE: sync with backend/grant/proposal/models.py STATUSES +export enum STATUS { + DRAFT = 'DRAFT', + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + LIVE = 'LIVE', + DELETED = 'DELETED', }