From 6872aee18c68865311fe27877cda5977c379d5b0 Mon Sep 17 00:00:00 2001 From: Peter Salomonsen Date: Wed, 11 Dec 2024 18:18:56 +0100 Subject: [PATCH] show modal with impacted proposals when changing voting duration (#148) - Removes the current list of affected proposals, that is updated as you type - Adds a when clicking the "Submit request" button ( and only if there are proposals that will be affected by the voting duration change ) - Shows expired proposals that become active when extending duration days - Shows pending proposals that will follow the new voting duration ( without expiring or becoming active ) https://github.com/user-attachments/assets/f858e549-b293-4122-9baf-ca3157c32dd3 resolves #145 --- .../treasury-devdao.near/widget/lib/modal.jsx | 109 +++++ .../pages/settings/VotingDurationPage.jsx | 398 +++++++++++++----- .../tests/settings/voting-duration.spec.js | 147 ++++++- 3 files changed, 543 insertions(+), 111 deletions(-) create mode 100644 instances/treasury-devdao.near/widget/lib/modal.jsx diff --git a/instances/treasury-devdao.near/widget/lib/modal.jsx b/instances/treasury-devdao.near/widget/lib/modal.jsx new file mode 100644 index 00000000..75a5ef64 --- /dev/null +++ b/instances/treasury-devdao.near/widget/lib/modal.jsx @@ -0,0 +1,109 @@ +const Modal = styled.div` + display: ${({ hidden }) => (hidden ? "none" : "flex")}; + position: fixed; + inset: 0; + justify-content: center; + align-items: center; + opacity: 1; + z-index: 999; + + .black-btn { + background-color: #000 !important; + border: none; + color: white; + &:active { + color: white; + } + } + + @media screen and (max-width: 768px) { + h5 { + font-size: 16px !important; + } + } + + .btn { + font-size: 14px; + } + + .theme-btn { + background: var(--theme-color) !important; + color: white; + } +`; + +const ModalBackdrop = styled.div` + position: absolute; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + opacity: 0.4; +`; + +const ModalDialog = styled.div` + padding: 2em; + z-index: 999; + overflow-y: auto; + max-height: 85%; + margin-top: 5%; + width: 35%; + border-radius: 20px; + + @media screen and (max-width: 768px) { + margin: 2rem; + width: 100%; + } +`; + +const ModalHeader = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding-bottom: 4px; +`; + +const ModalFooter = styled.div` + padding-top: 4px; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: items-center; +`; + +const CloseButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + background-color: white; + padding: 0.5em; + border-radius: 6px; + border: 0; + color: #344054; + + &:hover { + background-color: #d3d3d3; + } +`; + +const ModalContent = styled.div` + flex: 1; + font-size: 14px; + margin-top: 4px; + margin-bottom: 4px; + overflow-y: auto; + max-height: 50%; + text-align: left !important; + @media screen and (max-width: 768px) { + font-size: 12px !important; + } +`; + +const NoButton = styled.button` + background: transparent; + border: none; + padding: 0; + margin: 0; + box-shadow: none; +`; + +return { Modal, ModalBackdrop, ModalContent, ModalDialog, ModalHeader }; diff --git a/instances/treasury-devdao.near/widget/pages/settings/VotingDurationPage.jsx b/instances/treasury-devdao.near/widget/pages/settings/VotingDurationPage.jsx index d7c289d2..e9262b40 100644 --- a/instances/treasury-devdao.near/widget/pages/settings/VotingDurationPage.jsx +++ b/instances/treasury-devdao.near/widget/pages/settings/VotingDurationPage.jsx @@ -4,6 +4,8 @@ if (!instance) { } const { treasuryDaoID } = VM.require(`${instance}/widget/config.data`); +const { Modal, ModalBackdrop, ModalContent, ModalDialog, ModalHeader } = + VM.require("${REPL_BASE_DEPLOYMENT_ACCOUNT}/widget/lib.modal"); const daoPolicy = Near.view(treasuryDaoID, "get_policy", {}); @@ -26,8 +28,12 @@ const currentDurationDays = const [durationDays, setDurationDays] = useState(currentDurationDays); const [proposalsThatWillExpire, setProposalsThatWillExpire] = useState([]); +const [proposalsThatWillBeActive, setProposalsThatWillBeActive] = useState([]); +const [otherPendingRequests, setOtherPendingRequests] = useState([]); const [showToastStatus, setToastStatus] = useState(null); const [isSubmittingChangeRequest, setSubmittingChangeRequest] = useState(false); +const [showAffectedProposalsModal, setShowAffectedProposalsModal] = + useState(false); const Container = styled.div` font-size: 14px; @@ -117,61 +123,23 @@ const ToastContainer = styled.div` `; const cancelChangeRequest = () => { + setShowAffectedProposalsModal(false); setDurationDays(currentDurationDays); }; -const submitChangeRequest = () => { - setSubmittingChangeRequest(true); - Near.call({ - contractName: treasuryDaoID, - methodName: "add_proposal", - deposit, - args: { - proposal: { - description: "Change proposal period", - kind: { - ChangePolicyUpdateParameters: { - parameters: { - proposal_period: - (60 * 60 * 24 * durationDays).toString() + "000000000", - }, - }, - }, - }, - }, - }); -}; - -useEffect(() => { - Near.asyncView(treasuryDaoID, "get_proposal", { - id: lastProposalId - 1, - }).then((proposal) => { - const proposal_period = - proposal?.kind?.ChangePolicyUpdateParameters?.parameters?.proposal_period; - - if ( - proposal_period && - isSubmittingChangeRequest && - Number(proposal_period.substring(0, proposal_period.length - 9)) / - (24 * 60 * 60) === - Number(durationDays) - ) { - setToastStatus(true); - setSubmittingChangeRequest(false); - } - }); -}, [isSubmittingChangeRequest, lastProposalId]); - -const changeDurationDays = (newDurationDays) => { - setDurationDays(newDurationDays); +const findAffectedProposals = (callback) => { + setProposalsThatWillExpire([]); + setProposalsThatWillBeActive([]); + setOtherPendingRequests([]); const limit = 10; - if (newDurationDays < currentDurationDays) { + if (durationDays < currentDurationDays) { const fetchProposalsThatWillExpire = ( lastIndex, - newProposalsThatWillExpire + newProposalsThatWillExpire, + newOtherPendingRequests ) => { Near.asyncView(treasuryDaoID, "get_proposals", { - from_index: lastIndex - limit, + from_index: lastIndex < limit ? 0 : lastIndex - limit, limit, }).then((/** @type Array */ proposals) => { const now = new Date().getTime(); @@ -187,7 +155,7 @@ const changeDurationDays = (newDurationDays) => { const currentExpiryTime = submissionTimeMillis + 24 * 60 * 60 * 1000 * currentDurationDays; const newExpiryTime = - submissionTimeMillis + 24 * 60 * 60 * 1000 * newDurationDays; + submissionTimeMillis + 24 * 60 * 60 * 1000 * durationDays; if ( currentExpiryTime >= now && newExpiryTime < now && @@ -199,22 +167,155 @@ const changeDurationDays = (newDurationDays) => { submissionTimeMillis, ...proposal, }); + } else if (proposal.status === "InProgress" && newExpiryTime > now) { + newOtherPendingRequests.push({ + currentExpiryTime, + newExpiryTime, + submissionTimeMillis, + ...proposal, + }); } - fetchMore = currentExpiryTime >= now; + fetchMore = proposals.length === limit && currentExpiryTime >= now; } setProposalsThatWillExpire(newProposalsThatWillExpire); + setOtherPendingRequests(newOtherPendingRequests); if (fetchMore) { fetchProposalsThatWillExpire( lastIndex - limit, - newProposalsThatWillExpire + newProposalsThatWillExpire, + newOtherPendingRequests + ); + } else { + callback( + newProposalsThatWillExpire.length > 0 || + newOtherPendingRequests.length > 0 ); } }); }; - fetchProposalsThatWillExpire(lastProposalId, []); + fetchProposalsThatWillExpire(lastProposalId, [], []); + } else if (durationDays > currentDurationDays) { + const fetchProposalsThatWillBeActive = ( + lastIndex, + newProposalsThatWillBeActive, + newOtherPendingRequests + ) => { + Near.asyncView(treasuryDaoID, "get_proposals", { + from_index: lastIndex < limit ? 0 : lastIndex - limit, + limit, + }).then((/** @type Array */ proposals) => { + const now = new Date().getTime(); + + let fetchMore = false; + for (const proposal of proposals.reverse()) { + const submissionTimeMillis = Number( + proposal.submission_time.substr( + 0, + proposal.submission_time.length - 6 + ) + ); + const currentExpiryTime = + submissionTimeMillis + 24 * 60 * 60 * 1000 * currentDurationDays; + const newExpiryTime = + submissionTimeMillis + 24 * 60 * 60 * 1000 * durationDays; + if ( + currentExpiryTime <= now && + newExpiryTime > now && + proposal.status === "InProgress" + ) { + newProposalsThatWillBeActive.push({ + currentExpiryTime, + newExpiryTime, + submissionTimeMillis, + ...proposal, + }); + } else if (proposal.status === "InProgress" && newExpiryTime > now) { + newOtherPendingRequests.push({ + currentExpiryTime, + newExpiryTime, + submissionTimeMillis, + ...proposal, + }); + } + fetchMore = proposals.length === limit && newExpiryTime >= now; + } + setProposalsThatWillBeActive(newProposalsThatWillBeActive); + setOtherPendingRequests(newOtherPendingRequests); + if (fetchMore) { + fetchProposalsThatWillBeActive( + lastIndex - limit, + newProposalsThatWillBeActive, + newOtherPendingRequests + ); + } else { + callback( + newProposalsThatWillBeActive.length > 0 || + newOtherPendingRequests.length > 0 + ); + } + }); + }; + fetchProposalsThatWillBeActive(lastProposalId, [], []); } }; +const submitChangeRequest = () => { + findAffectedProposals((shouldShowAffectedProposalsModal) => { + if (!showAffectedProposalsModal && shouldShowAffectedProposalsModal) { + setShowAffectedProposalsModal(true); + return; + } + + setShowAffectedProposalsModal(false); + setSubmittingChangeRequest(true); + Near.call({ + contractName: treasuryDaoID, + methodName: "add_proposal", + deposit, + args: { + proposal: { + description: "Change proposal period", + kind: { + ChangePolicyUpdateParameters: { + parameters: { + proposal_period: + (60 * 60 * 24 * durationDays).toString() + "000000000", + }, + }, + }, + }, + }, + }); + }); +}; + +useEffect(() => { + Near.asyncView(treasuryDaoID, "get_proposal", { + id: lastProposalId - 1, + }).then((proposal) => { + const proposal_period = + proposal?.kind?.ChangePolicyUpdateParameters?.parameters?.proposal_period; + + if ( + proposal_period && + isSubmittingChangeRequest && + Number(proposal_period.substring(0, proposal_period.length - 9)) / + (24 * 60 * 60) === + Number(durationDays) + ) { + setToastStatus(true); + setSubmittingChangeRequest(false); + } + }); +}, [isSubmittingChangeRequest, lastProposalId]); + +const changeDurationDays = (newDurationDays) => { + setDurationDays(newDurationDays); +}; + +const showImpactedRequests = + proposalsThatWillExpire.length > 0 || proposalsThatWillBeActive.length > 0; + return (
@@ -241,55 +342,164 @@ return (

- {proposalsThatWillExpire.length > 0 ? ( -

-

- - - - - - - - - - - - {proposalsThatWillExpire.map((proposal) => ( - - - - - - - - ))} - -
IdDescriptionSubmission dateCurrent expiryNew expiry
{proposal.id}{proposal.description} - {new Date(proposal.submissionTimeMillis) - .toJSON() - .substring(0, "yyyy-mm-dd".length)} - - {new Date(proposal.currentExpiryTime) - .toJSON() - .substring(0, "yyyy-mm-dd".length)} - - {new Date(proposal.newExpiryTime) - .toJSON() - .substring(0, "yyyy-mm-dd".length)} -
-

+ {showAffectedProposalsModal ? ( + + + + +
+ + Impact of changing voting duration +
+
+ +

+ You are about to update the voting duration. This will affect + the following existing requests. +

+
    + {otherPendingRequests.length > 0 ? ( +
  • + {otherPendingRequests.length} pending requests will + now follow the new voting duration policy. +
  • + ) : ( + "" + )} + {proposalsThatWillExpire.length > 0 ? ( +
  • + {proposalsThatWillExpire.length} active requests{" "} + under the old voting duration will move to the "Archived" + tab and close for voting. These requests were created + outside the new voting period and are no longer considered + active. +
  • + ) : ( + "" + )} + {proposalsThatWillBeActive.length > 0 ? ( +
  • + {proposalsThatWillBeActive.length} expired requests{" "} + under the old voting duration will move back to the + "Pending Requests" tab and reopen for voting. These + requests were created within the new voting period and are + no longer considered expired. +
  • + ) : ( + "" + )} +
+ {showImpactedRequests ? ( + <> +

Summary of changes

+ + + + + + + + + + + + {proposalsThatWillExpire.map((proposal) => ( + + + + + + + + ))} + {proposalsThatWillBeActive.map((proposal) => ( + + + + + + + + ))} + +
IdDescriptionSubmission dateCurrent expiryNew expiry
{proposal.id}{proposal.description} + {new Date(proposal.submissionTimeMillis) + .toJSON() + .substring(0, "yyyy-mm-dd".length)} + + {new Date(proposal.currentExpiryTime) + .toJSON() + .substring(0, "yyyy-mm-dd".length)} + + {new Date(proposal.newExpiryTime) + .toJSON() + .substring(0, "yyyy-mm-dd".length)} +
{proposal.id}{proposal.description} + {new Date(proposal.submissionTimeMillis) + .toJSON() + .substring(0, "yyyy-mm-dd".length)} + + {new Date(proposal.currentExpiryTime) + .toJSON() + .substring(0, "yyyy-mm-dd".length)} + + {new Date(proposal.newExpiryTime) + .toJSON() + .substring(0, "yyyy-mm-dd".length)} +
+ + ) : ( + "" + )} + {proposalsThatWillBeActive.length > 0 ? ( +

+ If you do not want expired proposals to be open for voting + again, you may need to delete them. +

+ ) : ( + "" + )} +
+
+ + +
+
+
) : ( "" )} - -
diff --git a/playwright-tests/tests/settings/voting-duration.spec.js b/playwright-tests/tests/settings/voting-duration.spec.js index c4a255af..89d87ca0 100644 --- a/playwright-tests/tests/settings/voting-duration.spec.js +++ b/playwright-tests/tests/settings/voting-duration.spec.js @@ -65,6 +65,10 @@ test.describe("admin connected", function () { await page.waitForTimeout(500); await page.locator("button", { hasText: "Submit" }).click(); + await page + .locator(".modalfooter button", { hasText: "Yes, proceed" }) + .click(); + await expect(await getTransactionModalObject(page)).toEqual({ proposal: { description: "Change proposal period", @@ -126,6 +130,7 @@ test.describe("admin connected", function () { instanceAccount, daoAccount, }) => { + test.setTimeout(60_000); const daoName = daoAccount.split(".")[0]; const sandbox = new SandboxRPC(); @@ -166,6 +171,10 @@ test.describe("admin connected", function () { await page.waitForTimeout(500); await page.locator("button", { hasText: "Submit" }).click(); + await page + .locator(".modalfooter button", { hasText: "Yes, proceed" }) + .click(); + const transactionToSendPromise = page.evaluate(async () => { const selector = await document.querySelector("near-social-viewer") .selectorPromise; @@ -174,7 +183,6 @@ test.describe("admin connected", function () { return new Promise((resolve) => { wallet.signAndSendTransactions = async (transactions) => { - console.log("sign and send tx", transactions); resolve(transactions.transactions[0]); return await new Promise( (transactionSentPromiseResolve) => @@ -213,6 +221,7 @@ test.describe("admin connected", function () { instanceAccount, daoAccount, }) => { + test.setTimeout(60_000); const lastProposalId = 100; const proposals = []; for (let id = 0; id <= lastProposalId; id++) { @@ -247,7 +256,7 @@ test.describe("admin connected", function () { return lastProposalId; } else if (postData.params.method_name === "get_policy") { originalResult.proposal_period = ( - 7n * + 7n * // 7 days 24n * 60n * 60n * @@ -278,13 +287,13 @@ test.describe("admin connected", function () { .getByPlaceholder("Enter voting duration days") .inputValue(); - let newDurationDays = Number(currentDurationDays) - 1; + let newDurationDays = Number(currentDurationDays) + 3; while (newDurationDays > 0) { await page .getByPlaceholder("Enter voting duration days") .fill(newDurationDays.toString()); - const checkExpectedNewExpiredProposals = async () => { + const checkExpectedNewAffectedProposals = async () => { const expectedNewExpiredProposals = proposals .filter( (proposal) => @@ -297,21 +306,125 @@ test.describe("admin connected", function () { proposal.status === "InProgress" ) .reverse(); - await expect(await page.locator(".alert-danger")).toBeVisible(); - await expect(await page.locator(".alert-danger")).toHaveText( - "The following proposals will expire because of the changed duration" - ); + const expectedNewActiveProposals = proposals + .filter( + (proposal) => + Number(BigInt(proposal.submission_time) / 1_000_000n) + + currentDurationDays * 24 * 60 * 60 * 1000 < + new Date().getTime() && + Number(BigInt(proposal.submission_time) / 1_000_000n) + + newDurationDays * 24 * 60 * 60 * 1000 > + new Date().getTime() && + proposal.status === "InProgress" + ) + .reverse(); + const expectedUnaffectedActiveProposals = proposals + .filter( + (proposal) => + Number(BigInt(proposal.submission_time) / 1_000_000n) + + currentDurationDays * 24 * 60 * 60 * 1000 > + new Date().getTime() && + Number(BigInt(proposal.submission_time) / 1_000_000n) + + newDurationDays * 24 * 60 * 60 * 1000 > + new Date().getTime() && + proposal.status === "InProgress" + ) + .reverse(); + if (newDurationDays > currentDurationDays) { + expect(expectedNewActiveProposals.length).toBeGreaterThanOrEqual(0); + expect(expectedNewExpiredProposals.length).toBe(0); + } else if (newDurationDays < currentDurationDays) { + expect(expectedNewActiveProposals.length).toBe(0); + expect(expectedNewExpiredProposals.length).toBeGreaterThanOrEqual(0); + } else { + expect(expectedNewActiveProposals.length).toBe(0); + expect(expectedNewExpiredProposals.length).toBe(0); + await expect( + await page.getByRole("button", { name: "Submit Request" }) + ).toBeDisabled(); + return; + } + + await page.waitForTimeout(300); await expect( - await page.locator(".proposal-that-will-expire") - ).toHaveCount(expectedNewExpiredProposals.length); - const visibleProposalIds = await page - .locator(".proposal-that-will-expire td:first-child") - .allInnerTexts(); - expect(visibleProposalIds).toEqual( - expectedNewExpiredProposals.map((proposal) => proposal.id.toString()) - ); + await page.getByRole("button", { name: "Submit Request" }) + ).toBeEnabled(); + await page.getByRole("button", { name: "Submit Request" }).click(); + + if (expectedUnaffectedActiveProposals.length > 0) { + await expect( + page.getByText( + `${expectedUnaffectedActiveProposals.length} pending requests` + ) + ).toBeVisible(); + } + if (expectedNewExpiredProposals.length > 0) { + await expect( + await page.getByText( + `${expectedNewExpiredProposals.length} active requests` + ) + ).toBeVisible(); + await expect( + page.getByRole("heading", { + name: "Impact of changing voting duration", + }) + ).toBeVisible(); + await expect( + await page.locator(".proposal-that-will-expire") + ).toHaveCount(expectedNewExpiredProposals.length); + const visibleProposalIds = await page + .locator(".proposal-that-will-expire td:first-child") + .allInnerTexts(); + expect(visibleProposalIds).toEqual( + expectedNewExpiredProposals.map((proposal) => + proposal.id.toString() + ) + ); + await page + .locator(".modalfooter button", { hasText: "Cancel" }) + .click(); + await expect( + page.getByRole("heading", { + name: "Impact of changing voting duration", + }) + ).not.toBeVisible(); + } else if (expectedNewActiveProposals.length > 0) { + await expect( + await page.getByText(`${expectedNewActiveProposals.length} expired`) + ).toBeVisible(); + await expect( + page.getByRole("heading", { + name: "Impact of changing voting duration", + }) + ).toBeVisible(); + await expect( + await page.locator(".proposal-that-will-be-active") + ).toHaveCount(expectedNewActiveProposals.length); + const visibleProposalIds = await page + .locator(".proposal-that-will-be-active td:first-child") + .allInnerTexts(); + expect(visibleProposalIds).toEqual( + expectedNewActiveProposals.map((proposal) => proposal.id.toString()) + ); + await page + .locator(".modalfooter button", { hasText: "Cancel" }) + .click(); + await expect( + page.getByRole("heading", { + name: "Impact of changing voting duration", + }) + ).not.toBeVisible(); + } else { + await expect( + await page.getByText("Confirm Transaction") + ).toBeVisible(); + await page.locator("button", { hasText: "Close" }).click(); + await expect( + await page.getByText("Confirm Transaction") + ).not.toBeVisible(); + } }; - await checkExpectedNewExpiredProposals(); + await checkExpectedNewAffectedProposals(); await page.waitForTimeout(500); newDurationDays--; }