diff --git a/playwright-tests/tests/create.spec.js b/playwright-tests/tests/create.spec.js index 22dafad3f..804676b01 100644 --- a/playwright-tests/tests/create.spec.js +++ b/playwright-tests/tests/create.spec.js @@ -1,24 +1,179 @@ -import { test } from "@playwright/test"; -import { setInputAndAssert, selectAndAssert } from "../testUtils"; +import { expect, test } from "@playwright/test"; +import { selectAndAssert, setInputAndAssert } from "../testUtils"; -test("should be able to submit a solution with USDC as currency", async ({ - page, -}) => { - await page.goto("/devgovgigs.near/widget/app?page=create"); +test.describe("Wallet is not connected", () => { + // sign in to wallet + test.use({ + storageState: "playwright-tests/storage-states/wallet-not-connected.json", + }); - await page.click('button:has-text("Solution")'); + test("should not be able to create if not logged in", async ({ page }) => { + await page.goto("/devhub.near/widget/app?page=create"); - await setInputAndAssert( + const createPostButton = 'button[data-testid="submit-create-post"]'; + + await page.waitForSelector(createPostButton, { + state: "detached", + }); + + const isCreatePostButtonVisible = await page.isVisible(createPostButton); + + expect(isCreatePostButtonVisible).toBeFalsy(); + }); +}); + +test.describe("Wallet is connected", () => { + // sign in to wallet + test.use({ + storageState: "playwright-tests/storage-states/wallet-connected.json", + }); + + test("should be able to submit a solution with USDC as currency", async ({ page, - 'p:has-text("Title") + input', - "The test title" - ); + }) => { + await page.goto("/devhub.near/widget/app?page=create"); + + await page.click('button:has-text("Solution")'); + + await setInputAndAssert( + page, + 'input[data-testid="name-editor"]', + "The test title" + ); + + await page.click('label:has-text("Yes") button'); + await selectAndAssert(page, 'div:has-text("Currency") select', "USDT"); + await setInputAndAssert( + page, + 'input[data-testid="requested-amount-editor"]', + "300" + ); + }); - await page.click('label:has-text("Yes") button'); - await selectAndAssert(page, 'div:has-text("Currency") select', "USDT"); - await setInputAndAssert( + test("should init create post with single label from params", async ({ page, - 'div:has-text("Requested amount") input', - "300" - ); + }) => { + await page.goto("/devhub.near/widget/app?page=create&labels=devhub-test"); + + const selector = ".rbt-input-multi"; + const typeAheadElement = await page.waitForSelector(selector); + + const tokenWithValue = await typeAheadElement.$( + '.rbt-token:has-text("devhub-test")' + ); + expect(tokenWithValue).toBeTruthy(); + }); + + test("should init create post with multi label from params", async ({ + page, + }) => { + await page.goto( + "/devhub.near/widget/app?page=create&labels=devhub-test,security" + ); + + const selector = ".rbt-input-multi"; + const typeAheadElement = await page.waitForSelector(selector); + + const devhubTestToken = await typeAheadElement.$( + '.rbt-token:has-text("devhub-test")' + ); + expect(devhubTestToken).toBeTruthy(); + const securityToken = await typeAheadElement.$( + '.rbt-token:has-text("security")' + ); + expect(securityToken).toBeTruthy(); + }); + + test("should allow user to select multiple labels", async ({ page }) => { + await page.goto("/devhub.near/widget/app?page=create"); + const selector = ".rbt-input-main"; + await page.waitForSelector(selector); + await page.fill(selector, "devhub-test"); + + await page.evaluate(() => { + window.scrollTo(0, document.body.scrollHeight); // Scroll to the bottom of the page + }); // we do this because the input dropdown is not visible and cannot be clicked + await page.getByLabel("devhub-test").click(); + + await page.fill(selector, "security"); + await page.getByLabel("security").click(); + + const typeAheadElement = await page.waitForSelector(".rbt-input-multi"); + const devhubToken = await typeAheadElement.$( + `.rbt-token:has-text("devhub-test")` + ); + expect(devhubToken).toBeTruthy(); + + const securityToken = await typeAheadElement.$( + `.rbt-token:has-text("security")` + ); + expect(securityToken).toBeTruthy(); + }); + + test("should not allow user to use the blog label", async ({ page }) => { + await page.goto("/devhub.near/widget/app?page=create"); + + const selector = ".rbt-input-main"; + await page.waitForSelector(selector); + await page.fill(selector, "blog"); + const labelSelector = `:is(label:has-text("blog"))`; + await page.waitForSelector(labelSelector, { state: "detached" }); + }); + + test("should not allow user to use a protected label", async ({ page }) => { + await page.goto("/devhub.near/widget/app?page=create"); + + const selector = ".rbt-input-main"; + await page.waitForSelector(selector); + await page.fill(selector, "funding-requested"); + const labelSelector = `:is(label:has-text("funding-requested"))`; + await page.waitForSelector(labelSelector, { state: "detached" }); + + await page.waitForSelector(".alert", { state: "visible" }); + }); + + test("should allow the user to create new labels", async ({ page }) => { + await page.goto("/devhub.near/widget/app?page=create"); + + const selector = ".rbt-input-main"; + await page.waitForSelector(selector); + await page.fill(selector, "random-crazy-label-lol"); + const labelSelector = `:is(mark:has-text("random-crazy-label-lol"))`; + const element = await page.waitForSelector(labelSelector); + await page.evaluate(() => { + window.scrollTo(0, document.body.scrollHeight); // Scroll to the bottom of the page + }); + await element.click(); + + const typeAheadElement = await page.waitForSelector(".rbt-input-multi"); + const newToken = await typeAheadElement.$( + `.rbt-token:has-text("random-crazy-label-lol")` + ); + expect(newToken).toBeTruthy(); + }); +}); + +test.describe("Admin is connected", () => { + // sign in to wallet + test.use({ + storageState: "playwright-tests/storage-states/wallet-connected-admin.json", + }); + + test("should allow admin to use a protected label", async ({ page }) => { + await page.goto("/devhub.near/widget/app?page=create"); + + const selector = ".rbt-input-main"; + await page.waitForSelector(selector); + await page.fill(selector, "funding-requested"); + await page.evaluate(() => { + window.scrollTo(0, document.body.scrollHeight); // Scroll to the bottom of the page + }); // we do this because the input dropdown is not visible and cannot be clicked + await page.getByLabel("funding-requested").click(); + + const typeAheadElement = await page.waitForSelector(".rbt-input-multi"); + const protectedToken = await typeAheadElement.$( + `.rbt-token:has-text("funding-requested")` + ); + expect(protectedToken).toBeTruthy(); + }); }); diff --git a/src/devhub/page/create.jsx b/src/devhub/page/create.jsx index 54ecdb65e..127740edb 100644 --- a/src/devhub/page/create.jsx +++ b/src/devhub/page/create.jsx @@ -1,26 +1,69 @@ +const CenteredMessage = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 384px; +`; + +if (!context.accountId) { + return ( + +

Please sign in to create a post.

+
+ ); +} + +const postTypeOptions = { + Idea: { + name: "Idea", + icon: "bi-lightbulb", + + description: + "Get feedback from the community about a problem, opportunity, or need.", + }, + + Solution: { + name: "Solution", + icon: "bi-rocket", + + description: + "Provide a specific proposal or implementation to an idea, optionally requesting funding. If your solution relates to an existing idea, please reply to the original post with a solution.", + }, +}; + +const typeSwitch = (optionName) => { + State.update({ + postType: optionName, + }); +}; + +function initLabels() { + const labels = []; + if (props.labels) { + labels.push(...props.labels.split(",")); + } + if (props.referral) { + labels.push(`referral:${props.referral}`); + } + return labels; +} + State.init({ seekingFunding: false, - author_id: context.accountId, - // Should be a list of objects with field "name". - labels: [], - // Should be a list of labels as strings. - // Both of the label structures should be modified together. - labelStrings: [], + labels: initLabels(), postType: "Idea", name: props.name ?? "", description: props.description ?? "", amount: props.amount ?? "", token: props.token ?? "USDT", supervisor: props.supervisor ?? "neardevdao.near", - githubLink: props.githubLink ?? "", warning: "", - waitForDraftStateRestore: true, mentionInput: "", // text next to @ tag mentionsArray: [], // all the mentions in the description }); -/* INCLUDE: "core/lib/autocomplete" */ - const autocompleteEnabled = true; const AutoComplete = styled.div` @@ -33,20 +76,19 @@ const AutoComplete = styled.div` function textareaInputHandler(value) { const words = value.split(/\s+/); - const allMentiones = words + const allMentions = words .filter((word) => word.startsWith("@")) .map((mention) => mention.slice(1)); - const newMentiones = allMentiones.filter( + const newMentions = allMentions.filter( (item) => !state.mentionsArray.includes(item) ); - State.update((lastKnownState) => ({ - ...lastKnownState, + State.update({ text: value, - showAccountAutocomplete: newMentiones?.length > 0, - mentionsArray: allMentiones, - mentionInput: newMentiones?.[0] ?? "", - })); + showAccountAutocomplete: newMentions?.length > 0, + mentionsArray: allMentions, + mentionInput: newMentions?.[0] ?? "", + }); } function autoCompleteAccountId(id) { @@ -65,59 +107,19 @@ function autoCompleteAccountId(id) { } ); - State.update((lastKnownState) => ({ - ...lastKnownState, + State.update({ handler: "autocompleteSelected", description: updatedDescription, showAccountAutocomplete: false, - })); + }); } -/* END_INCLUDE: "core/lib/autocomplete" */ const { href } = VM.require("${REPL_DEVHUB}/widget/core.lib.url"); -const { DRAFT_STATE_STORAGE_KEY, draftState, onDraftStateChange } = VM.require( - "${REPL_DEVHUB}/widget/devhub.entity.post.draft" -); - if (!href) { return

Loading modules...

; } -const parentId = props.parentId ?? null; -const postId = props.postId ?? null; -const mode = props.mode ?? "Create"; - -const referralLabels = props.referral ? [`referral:${props.referral}`] : []; -const labelStrings = (props.labels ? props.labels.split(",") : []).concat( - referralLabels -); -const labels = labelStrings.map((s) => { - return { name: s }; -}); - -State.update({ - labels, -}); - -if (state.waitForDraftStateRestore) { - const draftstatestring = Storage.privateGet(DRAFT_STATE_STORAGE_KEY); - if (draftstatestring != null) { - if (props.transactionHashes) { - State.update({ waitForDraftStateRestore: false }); - Storage.privateSet(DRAFT_STATE_STORAGE_KEY, undefined); - } else { - try { - const draftstate = JSON.parse(draftstatestring); - State.update(draftstate); - } catch (e) { - console.error("error restoring draft", draftstatestring); - } - } - State.update({ waitForDraftStateRestore: false }); - } -} - // This must be outside onClick, because Near.view returns null at first, and when the view call finished, it returns true/false. // If checking this inside onClick, it will give `null` and we cannot tell the result is true or false. let grantNotify = Near.view("social.near", "is_write_permission_granted", { @@ -129,10 +131,6 @@ if (grantNotify === null) { } const onSubmit = () => { - Storage.privateSet(DRAFT_STATE_STORAGE_KEY, JSON.stringify(state)); - - let labels = state.labelStrings; - let body = { name: state.name, description: generateDescription( @@ -159,47 +157,33 @@ const onSubmit = () => { }; } - if (!context.accountId) return; - let txn = []; - if (mode == "Create") { - txn.push({ - contractName: "${REPL_DEVHUB_CONTRACT}", - methodName: "add_post", - args: { - parent_id: parentId, - labels, - body: body, - }, - gas: Big(10).pow(14), - }); - } else if (mode == "Edit") { - txn.push({ - contractName: "${REPL_DEVHUB_CONTRACT}", - methodName: "edit_post", + + txn.push({ + contractName: "${REPL_DEVHUB_CONTRACT}", + methodName: "add_post", + args: { + parent_id: null, + labels: state.labels, + body: body, + }, + gas: Big(10).pow(14), + }); + + if (grantNotify === false) { + txn.unshift({ + contractName: "social.near", + methodName: "grant_write_permission", args: { - id: postId, - labels, - body: body, + predecessor_id: "${REPL_DEVHUB_CONTRACT}", + keys: [context.accountId + "/index/notify"], }, gas: Big(10).pow(14), + deposit: Big(10).pow(22), }); } - if (mode == "Create" || mode == "Edit") { - if (grantNotify === false) { - txn.unshift({ - contractName: "social.near", - methodName: "grant_write_permission", - args: { - predecessor_id: "${REPL_DEVHUB_CONTRACT}", - keys: [context.accountId + "/index/notify"], - }, - gas: Big(10).pow(14), - deposit: Big(10).pow(22), - }); - } - Near.call(txn); - } + + Near.call(txn); }; const onIdeaClick = () => { @@ -240,218 +224,223 @@ const checkLabel = (label) => { }; const setLabels = (labels) => { - labels = labels.map((o) => { - o.name = normalizeLabel(o.name); - return o; - }); - if (labels.length < state.labels.length) { - let oldLabels = new Set(state.labels.map((label) => label.name)); - for (let label of labels) { - oldLabels.delete(label.name); - } - let removed = oldLabels.values().next().value; - Near.asyncView("${REPL_DEVHUB_CONTRACT}", "is_allowed_to_use_labels", { - editor: context.accountId, - labels: [removed], - }).then((allowed) => { - if (allowed) { - let labelStrings = labels.map(({ name }) => name); - State.update({ labels, labelStrings }); - } else { - State.update({ - warning: - 'The label "' + - removed + - '" is protected and can only be updated by moderators', - }); - return; + const normalizedLabels = labels.map((o) => + o.customOption ? normalizeLabel(o.label) : normalizeLabel(o) + ); + const uniqueLabels = [...new Set(normalizedLabels)]; + + if (uniqueLabels.length < state.labels.length) { + const removedLabel = state.labels.find( + (label) => !uniqueLabels.includes(label) + ); + + const allowed = Near.asyncView( + "${REPL_DEVHUB_CONTRACT}", + "is_allowed_to_use_labels", + { + editor: context.accountId, + labels: [removedLabel], } - }); + ); + + if (allowed) { + State.update({ labels: uniqueLabels }); + } else { + State.update({ + warning: `The label "${removedLabel}" is protected and can only be updated by moderators`, + }); + } } else { - let labelStrings = labels.map((o) => { - return o.name; - }); - State.update({ labels, labelStrings }); + State.update({ labels: uniqueLabels }); } }; -const existingLabelStrings = + +const existingLabels = Near.view("${REPL_DEVHUB_CONTRACT}", "get_all_allowed_labels", { editor: context.accountId, }) ?? []; -const existingLabelSet = new Set(existingLabelStrings); -const existingLabels = existingLabelStrings - .filter((it) => it !== "blog") // remove blog label so users cannot publish blogs from feed - .map((s) => { - return { name: s }; - }); - -const labelEditor = ( -
-

Labels

- { - return ( - !existingLabelSet.has(props.text) && - props.text.toLowerCase() !== "blog" && // dont allow adding "Blog" - props.selected.filter((selected) => selected.name === props.text) - .length == 0 && - Near.view("${REPL_DEVHUB_CONTRACT}", "is_allowed_to_use_labels", { - editor: context.accountId, - labels: [props.text], - }) - ); - }} - /> -
-); - -const nameDiv = ( -
-

Title

- State.update({ name: event.target.value })} - /> -
-); +const allowedLabels = existingLabels.filter((it) => it !== "blog"); // remove blog label so users cannot publish blogs from feed -const descriptionDiv = ( -
-

Description

- { - State.update({ description: content, handler: "update" }); - textareaInputHandler(content); - }, - }} - /> - {autocompleteEnabled && state.showAccountAutocomplete && ( - - State.update({ showAccountAutocomplete: false }), - }} - /> - - )} -
-); +function NameEditor() { + return ( +
+

Title

+ State.update({ name: event.target.value })} + /> +
+ ); +} -const isFundraisingDiv = ( - // This is jank with just btns and not radios. But the radios were glitchy af - <> -
-

- Are you seeking funding for your solution? - (Optional) -

-
-
-
-
+ + )}
- -); + ); +} -const fundraisingDiv = ( -
-
- Currency - -
-
- Requested amount (Numbers Only) - 0 ? state.amount : ""} - min={0} - onChange={(event) => - State.update({ - amount: Number( - event.target.value.toString().replace(/e/g, "") - ).toString(), - }) - } +function LabelsEditor() { + return ( +
+

Labels

+ { + return ( + !allowedLabels.includes(props.text) && + props.text.toLowerCase() !== "blog" && // dont allow adding "Blog" + props.selected.filter((selected) => selected.name === props.text) + .length == 0 && + Near.view("${REPL_DEVHUB_CONTRACT}", "is_allowed_to_use_labels", { + editor: context.accountId, + labels: [props.text], + }) + ); + }} />
-
-

- Requested sponsor (Optional) -

-

- If you are requesting funding from a specific sponsor, please enter - their username. -

-
- - @ - + ); +} + +function FundraisingToggle() { + return ( + <> +
+

+ Are you seeking funding for your solution? + (Optional) +

+
+
+
+
+
+ + ); +} + +function Fundraising() { + return ( +
+
+ Currency + +
+
+ Requested amount{" "} + (Numbers Only) State.update({ supervisor: event.target.value })} + data-testid="requested-amount-editor" + type="number" + value={parseInt(state.amount) > 0 ? state.amount : ""} + min={0} + onChange={(event) => + State.update({ + amount: Number( + event.target.value.toString().replace(/e/g, "") + ).toString(), + }) + } />
+
+

+ Requested sponsor (Optional) +

+

+ If you are requesting funding from a specific sponsor, please enter + their username. +

+
+ + @ + + + State.update({ supervisor: event.target.value }) + } + /> +
+
-
-); + ); +} function generateDescription(text, amount, token, supervisor, seekingFunding) { const fundingText = @@ -467,27 +456,6 @@ const [tab, setTab] = useState("editor"); return (
-
- -
{props.transactionHashes ? ( <> Post created successfully. Back to{" "} @@ -528,12 +496,7 @@ return (
- {tab === "editor" && ( -
Create a new post
- )} - {tab === "preview" &&
Post Preview
}
-

{state.seekingFunding}

{tab === "editor" && ( <> @@ -542,43 +505,33 @@ return ( What do you want to create?

- - + {Object.values(postTypeOptions).map((option) => ( + + ))}

- {state.postType === "Idea" - ? "Get feedback from the community about a problem, opportunity, or need." - : "Provide a specific proposal or implementation to an idea, optionally requesting funding. If your solution relates to an existing idea, please reply to the original post with a solution."} + {postTypeOptions[state.postType].description}

{state.warning && (
)}
- {nameDiv} - {descriptionDiv} - {labelEditor} - {state.postType === "Solution" && isFundraisingDiv} - {state.seekingFunding && fundraisingDiv} + + + + {state.postType === "Solution" && } + {state.seekingFunding && }