Skip to content

Commit

Permalink
feat(governance): add mock mode for bounties display
Browse files Browse the repository at this point in the history
  • Loading branch information
fedosov committed Jan 7, 2025
1 parent 937b6c9 commit f5dd0d3
Show file tree
Hide file tree
Showing 4 changed files with 445 additions and 187 deletions.
68 changes: 53 additions & 15 deletions app/components/governance/BountyDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
import { hexToString } from "@polkadot/util";
import { BountyProgress } from "./BountyProgress";
import { BountyNextAction } from "./BountyNextAction";
import { mockBounties } from "app/utils/mock";

interface BountyDetailsProps {
bountyId: string;
Expand All @@ -26,29 +27,66 @@ export function BountyDetails({ bountyId, motion, type, curator, fee, showHeader
const [description, setDescription] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [bestNumber, setBestNumber] = useState<bigint | undefined>(undefined);
const isMockMode = process.env.NODE_ENV === "development" && mockBounties.size > 0;

useEffect(() => {
if (!api) return;

const loadBounty = async () => {
try {
const bountyInfo = (await api.query.bounties.bounties(bountyId)) as Option<any>;
const unwrapped = bountyInfo.unwrapOr(null);
if (isMockMode) {
const mockBounty = mockBounties.get(bountyId);
if (mockBounty) {
const mockBountyData = {
proposer: { toString: () => mockBounty.proposer },
value: { toBigInt: () => mockBounty.value },
fee: mockBounty.fee ? { toBigInt: () => mockBounty.fee } : undefined,
curator: mockBounty.curator ? { toString: () => mockBounty.curator } : undefined,
status: {
type: mockBounty.status,
...(mockBounty.status === "Active" && {
asActive: {
curator: { toString: () => mockBounty.curator },
updateDue: { toBigInt: () => BigInt(1000) },
},
}),
...(mockBounty.status === "PendingPayout" && {
asPendingPayout: {
curator: { toString: () => mockBounty.curator },
unlockAt: { toBigInt: () => BigInt(2000) },
},
}),
...(mockBounty.status === "CuratorProposed" && {
asCuratorProposed: {
curator: { toString: () => mockBounty.curator },
},
}),
},
};
setBountyData(mockBountyData);
setDescription(mockBounty.description);
} else {
setBountyData(null);
}
} else {
const bountyInfo = (await api.query.bounties.bounties(bountyId)) as Option<any>;
const unwrapped = bountyInfo.unwrapOr(null);

// Fetch description from bounty description storage
const descriptionHash = (await api.query.bounties.bountyDescriptions(bountyId)) as Option<any>;
if (descriptionHash.isSome) {
const rawDescription = descriptionHash.unwrap();
try {
const decodedDescription = hexToString(rawDescription.toHex());
setDescription(decodedDescription);
} catch (error) {
console.error("Failed to decode description:", error);
setDescription(rawDescription.toString());
// Fetch description from bounty description storage
const descriptionHash = (await api.query.bounties.bountyDescriptions(bountyId)) as Option<any>;
if (descriptionHash.isSome) {
const rawDescription = descriptionHash.unwrap();
try {
const decodedDescription = hexToString(rawDescription.toHex());
setDescription(decodedDescription);
} catch (error) {
console.error("Failed to decode description:", error);
setDescription(rawDescription.toString());
}
}
}

setBountyData(unwrapped);
setBountyData(unwrapped);
}
} catch (error) {
console.error("Failed to load bounty:", error);
} finally {
Expand All @@ -73,7 +111,7 @@ export function BountyDetails({ bountyId, motion, type, curator, fee, showHeader
unsubscribe();
}
};
}, [api, bountyId]);
}, [api, bountyId, isMockMode]);

const title = {
approval: "Approve Bounty",
Expand Down
202 changes: 202 additions & 0 deletions app/routes/governance.bounties.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { useTranslation } from "react-i18next";
import { Card, Spinner, Tag, Intent, Classes, Button, Icon, Switch } from "@blueprintjs/core";
import { useApi } from "app/components/Api";
import { useEffect, useState } from "react";
import { hexToString } from "@polkadot/util";
import { BountyDetails } from "app/components/governance/BountyDetails";
import type { Option } from "@polkadot/types";
import type { Codec } from "@polkadot/types/types";
import { DeriveCollectiveProposal } from "@polkadot/api-derive/types";
import { Link } from "@remix-run/react";
import { enableMockMode, disableMockMode } from "app/utils/sign";
import { mockBounties, initializeMockBounties } from "app/utils/mock";

interface Bounty {
id: string;
description: string;
proposer: string;
value: bigint;
fee?: bigint;
curator?: string;
status: string;
relatedMotions?: DeriveCollectiveProposal[];
}

interface BountyData extends Codec {
proposer: { toString(): string };
value: { toBigInt(): bigint };
fee?: { toBigInt(): bigint };
curator?: { toString(): string };
status: { type: string };
}

export default function BountiesClient() {
const { t } = useTranslation();
const api = useApi();
const [bounties, setBounties] = useState<Bounty[]>([]);
const [loading, setLoading] = useState(true);
const [isMockMode, setIsMockMode] = useState(false);

useEffect(() => {
if (!api) return;

const loadBounties = async () => {
try {
if (isMockMode) {
// Load mock bounties
const loadedBounties = Array.from(mockBounties.entries()).map(([id, bounty]) => ({
id,
...bounty,
}));
setBounties(loadedBounties.sort((a, b) => Number(b.id) - Number(a.id)));
setLoading(false);
return;
}

// Get all bounty entries
const bountyEntries = await api.query.bounties.bounties.entries();
const motions = await api.derive.council.proposals();

// Process each bounty
const bountyPromises = bountyEntries.map(async ([key, bountyOpt]) => {
const id = key.args[0].toString();
const bountyOption = bountyOpt as Option<BountyData>;

if (!bountyOption.isSome) {
return null;
}

const bounty = bountyOption.unwrap();

// Get bounty description
const descriptionHash = (await api.query.bounties.bountyDescriptions(id)) as Option<Codec>;
let description = "";

if (descriptionHash.isSome) {
try {
description = hexToString(descriptionHash.unwrap().toHex());
} catch (error) {
console.error("Failed to decode description:", error);
description = descriptionHash.toString();
}
}

// Find related motions
const relatedMotions = motions.filter((motion) => {
if (!motion.proposal) return false;
const { section, method, args } = motion.proposal;
if (section !== "bounties") return false;

// Check if this motion is related to this bounty
const bountyId = args[0]?.toString();
return bountyId === id;
});

return {
id,
description,
proposer: bounty.proposer.toString(),
value: bounty.value.toBigInt(),
fee: bounty.fee?.toBigInt(),
curator: bounty.curator?.toString(),
status: bounty.status.type,
relatedMotions: relatedMotions.length > 0 ? relatedMotions : undefined,
} as Bounty;
});

const loadedBounties = (await Promise.all(bountyPromises)).filter((b): b is Bounty => b !== null);
// Sort bounties by ID in descending order
setBounties(loadedBounties.sort((a, b) => Number(b.id) - Number(a.id)));
} catch (error) {
console.error("Failed to load bounties:", error);
} finally {
setLoading(false);
}
};

loadBounties();
}, [api, isMockMode]);

const handleMockModeToggle = () => {
if (!isMockMode) {
enableMockMode();
initializeMockBounties();
} else {
disableMockMode();
setBounties([]);
}
setIsMockMode(!isMockMode);
setLoading(true); // Force reload of bounties
};

if (loading) {
return <Spinner />;
}

return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className={Classes.HEADING}>{t("governance.bounties")}</h2>
{process.env.NODE_ENV === "development" && <Switch checked={isMockMode} label={t("governance.mock_mode")} onChange={handleMockModeToggle} />}
</div>
<div className="space-y-3">
{bounties.length === 0 ? (
<Card>
<Tag intent={Intent.WARNING}>{t("governance.no_active_bounties")}</Tag>
</Card>
) : (
bounties.map((bounty) => (
<Card key={bounty.id} className="mb-3">
<div className="flex justify-between items-start">
<div className="w-full">
<div className="flex items-center gap-2">
<Tag minimal round intent={Intent.PRIMARY}>
#{bounty.id}
</Tag>
</div>
<div className="mt-6">
<BountyDetails bountyId={bounty.id} type="approval" motion={null as any} showHeader={false} />
</div>
{bounty.relatedMotions && bounty.relatedMotions.length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{t("governance.related_motions")}</h4>
<div className="space-y-2">
{bounty.relatedMotions.map((motion) => {
const hash = motion.hash?.toString();
if (!hash) return null;

const section = motion.proposal?.section;
const method = motion.proposal?.method;

return (
<div key={hash} className="flex items-center justify-between bg-gray-50 dark:bg-gray-800 rounded p-2">
<div className="flex items-center gap-2">
<Tag minimal intent={Intent.PRIMARY} className="whitespace-nowrap">
#{motion.votes?.index.toString()}
</Tag>
<span className="text-sm text-gray-600 dark:text-gray-400">
{section}.{method}
</span>
</div>
<Link
to={`/governance/motions?highlight=${hash}`}
className="flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
{t("governance.view_motion")}
<Icon icon="arrow-right" size={12} />
</Link>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</Card>
))
)}
</div>
</div>
);
}
Loading

0 comments on commit f5dd0d3

Please sign in to comment.