Skip to content
Merged
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
80 changes: 80 additions & 0 deletions e2e/flows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,3 +388,83 @@ test("language: ?lang=en opens the site in English", async ({ page }) => {
page.getByRole("link", { name: "Config constructor" }),
).toBeVisible();
});

for (const [kind, block, service, docs, title] of [
[
"provider",
"1. Где находится ваш домен?",
"Gandi",
"https://api.gandi.net/docs/",
"Provider request: Gandi",
],
[
"retriever",
"2. Как узнавать ваш адрес",
"myip",
"https://api.myip.com/",
"Retriever request: myip",
],
[
"notifier",
"4. Уведомления (необязательно)",
"NATS",
"https://docs.nats.io/",
"Notifier request: NATS",
],
] as const) {
test(`missing ${kind}: the form opens a filled-in GitHub issue`, async ({
page,
}) => {
await page.addInitScript(() => {
(window as unknown as { opened: string[] }).opened = [];
window.open = (url) => {
(window as unknown as { opened: string[] }).opened.push(String(url));
return null;
};
});
await page.goto("#/config");
const step = page.locator("details.step").filter({ hasText: block });
// Some blocks are folded until opened.
if (!(await step.evaluate((d: HTMLDetailsElement) => d.open)))
await step.locator("summary").click();
await step.getByRole("button", { name: "Нет моего сервиса?" }).click();
const dialog = page.locator(`dialog[data-kind="${kind}"]`);
await dialog.getByLabel(/^Название сервиса/).fill(service);
await dialog.getByLabel(/^Ссылка на документацию/).fill(docs);
await dialog
.getByLabel("Почему нужен именно этот сервис")
.fill("My domain lives there.");
await dialog
.getByRole("button", { name: "Открыть заявку на GitHub" })
.click();
const [opened] = await page.evaluate(
() => (window as unknown as { opened: string[] }).opened,
);
const url = new URL(opened ?? "");
expect(url.pathname).toBe("/dnspatch/dnspatch/issues/new");
expect(url.searchParams.get("template")).toBe(`${kind}_request.yml`);
expect(url.searchParams.get("title")).toBe(title);
await expect(dialog).toBeHidden();
});
}

test("window: letting go of the mouse outside after a selection keeps it open, a click outside closes it", async ({
page,
}) => {
await page.goto("#/config");
const step = page.locator("details.step").first();
await step.getByRole("button", { name: "Нет моего сервиса?" }).click();
const dialog = page.locator('dialog[data-kind="provider"]');
const box = await dialog.boundingBox();
if (!box) throw new Error("the window has no box");

// Press on the title, drag out over the dimmed area, release there.
await page.mouse.move(box.x + 40, box.y + 30);
await page.mouse.down();
await page.mouse.move(2, 2);
await page.mouse.up();
await expect(dialog).toBeVisible();

await page.mouse.click(2, 2);
await expect(dialog).toBeHidden();
});
58 changes: 58 additions & 0 deletions src/core/issue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, test } from "vitest";
import { pluginRequestUrl } from "./issue";

const base = {
service: " Gandi ",
apiDocs: "https://api.gandi.net/docs/",
why: "My domain lives there.",
extra: "",
willing: false,
};

describe("pluginRequestUrl", () => {
test("fills the fields of the provider request form", () => {
const url = new URL(pluginRequestUrl("provider", base));
expect(url.origin + url.pathname).toBe(
"https://github.com/dnspatch/dnspatch/issues/new",
);
expect(url.searchParams.get("template")).toBe("provider_request.yml");
expect(url.searchParams.get("title")).toBe("Provider request: Gandi");
expect(url.searchParams.get("service")).toBe("Gandi");
expect(url.searchParams.get("api-docs")).toBe(base.apiDocs);
expect(url.searchParams.get("why")).toBe(base.why);
});

test("opens the retriever form for a retriever", () => {
const url = new URL(pluginRequestUrl("retriever", base));
expect(url.searchParams.get("template")).toBe("retriever_request.yml");
expect(url.searchParams.get("title")).toBe("Retriever request: Gandi");
});

test("opens the notifier form for a notifier", () => {
const url = new URL(pluginRequestUrl("notifier", base));
expect(url.searchParams.get("template")).toBe("notifier_request.yml");
expect(url.searchParams.get("title")).toBe("Notifier request: Gandi");
});

test("leaves the optional field out when it is empty", () => {
const url = new URL(pluginRequestUrl("provider", base));
expect(url.searchParams.has("auth")).toBe(false);
});

test("puts the last field under the id its form uses", () => {
const extra = { ...base, extra: "plain text" };
const provider = new URL(pluginRequestUrl("provider", extra));
const retriever = new URL(pluginRequestUrl("retriever", extra));
expect(provider.searchParams.get("auth")).toBe("plain text");
expect(retriever.searchParams.get("details")).toBe("plain text");
});

test("adds the willingness to the reason", () => {
const url = new URL(
pluginRequestUrl("provider", { ...base, willing: true }),
);
expect(url.searchParams.get("why")).toBe(
"My domain lives there.\n\nI'd be willing to write and test this plugin myself.",
);
});
});
53 changes: 53 additions & 0 deletions src/core/issue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export type RequestKind = "provider" | "retriever" | "notifier";

/** What the "my service is missing" form collects. */
export interface PluginRequest {
service: string;
apiDocs: string;
why: string;
/** The last field of the form: how the API authenticates, or what a retriever answers with. */
extra: string;
willing: boolean;
}

const newIssue = "https://github.com/dnspatch/dnspatch/issues/new";

/** Id of the form's last field in .github/ISSUE_TEMPLATE/<kind>_request.yml. */
const extraField: Record<RequestKind, string> = {
provider: "auth",
retriever: "details",
notifier: "auth",
};

const titlePrefix: Record<RequestKind, string> = {
provider: "Provider request",
retriever: "Retriever request",
notifier: "Notifier request",
};

/**
* A link that opens dnspatch's "new <kind> request" issue form with the answers
* filled in. The page has no token and sends nothing itself: the person reads
* the result on GitHub and presses "Submit" there. Only text fields can be
* filled this way (GitHub ignores dropdowns and checkboxes), so the forms have
* none. Query keys are the field ids of the form.
*/
export function pluginRequestUrl(kind: RequestKind, r: PluginRequest): string {
const service = r.service.trim();
const why = [
r.why.trim(),
r.willing ? "I'd be willing to write and test this plugin myself." : "",
]
.filter(Boolean)
.join("\n\n");
const params = new URLSearchParams({
template: `${kind}_request.yml`,
title: `${titlePrefix[kind]}: ${service}`,
service,
"api-docs": r.apiDocs.trim(),
why,
});
const extra = r.extra.trim();
if (extra) params.set(extraField[kind], extra);
return `${newIssue}?${params}`;
}
53 changes: 53 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,32 @@ const ru = {
platformLabel: "Где будет работать dnspatch",
helpMeBuild: "Помогите мне собрать dnspatch",
close: "Закрыть",
noPluginButton: "Нет моего сервиса?",
noProviderTitle: "Попросить добавить сервис",
noProviderIntro:
"Если вашего сервиса нет в списке, расскажите о нём: мы откроем заявку в репозитории dnspatch.",
noProviderService: "Название сервиса",
noProviderDocs: "Ссылка на документацию API",
noPluginWhy: "Почему нужен именно этот сервис",
noPluginWhyHint:
"Что вы хотите сделать такого, чего не закрывают существующие плагины",
noProviderAuth: "Как API проверяет доступ (необязательно)",
noRetrieverTitle: "Попросить добавить способ узнавать адрес",
noRetrieverIntro:
"Если нужного сервиса нет в списке, расскажите о нём: мы откроем заявку в репозитории dnspatch.",
noRetrieverService: "Название сервиса или источника",
noRetrieverDocs: "Ссылка на документацию или на адрес, который отдаёт IP",
noRetrieverDetails: "Что сервис отвечает и есть ли лимиты (необязательно)",
noNotifierTitle: "Попросить добавить способ уведомлений",
noNotifierIntro:
"Если нужного сервиса нет в списке, расскажите о нём: мы откроем заявку в репозитории dnspatch.",
noNotifierService: "Название сервиса или протокола",
noNotifierDocs: "Ссылка на документацию",
noNotifierAuth: "Как к нему подключаются (необязательно)",
noPluginWilling: "Готов(а) сам(а) написать и проверить этот плагин",
noPluginNote:
"Форма откроется на GitHub уже заполненной. Там её можно поправить и отправить; для этого нужен аккаунт GitHub.",
noPluginSubmit: "Открыть заявку на GitHub",
noGo: "У меня не установлен Go",
noGoTitle: "Go ставить не нужно, достаточно архива",
noGoStep1: "Скачайте архив Go",
Expand Down Expand Up @@ -207,6 +233,33 @@ const en: Record<Key, string> = {
platformLabel: "Where dnspatch will run",
helpMeBuild: "Help me build dnspatch",
close: "Close",
noPluginButton: "Can't find your service?",
noProviderTitle: "Ask for a new service",
noProviderIntro:
"If your service is not in the list, tell us about it: we will open a request in the dnspatch repository.",
noProviderService: "Service name",
noProviderDocs: "Link to the API documentation",
noPluginWhy: "Why this one",
noPluginWhyHint:
"What you are trying to do that existing plugins do not cover",
noProviderAuth: "How the API checks access (optional)",
noRetrieverTitle: "Ask for a new way to find your address",
noRetrieverIntro:
"If the service you need is not in the list, tell us about it: we will open a request in the dnspatch repository.",
noRetrieverService: "Service or source name",
noRetrieverDocs:
"Link to the documentation or to the URL that returns the IP",
noRetrieverDetails: "What the service answers with and any limits (optional)",
noNotifierTitle: "Ask for a new way to be notified",
noNotifierIntro:
"If the service you need is not in the list, tell us about it: we will open a request in the dnspatch repository.",
noNotifierService: "Service or protocol name",
noNotifierDocs: "Link to the documentation",
noNotifierAuth: "How a client connects to it (optional)",
noPluginWilling: "I'd be willing to write and test this plugin myself",
noPluginNote:
"The form opens on GitHub already filled in. You can edit and submit it there; that needs a GitHub account.",
noPluginSubmit: "Open the request on GitHub",
noGo: "I do not have Go installed",
noGoTitle: "No need to install Go, an archive is enough",
noGoStep1: "Download the Go archive",
Expand Down
23 changes: 23 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ h3 {
}

input[type="text"],
input[type="url"],
textarea,
select {
width: 100%;
Expand Down Expand Up @@ -190,12 +191,14 @@ select {

select:hover,
input[type="text"]:hover,
input[type="url"]:hover,
textarea:hover {
border-color: var(--accent);
}

select:focus-visible,
input[type="text"]:focus-visible,
input[type="url"]:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
Expand Down Expand Up @@ -727,6 +730,26 @@ button.primary:hover {
margin: 0.3rem 0;
}

/* The request form: prose in the text areas, narrower than the build helper. */
.modal.narrow {
width: min(36rem, calc(100vw - 2rem));
}

.modal form .label {
margin-top: 0.75rem;
}

.modal form textarea {
min-height: 4.5rem;
white-space: pre-wrap;
font-family: inherit;
font-size: inherit;
}

.missing-plugin {
margin-top: 0.75rem;
}

.cards.two {
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
margin: 0.75rem 0;
Expand Down
13 changes: 3 additions & 10 deletions src/tools/build/BuildHelper.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { useRef, useState } from "preact/hooks";
import {
binaryName,
buildCommand,
Expand All @@ -11,6 +11,7 @@ import {
import { type Key, t, useLang } from "../../i18n";
import { CodeBlock } from "../CodeBlock";
import { Tabs } from "../Tabs";
import { useBackdropClose } from "../useBackdropClose";

const shells: { id: Shell; label: Key }[] = [
{ id: "posix", label: "shellPosix" },
Expand Down Expand Up @@ -42,15 +43,7 @@ export function BuildHelper({
content: <CodeBlock title="" text={command(id)} />,
}));

// A click on the dimmed area around the window closes it; Esc works natively.
useEffect(() => {
const el = dialog.current;
const onClick = (e: MouseEvent) => {
if (e.target === el) el?.close();
};
el?.addEventListener("click", onClick);
return () => el?.removeEventListener("click", onClick);
}, []);
useBackdropClose(dialog);

return (
<>
Expand Down
2 changes: 2 additions & 0 deletions src/tools/config/ConfigTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { sendToBuild } from "../../handoff";
import { t, useLang } from "../../i18n";
import { CodeBlock } from "../CodeBlock";
import { FieldsEditor } from "./FieldsEditor";
import { MissingPlugin } from "./MissingPlugin";
import { MonitorSection } from "./MonitorSection";
import { NotifierSection } from "./NotifierSection";
import { RetrieverSection } from "./RetrieverSection";
Expand Down Expand Up @@ -138,6 +139,7 @@ export function ConfigTool({ schema }: { schema: Schema }) {
);
})}
</div>
<MissingPlugin kind="provider" />

<h3>{t("stepFields")}</h3>
{provider && (
Expand Down
Loading
Loading