Skip to content

Commit 6717b0f

Browse files
authored
improve: candidates tab and editing candidates (nounsDAO#1278)
* improve: candidates tab and editing candidates * compile i18n strings * temporary english placeholders * fix lint errors * candidate pagination, lazy load, lint and type fixes
1 parent 3502beb commit 6717b0f

File tree

11 files changed

+334
-126
lines changed

11 files changed

+334
-126
lines changed

packages/nouns-webapp/src/components/CandidateCard/index.tsx

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import React from 'react';
22

33
import { Trans } from '@lingui/react/macro';
44
import clsx from 'clsx';
5-
import { Link } from 'react-router';
65

76
import ShortAddress from '@/components/ShortAddress';
87
import { relativeTimestamp } from '@/utils/timeUtils';
@@ -28,9 +27,9 @@ const CandidateCard: React.FC<Readonly<CandidateCardProps>> = ({
2827
const proposerVoteCount = candidate.proposerVotes;
2928

3029
return (
31-
<Link
30+
<a
3231
className={clsx(classes.candidateLink, classes.candidateLinkWithCountdown)}
33-
to={`/candidates/${candidate.id}`}
32+
href={`/candidates/${candidate.id}`}
3433
>
3534
<div className={classes.title}>
3635
<span className={classes.candidateTitle}>
@@ -48,7 +47,9 @@ const CandidateCard: React.FC<Readonly<CandidateCardProps>> = ({
4847
<CandidateSponsors
4948
signers={signers}
5049
nounsRequired={candidate.requiredVotes}
51-
currentBlock={currentBlock && currentBlock - 1n}
50+
currentBlock={
51+
currentBlock != null && currentBlock > 0n ? currentBlock - 1n : undefined
52+
}
5253
isThresholdMetByProposer={
5354
!!(proposerVoteCount && proposerVoteCount >= candidate.requiredVotes)
5455
}
@@ -59,23 +60,24 @@ const CandidateCard: React.FC<Readonly<CandidateCardProps>> = ({
5960
candidate.voteCount - candidate.requiredVotes > 0 && classes.sponsorCountOverflow,
6061
)}
6162
>
62-
<strong>
63-
{candidate.voteCount} /{' '}
64-
{candidate.proposerVotes > nounsRequired ? (
65-
<em className={classes.naVotesLabel}>n/a</em>
66-
) : (
67-
candidate.requiredVotes
68-
)}
69-
</strong>{' '}
70-
<Trans>sponsored votes</Trans>
63+
{candidate.proposerVotes > nounsRequired && candidate.voteCount === 0 ? (
64+
<Trans>No sponsors needed</Trans>
65+
) : (
66+
<>
67+
<strong>
68+
{candidate.voteCount} / {candidate.requiredVotes}
69+
</strong>{' '}
70+
<Trans>sponsored votes</Trans>
71+
</>
72+
)}
7173
</span>
7274
</div>
7375
<p className={classes.timestamp}>
7476
{relativeTimestamp(Number(candidate.lastUpdatedTimestamp))}
7577
</p>
7678
</div>
7779
</div>
78-
</Link>
80+
</a>
7981
);
8082
};
8183

packages/nouns-webapp/src/components/Proposals/Proposals.module.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,15 @@
388388
box-shadow: none;
389389
}
390390

391+
.candidatesSectionHeader {
392+
font-family: 'PT Root UI';
393+
font-size: 1rem;
394+
font-weight: bold;
395+
color: var(--brand-gray-light-text);
396+
margin-top: 1rem;
397+
margin-bottom: 0.5rem;
398+
}
399+
391400
.dataStatus {
392401
text-align: center;
393402
margin-top: 1rem;

packages/nouns-webapp/src/components/Proposals/index.tsx

Lines changed: 92 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react';
1+
import { useCallback, useEffect, useState } from 'react';
22

33
import { ClockIcon } from '@heroicons/react/solid';
44
import { i18n } from '@lingui/core';
@@ -103,11 +103,17 @@ interface ProposalsProps {
103103

104104
const Proposals = ({ proposals, nounsRequired }: ProposalsProps) => {
105105
const [showDelegateModal, setShowDelegateModal] = useState(false);
106-
const [activeTab, setActiveTab] = useState(0);
106+
const { hash } = useLocation();
107+
const [activeTab, setActiveTab] = useState(hash === '#candidates' ? 1 : 0);
107108
const { data: blockNumber } = useBlockNumber();
108109
const { address: account } = useAccount();
109110
const navigate = useNavigate();
110-
const { data: candidatesData, refetch: refetchCandidates } = useCandidateProposals(blockNumber);
111+
const {
112+
data: candidatesData,
113+
fetchMore,
114+
loadingMore,
115+
hasMore,
116+
} = useCandidateProposals(blockNumber, activeTab === 1);
111117
const dispatch = useAppDispatch();
112118
const candidates = useAppSelector(state => state.candidates.data);
113119
const connectedAccountNounVotes = useUserVotes() ?? 0;
@@ -118,37 +124,54 @@ const Proposals = ({ proposals, nounsRequired }: ProposalsProps) => {
118124
const hasNounBalance = (useNounTokenBalance(account ?? '0x0') ?? 0) > 0;
119125
const isDaoGteV3 = useIsDaoGteV3();
120126
const tabs = ['Proposals', config.featureToggles.candidates && isDaoGteV3 && 'Candidates'];
121-
const { hash } = useLocation();
127+
128+
// Infinite scroll: the sentinel div uses a `key` tied to the candidates
129+
// count, so React unmounts/remounts it after each page load. The callback
130+
// ref fires on mount, sets up a one-shot IntersectionObserver, and
131+
// fetchMore is guarded by refs in the hook to prevent double-fires.
132+
const sentinelRef = useCallback(
133+
(node: HTMLDivElement | null) => {
134+
if (!node) return;
135+
const observer = new IntersectionObserver(
136+
entries => {
137+
if (entries[0]?.isIntersecting) {
138+
observer.disconnect();
139+
fetchMore();
140+
}
141+
},
142+
{ rootMargin: '200px' },
143+
);
144+
observer.observe(node);
145+
},
146+
[fetchMore],
147+
);
122148

123149
useEffect(() => {
124-
(async () => {
125-
if (candidates) {
126-
return;
127-
}
128-
await refetchCandidates();
150+
if (candidatesData.length > 0) {
129151
const filteredCandidates = filter(
130-
candidatesData ?? [],
152+
candidatesData,
131153
(candidate): candidate is ProposalCandidate => candidate !== undefined,
132154
);
133155
if (filteredCandidates.length > 0) {
134156
dispatch(setCandidates(filteredCandidates));
135157
}
136-
})();
137-
}, [candidates, candidatesData, refetchCandidates, dispatch]);
158+
}
159+
}, [candidatesData, dispatch]);
138160

139161
useEffect(() => {
140162
if (hash === '#candidates') {
141163
setActiveTab(1);
142164
}
143165
}, [hash]);
144166

145-
useEffect(() => {
146-
if (activeTab === 1) {
147-
navigate('/vote#candidates');
167+
const handleTabChange = (index: number) => {
168+
setActiveTab(index);
169+
if (index === 1) {
170+
navigate('/vote#candidates', { replace: true });
148171
} else {
149-
navigate('/vote');
172+
navigate('/vote', { replace: true });
150173
}
151-
}, [activeTab, navigate]);
174+
};
152175

153176
const nullStateCopy = () => {
154177
if (!!account) {
@@ -177,7 +200,7 @@ const Proposals = ({ proposals, nounsRequired }: ProposalsProps) => {
177200
<button
178201
type="button"
179202
className={clsx(classes.tab, index === activeTab ? classes.activeTab : '')}
180-
onClick={() => setActiveTab(index)}
203+
onClick={() => handleTabChange(index)}
181204
key={index}
182205
>
183206
{tab}
@@ -348,10 +371,20 @@ const Proposals = ({ proposals, nounsRequired }: ProposalsProps) => {
348371
<Row>
349372
<Col lg={9}>
350373
{nounsRequired !== undefined && candidates && candidates.length > 0 ? (
351-
candidates
352-
.slice(0)
353-
.reverse()
354-
.map((c, i) => {
374+
(() => {
375+
const sortedCandidates = candidates;
376+
const myCandidates = account
377+
? sortedCandidates.filter(
378+
c => c.proposer?.toLowerCase() === account.toLowerCase(),
379+
)
380+
: [];
381+
const otherCandidates = account
382+
? sortedCandidates.filter(
383+
c => c.proposer?.toLowerCase() !== account.toLowerCase(),
384+
)
385+
: sortedCandidates;
386+
387+
const renderCandidate = (c: (typeof candidates)[number], i: number) => {
355388
if (c.proposalIdToUpdate !== undefined && +c.proposalIdToUpdate > 0) {
356389
const prop = find(proposals ?? [], p => p.id == c.proposalIdToUpdate);
357390
const isOriginalPropUpdatable = !!(
@@ -372,7 +405,43 @@ const Proposals = ({ proposals, nounsRequired }: ProposalsProps) => {
372405
/>
373406
</div>
374407
);
375-
})
408+
};
409+
410+
return (
411+
<>
412+
{myCandidates.length > 0 && (
413+
<>
414+
<h5 className={classes.candidatesSectionHeader}>
415+
<Trans>Your Candidates</Trans>
416+
</h5>
417+
{myCandidates.map(renderCandidate)}
418+
</>
419+
)}
420+
{otherCandidates.length > 0 && (
421+
<>
422+
{myCandidates.length > 0 && (
423+
<h5 className={classes.candidatesSectionHeader}>
424+
<Trans>All Candidates</Trans>
425+
</h5>
426+
)}
427+
{otherCandidates.map(renderCandidate)}
428+
</>
429+
)}
430+
{hasMore && (
431+
<div
432+
ref={sentinelRef}
433+
key={`sentinel-${candidates.length}`}
434+
style={{ height: 1 }}
435+
/>
436+
)}
437+
{loadingMore && (
438+
<div className="d-flex justify-content-center py-3">
439+
<Spinner animation="border" size="sm" />
440+
</div>
441+
)}
442+
</>
443+
);
444+
})()
376445
) : (
377446
<>
378447
{!candidates && (

packages/nouns-webapp/src/locales/en-US.po

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,6 +1465,10 @@ msgstr "The Nouns Foundation considers the veto an emergency power that should n
14651465
msgid "Nouns govern <0>Nouns DAO</0>. Nouns can vote on proposals or delegate their vote to a third party. A minimum of <1>{0}</1> is required to submit proposals."
14661466
msgstr "Nouns govern <0>Nouns DAO</0>. Nouns can vote on proposals or delegate their vote to a third party. A minimum of <1>{0}</1> is required to submit proposals."
14671467

1468+
#: src/components/Proposals/index.tsx
1469+
msgid "Your Candidates"
1470+
msgstr "Your Candidates"
1471+
14681472
#: src/pages/Fork/DeployForkButton.tsx
14691473
msgid "Deploying Nouns fork and beginning the forking period"
14701474
msgstr "Deploying Nouns fork and beginning the forking period"
@@ -1500,6 +1504,10 @@ msgstr "Adding your feedback"
15001504
msgid "Proposed Transactions"
15011505
msgstr "Proposed Transactions"
15021506

1507+
#: src/components/CandidateCard/index.tsx
1508+
msgid "No sponsors needed"
1509+
msgstr "No sponsors needed"
1510+
15031511
#. placeholder {0}: i18n.date(new Date(proposalCreationTimestamp * 1000), { dateStyle: 'long', timeStyle: 'long', })
15041512
#: src/components/ProposalHeader/index.tsx
15051513
msgid "Only Nouns you owned or were delegated to you before {0} are eligible to vote."
@@ -1557,6 +1565,10 @@ msgstr "No arguments required "
15571565
msgid "Add Streaming Payment Action"
15581566
msgstr "Add Streaming Payment Action"
15591567

1568+
#: src/components/Proposals/index.tsx
1569+
msgid "All Candidates"
1570+
msgstr "All Candidates"
1571+
15601572
#: src/pages/BrandAssets/BrandAssetsPage.tsx
15611573
msgid "Generate endless Nouns assembled from the onchain artwork"
15621574
msgstr "Generate endless Nouns assembled from the onchain artwork"

packages/nouns-webapp/src/locales/ja-JP.po

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1470,6 +1470,10 @@ msgstr "Nouns Foundationは、拒否権を通常の業務で行使すべきで
14701470
msgid "Nouns govern <0>Nouns DAO</0>. Nouns can vote on proposals or delegate their vote to a third party. A minimum of <1>{0}</1> is required to submit proposals."
14711471
msgstr "Nounsは<0>Nouns DAO</0>を統治します。Nounsは提案に投票するか、第三者に投票を委任することができます。提案を提出するには、最低<1>{0}</1>が必要です。"
14721472

1473+
#: src/components/Proposals/index.tsx
1474+
msgid "Your Candidates"
1475+
msgstr "Your Candidates"
1476+
14731477
#: src/pages/Fork/DeployForkButton.tsx
14741478
msgid "Deploying Nouns fork and beginning the forking period"
14751479
msgstr "Nounsフォークをデプロイし、フォーク期間を開始"
@@ -1505,6 +1509,10 @@ msgstr "フィードバックを追加"
15051509
msgid "Proposed Transactions"
15061510
msgstr "提案中のトランザクション"
15071511

1512+
#: src/components/CandidateCard/index.tsx
1513+
msgid "No sponsors needed"
1514+
msgstr "No sponsors needed"
1515+
15081516
#. placeholder {0}: i18n.date(new Date(proposalCreationTimestamp * 1000), { dateStyle: 'long', timeStyle: 'long', })
15091517
#: src/components/ProposalHeader/index.tsx
15101518
msgid "Only Nouns you owned or were delegated to you before {0} are eligible to vote."
@@ -1562,6 +1570,10 @@ msgstr "引数は不要"
15621570
msgid "Add Streaming Payment Action"
15631571
msgstr "ストリーミング支払いアクションを追加"
15641572

1573+
#: src/components/Proposals/index.tsx
1574+
msgid "All Candidates"
1575+
msgstr "All Candidates"
1576+
15651577
#: src/pages/BrandAssets/BrandAssetsPage.tsx
15661578
msgid "Generate endless Nouns assembled from the onchain artwork"
15671579
msgstr "オンチェーンアートワークから組み立てられた無限の名詞を生成する"

packages/nouns-webapp/src/locales/pseudo.po

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,6 +1465,10 @@ msgstr ""
14651465
msgid "Nouns govern <0>Nouns DAO</0>. Nouns can vote on proposals or delegate their vote to a third party. A minimum of <1>{0}</1> is required to submit proposals."
14661466
msgstr ""
14671467

1468+
#: src/components/Proposals/index.tsx
1469+
msgid "Your Candidates"
1470+
msgstr "Your Candidates"
1471+
14681472
#: src/pages/Fork/DeployForkButton.tsx
14691473
msgid "Deploying Nouns fork and beginning the forking period"
14701474
msgstr ""
@@ -1500,6 +1504,10 @@ msgstr ""
15001504
msgid "Proposed Transactions"
15011505
msgstr ""
15021506

1507+
#: src/components/CandidateCard/index.tsx
1508+
msgid "No sponsors needed"
1509+
msgstr "No sponsors needed"
1510+
15031511
#. placeholder {0}: i18n.date(new Date(proposalCreationTimestamp * 1000), { dateStyle: 'long', timeStyle: 'long', })
15041512
#: src/components/ProposalHeader/index.tsx
15051513
msgid "Only Nouns you owned or were delegated to you before {0} are eligible to vote."
@@ -1557,6 +1565,10 @@ msgstr ""
15571565
msgid "Add Streaming Payment Action"
15581566
msgstr ""
15591567

1568+
#: src/components/Proposals/index.tsx
1569+
msgid "All Candidates"
1570+
msgstr "All Candidates"
1571+
15601572
#: src/pages/BrandAssets/BrandAssetsPage.tsx
15611573
msgid "Generate endless Nouns assembled from the onchain artwork"
15621574
msgstr ""

packages/nouns-webapp/src/locales/zh-CN.po

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,6 +1465,10 @@ msgstr "Nouns 基金会认为,否决权是一种紧急权力,不应在日常
14651465
msgid "Nouns govern <0>Nouns DAO</0>. Nouns can vote on proposals or delegate their vote to a third party. A minimum of <1>{0}</1> is required to submit proposals."
14661466
msgstr "Nouns 管理<0>Nouns DAO</0>。Nouns 可以对提案进行投票或将其投票委托给第三方。提交提案至少需要 <1>{0}</1>。"
14671467

1468+
#: src/components/Proposals/index.tsx
1469+
msgid "Your Candidates"
1470+
msgstr "Your Candidates"
1471+
14681472
#: src/pages/Fork/DeployForkButton.tsx
14691473
msgid "Deploying Nouns fork and beginning the forking period"
14701474
msgstr "正在部署 Nouns 分叉并开始分叉期"
@@ -1500,6 +1504,10 @@ msgstr "正在添加您的反馈"
15001504
msgid "Proposed Transactions"
15011505
msgstr "提议的交易"
15021506

1507+
#: src/components/CandidateCard/index.tsx
1508+
msgid "No sponsors needed"
1509+
msgstr "No sponsors needed"
1510+
15031511
#. placeholder {0}: i18n.date(new Date(proposalCreationTimestamp * 1000), { dateStyle: 'long', timeStyle: 'long', })
15041512
#: src/components/ProposalHeader/index.tsx
15051513
msgid "Only Nouns you owned or were delegated to you before {0} are eligible to vote."
@@ -1557,6 +1565,10 @@ msgstr "无需参数 "
15571565
msgid "Add Streaming Payment Action"
15581566
msgstr "添加流支付操作"
15591567

1568+
#: src/components/Proposals/index.tsx
1569+
msgid "All Candidates"
1570+
msgstr "All Candidates"
1571+
15601572
#: src/pages/BrandAssets/BrandAssetsPage.tsx
15611573
msgid "Generate endless Nouns assembled from the onchain artwork"
15621574
msgstr "从链上艺术作品中生成无尽的名词"

0 commit comments

Comments
 (0)