feat(3779): proposal terms in table, full proposal json, description … (#3793)
This commit is contained in:
parent
09778a5d3f
commit
68ede90609
@ -594,7 +594,9 @@
|
||||
"numberOfAgainstVotes": "Number of votes against",
|
||||
"yesPercentage": "Yes percentage",
|
||||
"noPercentage": "No percentage",
|
||||
"proposalTerms": "Proposal terms",
|
||||
"proposalJson": "Full proposal JSON",
|
||||
"proposalDetails": "Proposal details",
|
||||
"proposalDescription": "Description",
|
||||
"currentlySetTo": "Currently expected to ",
|
||||
"currently": "currently",
|
||||
"finalOutcomeMayDiffer": "Final outcome may differ",
|
||||
|
@ -0,0 +1 @@
|
||||
export { ProposalDescription } from './proposal-description';
|
@ -0,0 +1,51 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import classnames from 'classnames';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon, RoundedWrapper } from '@vegaprotocol/ui-toolkit';
|
||||
import { SubHeading } from '../../../../components/heading';
|
||||
|
||||
export const ProposalDescription = ({
|
||||
description,
|
||||
}: {
|
||||
description: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showDescription, setShowDescription] = useState(false);
|
||||
const showDescriptionIconClasses = classnames('mb-4', {
|
||||
'rotate-180': showDescription,
|
||||
});
|
||||
|
||||
return (
|
||||
<section data-testid="proposal-description">
|
||||
<button
|
||||
onClick={() => setShowDescription(!showDescription)}
|
||||
data-testid="proposal-description-toggle"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<SubHeading title={t('proposalDescription')} />
|
||||
<div className={showDescriptionIconClasses}>
|
||||
<Icon name="chevron-down" size={8} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showDescription && (
|
||||
<RoundedWrapper paddingBottom={true} marginBottomLarge={true}>
|
||||
<div className="p-2">
|
||||
<ReactMarkdown
|
||||
className="react-markdown-container"
|
||||
/* Prevents HTML embedded in the description from rendering */
|
||||
skipHtml={true}
|
||||
/* Stops users embedding images which could be used for tracking */
|
||||
disallowedElements={['img']}
|
||||
linkTarget="_blank"
|
||||
>
|
||||
{description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</RoundedWrapper>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
@ -206,28 +206,6 @@ describe('Proposal header', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Renders Freeform proposal - long rationale (105 chars) - details', () => {
|
||||
renderComponent(
|
||||
generateProposal({
|
||||
id: 'long',
|
||||
rationale: {
|
||||
title: '0x0',
|
||||
description:
|
||||
'Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean dolor.',
|
||||
},
|
||||
terms: {
|
||||
change: {
|
||||
__typename: 'NewFreeform',
|
||||
},
|
||||
},
|
||||
}),
|
||||
false
|
||||
);
|
||||
expect(screen.getByTestId('proposal-description')).toHaveTextContent(
|
||||
/Class aptent/
|
||||
);
|
||||
});
|
||||
|
||||
// Remove once proposals have rationale and re-enable above tests
|
||||
it('Renders Freeform proposal - id for title', () => {
|
||||
renderComponent(
|
||||
|
@ -5,7 +5,6 @@ import { Heading, SubHeading } from '../../../../components/heading';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
|
||||
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { truncateMiddle } from '../../../../lib/truncate-middle';
|
||||
import { CurrentProposalState } from '../current-proposal-state';
|
||||
import { ProposalInfoLabel } from '../proposal-info-label';
|
||||
@ -23,12 +22,7 @@ export const ProposalHeader = ({
|
||||
let details: ReactNode;
|
||||
let proposalType = '';
|
||||
|
||||
let title = proposal?.rationale.title.trim();
|
||||
let description = proposal?.rationale.description.trim();
|
||||
if (title?.length === 0 && description && description.length > 0) {
|
||||
title = description;
|
||||
description = '';
|
||||
}
|
||||
const title = proposal?.rationale.title.trim();
|
||||
|
||||
const titleContent = shorten(title ?? '', 100);
|
||||
|
||||
@ -145,23 +139,6 @@ export const ProposalHeader = ({
|
||||
{details}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{description && !isListItem && (
|
||||
<div data-testid="proposal-description">
|
||||
{/*<div className="uppercase mr-2">{t('ProposalDescription')}:</div>*/}
|
||||
<SubHeading title={t('ProposalDescription')} />
|
||||
<ReactMarkdown
|
||||
className="react-markdown-container"
|
||||
/* Prevents HTML embedded in the description from rendering */
|
||||
skipHtml={true}
|
||||
/* Stops users embedding images which could be used for tracking */
|
||||
disallowedElements={['img']}
|
||||
linkTarget="_blank"
|
||||
>
|
||||
{description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1 @@
|
||||
export { ProposalJson } from './proposal-json';
|
@ -1,15 +1,15 @@
|
||||
import classnames from 'classnames';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon, SyntaxHighlighter } from '@vegaprotocol/ui-toolkit';
|
||||
import { SubHeading } from '../../../../components/heading';
|
||||
import type { PartialDeep } from 'type-fest';
|
||||
import type * as Schema from '@vegaprotocol/types';
|
||||
import { useState } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
|
||||
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
|
||||
|
||||
export const ProposalTermsJson = ({
|
||||
terms,
|
||||
export const ProposalJson = ({
|
||||
proposal,
|
||||
}: {
|
||||
terms: PartialDeep<Schema.ProposalTerms>;
|
||||
proposal: ProposalFieldsFragment | ProposalQuery['proposal'];
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
@ -18,20 +18,20 @@ export const ProposalTermsJson = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<section>
|
||||
<section data-testid="proposal-json">
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
data-testid="proposal-terms-toggle"
|
||||
data-testid="proposal-json-toggle"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<SubHeading title={t('proposalTerms')} />
|
||||
<SubHeading title={t('proposalJson')} />
|
||||
<div className={showDetailsIconClasses}>
|
||||
<Icon name="chevron-down" size={8} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showDetails && <SyntaxHighlighter data={terms} />}
|
||||
{showDetails && <SyntaxHighlighter data={proposal} />}
|
||||
</section>
|
||||
);
|
||||
};
|
@ -1 +0,0 @@
|
||||
export { ProposalTermsJson } from './proposal-terms-json';
|
@ -0,0 +1 @@
|
||||
export { ProposalTerms } from './proposal-terms';
|
@ -0,0 +1,142 @@
|
||||
import {
|
||||
RoundedWrapper,
|
||||
KeyValueTable,
|
||||
KeyValueTableRow,
|
||||
Icon,
|
||||
} from '@vegaprotocol/ui-toolkit';
|
||||
import { BigNumber } from '../../../../lib/bignumber';
|
||||
import { SubHeading } from '../../../../components/heading';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
interface ProposalTermsProps {
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const getParsedValue = (value: unknown) => {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
// Check if value is a number string - if so use bignumber to maintain precision
|
||||
if (/^\d+(\.\d+)?$/.test(value)) {
|
||||
return new BigNumber(value).toString();
|
||||
} else {
|
||||
// This would convert, for example, a JSON object ('{"key":"value"}')
|
||||
// into an actual JS object ({key: "value"})
|
||||
return JSON.parse(value);
|
||||
}
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const RenderKeyValue = ({
|
||||
title,
|
||||
value,
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
}) => (
|
||||
<KeyValueTable>
|
||||
<KeyValueTableRow>
|
||||
{title}
|
||||
{value}
|
||||
</KeyValueTableRow>
|
||||
</KeyValueTable>
|
||||
);
|
||||
|
||||
const RenderArray = ({ title, array }: { title: string; array: unknown[] }) => {
|
||||
if (array.every((item) => typeof item === 'string')) {
|
||||
return <RenderKeyValue title={title} value={array.join(', ')} />;
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2">{title}</div>
|
||||
{array.map((item, index) => (
|
||||
<div key={index}>
|
||||
<ProposalTermsRenderer data={item as Record<string, unknown>} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Working with 'unknown' type as a proposal's terms can be in many shapes
|
||||
const RenderTerm = ({ title, value }: { title: string; value: unknown }) => {
|
||||
const parsedValue = getParsedValue(value);
|
||||
|
||||
if (parsedValue === null || typeof parsedValue === 'boolean') {
|
||||
return <RenderKeyValue title={title} value={String(parsedValue)} />;
|
||||
} else if (
|
||||
typeof parsedValue === 'string' ||
|
||||
typeof parsedValue === 'number'
|
||||
) {
|
||||
return <RenderKeyValue title={title} value={parsedValue} />;
|
||||
} else if (typeof parsedValue === 'object') {
|
||||
if (Array.isArray(parsedValue)) {
|
||||
return <RenderArray title={title} array={parsedValue} />;
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<div className="my-2">{title}</div>
|
||||
<ProposalTermsRenderer
|
||||
data={parsedValue as Record<string, unknown>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else if (parsedValue === undefined) {
|
||||
return <span>{'undefined'}</span>;
|
||||
} else {
|
||||
return <span>{String(parsedValue)}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const ProposalTermsRenderer = ({ data }: ProposalTermsProps) => {
|
||||
return (
|
||||
<RoundedWrapper paddingBottom={true}>
|
||||
{Object.keys(data)
|
||||
.filter(
|
||||
(key) =>
|
||||
!['__typename', 'closingDatetime', 'enactmentDatetime'].includes(
|
||||
key
|
||||
)
|
||||
)
|
||||
.map((key, index) => (
|
||||
<div key={index}>
|
||||
<RenderTerm title={key} value={data[key]} />
|
||||
</div>
|
||||
))}
|
||||
</RoundedWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProposalTerms = ({ data }: ProposalTermsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [showTerms, setShowTerms] = useState(false);
|
||||
const showTermsIconClasses = classnames('mb-4', {
|
||||
'rotate-180': showTerms,
|
||||
});
|
||||
|
||||
return (
|
||||
<section data-testid="proposal-terms">
|
||||
<button
|
||||
onClick={() => setShowTerms(!showTerms)}
|
||||
data-testid="proposal-terms-toggle"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<SubHeading title={t('proposalDetails')} />
|
||||
<div className={showTermsIconClasses}>
|
||||
<Icon name="chevron-down" size={8} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showTerms && <ProposalTermsRenderer data={data} />}
|
||||
</section>
|
||||
);
|
||||
};
|
@ -25,8 +25,11 @@ jest.mock('../proposal-detail-header/proposal-header', () => ({
|
||||
jest.mock('../proposal-change-table', () => ({
|
||||
ProposalChangeTable: () => <div data-testid="proposal-change-table"></div>,
|
||||
}));
|
||||
jest.mock('../proposal-terms-json', () => ({
|
||||
ProposalTermsJson: () => <div data-testid="proposal-terms-json"></div>,
|
||||
jest.mock('../proposal-json', () => ({
|
||||
ProposalJson: () => <div data-testid="proposal-json"></div>,
|
||||
}));
|
||||
jest.mock('../proposal-terms/proposal-terms', () => ({
|
||||
ProposalTerms: () => <div data-testid="proposal-terms"></div>,
|
||||
}));
|
||||
jest.mock('../proposal-votes-table', () => ({
|
||||
ProposalVotesTable: () => <div data-testid="proposal-votes-table"></div>,
|
||||
@ -49,7 +52,8 @@ it('renders each section', async () => {
|
||||
render(<Proposal proposal={proposal as ProposalQuery['proposal']} />);
|
||||
expect(await screen.findByTestId('proposal-header')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('proposal-change-table')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('proposal-terms-json')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('proposal-json')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('proposal-terms')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('proposal-votes-table')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('proposal-vote-details')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('proposal-list-asset')).not.toBeInTheDocument();
|
||||
|
@ -6,8 +6,10 @@ import { AsyncRenderer, RoundedWrapper } from '@vegaprotocol/ui-toolkit';
|
||||
import { ProposalHeader } from '../proposal-detail-header/proposal-header';
|
||||
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
|
||||
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
|
||||
import { ProposalDescription } from '../proposal-description';
|
||||
import { ProposalChangeTable } from '../proposal-change-table';
|
||||
import { ProposalTermsJson } from '../proposal-terms-json';
|
||||
import { ProposalJson } from '../proposal-json';
|
||||
import { ProposalTerms } from '../proposal-terms';
|
||||
import { ProposalVotesTable } from '../proposal-votes-table';
|
||||
import { VoteDetails } from '../vote-details';
|
||||
import { ListAsset } from '../list-asset';
|
||||
@ -20,7 +22,7 @@ export enum ProposalType {
|
||||
PROPOSAL_NETWORK_PARAMETER = 'PROPOSAL_NETWORK_PARAMETER',
|
||||
PROPOSAL_FREEFORM = 'PROPOSAL_FREEFORM',
|
||||
}
|
||||
interface ProposalProps {
|
||||
export interface ProposalProps {
|
||||
proposal: ProposalFieldsFragment | ProposalQuery['proposal'];
|
||||
}
|
||||
|
||||
@ -78,9 +80,11 @@ export const Proposal = ({ proposal }: ProposalProps) => {
|
||||
<AsyncRenderer data={params} loading={loading} error={error}>
|
||||
<section data-testid="proposal">
|
||||
<ProposalHeader proposal={proposal} isListItem={false} />
|
||||
|
||||
<div className="my-10">
|
||||
<ProposalChangeTable proposal={proposal} />
|
||||
</div>
|
||||
|
||||
{proposal.terms.change.__typename === 'NewAsset' &&
|
||||
proposal.terms.change.source.__typename === 'ERC20' &&
|
||||
proposal.id ? (
|
||||
@ -90,7 +94,20 @@ export const Proposal = ({ proposal }: ProposalProps) => {
|
||||
lifetimeLimit={proposal.terms.change.source.lifetimeLimit}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mb-12">
|
||||
|
||||
<div className="mb-4">
|
||||
<ProposalDescription description={proposal.rationale.description} />
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<ProposalTerms data={proposal.terms} />
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<ProposalJson proposal={proposal} />
|
||||
</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<RoundedWrapper paddingBottom={true}>
|
||||
<VoteDetails
|
||||
proposal={proposal}
|
||||
@ -102,10 +119,10 @@ export const Proposal = ({ proposal }: ProposalProps) => {
|
||||
/>
|
||||
</RoundedWrapper>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<ProposalVotesTable proposal={proposal} proposalType={proposalType} />
|
||||
</div>
|
||||
<ProposalTermsJson terms={proposal.terms} />
|
||||
</section>
|
||||
</AsyncRenderer>
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user