Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions ui/src/i18n/catalogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ const englishApplyMessages = {
"apply.organization.choose": "Choose an organization",
"apply.organization.continue": "Continue",
"apply.organization.manage": "Manage organizations",
"apply.organization.pickPrompt":
"This organization can't start a community — it may be a personal workspace, already run one, or you aren't its owner. Open your organizations and pick one you own.",
"apply.near.description": "Link the NEAR account you're applying with.",
"apply.near.link": "Link NEAR account",
"apply.kind.label": "Type",
Expand All @@ -152,7 +154,6 @@ const englishApplyMessages = {
"apply.slug.taken": "{hostname} is taken",
"apply.slug.organizationTaken": "An organization already uses this address. Choose another one.",
"apply.slug.usesOrganization": "Will use your organization @{slug}.",
"apply.slug.useDifferentAddress": "Use a different address",
"apply.motivation.label": "Why you want to run it",
"apply.motivation.placeholder": "Who is the community, and what will you do for it?",
"apply.motivation.description": "Reviewers read this before approving.",
Expand Down Expand Up @@ -326,6 +327,8 @@ const spanishAppMessages = {
"apply.organization.choose": "Elige una organización",
"apply.organization.continue": "Continuar",
"apply.organization.manage": "Gestionar organizaciones",
"apply.organization.pickPrompt":
"Esta organización no puede crear una comunidad: puede ser un espacio personal, tener ya una, o no eres su dueño. Abre tus organizaciones y elige una que te pertenezca.",
"apply.near.description": "Vincula la cuenta de NEAR con la que presentas la solicitud.",
"apply.near.link": "Vincular cuenta de NEAR",
"apply.kind.label": "Tipo",
Expand All @@ -349,7 +352,6 @@ const spanishAppMessages = {
"apply.slug.taken": "{hostname} está ocupado",
"apply.slug.organizationTaken": "Una organización ya usa esta dirección. Elige otra.",
"apply.slug.usesOrganization": "Usará tu organización @{slug}.",
"apply.slug.useDifferentAddress": "Usar otra dirección",
"apply.motivation.label": "Por qué quieres gestionarla",
"apply.motivation.placeholder": "¿A quién sirve la comunidad y qué harás por ella?",
"apply.motivation.description": "Los revisores leen esto antes de aprobar.",
Expand Down Expand Up @@ -506,6 +508,8 @@ const frenchAppMessages = {
"apply.organization.choose": "Choisir une organisation",
"apply.organization.continue": "Continuer",
"apply.organization.manage": "Gérer les organisations",
"apply.organization.pickPrompt":
"Cette organisation ne peut pas créer de communauté : il se peut que ce soit un espace personnel, qu'elle en ait déjà une, ou que vous n'en soyez pas le propriétaire. Ouvrez vos organisations et choisissez-en une qui vous appartient.",
"apply.near.description": "Associez le compte NEAR utilisé pour votre demande.",
"apply.near.link": "Associer un compte NEAR",
"apply.kind.label": "Type",
Expand All @@ -530,7 +534,6 @@ const frenchAppMessages = {
"apply.slug.organizationTaken":
"Une organisation utilise déjà cette adresse. Choisissez-en une autre.",
"apply.slug.usesOrganization": "Utilisera votre organisation @{slug}.",
"apply.slug.useDifferentAddress": "Utiliser une autre adresse",
"apply.motivation.label": "Pourquoi souhaitez-vous la gérer ?",
"apply.motivation.placeholder": "À qui s’adresse la communauté et que ferez-vous pour elle ?",
"apply.motivation.description":
Expand Down Expand Up @@ -686,6 +689,8 @@ const chineseAppMessages = {
"apply.organization.choose": "选择组织",
"apply.organization.continue": "继续",
"apply.organization.manage": "管理组织",
"apply.organization.pickPrompt":
"该组织无法创建社区:它可能是个人工作区、已有社区,或者你不是它的所有者。打开你的组织页面,选择一个你拥有的组织。",
"apply.near.description": "关联你用于申请的 NEAR 账户。",
"apply.near.link": "关联 NEAR 账户",
"apply.kind.label": "类型",
Expand All @@ -709,7 +714,6 @@ const chineseAppMessages = {
"apply.slug.taken": "{hostname} 已被占用",
"apply.slug.organizationTaken": "已有组织使用此地址。请选择其他地址。",
"apply.slug.usesOrganization": "将使用你的组织 @{slug}。",
"apply.slug.useDifferentAddress": "使用其他地址",
"apply.motivation.label": "为什么要运营它",
"apply.motivation.placeholder": "社区面向哪些人?你会为社区做什么?",
"apply.motivation.description": "审核人员会在批准前阅读此内容。",
Expand Down
60 changes: 30 additions & 30 deletions ui/src/routes/_authenticated/_dashboard/-apply-node-fields.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Expand Down Expand Up @@ -41,7 +40,6 @@ export function ApplyNodeFields({
preflightLoading,
organizationSlugAvailable,
linkedOrganizationSlug,
onUnlockSlug,
rootNodes,
rootParentId,
setRootParentId,
Expand All @@ -57,7 +55,6 @@ export function ApplyNodeFields({
preflightLoading: boolean;
organizationSlugAvailable?: boolean;
linkedOrganizationSlug?: string;
onUnlockSlug?: () => void;
rootNodes: NodeOption[];
rootParentId: string;
setRootParentId: (value: string) => void;
Expand Down Expand Up @@ -220,6 +217,24 @@ export function ApplyNodeFields({
const errors = field.state.meta.isTouched ? field.state.meta.errors : [];
const available = preflight?.hostname.available;
const organizationTaken = organizationSlugAvailable === false;
if (linkedSlug) {
return (
<Field data-invalid={errors.length > 0 || undefined}>
<FieldLabel htmlFor="application-slug">{t("apply.slug.label")}</FieldLabel>
<p
className="break-all font-mono text-sm text-foreground"
data-testid="apply.slug-locked"
>
{linkedSlug}
{gatewayId ? `.${gatewayId}` : ""}
</p>
<FieldDescription data-testid="apply.slug-status">
{t("apply.slug.usesOrganization", { slug: linkedSlug })}
</FieldDescription>
<FieldError errors={errors} />
</Field>
);
}
return (
<Field
data-invalid={
Expand All @@ -233,8 +248,7 @@ export function ApplyNodeFields({
data-testid="apply.slug"
className="font-mono"
name={field.name}
value={linkedSlug || field.state.value}
readOnly={!!linkedSlug}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => {
slugManuallyEdited.current = true;
Expand All @@ -254,32 +268,18 @@ export function ApplyNodeFields({
</InputGroupAddon>
</InputGroup>
<FieldDescription data-testid="apply.slug-status">
{linkedSlug
? t("apply.slug.usesOrganization", { slug: linkedSlug })
: !hostname
? t("apply.slug.rules")
: preflightLoading
? t("apply.slug.checking")
: organizationTaken
? t("apply.slug.organizationTaken")
: available === true
? t("apply.slug.available", { hostname })
: available === false
? t("apply.slug.taken", { hostname })
: hostname}
{!hostname
? t("apply.slug.rules")
: preflightLoading
? t("apply.slug.checking")
: organizationTaken
? t("apply.slug.organizationTaken")
: available === true
? t("apply.slug.available", { hostname })
: available === false
? t("apply.slug.taken", { hostname })
: hostname}
</FieldDescription>
{linkedSlug && onUnlockSlug && (
<Button
type="button"
variant="link"
size="sm"
className="self-start px-0"
onClick={onUnlockSlug}
data-testid="apply.slug-unlock"
>
{t("apply.slug.useDifferentAddress")}
</Button>
)}
<FieldError errors={errors} />
</Field>
);
Expand Down
131 changes: 90 additions & 41 deletions ui/src/routes/_authenticated/_dashboard/-apply-ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const harness = vi.hoisted(() => ({
apiClient: null as ApplyApiClient | null,
authClient: null as ApplyAuthClient | null,
activeOrganizationId: "org-1" as string | null,
sessionActiveOrganizationId: "org-1" as string | null,
daoConnection: { status: "connected", daoAccountId: "dao.sputnik" } as {
status: string;
daoAccountId: string | null;
Expand Down Expand Up @@ -155,10 +156,13 @@ function createApiClient(): ApplyApiClient {

function createAuthClient(): ApplyAuthClient {
return {
getSession: vi.fn().mockResolvedValue({
data: { user: { id: "user-1" }, session: { activeOrganizationId: "org-1" } },
getSession: vi.fn(async () => ({
data: {
user: { id: "user-1" },
session: { activeOrganizationId: harness.sessionActiveOrganizationId },
},
error: null,
}),
})),
organization: {
list: vi.fn().mockResolvedValue({
data: [{ id: "org-1", name: "CityNode" }],
Expand Down Expand Up @@ -203,6 +207,10 @@ async function fillValidApplication() {
});
}

function freeFormMode() {
harness.sessionActiveOrganizationId = null;
}

function stepStatus(id: string) {
return screen.getByTestId(`apply.step-${id}`).getAttribute("data-status");
}
Expand All @@ -212,6 +220,7 @@ beforeEach(() => {
harness.apiClient = createApiClient();
harness.authClient = createAuthClient();
harness.activeOrganizationId = "org-1";
harness.sessionActiveOrganizationId = "org-1";
harness.daoConnection = { status: "connected", daoAccountId: "dao.sputnik" };
harness.daoAccountIdForVerification = "dao.sputnik";
harness.nearAccount = "applicant.near";
Expand All @@ -232,6 +241,7 @@ afterEach(() => {

describe("apply route submission", () => {
it("submits the validated payload, shows the proposal id, and only invalidates proposal queries", async () => {
freeFormMode();
const queryClient = createQueryClient();
queryClient.setQueryData(proposalReviewQueryKeys.list("pending"), []);
queryClient.setQueryData(proposalReviewQueryKeys.pendingCount(), 0);
Expand Down Expand Up @@ -289,6 +299,7 @@ describe("apply route submission", () => {
});

it("starts as a standalone city and requires a country for a state", async () => {
freeFormMode();
await renderApply();
fireEvent.click(screen.getByRole("button", { name: "verify DAO" }));
await waitFor(() => expect(stepStatus("details")).toBe("current"));
Expand Down Expand Up @@ -485,6 +496,7 @@ describe("apply route submission", () => {
});

it("does not carry an earlier application's organization into a new application", async () => {
freeFormMode();
harness.apiClient?.proposals.getMyNodeApplications.mockResolvedValue({
data: [
{
Expand Down Expand Up @@ -560,6 +572,7 @@ describe("apply route submission", () => {
});

it("keeps the organization when revising a rejected legacy application", async () => {
freeFormMode();
harness.apiClient?.proposals.getMyNodeApplications.mockResolvedValue({
data: [
{
Expand Down Expand Up @@ -612,34 +625,11 @@ describe("apply route submission", () => {
});
}

it("links the applicant's own organization instead of reporting the address as taken", async () => {
it("reports the address as taken when a free-form application matches another organization", async () => {
freeFormMode();
malaysiaOrganization();
await renderApply();
await fillMalaysiaApplication();
await waitFor(() =>
expect(screen.getByTestId("apply.slug-status").textContent).toContain("@malaysia"),
);
const submitButton = screen.getByRole("button", { name: "Submit for review" });
await waitFor(() => expect(submitButton).toHaveProperty("disabled", false));
fireEvent.click(submitButton);

await waitFor(() => expect(harness.apiClient?.proposals.propose).toHaveBeenCalledOnce());
expect(harness.apiClient?.proposals.propose.mock.calls[0]?.[0].payload).toMatchObject({
slug: "malaysia",
orgId: "org-malaysia",
});
});

it("still reports the address as taken when the matching organization has another owner", async () => {
malaysiaOrganization();
harness.apiClient?.auth.listMembers.mockResolvedValue({
members: [
{ userId: "user-9", role: "owner" },
{ userId: "user-1", role: "member" },
],
});
await renderApply();
await fillMalaysiaApplication();
await waitFor(() =>
expect(screen.getByTestId("apply.slug-status").textContent).toContain(
"An organization already uses this address",
Expand All @@ -651,6 +641,31 @@ describe("apply route submission", () => {
expect(harness.apiClient?.proposals.propose).not.toHaveBeenCalled();
});

it("prefills and locks the active organization without an ?org= parameter", async () => {
await renderApply();
fireEvent.click(screen.getByRole("button", { name: "verify DAO" }));
await waitFor(() =>
expect((screen.getByTestId("apply.name") as HTMLInputElement).value).toBe("CityNode"),
);
expect(screen.getByTestId("apply.slug-locked").textContent).toBe("citynode.citynode.app");
expect(screen.queryByTestId("apply.slug")).toBeNull();
expect(screen.queryByTestId("apply.slug-unlock")).toBeNull();
expect(harness.apiClient?.auth.checkSlug).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText("Why you want to run it"), {
target: { value: "Serve the local community." },
});
const submitButton = screen.getByRole("button", { name: "Submit for review" });
await waitFor(() => expect(submitButton).toHaveProperty("disabled", false));
fireEvent.click(submitButton);

await waitFor(() => expect(harness.apiClient?.proposals.propose).toHaveBeenCalledOnce());
expect(harness.apiClient?.proposals.propose.mock.calls[0]?.[0].payload).toMatchObject({
name: "CityNode",
slug: "citynode",
orgId: "org-1",
});
});

it("prefills the organization name and locks the address when arriving with ?org=", async () => {
harness.search = { org: "malaysia" };
malaysiaOrganization();
Expand All @@ -659,9 +674,10 @@ describe("apply route submission", () => {
await waitFor(() =>
expect((screen.getByTestId("apply.name") as HTMLInputElement).value).toBe("Malaysia"),
);
const slugInput = screen.getByTestId("apply.slug") as HTMLInputElement;
expect(slugInput.value).toBe("malaysia");
expect(slugInput.readOnly).toBe(true);
expect(screen.getByTestId("apply.slug-locked").textContent).toBe("malaysia.citynode.app");
expect(screen.queryByTestId("apply.slug")).toBeNull();
expect(screen.queryByTestId("apply.slug-unlock")).toBeNull();
expect(harness.apiClient?.auth.checkSlug).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText("Why you want to run it"), {
target: { value: "Serve the local community." },
});
Expand All @@ -671,25 +687,58 @@ describe("apply route submission", () => {

await waitFor(() => expect(harness.apiClient?.proposals.propose).toHaveBeenCalledOnce());
expect(harness.apiClient?.proposals.propose.mock.calls[0]?.[0].payload).toMatchObject({
name: "Malaysia",
slug: "malaysia",
orgId: "org-malaysia",
});
});

it("releases the locked address when the applicant chooses a different one", async () => {
it("shows no unlock control once the web address is locked to the organization", async () => {
harness.search = { org: "malaysia" };
malaysiaOrganization();
await renderApply();
fireEvent.click(screen.getByRole("button", { name: "verify DAO" }));
await waitFor(() =>
expect((screen.getByTestId("apply.slug") as HTMLInputElement).readOnly).toBe(true),
);
fireEvent.click(screen.getByTestId("apply.slug-unlock"));
await waitFor(() =>
expect(screen.getByTestId("apply.slug-status").textContent).toContain(
"An organization already uses this address",
),
);
await waitFor(() => expect(screen.getByTestId("apply.slug-locked")).toBeTruthy());
expect(screen.queryByTestId("apply.slug-unlock")).toBeNull();
expect(harness.apiClient?.auth.checkSlug).not.toHaveBeenCalled();
});

it("prompts for another organization when the active one already runs a community", async () => {
harness.apiClient?.auth.listOrganizations.mockResolvedValue([
{
id: "org-1",
name: "CityNode",
slug: "citynode",
status: "active",
metadata: { communityProposalId: "proposal-1" },
},
]);
await renderApply();
fireEvent.click(screen.getByRole("button", { name: "verify DAO" }));
await waitFor(() => expect(screen.getByText("Choose an organization")).toBeTruthy());
expect(screen.queryByTestId("apply.name")).toBeNull();
expect(harness.apiClient?.proposals.propose).not.toHaveBeenCalled();
});

it("prompts for another organization when the applicant is not the active organization's owner", async () => {
harness.apiClient?.auth.listMembers.mockResolvedValue({
members: [
{ userId: "user-9", role: "owner" },
{ userId: "user-1", role: "member" },
],
});
await renderApply();
fireEvent.click(screen.getByRole("button", { name: "verify DAO" }));
await waitFor(() => expect(screen.getByText("Choose an organization")).toBeTruthy());
expect(screen.queryByTestId("apply.name")).toBeNull();
expect(harness.apiClient?.proposals.propose).not.toHaveBeenCalled();
});

it("keeps the plain application form when there is no selected organization", async () => {
freeFormMode();
await renderApply();
fireEvent.click(screen.getByRole("button", { name: "verify DAO" }));
expect(await screen.findByTestId("apply.slug")).toBeTruthy();
});

it("starts with the NEAR account when there is no active organization", async () => {
Expand Down
Loading
Loading