diff --git a/e2e/flows.spec.ts b/e2e/flows.spec.ts index 9379fdf..64cff88 100644 --- a/e2e/flows.spec.ts +++ b/e2e/flows.spec.ts @@ -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(); +}); diff --git a/src/core/issue.test.ts b/src/core/issue.test.ts new file mode 100644 index 0000000..bff238f --- /dev/null +++ b/src/core/issue.test.ts @@ -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.", + ); + }); +}); diff --git a/src/core/issue.ts b/src/core/issue.ts new file mode 100644 index 0000000..6708283 --- /dev/null +++ b/src/core/issue.ts @@ -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/_request.yml. */ +const extraField: Record = { + provider: "auth", + retriever: "details", + notifier: "auth", +}; + +const titlePrefix: Record = { + provider: "Provider request", + retriever: "Retriever request", + notifier: "Notifier request", +}; + +/** + * A link that opens dnspatch's "new 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}`; +} diff --git a/src/i18n.ts b/src/i18n.ts index 30ab514..127a3fa 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -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", @@ -207,6 +233,33 @@ const en: Record = { 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", diff --git a/src/style.css b/src/style.css index b855292..163d77e 100644 --- a/src/style.css +++ b/src/style.css @@ -152,6 +152,7 @@ h3 { } input[type="text"], +input[type="url"], textarea, select { width: 100%; @@ -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; @@ -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; diff --git a/src/tools/build/BuildHelper.tsx b/src/tools/build/BuildHelper.tsx index b8e25cf..ecefdf5 100644 --- a/src/tools/build/BuildHelper.tsx +++ b/src/tools/build/BuildHelper.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "preact/hooks"; +import { useRef, useState } from "preact/hooks"; import { binaryName, buildCommand, @@ -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" }, @@ -42,15 +43,7 @@ export function BuildHelper({ content: , })); - // 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 ( <> diff --git a/src/tools/config/ConfigTool.tsx b/src/tools/config/ConfigTool.tsx index ef4a8cd..5f8adc2 100644 --- a/src/tools/config/ConfigTool.tsx +++ b/src/tools/config/ConfigTool.tsx @@ -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"; @@ -138,6 +139,7 @@ export function ConfigTool({ schema }: { schema: Schema }) { ); })} +

{t("stepFields")}

{provider && ( diff --git a/src/tools/config/MissingPlugin.tsx b/src/tools/config/MissingPlugin.tsx new file mode 100644 index 0000000..0ab3024 --- /dev/null +++ b/src/tools/config/MissingPlugin.tsx @@ -0,0 +1,157 @@ +import { useRef, useState } from "preact/hooks"; +import { pluginRequestUrl, type RequestKind } from "../../core/issue"; +import { type Key, t } from "../../i18n"; +import { useBackdropClose } from "../useBackdropClose"; + +/** The texts that differ between the forms; the rest is shared. */ +const texts: Record< + RequestKind, + { + title: Key; + intro: Key; + service: Key; + docs: Key; + extra: Key; + example: string; + } +> = { + provider: { + title: "noProviderTitle", + intro: "noProviderIntro", + service: "noProviderService", + docs: "noProviderDocs", + extra: "noProviderAuth", + example: "Gandi, DuckDNS, …", + }, + retriever: { + title: "noRetrieverTitle", + intro: "noRetrieverIntro", + service: "noRetrieverService", + docs: "noRetrieverDocs", + extra: "noRetrieverDetails", + example: "api.myip.com, …", + }, + notifier: { + title: "noNotifierTitle", + intro: "noNotifierIntro", + service: "noNotifierService", + docs: "noNotifierDocs", + extra: "noNotifierAuth", + example: "Matrix, NATS, …", + }, +}; + +/** A button that opens a form for asking for a service the list lacks. */ +export function MissingPlugin({ kind }: { kind: RequestKind }) { + const text = texts[kind]; + const dialog = useRef(null); + const [service, setService] = useState(""); + const [apiDocs, setApiDocs] = useState(""); + const [why, setWhy] = useState(""); + const [extra, setExtra] = useState(""); + const [willing, setWilling] = useState(false); + + useBackdropClose(dialog); + + const submit = (e: Event) => { + e.preventDefault(); + const url = pluginRequestUrl(kind, { + service, + apiDocs, + why, + extra, + willing, + }); + window.open(url, "_blank", "noopener"); + dialog.current?.close(); + }; + + const id = (field: string) => `mp-${kind}-${field}`; + + return ( +
+ + + + +

{t(text.intro)}

+ +
+ + setService(e.currentTarget.value)} + /> + + + setApiDocs(e.currentTarget.value)} + /> + + +