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",
|
"numberOfAgainstVotes": "Number of votes against",
|
||||||
"yesPercentage": "Yes percentage",
|
"yesPercentage": "Yes percentage",
|
||||||
"noPercentage": "No percentage",
|
"noPercentage": "No percentage",
|
||||||
"proposalTerms": "Proposal terms",
|
"proposalJson": "Full proposal JSON",
|
||||||
|
"proposalDetails": "Proposal details",
|
||||||
|
"proposalDescription": "Description",
|
||||||
"currentlySetTo": "Currently expected to ",
|
"currentlySetTo": "Currently expected to ",
|
||||||
"currently": "currently",
|
"currently": "currently",
|
||||||
"finalOutcomeMayDiffer": "Final outcome may differ",
|
"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();
|
).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
|
// Remove once proposals have rationale and re-enable above tests
|
||||||
it('Renders Freeform proposal - id for title', () => {
|
it('Renders Freeform proposal - id for title', () => {
|
||||||
renderComponent(
|
renderComponent(
|
||||||
|
@ -5,7 +5,6 @@ import { Heading, SubHeading } from '../../../../components/heading';
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
|
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
|
||||||
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
|
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import { truncateMiddle } from '../../../../lib/truncate-middle';
|
import { truncateMiddle } from '../../../../lib/truncate-middle';
|
||||||
import { CurrentProposalState } from '../current-proposal-state';
|
import { CurrentProposalState } from '../current-proposal-state';
|
||||||
import { ProposalInfoLabel } from '../proposal-info-label';
|
import { ProposalInfoLabel } from '../proposal-info-label';
|
||||||
@ -23,12 +22,7 @@ export const ProposalHeader = ({
|
|||||||
let details: ReactNode;
|
let details: ReactNode;
|
||||||
let proposalType = '';
|
let proposalType = '';
|
||||||
|
|
||||||
let title = proposal?.rationale.title.trim();
|
const title = proposal?.rationale.title.trim();
|
||||||
let description = proposal?.rationale.description.trim();
|
|
||||||
if (title?.length === 0 && description && description.length > 0) {
|
|
||||||
title = description;
|
|
||||||
description = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const titleContent = shorten(title ?? '', 100);
|
const titleContent = shorten(title ?? '', 100);
|
||||||
|
|
||||||
@ -145,23 +139,6 @@ export const ProposalHeader = ({
|
|||||||
{details}
|
{details}
|
||||||
</div>
|
</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 { useTranslation } from 'react-i18next';
|
||||||
import { Icon, SyntaxHighlighter } from '@vegaprotocol/ui-toolkit';
|
import { Icon, SyntaxHighlighter } from '@vegaprotocol/ui-toolkit';
|
||||||
import { SubHeading } from '../../../../components/heading';
|
import { SubHeading } from '../../../../components/heading';
|
||||||
import type { PartialDeep } from 'type-fest';
|
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
|
||||||
import type * as Schema from '@vegaprotocol/types';
|
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
|
||||||
import { useState } from 'react';
|
|
||||||
import classnames from 'classnames';
|
|
||||||
|
|
||||||
export const ProposalTermsJson = ({
|
export const ProposalJson = ({
|
||||||
terms,
|
proposal,
|
||||||
}: {
|
}: {
|
||||||
terms: PartialDeep<Schema.ProposalTerms>;
|
proposal: ProposalFieldsFragment | ProposalQuery['proposal'];
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showDetails, setShowDetails] = useState(false);
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
@ -18,20 +18,20 @@ export const ProposalTermsJson = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section data-testid="proposal-json">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDetails(!showDetails)}
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
data-testid="proposal-terms-toggle"
|
data-testid="proposal-json-toggle"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<SubHeading title={t('proposalTerms')} />
|
<SubHeading title={t('proposalJson')} />
|
||||||
<div className={showDetailsIconClasses}>
|
<div className={showDetailsIconClasses}>
|
||||||
<Icon name="chevron-down" size={8} />
|
<Icon name="chevron-down" size={8} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showDetails && <SyntaxHighlighter data={terms} />}
|
{showDetails && <SyntaxHighlighter data={proposal} />}
|
||||||
</section>
|
</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', () => ({
|
jest.mock('../proposal-change-table', () => ({
|
||||||
ProposalChangeTable: () => <div data-testid="proposal-change-table"></div>,
|
ProposalChangeTable: () => <div data-testid="proposal-change-table"></div>,
|
||||||
}));
|
}));
|
||||||
jest.mock('../proposal-terms-json', () => ({
|
jest.mock('../proposal-json', () => ({
|
||||||
ProposalTermsJson: () => <div data-testid="proposal-terms-json"></div>,
|
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', () => ({
|
jest.mock('../proposal-votes-table', () => ({
|
||||||
ProposalVotesTable: () => <div data-testid="proposal-votes-table"></div>,
|
ProposalVotesTable: () => <div data-testid="proposal-votes-table"></div>,
|
||||||
@ -49,7 +52,8 @@ it('renders each section', async () => {
|
|||||||
render(<Proposal proposal={proposal as ProposalQuery['proposal']} />);
|
render(<Proposal proposal={proposal as ProposalQuery['proposal']} />);
|
||||||
expect(await screen.findByTestId('proposal-header')).toBeInTheDocument();
|
expect(await screen.findByTestId('proposal-header')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('proposal-change-table')).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-votes-table')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('proposal-vote-details')).toBeInTheDocument();
|
expect(screen.getByTestId('proposal-vote-details')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('proposal-list-asset')).not.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 { ProposalHeader } from '../proposal-detail-header/proposal-header';
|
||||||
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
|
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
|
||||||
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
|
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
|
||||||
|
import { ProposalDescription } from '../proposal-description';
|
||||||
import { ProposalChangeTable } from '../proposal-change-table';
|
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 { ProposalVotesTable } from '../proposal-votes-table';
|
||||||
import { VoteDetails } from '../vote-details';
|
import { VoteDetails } from '../vote-details';
|
||||||
import { ListAsset } from '../list-asset';
|
import { ListAsset } from '../list-asset';
|
||||||
@ -20,7 +22,7 @@ export enum ProposalType {
|
|||||||
PROPOSAL_NETWORK_PARAMETER = 'PROPOSAL_NETWORK_PARAMETER',
|
PROPOSAL_NETWORK_PARAMETER = 'PROPOSAL_NETWORK_PARAMETER',
|
||||||
PROPOSAL_FREEFORM = 'PROPOSAL_FREEFORM',
|
PROPOSAL_FREEFORM = 'PROPOSAL_FREEFORM',
|
||||||
}
|
}
|
||||||
interface ProposalProps {
|
export interface ProposalProps {
|
||||||
proposal: ProposalFieldsFragment | ProposalQuery['proposal'];
|
proposal: ProposalFieldsFragment | ProposalQuery['proposal'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,9 +80,11 @@ export const Proposal = ({ proposal }: ProposalProps) => {
|
|||||||
<AsyncRenderer data={params} loading={loading} error={error}>
|
<AsyncRenderer data={params} loading={loading} error={error}>
|
||||||
<section data-testid="proposal">
|
<section data-testid="proposal">
|
||||||
<ProposalHeader proposal={proposal} isListItem={false} />
|
<ProposalHeader proposal={proposal} isListItem={false} />
|
||||||
|
|
||||||
<div className="my-10">
|
<div className="my-10">
|
||||||
<ProposalChangeTable proposal={proposal} />
|
<ProposalChangeTable proposal={proposal} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{proposal.terms.change.__typename === 'NewAsset' &&
|
{proposal.terms.change.__typename === 'NewAsset' &&
|
||||||
proposal.terms.change.source.__typename === 'ERC20' &&
|
proposal.terms.change.source.__typename === 'ERC20' &&
|
||||||
proposal.id ? (
|
proposal.id ? (
|
||||||
@ -90,7 +94,20 @@ export const Proposal = ({ proposal }: ProposalProps) => {
|
|||||||
lifetimeLimit={proposal.terms.change.source.lifetimeLimit}
|
lifetimeLimit={proposal.terms.change.source.lifetimeLimit}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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}>
|
<RoundedWrapper paddingBottom={true}>
|
||||||
<VoteDetails
|
<VoteDetails
|
||||||
proposal={proposal}
|
proposal={proposal}
|
||||||
@ -102,10 +119,10 @@ export const Proposal = ({ proposal }: ProposalProps) => {
|
|||||||
/>
|
/>
|
||||||
</RoundedWrapper>
|
</RoundedWrapper>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<ProposalVotesTable proposal={proposal} proposalType={proposalType} />
|
<ProposalVotesTable proposal={proposal} proposalType={proposalType} />
|
||||||
</div>
|
</div>
|
||||||
<ProposalTermsJson terms={proposal.terms} />
|
|
||||||
</section>
|
</section>
|
||||||
</AsyncRenderer>
|
</AsyncRenderer>
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user