diff --git a/plugins-structure/apps/politeia/src/components/Proposal/ModalProposalDiff.js b/plugins-structure/apps/politeia/src/components/Proposal/ModalProposalDiff.js index daceb8a97..a920ff30a 100644 --- a/plugins-structure/apps/politeia/src/components/Proposal/ModalProposalDiff.js +++ b/plugins-structure/apps/politeia/src/components/Proposal/ModalProposalDiff.js @@ -10,7 +10,11 @@ import { RecordCard, ThumbnailGrid, } from "@politeiagui/common-ui"; -import { decodeProposalRecord, getFilesDiff, getImagesByDigest } from "./utils"; +import { + decodeProposalRecord, + getFilesDiff, + getImagesByDigest, +} from "../../pi/proposals/utils"; import { ProposalDownloads } from "./common"; import styles from "./ModalProposalDiff.module.css"; import range from "lodash/range"; @@ -54,7 +58,7 @@ function AttachmentsDiff({ newFiles, oldFiles }) { function VersionSelector({ maxVersion, onChange, current, minVersion = 1 }) { const options = range(maxVersion, minVersion - 1, -1).map((v) => ({ - label: `version ${v}`, + label: `v${v}`, value: v, })); function getValueOption(value) { @@ -250,7 +254,7 @@ function ProposalDiff({ setDiffView={setIsMarkdownView} /> } - subtitle={ + secondRow={ *:last-child { diff --git a/plugins-structure/apps/politeia/src/components/Proposal/ProposalCard.js b/plugins-structure/apps/politeia/src/components/Proposal/ProposalCard.js index 0022c0c37..9ffe8d04b 100644 --- a/plugins-structure/apps/politeia/src/components/Proposal/ProposalCard.js +++ b/plugins-structure/apps/politeia/src/components/Proposal/ProposalCard.js @@ -1,15 +1,27 @@ import React from "react"; -import { Button, StatusTag } from "pi-ui"; +import { Button } from "pi-ui"; import { RecordCard } from "@politeiagui/common-ui"; import { CommentsCount } from "@politeiagui/comments/ui"; import { getShortToken } from "@politeiagui/core/records/utils"; -import { decodeProposalRecord, getLegacyProposalStatusTagProps } from "./utils"; -import { ProposalStatusBar, ProposalSubtitle } from "./common"; +import { decodeProposalRecord } from "../../pi/proposals/utils"; +import { + ProposalStatusBar, + ProposalStatusLabel, + ProposalStatusTag, + ProposalSubtitle, +} from "./common"; -const ProposalCard = ({ record, voteSummary, commentsCount }) => { +const ProposalCard = ({ + record, + voteSummary, + commentsCount, + proposalSummary, + proposalStatusChanges, +}) => { const proposal = decodeProposalRecord(record); - const statusTagProps = getLegacyProposalStatusTagProps(record, voteSummary); const proposalLink = `/record/${getShortToken(proposal.token)}`; + const currentStatusChange = + proposalSummary && proposalStatusChanges?.[proposalSummary.status]; return (
{ version={proposal.version} /> } - rightHeader={} + rightHeader={} + rightHeaderSubtitle={ + + } secondRow={} footer={ <> diff --git a/plugins-structure/apps/politeia/src/components/Proposal/ProposalDetails.js b/plugins-structure/apps/politeia/src/components/Proposal/ProposalDetails.js index 2cfbf52a6..5b07f6db4 100644 --- a/plugins-structure/apps/politeia/src/components/Proposal/ProposalDetails.js +++ b/plugins-structure/apps/politeia/src/components/Proposal/ProposalDetails.js @@ -8,11 +8,15 @@ import { ThumbnailGrid, useModal, } from "@politeiagui/common-ui"; -import { decodeProposalRecord, getImagesByDigest } from "./utils"; +import { + decodeProposalRecord, + getImagesByDigest, +} from "../../pi/proposals/utils"; import { ProposalDownloads, ProposalMetadata, ProposalStatusBar, + ProposalStatusLabel, ProposalStatusTag, ProposalSubtitle, } from "./common"; @@ -24,8 +28,9 @@ import ModalProposalDiff from "./ModalProposalDiff"; const ProposalDetails = ({ record, voteSummary, - piSummary, + proposalSummary, onFetchRecordTimestamps, + proposalStatusChanges, }) => { const [open] = useModal(); @@ -61,11 +66,15 @@ const ProposalDetails = ({ const isAbandoned = proposalDetails.archived || proposalDetails.censored; + const currentStatusChange = + proposalSummary && proposalStatusChanges?.[proposalSummary.status]; + return (
- {isAbandoned && ( + {currentStatusChange?.reason && ( - Reason: {proposalDetails.abandonmentReason} +
Proposal is {currentStatusChange.status}.
+
Reason: {currentStatusChange.reason}
)} } - rightHeader={} + rightHeader={} + rightHeaderSubtitle={ + + } secondRow={
diff --git a/plugins-structure/apps/politeia/src/components/Proposal/common/ProposalStatusBar.js b/plugins-structure/apps/politeia/src/components/Proposal/common/ProposalStatusBar.js index c3c1f29e2..21b65363b 100644 --- a/plugins-structure/apps/politeia/src/components/Proposal/common/ProposalStatusBar.js +++ b/plugins-structure/apps/politeia/src/components/Proposal/common/ProposalStatusBar.js @@ -1,6 +1,6 @@ import React from "react"; import { TicketvoteRecordVoteStatusBar } from "@politeiagui/ticketvote/ui"; -import { showVoteStatusBar } from "../utils"; +import { showVoteStatusBar } from "../../../pi/proposals/utils"; function ProposalStatusBar({ voteSummary }) { return ( diff --git a/plugins-structure/apps/politeia/src/components/Proposal/common/ProposalStatusLabel.js b/plugins-structure/apps/politeia/src/components/Proposal/common/ProposalStatusLabel.js new file mode 100644 index 000000000..d8ffe810f --- /dev/null +++ b/plugins-structure/apps/politeia/src/components/Proposal/common/ProposalStatusLabel.js @@ -0,0 +1,36 @@ +import React from "react"; +import { Event } from "@politeiagui/common-ui"; +import { Tooltip } from "pi-ui"; +import styles from "./styles.module.css"; +import { getProposalStatusDescription } from "../../../pi/proposals/utils"; + +function ProposalStatusLabel({ statusChange }) { + if (!statusChange) return null; + const { event, description } = getProposalStatusDescription(statusChange); + return ( +
+ {event && ( + + )} + {description && ( +
{description}
+ )} + {statusChange.blocksCount > 0 && ( + + {statusChange.blocksCount} blocks left + + )} +
+ ); +} + +export default ProposalStatusLabel; diff --git a/plugins-structure/apps/politeia/src/components/Proposal/common/ProposalStatusTag.js b/plugins-structure/apps/politeia/src/components/Proposal/common/ProposalStatusTag.js index e80769cdf..9c53d4b5e 100644 --- a/plugins-structure/apps/politeia/src/components/Proposal/common/ProposalStatusTag.js +++ b/plugins-structure/apps/politeia/src/components/Proposal/common/ProposalStatusTag.js @@ -1,10 +1,10 @@ import React from "react"; import { StatusTag } from "pi-ui"; -import { getProposalStatusTagProps } from "../utils"; +import { getProposalStatusTagProps } from "../../../pi/proposals/utils"; -function ProposalStatusTag({ piSummary }) { - const statusTagProps = getProposalStatusTagProps(piSummary); - return ; +function ProposalStatusTag({ proposalSummary }) { + const { text, type } = getProposalStatusTagProps(proposalSummary); + return ; } export default ProposalStatusTag; diff --git a/plugins-structure/apps/politeia/src/components/Proposal/common/ProposalSubtitle.js b/plugins-structure/apps/politeia/src/components/Proposal/common/ProposalSubtitle.js index 59bc6d76d..53a4961f0 100644 --- a/plugins-structure/apps/politeia/src/components/Proposal/common/ProposalSubtitle.js +++ b/plugins-structure/apps/politeia/src/components/Proposal/common/ProposalSubtitle.js @@ -12,14 +12,12 @@ function ProposalSubtitle({ timestamps = {}, onChangeVersion, }) { - const { publishedat, editedat, abandonedat, censoredat } = timestamps; + const { publishedat, editedat } = timestamps; return ( {username} {publishedat && } {editedat && } - {abandonedat && } - {censoredat && } {version > 1 && (onChangeVersion ? ( {comments && ( diff --git a/plugins-structure/apps/politeia/src/pages/Details/index.js b/plugins-structure/apps/politeia/src/pages/Details/index.js index 560c303bf..5e62ec921 100644 --- a/plugins-structure/apps/politeia/src/pages/Details/index.js +++ b/plugins-structure/apps/politeia/src/pages/Details/index.js @@ -2,7 +2,11 @@ import App from "../../app"; import { routeCleanup } from "../../utils/routeCleanup"; import { createRouteView } from "../../utils/createRouteView"; import { + fetchBillingStatusChangesListenerCreator, fetchDetailsListenerCreator, + fetchProposalSummaryListenerCreator, + fetchRecordDetailsListenerCreator, + fetchVoteSummaryListenerCreator, recordFetchDetailsListenerCreator, } from "./listeners"; import Details from "./Details"; @@ -29,9 +33,26 @@ export default App.createRoute({ listenerCreator: fetchDetailsListenerCreator, }, { - id: "pi/summaries", + id: "pi/summaries/single", listenerCreator: fetchDetailsListenerCreator, }, + { + id: "pi/billingStatusChanges/single", + listenerCreator: fetchProposalSummaryListenerCreator, + }, + // Proposal status changes services + { + id: "pi/proposals/voteStatusChanges", + listenerCreator: fetchVoteSummaryListenerCreator, + }, + { + id: "pi/proposals/recordStatusChanges", + listenerCreator: fetchRecordDetailsListenerCreator, + }, + { + id: "pi/proposals/billingStatusChanges", + listenerCreator: fetchBillingStatusChangesListenerCreator, + }, ], cleanup: routeCleanup, view: createRouteView(Details), diff --git a/plugins-structure/apps/politeia/src/pages/Details/listeners.js b/plugins-structure/apps/politeia/src/pages/Details/listeners.js index 6756f06d2..f917ce89d 100644 --- a/plugins-structure/apps/politeia/src/pages/Details/listeners.js +++ b/plugins-structure/apps/politeia/src/pages/Details/listeners.js @@ -1,4 +1,5 @@ import { fetchProposalDetails } from "./actions"; +import { isProposalCompleteOrClosed } from "../../pi/utils"; function injectEffect(effect) { return async ({ payload }, { getState, dispatch }) => { @@ -21,12 +22,61 @@ function injectRecordDetailsEffect(effect) { }; } +function injectCompletedOrClosedProposalEffect(effect) { + return async ( + { payload, meta }, + { getState, dispatch, unsubscribe, subscribe } + ) => { + unsubscribe(); + const state = getState(); + const [token] = meta.arg.tokens; + const { status } = Object.values(payload.summaries)[0]; + + if (isProposalCompleteOrClosed(status)) { + await effect(state, dispatch, { token }); + } + subscribe(); + }; +} + +function injectPayloadEffect(effect) { + return async ( + { payload }, + { getState, dispatch, unsubscribe, subscribe } + ) => { + unsubscribe(); + const state = getState(); + await effect(state, dispatch, payload); + subscribe(); + }; +} + export const fetchDetailsListenerCreator = { type: "records/fetchDetails/fulfilled", injectEffect, }; +export const fetchProposalSummaryListenerCreator = { + type: "piSummaries/fetch/fulfilled", + injectEffect: injectCompletedOrClosedProposalEffect, +}; + export const recordFetchDetailsListenerCreator = { actionCreator: fetchProposalDetails, injectEffect: injectRecordDetailsEffect, }; + +export const fetchVoteSummaryListenerCreator = { + type: "ticketvoteSummaries/fetch/fulfilled", + injectEffect: injectPayloadEffect, +}; + +export const fetchBillingStatusChangesListenerCreator = { + type: "piBilling/fetchStatusChanges/fulfilled", + injectEffect: injectPayloadEffect, +}; + +export const fetchRecordDetailsListenerCreator = { + type: "records/fetchDetails/fulfilled", + injectEffect: injectPayloadEffect, +}; diff --git a/plugins-structure/apps/politeia/src/pages/Details/useProposalDetails.js b/plugins-structure/apps/politeia/src/pages/Details/useProposalDetails.js index 870c9181b..37bf20ad3 100644 --- a/plugins-structure/apps/politeia/src/pages/Details/useProposalDetails.js +++ b/plugins-structure/apps/politeia/src/pages/Details/useProposalDetails.js @@ -6,7 +6,7 @@ import { selectDetailsStatus } from "./selectors"; import { records } from "@politeiagui/core/records"; import { ticketvoteSummaries } from "@politeiagui/ticketvote/summaries"; import { recordComments } from "@politeiagui/comments/comments"; -import { piSummaries } from "../../pi"; +import { piBilling, piSummaries, proposals } from "../../pi"; function useProposalDetails({ token }) { const dispatch = useDispatch(); @@ -24,13 +24,21 @@ function useProposalDetails({ token }) { const comments = useSelector((state) => recordComments.selectByToken(state, fullToken) ); - const piSummary = useSelector((state) => + const proposalSummary = useSelector((state) => piSummaries.selectByToken(state, fullToken) ); + const billingStatusChange = useSelector((state) => + piBilling.selectLastByToken(state, fullToken) + ); + const recordDetailsError = useSelector(records.selectError); const voteSummaryError = useSelector(ticketvoteSummaries.selectError); const commentsError = useSelector(recordComments.selectError); + const proposalStatusChanges = useSelector((state) => + proposals.selectStatusChangesByToken(state, fullToken) + ); + async function onFetchRecordTimestamps({ token, version }) { const res = await dispatch(recordsTimestamps.fetch({ token, version })); return res.payload; @@ -50,9 +58,11 @@ function useProposalDetails({ token }) { detailsStatus, fullToken, onFetchRecordTimestamps, - piSummary, + proposalSummary, record, voteSummary, + billingStatusChange, + proposalStatusChanges, }; } diff --git a/plugins-structure/apps/politeia/src/pages/Home/Approved/Approved.js b/plugins-structure/apps/politeia/src/pages/Home/Approved/Approved.js index 780469c87..d33d6728a 100644 --- a/plugins-structure/apps/politeia/src/pages/Home/Approved/Approved.js +++ b/plugins-structure/apps/politeia/src/pages/Home/Approved/Approved.js @@ -11,7 +11,7 @@ function Approved() { return isListEmpty ? ( ) : ( - + ); } diff --git a/plugins-structure/apps/politeia/src/pages/Home/actions.js b/plugins-structure/apps/politeia/src/pages/Home/actions.js index 0880dfe09..15ae63ff6 100644 --- a/plugins-structure/apps/politeia/src/pages/Home/actions.js +++ b/plugins-structure/apps/politeia/src/pages/Home/actions.js @@ -5,11 +5,16 @@ export const fetchNextBatchSummaries = createAction( "home/fetchNextBatchSummaries" ); export const fetchNextBatchRecords = createAction("home/fetchNextBatchRecords"); +export const fetchNextBatchBillingStatuses = createAction( + "home/fetchNextBatchBillingStatuses" +); -export function fetchNextBatch(payload) { +export function fetchNextBatch(status) { + const hasBillingStatus = status === "approved"; return (dispatch) => { - dispatch(fetchNextBatchCount(payload)); - dispatch(fetchNextBatchSummaries(payload)); - dispatch(fetchNextBatchRecords(payload)); + dispatch(fetchNextBatchCount(status)); + dispatch(fetchNextBatchSummaries(status)); + dispatch(fetchNextBatchRecords(status)); + hasBillingStatus && dispatch(fetchNextBatchBillingStatuses(status)); }; } diff --git a/plugins-structure/apps/politeia/src/pages/Home/common/RecordsStatusList.js b/plugins-structure/apps/politeia/src/pages/Home/common/RecordsStatusList.js index 7ebaee81e..f498b9356 100644 --- a/plugins-structure/apps/politeia/src/pages/Home/common/RecordsStatusList.js +++ b/plugins-structure/apps/politeia/src/pages/Home/common/RecordsStatusList.js @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { ticketvoteInventory } from "@politeiagui/ticketvote/inventory"; import StatusList from "./StatusList"; -function RecordsStatusList({ status, onRenderNextStatus }) { +function RecordsStatusList({ status, onRenderNextStatus, hasBillingStatus }) { const [page, setPage] = useState(1); function handleFetchNextInventoryPage() { setPage(page + 1); @@ -20,6 +20,7 @@ function RecordsStatusList({ status, onRenderNextStatus }) { inventoryStatus={inventoryStatus} inventory={inventory} onRenderNextStatus={onRenderNextStatus} + hasBillingStatus={hasBillingStatus} /> ); } diff --git a/plugins-structure/apps/politeia/src/pages/Home/common/StatusList.js b/plugins-structure/apps/politeia/src/pages/Home/common/StatusList.js index 6ff8cf32b..643535f3d 100644 --- a/plugins-structure/apps/politeia/src/pages/Home/common/StatusList.js +++ b/plugins-structure/apps/politeia/src/pages/Home/common/StatusList.js @@ -33,10 +33,12 @@ function StatusList({ hasMoreInventory, homeStatus, countComments, - summaries, + voteSummaries, + proposalSummaries, fetchNextBatch, recordsInOrder, areAllInventoryEntriesFetched, + proposalsStatusChanges, } = useStatusList({ inventory, inventoryStatus, status }); function handleFetchMore() { @@ -82,7 +84,9 @@ function StatusList({ key={token} record={record} commentsCount={countComments?.[token]} - voteSummary={summaries?.[token]} + voteSummary={voteSummaries?.[token]} + proposalSummary={proposalSummaries?.[token]} + proposalStatusChanges={proposalsStatusChanges?.[token]} /> ); })} diff --git a/plugins-structure/apps/politeia/src/pages/Home/index.js b/plugins-structure/apps/politeia/src/pages/Home/index.js index e8009c4d1..f2a05c568 100644 --- a/plugins-structure/apps/politeia/src/pages/Home/index.js +++ b/plugins-structure/apps/politeia/src/pages/Home/index.js @@ -3,9 +3,13 @@ import { routeCleanup } from "../../utils/routeCleanup"; import { createRouteView } from "../../utils/createRouteView"; import Home from "./Home"; import { + fetchBillingStatusChangesListenerCreator, + fetchNextBatchBillingStatusesListenerCreator, fetchNextBatchCountListenerCreator, fetchNextBatchRecordsListenerCreator, fetchNextBatchSummariesListenerCreator, + fetchRecordsListenerCreator, + fetchVoteSummariesListenerCreator, listeners, } from "./listeners"; @@ -23,10 +27,31 @@ export default App.createRoute({ id: "ticketvote/summaries", listenerCreator: fetchNextBatchSummariesListenerCreator, }, + { + id: "pi/summaries", + listenerCreator: fetchNextBatchSummariesListenerCreator, + }, + { + id: "pi/billingStatusChanges", + listenerCreator: fetchNextBatchBillingStatusesListenerCreator, + }, { id: "comments/count", listenerCreator: fetchNextBatchCountListenerCreator, }, + // Proposals Status changes + { + id: "pi/proposals/voteStatusChanges", + listenerCreator: fetchVoteSummariesListenerCreator, + }, + { + id: "pi/proposals/recordStatusChanges", + listenerCreator: fetchRecordsListenerCreator, + }, + { + id: "pi/proposals/billingStatusChanges", + listenerCreator: fetchBillingStatusChangesListenerCreator, + }, ], listeners, cleanup: routeCleanup, diff --git a/plugins-structure/apps/politeia/src/pages/Home/listeners.js b/plugins-structure/apps/politeia/src/pages/Home/listeners.js index 159d5ad8a..0b7bce620 100644 --- a/plugins-structure/apps/politeia/src/pages/Home/listeners.js +++ b/plugins-structure/apps/politeia/src/pages/Home/listeners.js @@ -1,5 +1,6 @@ import { fetchNextBatch, + fetchNextBatchBillingStatuses, fetchNextBatchCount, fetchNextBatchRecords, fetchNextBatchSummaries, @@ -29,6 +30,18 @@ function injectRecordsBatchEffect(effect) { }; } +function injectPayloadEffect(effect) { + return async ( + { payload }, + { getState, dispatch, unsubscribe, subscribe } + ) => { + unsubscribe(); + const state = getState(); + await effect(state, dispatch, payload); + subscribe(); + }; +} + export const fetchNextBatchCountListenerCreator = { actionCreator: fetchNextBatchCount, injectEffect, @@ -44,12 +57,33 @@ export const fetchNextBatchRecordsListenerCreator = { injectEffect: injectRecordsBatchEffect, }; +export const fetchNextBatchBillingStatusesListenerCreator = { + actionCreator: fetchNextBatchBillingStatuses, + injectEffect, +}; + +export const fetchVoteSummariesListenerCreator = { + type: "ticketvoteSummaries/fetch/fulfilled", + injectEffect: injectPayloadEffect, +}; + +export const fetchRecordsListenerCreator = { + type: "records/fetch/fulfilled", + injectEffect: injectPayloadEffect, +}; + +export const fetchBillingStatusChangesListenerCreator = { + type: "piBilling/fetchStatusChanges/fulfilled", + injectEffect: injectPayloadEffect, +}; + export const listeners = [ { type: "ticketvoteInventory/fetch/fulfilled", effect: ({ meta, payload }, listenerApi) => { - if (payload.inventory[meta.arg.status].length > 0) { - listenerApi.dispatch(fetchNextBatch(meta.arg.status)); + const { status } = meta.arg; + if (payload.inventory[status].length > 0) { + listenerApi.dispatch(fetchNextBatch(status)); } }, }, diff --git a/plugins-structure/apps/politeia/src/pages/Home/useStatusList.js b/plugins-structure/apps/politeia/src/pages/Home/useStatusList.js index ab3516086..f9904f573 100644 --- a/plugins-structure/apps/politeia/src/pages/Home/useStatusList.js +++ b/plugins-structure/apps/politeia/src/pages/Home/useStatusList.js @@ -5,6 +5,9 @@ import { records } from "@politeiagui/core/records"; import { recordsPolicy } from "@politeiagui/core/records/policy"; import { ticketvoteSummaries } from "@politeiagui/ticketvote/summaries"; import { commentsCount } from "@politeiagui/comments/count"; +import { piSummaries } from "../../pi/summaries"; +import { piBilling } from "../../pi/billing"; +import { proposals } from "../../pi/proposals"; function areAllEntriesFetched(inventoryList, records) { if (!inventoryList) return false; @@ -17,7 +20,8 @@ function areAllEntriesFetched(inventoryList, records) { function useStatusList({ inventory, inventoryStatus }) { const homeStatus = useSelector(selectHomeStatus); const countComments = useSelector(commentsCount.selectAll); - const summaries = useSelector(ticketvoteSummaries.selectAll); + const voteSummaries = useSelector(ticketvoteSummaries.selectAll); + const proposalSummaries = useSelector(piSummaries.selectAll); const recordsPageSize = useSelector((state) => recordsPolicy.selectRule(state, "recordspagesize") ); @@ -25,6 +29,9 @@ function useStatusList({ inventory, inventoryStatus }) { records.selectByTokensBatch(state, inventory) ); const allRecords = useSelector(records.selectAll); + const billingStatusChanges = useSelector(piBilling.selectAll); + + const proposalsStatusChanges = useSelector(proposals.selectAllStatusChanges); const hasMoreRecords = recordsInOrder.length !== 0 && recordsInOrder.length < inventory.length; @@ -36,11 +43,14 @@ function useStatusList({ inventory, inventoryStatus }) { hasMoreRecords, homeStatus, countComments, - summaries, + voteSummaries, + proposalSummaries, fetchNextBatch, recordsInOrder, recordsPageSize, areAllInventoryEntriesFetched: areAllEntriesFetched(inventory, allRecords), + billingStatusChanges, + proposalsStatusChanges, }; } diff --git a/plugins-structure/apps/politeia/src/pi/billing/billing.test.js b/plugins-structure/apps/politeia/src/pi/billing/billing.test.js new file mode 100644 index 000000000..5eb332eef --- /dev/null +++ b/plugins-structure/apps/politeia/src/pi/billing/billing.test.js @@ -0,0 +1,110 @@ +import { configureStore } from "@reduxjs/toolkit"; +import * as api from "../lib/api"; +import reducer, { + fetchBillingStatusChanges, + initialState, +} from "./billingSlice"; +import policyReducer from "../policy/policySlice"; + +const mockBillingResponse = [ + { + token: "0b40715ced95bb13", + status: 3, + publickey: + "51a5877c828cbc61fa609e12167ec78ec506998928d787235961ab11f185a618", + signature: + "ac14aa4c1ceba895a320b964dde059869e17bd0af0f6f2ec50f5e325cc61e12085f10d72a16d45c0cdd9feae4d12fed3862e3fa214f022b09914e255b8e3ab08", + receipt: + "ceb536ed55db7c0ddbaa8b137f008be4220b15d6efe143cdcdaa60ce5a34bb8d29f2258d70a62d4eafef3db91bfe4cb101287c1c44583880a8059512f59b7a09", + timestamp: 1658415512, + }, +]; + +// Pi Billing +describe("Given fetchBillingStatusChanges from billing service", () => { + let store; + let fetchBillingStatusChangesSpy; + const params = { + tokens: ["fakeToken", "fakeToken2"], + }; + beforeEach(() => { + store = configureStore({ + reducer: { + piBilling: reducer, + piPolicy: policyReducer, + }, + preloadedState: { + piPolicy: { + policy: { + billingstatuschangespagesize: 5, + }, + }, + }, + }); + fetchBillingStatusChangesSpy = jest.spyOn(api, "fetchBillingStatusChanges"); + }); + afterEach(() => { + fetchBillingStatusChangesSpy.mockRestore(); + }); + describe("when given parameters are empty", () => { + it("should return the initial state", () => { + expect(reducer(undefined, {})).toEqual(initialState); + }); + }); + describe("when policy isn't loaded before dispatching fetchBillingStatusChanges", () => { + it("should update the status to failed and log error", async () => { + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + + store = configureStore({ reducer: { piBilling: reducer } }); + await store.dispatch(fetchBillingStatusChanges(params)); + expect(fetchBillingStatusChangesSpy).not.toBeCalled(); + expect(consoleErrorSpy).toBeCalled(); + const state = store.getState().piBilling; + expect(state.byToken).toEqual({}); + expect(state.status).toEqual("failed"); + + consoleErrorSpy.mockRestore(); + }); + }); + describe("when fetchBillingStatusChanges dispatches with valid params", () => { + it("should update the status to loading", () => { + store.dispatch(fetchBillingStatusChanges(params)); + + expect(fetchBillingStatusChangesSpy).toBeCalled(); + const state = store.getState().piBilling; + expect(state.byToken).toEqual({}); + expect(state.status).toEqual("loading"); + }); + }); + describe("when fetchBillingStatusChanges succeeds", () => { + it("should update byToken and status", async () => { + const resValue = { + billingstatuschanges: { + fakeToken: mockBillingResponse, + fakeToken2: mockBillingResponse, + }, + }; + fetchBillingStatusChangesSpy.mockResolvedValueOnce(resValue); + + await store.dispatch(fetchBillingStatusChanges(params)); + + expect(fetchBillingStatusChangesSpy).toBeCalled(); + const state = store.getState().piBilling; + expect(state.byToken).toEqual(resValue.billingstatuschanges); + expect(state.status).toEqual("succeeded"); + }); + }); + describe("when fetchBillingStatusChanges fails", () => { + it("should dispatch failure and update the error", async () => { + const error = new Error("ERROR"); + fetchBillingStatusChangesSpy.mockRejectedValueOnce(error); + + await store.dispatch(fetchBillingStatusChanges(params)); + + expect(fetchBillingStatusChangesSpy).toBeCalled(); + const state = store.getState().piBilling; + expect(state.status).toEqual("failed"); + expect(state.error).toEqual("ERROR"); + }); + }); +}); diff --git a/plugins-structure/apps/politeia/src/pi/billing/billingSlice.js b/plugins-structure/apps/politeia/src/pi/billing/billingSlice.js new file mode 100644 index 000000000..8a759512a --- /dev/null +++ b/plugins-structure/apps/politeia/src/pi/billing/billingSlice.js @@ -0,0 +1,61 @@ +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; +import * as api from "../lib/api"; +import { validatePiBillingStatusChangesPageSize } from "../lib/validation"; +import last from "lodash/last"; + +export const initialState = { + byToken: {}, + status: "idle", + error: null, +}; + +export const fetchBillingStatusChanges = createAsyncThunk( + "piBilling/fetchStatusChanges", + async ({ tokens }, { getState, rejectWithValue }) => { + try { + return await api.fetchBillingStatusChanges(getState(), { tokens }); + } catch (error) { + // TODO: user friendly error message + return rejectWithValue(error.message); + } + }, + { + condition: (body, { getState }) => + body && + !!body.tokens && + validatePiBillingStatusChangesPageSize(getState()), + } +); + +const piBillingSlice = createSlice({ + name: "piBilling", + initialState, + extraReducers(builder) { + builder + .addCase(fetchBillingStatusChanges.pending, (state) => { + state.status = "loading"; + }) + .addCase(fetchBillingStatusChanges.fulfilled, (state, action) => { + state.status = "succeeded"; + state.byToken = { + ...state.byToken, + ...action.payload.billingstatuschanges, + }; + }) + .addCase(fetchBillingStatusChanges.rejected, (state, action) => { + state.status = "failed"; + state.error = action.payload; + }); + }, +}); + +export const selectPiBillingStatusChangesByToken = (state, token) => + state.piBilling?.byToken[token]; +export const selectPiBillingLastStatusChangeByToken = (state, token) => { + const statusChanges = selectPiBillingStatusChangesByToken(state, token); + return last(statusChanges); +}; +export const selectPiBillingStatusChanges = (state) => state.piBilling?.byToken; +export const selectPiBillingStatus = (state) => state.piBilling?.status; + +export default piBillingSlice.reducer; diff --git a/plugins-structure/apps/politeia/src/pi/billing/effects.js b/plugins-structure/apps/politeia/src/pi/billing/effects.js new file mode 100644 index 000000000..9cbaca456 --- /dev/null +++ b/plugins-structure/apps/politeia/src/pi/billing/effects.js @@ -0,0 +1,36 @@ +import { piBilling } from "./"; +import { getTokensToFetch } from "@politeiagui/core/records/utils"; +import isEmpty from "lodash/isEmpty"; + +export async function fetchSingleRecordBillingStatusChanges( + state, + dispatch, + { token } +) { + const hasBillingStatusChanges = piBilling.selectByToken(state, token); + if (!hasBillingStatusChanges) + await dispatch(piBilling.fetch({ tokens: [token] })); +} + +export async function fetchRecordsBillingStatusChanges( + state, + dispatch, + { inventoryList } +) { + const { + piBilling: { byToken }, + piPolicy: { + policy: { billingstatuschangespagesize }, + }, + } = state; + + const billingStatusesToFetch = getTokensToFetch({ + inventoryList, + lookupTable: byToken, + pageSize: billingstatuschangespagesize, + }); + + if (!isEmpty(billingStatusesToFetch)) { + await dispatch(piBilling.fetch({ tokens: billingStatusesToFetch })); + } +} diff --git a/plugins-structure/apps/politeia/src/pi/billing/index.js b/plugins-structure/apps/politeia/src/pi/billing/index.js new file mode 100644 index 000000000..5d229c307 --- /dev/null +++ b/plugins-structure/apps/politeia/src/pi/billing/index.js @@ -0,0 +1,15 @@ +import { + fetchBillingStatusChanges, + selectPiBillingLastStatusChangeByToken, + selectPiBillingStatus, + selectPiBillingStatusChanges, + selectPiBillingStatusChangesByToken, +} from "./billingSlice"; + +export const piBilling = { + fetch: fetchBillingStatusChanges, + selectStatus: selectPiBillingStatus, + selectAll: selectPiBillingStatusChanges, + selectByToken: selectPiBillingStatusChangesByToken, + selectLastByToken: selectPiBillingLastStatusChangeByToken, +}; diff --git a/plugins-structure/apps/politeia/src/pi/billing/services.js b/plugins-structure/apps/politeia/src/pi/billing/services.js new file mode 100644 index 000000000..40944ee36 --- /dev/null +++ b/plugins-structure/apps/politeia/src/pi/billing/services.js @@ -0,0 +1,22 @@ +import { fetchPolicyIfIdle } from "../utils"; +import { + fetchRecordsBillingStatusChanges, + fetchSingleRecordBillingStatusChanges, +} from "./effects"; + +export const services = [ + { + id: "pi/billingStatusChanges/single", + action: async () => { + await fetchPolicyIfIdle(); + }, + effect: fetchSingleRecordBillingStatusChanges, + }, + { + id: "pi/billingStatusChanges", + action: async () => { + await fetchPolicyIfIdle(); + }, + effect: fetchRecordsBillingStatusChanges, + }, +]; diff --git a/plugins-structure/apps/politeia/src/pi/index.js b/plugins-structure/apps/politeia/src/pi/index.js index cc98dab66..91729adf8 100644 --- a/plugins-structure/apps/politeia/src/pi/index.js +++ b/plugins-structure/apps/politeia/src/pi/index.js @@ -1,4 +1,6 @@ export * from "./lib/constants"; export { default } from "./plugin"; export { piPolicy } from "./policy"; +export { piBilling } from "./billing"; export { piSummaries } from "./summaries"; +export { proposals } from "./proposals"; diff --git a/plugins-structure/apps/politeia/src/pi/lib/api.js b/plugins-structure/apps/politeia/src/pi/lib/api.js index 07509013e..3e7df5501 100644 --- a/plugins-structure/apps/politeia/src/pi/lib/api.js +++ b/plugins-structure/apps/politeia/src/pi/lib/api.js @@ -1,6 +1,7 @@ import { fetchOptions, getCsrf, parseResponse } from "@politeiagui/core/client"; import { PI_API_ROUTE, + ROUTE_BILLING_STATUS_CHANGES, ROUTE_POLICY, ROUTE_SUMMARIES, VERSION, @@ -23,3 +24,12 @@ export async function fetchPolicy(state) { ); return await parseResponse(response); } + +export async function fetchBillingStatusChanges(state, { tokens }) { + const csrf = await getCsrf(state); + const response = await fetch( + `${PI_API_ROUTE}${VERSION}${ROUTE_BILLING_STATUS_CHANGES}`, + fetchOptions(csrf, { tokens }, "POST") + ); + return await parseResponse(response); +} diff --git a/plugins-structure/apps/politeia/src/pi/lib/constants.js b/plugins-structure/apps/politeia/src/pi/lib/constants.js index cb8561859..7b6d533e8 100644 --- a/plugins-structure/apps/politeia/src/pi/lib/constants.js +++ b/plugins-structure/apps/politeia/src/pi/lib/constants.js @@ -1,22 +1,31 @@ export const PI_API_ROUTE = "/api/pi/"; export const VERSION = "v1"; +export const ROUTE_BILLING_STATUS_CHANGES = "/billingstatuschanges"; export const ROUTE_SUMMARIES = "/summaries"; export const ROUTE_POLICY = "/policy"; -export const PROPOSAL_SUMMARY_STATUS_UNVETTED = "unvetted"; -export const PROPOSAL_SUMMARY_STATUS_UNVETTED_ABANDONED = "unvetted-abandoned"; -export const PROPOSAL_SUMMARY_STATUS_UNVETTED_CENSORED = "unvetted-censored"; -export const PROPOSAL_SUMMARY_STATUS_UNDER_REVIEW = "under-review"; -export const PROPOSAL_SUMMARY_STATUS_ABANDONED = "abandoned"; -export const PROPOSAL_SUMMARY_STATUS_CENSORED = "censored"; -export const PROPOSAL_SUMMARY_STATUS_VOTE_AUTHORIZED = "vote-authorized"; -export const PROPOSAL_SUMMARY_STATUS_VOTE_STARTED = "vote-started"; -export const PROPOSAL_SUMMARY_STATUS_REJECTED = "rejected"; -export const PROPOSAL_SUMMARY_STATUS_ACTIVE = "active"; -export const PROPOSAL_SUMMARY_STATUS_COMPLETED = "completed"; -export const PROPOSAL_SUMMARY_STATUS_APPROVED = "approved"; -export const PROPOSAL_SUMMARY_STATUS_CLOSED = "closed"; +// Proposal Statuses +export const PROPOSAL_STATUS_UNVETTED = "unvetted"; +export const PROPOSAL_STATUS_UNVETTED_ABANDONED = "unvetted-abandoned"; +export const PROPOSAL_STATUS_UNVETTED_CENSORED = "unvetted-censored"; +export const PROPOSAL_STATUS_UNDER_REVIEW = "under-review"; +export const PROPOSAL_STATUS_ABANDONED = "abandoned"; +export const PROPOSAL_STATUS_CENSORED = "censored"; +export const PROPOSAL_STATUS_VOTE_AUTHORIZED = "vote-authorized"; +export const PROPOSAL_STATUS_VOTE_STARTED = "vote-started"; +export const PROPOSAL_STATUS_REJECTED = "rejected"; +export const PROPOSAL_STATUS_ACTIVE = "active"; +export const PROPOSAL_STATUS_COMPLETED = "completed"; +export const PROPOSAL_STATUS_APPROVED = "approved"; +export const PROPOSAL_STATUS_CLOSED = "closed"; +// UI only +export const PROPOSAL_STATUS_VOTE_ENDED = "vote-ended"; + +// Billing statuses +export const PROPOSAL_BILLING_STATUS_ACTIVE = 1; +export const PROPOSAL_BILLING_STATUS_CLOSED = 2; +export const PROPOSAL_BILLING_STATUS_COMPLETED = 3; export const PROPOSAL_TYPE_REGULAR = 1; export const PROPOSAL_TYPE_RFP = 2; diff --git a/plugins-structure/apps/politeia/src/pi/lib/validation.js b/plugins-structure/apps/politeia/src/pi/lib/validation.js index d7d13b1ff..c12d1bafa 100644 --- a/plugins-structure/apps/politeia/src/pi/lib/validation.js +++ b/plugins-structure/apps/politeia/src/pi/lib/validation.js @@ -19,3 +19,20 @@ export function validatePiSummariesPageSize(state) { } return true; } + +export function validatePiBillingStatusChangesPageSize(state) { + const pageSize = + state && + state.piPolicy && + state.piPolicy.policy && + state.piPolicy.policy.billingstatuschangespagesize; + + if (!pageSize) { + const error = Error( + "Pi policy should be loaded before fetching billing status changes" + ); + console.error(error); + throw error; + } + return true; +} diff --git a/plugins-structure/apps/politeia/src/pi/plugin.js b/plugins-structure/apps/politeia/src/pi/plugin.js index d7e809fdb..40859895e 100644 --- a/plugins-structure/apps/politeia/src/pi/plugin.js +++ b/plugins-structure/apps/politeia/src/pi/plugin.js @@ -2,11 +2,17 @@ import { pluginSetup } from "@politeiagui/core"; import { services } from "./services"; import policyReducer from "./policy/policySlice"; import summariesReducer from "./summaries/summariesSlice"; +import billingReducer from "./billing/billingSlice"; +import proposalsReducer from "./proposals/proposalsSlice"; // Declare pi plugin interface const PiPlugin = pluginSetup({ services, reducers: [ + { + key: "piBilling", + reducer: billingReducer, + }, { key: "piPolicy", reducer: policyReducer, @@ -15,6 +21,10 @@ const PiPlugin = pluginSetup({ key: "piSummaries", reducer: summariesReducer, }, + { + key: "piProposals", + reducer: proposalsReducer, + }, ], name: "pi", }); diff --git a/plugins-structure/apps/politeia/src/pi/proposals/effects.js b/plugins-structure/apps/politeia/src/pi/proposals/effects.js new file mode 100644 index 000000000..ee0378391 --- /dev/null +++ b/plugins-structure/apps/politeia/src/pi/proposals/effects.js @@ -0,0 +1,35 @@ +import { proposals } from "./"; + +export async function setProposalsVoteStatusChangesEffect( + state, + dispatch, + summaries +) { + const { + records: { records }, + } = state; + await dispatch(proposals.setVoteStatusChanges({ summaries, records })); +} + +// Works for both single record or records batch +export async function setProposalsRecordsStatusChangesEffect( + state, + dispatch, + payload +) { + const records = + payload.state && payload.status + ? { [payload.censorshiprecord.token]: payload } + : payload; + await dispatch(proposals.setRecordStatusChanges({ records })); +} + +export async function setProposalsBillingStatusChangesEffect( + state, + dispatch, + { billingstatuschanges } +) { + await dispatch( + proposals.setBillingStatusChanges({ billings: billingstatuschanges }) + ); +} diff --git a/plugins-structure/apps/politeia/src/pi/proposals/index.js b/plugins-structure/apps/politeia/src/pi/proposals/index.js new file mode 100644 index 000000000..69fa22340 --- /dev/null +++ b/plugins-structure/apps/politeia/src/pi/proposals/index.js @@ -0,0 +1,15 @@ +import { + selectProposalStatusChangesByToken, + selectProposalsStatusChanges, + setBillingStatusChanges, + setRecordStatusChanges, + setVoteStatusChanges, +} from "./proposalsSlice"; + +export const proposals = { + setBillingStatusChanges, + setRecordStatusChanges, + setVoteStatusChanges, + selectAllStatusChanges: selectProposalsStatusChanges, + selectStatusChangesByToken: selectProposalStatusChangesByToken, +}; diff --git a/plugins-structure/apps/politeia/src/pi/proposals/proposalsSlice.js b/plugins-structure/apps/politeia/src/pi/proposals/proposalsSlice.js new file mode 100644 index 000000000..cb8c4737e --- /dev/null +++ b/plugins-structure/apps/politeia/src/pi/proposals/proposalsSlice.js @@ -0,0 +1,123 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { + convertBillingStatusToProposalStatus, + convertRecordStatusToProposalStatus, + convertVoteStatusToProposalStatus, + getRecordStatusChangesMetadata, +} from "./utils"; +import { getTicketvoteSummariesStatusChanges } from "@politeiagui/ticketvote/utils"; +import keyBy from "lodash/keyBy"; + +export const initialState = { + statusChangesByToken: {}, +}; + +function mergeStatusChanges(statusChanges, newStatusChanges) { + const conflicting = Object.keys(newStatusChanges).reduce((scs, token) => { + const scFromState = statusChanges[token] || {}; + const newSc = newStatusChanges[token]; + return { + ...scs, + [token]: { ...scFromState, ...keyBy(newSc, "status") }, + }; + }, {}); + return { + ...statusChanges, + ...conflicting, + }; +} + +// TODO: Get parameter from correct env. +const blockTimeMinutes = 2; + +const proposalsSlice = createSlice({ + name: "piProposals", + initialState, + reducers: { + setVoteStatusChanges: (state, action) => { + const { summaries: voteSummariesByToken, records } = action.payload; + if (!voteSummariesByToken) return; + const voteStatusChangesByToken = getTicketvoteSummariesStatusChanges( + voteSummariesByToken, + blockTimeMinutes + ); + const proposalsStatusChangesByToken = Object.keys( + voteStatusChangesByToken + ).reduce((proposalStatusChanges, token) => { + const record = records[token]; + const vsc = voteStatusChangesByToken[token]; + if (!vsc) return proposalStatusChanges; + return { + ...proposalStatusChanges, + [token]: [ + { + ...vsc, + status: convertVoteStatusToProposalStatus( + vsc.status, + record?.status + ), + }, + ], + }; + }, {}); + state.statusChangesByToken = mergeStatusChanges( + state.statusChangesByToken, + proposalsStatusChangesByToken + ); + }, + setRecordStatusChanges: (state, action) => { + const { records } = action.payload; + const proposalsStatusChangesByToken = Object.keys(records).reduce( + (statusChanges, token) => { + const record = records[token]; + const recordStatusChanges = getRecordStatusChangesMetadata(record); + if (!recordStatusChanges) return statusChanges; + const proposalStatusChanges = recordStatusChanges.map((rsc) => ({ + ...rsc, + status: convertRecordStatusToProposalStatus( + rsc.status, + record.state + ), + })); + return { ...statusChanges, [token]: proposalStatusChanges }; + }, + {} + ); + state.statusChangesByToken = mergeStatusChanges( + state.statusChangesByToken, + proposalsStatusChangesByToken + ); + }, + setBillingStatusChanges: (state, action) => { + const { billings } = action.payload; + const proposalsStatusChangesByToken = Object.keys(billings).reduce( + (statusChanges, token) => ({ + ...statusChanges, + [token]: billings[token].map((bsc) => ({ + ...bsc, + status: convertBillingStatusToProposalStatus(bsc.status), + })), + }), + {} + ); + state.statusChangesByToken = mergeStatusChanges( + state.statusChangesByToken, + proposalsStatusChangesByToken + ); + }, + }, +}); + +export const { + setVoteStatusChanges, + setRecordStatusChanges, + setBillingStatusChanges, +} = proposalsSlice.actions; + +// Selectors +export const selectProposalStatusChangesByToken = (state, token) => + state.piProposals?.statusChangesByToken[token]; +export const selectProposalsStatusChanges = (state) => + state.piProposals?.statusChangesByToken; + +export default proposalsSlice.reducer; diff --git a/plugins-structure/apps/politeia/src/pi/proposals/services.js b/plugins-structure/apps/politeia/src/pi/proposals/services.js new file mode 100644 index 000000000..ad6ec1576 --- /dev/null +++ b/plugins-structure/apps/politeia/src/pi/proposals/services.js @@ -0,0 +1,20 @@ +import { + setProposalsBillingStatusChangesEffect, + setProposalsRecordsStatusChangesEffect, + setProposalsVoteStatusChangesEffect, +} from "./effects"; + +export const services = [ + { + id: "pi/proposals/voteStatusChanges", + effect: setProposalsVoteStatusChangesEffect, + }, + { + id: "pi/proposals/recordStatusChanges", + effect: setProposalsRecordsStatusChangesEffect, + }, + { + id: "pi/proposals/billingStatusChanges", + effect: setProposalsBillingStatusChangesEffect, + }, +]; diff --git a/plugins-structure/apps/politeia/src/components/Proposal/utils.js b/plugins-structure/apps/politeia/src/pi/proposals/utils.js similarity index 60% rename from plugins-structure/apps/politeia/src/components/Proposal/utils.js rename to plugins-structure/apps/politeia/src/pi/proposals/utils.js index 581bb9d32..1fb16e126 100644 --- a/plugins-structure/apps/politeia/src/components/Proposal/utils.js +++ b/plugins-structure/apps/politeia/src/pi/proposals/utils.js @@ -4,6 +4,8 @@ import { getShortToken, } from "@politeiagui/core/records/utils"; import { + RECORD_STATE_UNVETTED, + RECORD_STATE_VETTED, RECORD_STATUS_ARCHIVED, RECORD_STATUS_CENSORED, RECORD_STATUS_PUBLIC, @@ -13,26 +15,32 @@ import { TICKETVOTE_STATUS_APPROVED, TICKETVOTE_STATUS_AUTHORIZED, TICKETVOTE_STATUS_FINISHED, + TICKETVOTE_STATUS_INELIGIBLE, TICKETVOTE_STATUS_REJECTED, TICKETVOTE_STATUS_STARTED, TICKETVOTE_STATUS_UNAUTHORIZED, } from "@politeiagui/ticketvote/constants"; import { - PROPOSAL_SUMMARY_STATUS_ABANDONED, - PROPOSAL_SUMMARY_STATUS_ACTIVE, - PROPOSAL_SUMMARY_STATUS_APPROVED, - PROPOSAL_SUMMARY_STATUS_CENSORED, - PROPOSAL_SUMMARY_STATUS_CLOSED, - PROPOSAL_SUMMARY_STATUS_COMPLETED, - PROPOSAL_SUMMARY_STATUS_REJECTED, - PROPOSAL_SUMMARY_STATUS_UNDER_REVIEW, - PROPOSAL_SUMMARY_STATUS_UNVETTED, - PROPOSAL_SUMMARY_STATUS_UNVETTED_ABANDONED, - PROPOSAL_SUMMARY_STATUS_UNVETTED_CENSORED, - PROPOSAL_SUMMARY_STATUS_VOTE_AUTHORIZED, - PROPOSAL_SUMMARY_STATUS_VOTE_STARTED, -} from "../../pi/lib/constants"; + PROPOSAL_BILLING_STATUS_ACTIVE, + PROPOSAL_BILLING_STATUS_CLOSED, + PROPOSAL_BILLING_STATUS_COMPLETED, + PROPOSAL_STATUS_ABANDONED, + PROPOSAL_STATUS_ACTIVE, + PROPOSAL_STATUS_APPROVED, + PROPOSAL_STATUS_CENSORED, + PROPOSAL_STATUS_CLOSED, + PROPOSAL_STATUS_COMPLETED, + PROPOSAL_STATUS_REJECTED, + PROPOSAL_STATUS_UNDER_REVIEW, + PROPOSAL_STATUS_UNVETTED, + PROPOSAL_STATUS_UNVETTED_ABANDONED, + PROPOSAL_STATUS_UNVETTED_CENSORED, + PROPOSAL_STATUS_VOTE_AUTHORIZED, + PROPOSAL_STATUS_VOTE_ENDED, + PROPOSAL_STATUS_VOTE_STARTED, +} from "../lib/constants"; import isArray from "lodash/fp/isArray"; +import isEmpty from "lodash/isEmpty"; const PROPOSAL_METADATA_FILENAME = "proposalmetadata.json"; const PROPOSAL_INDEX_FILENAME = "index.md"; @@ -207,12 +215,12 @@ export function decodeProposalRecord(record) { proposalMetadata, archived: record.status === RECORD_STATUS_ARCHIVED, censored: record.status === RECORD_STATUS_CENSORED, - abandonmentReason: getAbandonmentReason(userMetadata), attachments, + userMetadata, }; } -function findStatusMetadataFromPayloads(mdPayloads, status) { +export function findStatusMetadataFromPayloads(mdPayloads, status) { if (!mdPayloads) return {}; const publicMd = mdPayloads.find((p) => p.status === status); if (!publicMd) { @@ -223,15 +231,23 @@ function findStatusMetadataFromPayloads(mdPayloads, status) { return publicMd; } -function getAbandonmentReason(userMetadata) { - if (!userMetadata) return null; - const payloads = userMetadata.map((md) => md.payload); - for (const status of [RECORD_STATUS_ARCHIVED, RECORD_STATUS_CENSORED]) { - const { reason } = findStatusMetadataFromPayloads(payloads, status); - if (reason) { - return reason; - } +// TODO: Docs +function getStatusMetadataPayloads(payloads) { + if (!payloads) return {}; + const statusChangePayloads = payloads.filter((p) => p?.status); + if (isEmpty(statusChangePayloads)) { + // traverse payload arrays + return payloads.find((p) => isArray(p)); } + return statusChangePayloads; +} + +// TODO: Docs +export function getRecordStatusChangesMetadata(record) { + if (!record?.metadata) return; + const userMetadata = decodeProposalUserMetadata(record.metadata); + const payloads = userMetadata.map((md) => md.payload); + return getStatusMetadataPayloads(payloads); } /** @@ -267,168 +283,125 @@ export function getPublicStatusChangeMetadata(userMetadata) { * getProposalTimestamps returns published, censored, edited and abandoned * timestamps for given record. Default timestamps values are 0. * @param {Record} record record object - * @returns {Object} `{publishedat: number, editedat: number, censoredat: number, - * abandonedat: number}` - Object with publishedat, censoredat, abandonedat + * @returns {Object} `{publishedat: number, editedat: number}` */ function getProposalTimestamps(record) { - if (!record) - return { publishedat: 0, editedat: 0, censoredat: 0, abandonedat: 0 }; + if (!record) return { publishedat: 0, editedat: 0 }; let publishedat = 0, - censoredat = 0, - abandonedat = 0, editedat = 0; const { status, timestamp, version, metadata } = record; const userMetadata = decodeProposalUserMetadata(metadata); - // unreviewed - if (status === RECORD_STATUS_UNREVIEWED) { - publishedat = timestamp; - } - // publlished but not edited - if (status === RECORD_STATUS_PUBLIC && version <= 1) { + + if (status === RECORD_STATUS_UNREVIEWED || version <= 1) { publishedat = timestamp; } // edited, have to grab published timestamp from metadata - if (status === RECORD_STATUS_PUBLIC && version > 1) { + if (version > 1) { const publishedMetadata = getPublicStatusChangeMetadata(userMetadata); publishedat = publishedMetadata.timestamp; editedat = timestamp; } - if (status === RECORD_STATUS_CENSORED) { - const publishedMetadata = getPublicStatusChangeMetadata(userMetadata); - publishedat = publishedMetadata.timestamp; - censoredat = timestamp; - } - if (status === RECORD_STATUS_ARCHIVED) { - const publishedMetadata = getPublicStatusChangeMetadata(userMetadata); - publishedat = publishedMetadata.timestamp; - abandonedat = timestamp; - } - return { publishedat, editedat, censoredat, abandonedat }; + return { publishedat, editedat }; } /** - * getProposalStatusTagProps returns the formatted `{ type, text }` - * props for StatusTag component for given record and ticketvote summary. - * @param {Record} record record object - * @param {VoteSummary} voteSummary ticketvote summary object + * getProposalStatusTagProps returns the formatted `{ type, text }` props for + * StatusTag component for given proposal summary. + * @param {Object} proposalSummary * @returns {Object} `{ type, text }` StatusTag props */ -export function getLegacyProposalStatusTagProps(record, voteSummary) { - const voteMetadata = decodeVoteMetadataFile(record.files); - const isRfpSubmission = voteMetadata?.linkto; - if (record.status === RECORD_STATUS_PUBLIC && !!voteSummary) { - switch (voteSummary.status) { - case TICKETVOTE_STATUS_UNAUTHORIZED: - return { - type: "blackTime", - text: isRfpSubmission - ? "Waiting for runoff vote to start" - : "Waiting for author to authorize voting", - }; - case TICKETVOTE_STATUS_AUTHORIZED: - return { - type: "yellowTime", - text: "Waiting for admin to start voting", - }; - case TICKETVOTE_STATUS_STARTED: - return { type: "bluePending", text: "Active" }; - case TICKETVOTE_STATUS_FINISHED: - return { - type: "grayNegative", - text: "Finished", - }; - case TICKETVOTE_STATUS_REJECTED: - return { - type: "orangeNegativeCircled", - text: "Rejected", - }; - case TICKETVOTE_STATUS_APPROVED: - return { type: "greenCheck", text: "Approved" }; - default: - break; - } - } - - if (record.status === RECORD_STATUS_ARCHIVED) { - return { - type: "grayNegative", - text: "Abandoned", - }; +export function getProposalStatusTagProps(proposalSummary) { + if (!proposalSummary?.status) + return { type: "grayNegative", text: "missing" }; + + switch (proposalSummary.status) { + case PROPOSAL_STATUS_UNVETTED: + return { + type: "yellowTime", + text: "Unvetted", + }; + case PROPOSAL_STATUS_UNVETTED_ABANDONED: + case PROPOSAL_STATUS_ABANDONED: + return { + type: "grayNegative", + text: "Abandoned", + }; + + case PROPOSAL_STATUS_UNVETTED_CENSORED: + case PROPOSAL_STATUS_CENSORED: + return { + type: "orangeNegativeCircled", + text: "Censored", + }; + + case PROPOSAL_STATUS_UNDER_REVIEW: + return { + type: "blackTime", + text: "Waiting for author to authorize voting", + }; + + case PROPOSAL_STATUS_VOTE_AUTHORIZED: + return { + type: "yellowTime", + text: "Waiting for admin to start voting", + }; + + case PROPOSAL_STATUS_VOTE_STARTED: + return { type: "bluePending", text: "Voting" }; + + case PROPOSAL_STATUS_REJECTED: + return { + type: "orangeNegativeCircled", + text: "Rejected", + }; + + case PROPOSAL_STATUS_ACTIVE: + return { type: "bluePending", text: "Active" }; + + case PROPOSAL_STATUS_CLOSED: + return { type: "grayNegative", text: "Closed" }; + + case PROPOSAL_STATUS_COMPLETED: + return { type: "greenCheck", text: "Completed" }; + + case PROPOSAL_STATUS_APPROVED: + return { type: "greenCheck", text: "Approved" }; + + default: + break; } - if (record.status === RECORD_STATUS_CENSORED) { - return { - type: "orangeNegativeCircled", - text: "Censored", - }; - } - - return { type: "grayNegative", text: "missing" }; } - -export const getProposalStatusTagProps = (proposalSummary, isDarkTheme) => { - if (proposalSummary) { - switch (proposalSummary.status) { - case PROPOSAL_SUMMARY_STATUS_UNVETTED: - return { - type: "yellowTime", - text: "Unvetted", - }; - case PROPOSAL_SUMMARY_STATUS_UNVETTED_ABANDONED: - case PROPOSAL_SUMMARY_STATUS_ABANDONED: - return { - type: isDarkTheme ? "blueNegative" : "grayNegative", - text: "Abandoned", - }; - - case PROPOSAL_SUMMARY_STATUS_UNVETTED_CENSORED: - case PROPOSAL_SUMMARY_STATUS_CENSORED: - return { - type: "orangeNegativeCircled", - text: "Censored", - }; - - case PROPOSAL_SUMMARY_STATUS_UNDER_REVIEW: - return { - type: isDarkTheme ? "blueTime" : "blackTime", - text: "Waiting for author to authorize voting", - }; - - case PROPOSAL_SUMMARY_STATUS_VOTE_AUTHORIZED: - return { - type: "yellowTime", - text: "Waiting for admin to start voting", - }; - - case PROPOSAL_SUMMARY_STATUS_VOTE_STARTED: - return { type: "bluePending", text: "Voting" }; - - case PROPOSAL_SUMMARY_STATUS_REJECTED: - return { - type: "orangeNegativeCircled", - text: "Rejected", - }; - - case PROPOSAL_SUMMARY_STATUS_ACTIVE: - return { type: "bluePending", text: "Active" }; - - case PROPOSAL_SUMMARY_STATUS_CLOSED: - return { type: "grayNegative", text: "Closed" }; - - case PROPOSAL_SUMMARY_STATUS_COMPLETED: - return { type: "greenCheck", text: "Completed" }; - - case PROPOSAL_SUMMARY_STATUS_APPROVED: - return { type: "greenCheck", text: "Approved" }; - - default: - break; - } +/** + * getProposalStatusDescription returns the formatted status change event description. + * @param {Object} statusChange proposal status change + * @returns {String} event description + */ +export function getProposalStatusDescription(statusChange) { + if (!statusChange?.status) return null; + switch (statusChange.status) { + case PROPOSAL_STATUS_VOTE_STARTED: + return { event: "vote ends" }; + case PROPOSAL_STATUS_ACTIVE: + case PROPOSAL_STATUS_VOTE_ENDED: + case PROPOSAL_STATUS_APPROVED: + case PROPOSAL_STATUS_REJECTED: + return { event: "vote ended" }; + case PROPOSAL_STATUS_CLOSED: + return { event: "billing closed" }; + case PROPOSAL_STATUS_COMPLETED: + return { event: "billing completed" }; + case PROPOSAL_STATUS_UNVETTED_ABANDONED: + case PROPOSAL_STATUS_ABANDONED: + return { event: "abandoned" }; + case PROPOSAL_STATUS_UNVETTED_CENSORED: + case PROPOSAL_STATUS_CENSORED: + return { event: "censored" }; + default: + return {}; } - - return { type: "grayNegative", text: "missing" }; -}; +} /** * showVoteStatusBar returns if vote has started, finished, approved or @@ -480,3 +453,51 @@ export function getImagesByDigest(text, files) { } return imagesByDigest; } + +// TODO: Docs +export function convertVoteStatusToProposalStatus(voteStatus, recordStatus) { + const voteStatusToProposalStatus = { + [TICKETVOTE_STATUS_UNAUTHORIZED]: PROPOSAL_STATUS_UNDER_REVIEW, + [TICKETVOTE_STATUS_AUTHORIZED]: PROPOSAL_STATUS_VOTE_AUTHORIZED, + [TICKETVOTE_STATUS_STARTED]: PROPOSAL_STATUS_VOTE_STARTED, + [TICKETVOTE_STATUS_APPROVED]: PROPOSAL_STATUS_ACTIVE, + [TICKETVOTE_STATUS_REJECTED]: PROPOSAL_STATUS_REJECTED, + [TICKETVOTE_STATUS_FINISHED]: PROPOSAL_STATUS_VOTE_ENDED, + [TICKETVOTE_STATUS_INELIGIBLE]: + recordStatus === RECORD_STATUS_CENSORED + ? PROPOSAL_STATUS_CENSORED + : PROPOSAL_STATUS_ABANDONED, + }; + return voteStatusToProposalStatus[voteStatus]; +} + +// TODO: Docs +export function convertRecordStatusToProposalStatus( + recordStatus, + recordState = RECORD_STATE_VETTED +) { + if (!recordStatus) return; + const mapStateAndStatusToProposalStatus = { + [RECORD_STATE_UNVETTED]: { + [RECORD_STATUS_UNREVIEWED]: PROPOSAL_STATUS_UNVETTED, + [RECORD_STATUS_ARCHIVED]: PROPOSAL_STATUS_UNVETTED_ABANDONED, + [RECORD_STATUS_CENSORED]: PROPOSAL_STATUS_UNVETTED_CENSORED, + }, + [RECORD_STATE_VETTED]: { + [RECORD_STATUS_PUBLIC]: PROPOSAL_STATUS_UNDER_REVIEW, + [RECORD_STATUS_ARCHIVED]: PROPOSAL_STATUS_ABANDONED, + [RECORD_STATUS_CENSORED]: PROPOSAL_STATUS_CENSORED, + }, + }; + return mapStateAndStatusToProposalStatus[recordState][recordStatus]; +} + +// TODO: Docs +export function convertBillingStatusToProposalStatus(billingStatus) { + const mapBillingStatusToProposalStatus = { + [PROPOSAL_BILLING_STATUS_ACTIVE]: PROPOSAL_STATUS_ACTIVE, + [PROPOSAL_BILLING_STATUS_CLOSED]: PROPOSAL_STATUS_CLOSED, + [PROPOSAL_BILLING_STATUS_COMPLETED]: PROPOSAL_STATUS_COMPLETED, + }; + return mapBillingStatusToProposalStatus[billingStatus]; +} diff --git a/plugins-structure/apps/politeia/src/components/Proposal/utils.test.js b/plugins-structure/apps/politeia/src/pi/proposals/utils.test.js similarity index 100% rename from plugins-structure/apps/politeia/src/components/Proposal/utils.test.js rename to plugins-structure/apps/politeia/src/pi/proposals/utils.test.js diff --git a/plugins-structure/apps/politeia/src/pi/services.js b/plugins-structure/apps/politeia/src/pi/services.js index 45cc115da..2388679f0 100644 --- a/plugins-structure/apps/politeia/src/pi/services.js +++ b/plugins-structure/apps/politeia/src/pi/services.js @@ -1,8 +1,12 @@ import { fetchPolicyIfIdle } from "./utils"; import { services as summariesServices } from "./summaries/services"; +import { services as billingServices } from "./billing/services"; +import { services as proposalsServices } from "./proposals/services"; export const services = [ + ...billingServices, ...summariesServices, + ...proposalsServices, { id: "pi/new", action: async () => { diff --git a/plugins-structure/apps/politeia/src/pi/summaries/effects.js b/plugins-structure/apps/politeia/src/pi/summaries/effects.js index b4349a440..d281b5cab 100644 --- a/plugins-structure/apps/politeia/src/pi/summaries/effects.js +++ b/plugins-structure/apps/politeia/src/pi/summaries/effects.js @@ -1,6 +1,29 @@ import { piSummaries } from "./"; +import { getTokensToFetch } from "@politeiagui/core/records/utils"; +import isEmpty from "lodash/isEmpty"; -export async function fetchRecordPiSummaries(state, dispatch, { token }) { +export async function fetchSingleRecordPiSummaries(state, dispatch, { token }) { const hasPiSummaries = piSummaries.selectByToken(state, token); if (!hasPiSummaries) await dispatch(piSummaries.fetch({ tokens: [token] })); } + +export async function fetchRecordsPiSummaries( + state, + dispatch, + { inventoryList } +) { + const { + piSummaries: { byToken }, + piPolicy: { + policy: { summariespagesize }, + }, + } = state; + const piSummariesToFetch = getTokensToFetch({ + inventoryList, + lookupTable: byToken, + pageSize: summariespagesize, + }); + if (!isEmpty(piSummariesToFetch)) { + await dispatch(piSummaries.fetch({ tokens: piSummariesToFetch })); + } +} diff --git a/plugins-structure/apps/politeia/src/pi/summaries/services.js b/plugins-structure/apps/politeia/src/pi/summaries/services.js index f70f10015..6b502ee1f 100644 --- a/plugins-structure/apps/politeia/src/pi/summaries/services.js +++ b/plugins-structure/apps/politeia/src/pi/summaries/services.js @@ -1,15 +1,26 @@ import { store } from "@politeiagui/core"; import { fetchPolicyIfIdle } from "../utils"; -import { fetchRecordPiSummaries } from "./effects"; +import { + fetchRecordsPiSummaries, + fetchSingleRecordPiSummaries, +} from "./effects"; import { validatePiSummariesPageSize } from "../lib/validation"; export const services = [ + { + id: "pi/summaries/single", + action: async () => { + await fetchPolicyIfIdle(); + validatePiSummariesPageSize(store.getState()); + }, + effect: fetchSingleRecordPiSummaries, + }, { id: "pi/summaries", action: async () => { await fetchPolicyIfIdle(); validatePiSummariesPageSize(store.getState()); }, - effect: fetchRecordPiSummaries, + effect: fetchRecordsPiSummaries, }, ]; diff --git a/plugins-structure/apps/politeia/src/pi/summaries/summariesSlice.js b/plugins-structure/apps/politeia/src/pi/summaries/summariesSlice.js index be68c16ed..430353939 100644 --- a/plugins-structure/apps/politeia/src/pi/summaries/summariesSlice.js +++ b/plugins-structure/apps/politeia/src/pi/summaries/summariesSlice.js @@ -14,6 +14,7 @@ export const fetchPiSummaries = createAsyncThunk( try { return await api.fetchSummaries(getState(), { tokens }); } catch (error) { + // TODO: user friendly error message return rejectWithValue(error.message); } }, diff --git a/plugins-structure/apps/politeia/src/pi/utils.js b/plugins-structure/apps/politeia/src/pi/utils.js index ae2f54013..81989a94d 100644 --- a/plugins-structure/apps/politeia/src/pi/utils.js +++ b/plugins-structure/apps/politeia/src/pi/utils.js @@ -1,8 +1,16 @@ import { store } from "@politeiagui/core"; import { piPolicy } from "./policy"; +import { + PROPOSAL_STATUS_CLOSED, + PROPOSAL_STATUS_COMPLETED, +} from "./lib/constants"; export function fetchPolicyIfIdle() { if (piPolicy.selectStatus(store.getState()) === "idle") { return store.dispatch(piPolicy.fetch()); } } + +export function isProposalCompleteOrClosed(status) { + return [PROPOSAL_STATUS_COMPLETED, PROPOSAL_STATUS_CLOSED].includes(status); +} diff --git a/plugins-structure/apps/politeia/src/routes/routes.js b/plugins-structure/apps/politeia/src/routes/routes.js index 13c9ccbfe..e554d0035 100644 --- a/plugins-structure/apps/politeia/src/routes/routes.js +++ b/plugins-structure/apps/politeia/src/routes/routes.js @@ -1,7 +1,7 @@ import { store } from "@politeiagui/core"; import { records } from "@politeiagui/core/records"; import { DetailsRoute, HomeRoute, NewProposalRoute } from "../pages"; -import { decodeProposalRecord } from "../components/Proposal/utils"; +import { decodeProposalRecord } from "../pi/proposals/utils"; import { routeCleanup } from "../utils/routeCleanup"; export const routes = [ diff --git a/plugins-structure/packages/common-ui/src/components/Event/Event.js b/plugins-structure/packages/common-ui/src/components/Event/Event.js index 62de1c42e..12f1f65e2 100644 --- a/plugins-structure/packages/common-ui/src/components/Event/Event.js +++ b/plugins-structure/packages/common-ui/src/components/Event/Event.js @@ -12,13 +12,15 @@ const Event = ({ event, timestamp, className, size }) => ( className={classNames(styles.eventTooltip, className)} truncate size={size} - >{`${event} ${timeAgo}`} + > + {event} {timeAgo} + )} ); Event.propTypes = { - event: PropTypes.string, + event: PropTypes.node, timestamp: PropTypes.number, show: PropTypes.bool, }; diff --git a/plugins-structure/packages/common-ui/src/components/RecordCard/RecordCard.js b/plugins-structure/packages/common-ui/src/components/RecordCard/RecordCard.js index c509dbf5b..896bbd7ea 100644 --- a/plugins-structure/packages/common-ui/src/components/RecordCard/RecordCard.js +++ b/plugins-structure/packages/common-ui/src/components/RecordCard/RecordCard.js @@ -1,12 +1,22 @@ import React from "react"; -import { Card, Column, H2, Row, classNames } from "pi-ui"; +import { Card, H2, classNames } from "pi-ui"; import styles from "./styles.module.css"; +const TitleWrapper = ({ titleLink, children }) => + !titleLink ? ( + children + ) : ( + + {children} + + ); + export function RecordCard({ title, titleLink, subtitle, rightHeader, + rightHeaderSubtitle, secondRow, thirdRow, fourthRow, @@ -23,27 +33,18 @@ export function RecordCard({ className )} > - - - {!titleLink ? ( -

{title}

- ) : ( -

- - {title} - -

- )} -
- +
+

+ {title} +

+
{rightHeader} - - -
{subtitle}
-
- - {secondRow && {secondRow}} - {thirdRow && {thirdRow}} + {rightHeaderSubtitle} +
+
{subtitle}
+
+ {secondRow &&
{secondRow}
} + {thirdRow &&
{thirdRow}
} {fourthRow &&
{fourthRow}
}
{footer}
diff --git a/plugins-structure/packages/common-ui/src/components/RecordCard/styles.module.css b/plugins-structure/packages/common-ui/src/components/RecordCard/styles.module.css index b50c98ad5..5d7a238f2 100644 --- a/plugins-structure/packages/common-ui/src/components/RecordCard/styles.module.css +++ b/plugins-structure/packages/common-ui/src/components/RecordCard/styles.module.css @@ -15,14 +15,6 @@ margin-top: var(--spacing-medium); } -.subtitle { - margin-top: var(--spacing-small); -} - -.rightHeader { - justify-self: end; -} - .footer { display: flex; width: 100%; @@ -41,6 +33,33 @@ border-color: var(--separator-color); } +.headerWrapper { + align-items: center; + display: grid; + grid-template-columns: 5fr 2fr; + gap: var(--spacing-small); + grid-template-areas: + "title rightHeader" + "subtitle rightHeader"; +} + +.title { + grid-area: title; + align-self: flex-start; +} + +.rightHeader { + align-self: flex-start; + align-items: center; + justify-self: end; + grid-area: rightHeader; + text-align: end; +} + +.subtitle { + grid-area: subtitle; +} + @media screen and (max-width: 768px) { .firstRow { flex-direction: column-reverse; @@ -48,12 +67,13 @@ } .rightHeader { - margin-bottom: 1rem; justify-self: start; - grid-row: 1; - width: 100%; + text-align: start; } - .header { - margin-top: var(--spacing-small); + + .headerWrapper { + grid-template-columns: auto; + grid-template-rows: auto; + grid-template-areas: "rightHeader" "title" "subtitle "; } } diff --git a/plugins-structure/packages/ticketvote/src/lib/utils.js b/plugins-structure/packages/ticketvote/src/lib/utils.js index 216d0ed9d..92f9ef280 100644 --- a/plugins-structure/packages/ticketvote/src/lib/utils.js +++ b/plugins-structure/packages/ticketvote/src/lib/utils.js @@ -78,3 +78,84 @@ export const validTicketvoteStatuses = [ ...validStringTicketvoteStatuses, ...validNumberTicketvoteStatuses, ]; + +/** + * Returns the amount of blocks from the bestBlock + * @param {Number} block + * @param {Number} bestBlock + * @returns {Number} number of blocks left + */ +export function getVoteBlocksDiff(block, bestBlock) { + if (!block || !bestBlock) return 0; + return +block - bestBlock; +} + +/** + * Returns the blocks difference from current block height in milliseconds + * @param {Number} block + * @param {Number} currentHeight + * @param {Number} blockDurationMinutes Block duration in minutes + * @returns {Number} + */ +export function getTimestampFromBlocks( + currentBlockHeight, + bestBlock, + blockDurationMinutes +) { + const blocksDiff = getVoteBlocksDiff(currentBlockHeight, bestBlock); + const blocksDiffMs = blocksDiff * blockDurationMinutes * 60 * 1000; + const dateMs = blocksDiffMs + Date.now(); + return Math.round(dateMs / 1000); // returns unix timestamp +} + +function getTicketvoteSummaryStatusChanges( + voteSummary, + blockDurationMinutes = 2 +) { + if (!voteSummary) return; + const { bestblock, endblockheight, startblockheight, status } = voteSummary; + switch (status) { + case TICKETVOTE_STATUS_AUTHORIZED: + return { status }; + case TICKETVOTE_STATUS_STARTED: + case TICKETVOTE_STATUS_REJECTED: + case TICKETVOTE_STATUS_APPROVED: + return { + endblockheight, + startblockheight, + timestamp: getTimestampFromBlocks( + endblockheight, + bestblock, + blockDurationMinutes + ), + blocksCount: getVoteBlocksDiff(endblockheight, bestblock), + status, + }; + default: + return; + } +} + +/** + * getTicketvoteSummariesStatusChanges returns the status changes timestamps + * for each summary from `summaries`, using given `blockDurationMinutes` param. + * @param {Object} summaries Vote Summaries + * @param {Number} blockDurationMinutes Block duration in minutes + */ +export function getTicketvoteSummariesStatusChanges( + summaries, + blockDurationMinutes +) { + if (!summaries) return; + return Object.keys(summaries).reduce((statusChanges, token) => { + const statusChange = getTicketvoteSummaryStatusChanges( + summaries[token], + blockDurationMinutes + ); + if (!statusChange) return statusChanges; + return { + ...statusChanges, + [token]: statusChange, + }; + }, {}); +}