Skip to content

Commit afb3d97

Browse files
committed
feat: add preview domain template UI with live preview and tests
Replace the Wildcard Domain field with a Preview Domain Template field that shows available template variables and a live example of the resulting URL. Add unit tests for the interpolation logic covering slugification, DNS length limits, and variable replacement.
1 parent 713fcc2 commit afb3d97

2 files changed

Lines changed: 148 additions & 17 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { interpolateSubdomainTemplate } from "../../../../packages/server/src/services/preview-deployment";
2+
import { expect, test } from "vitest";
3+
4+
const baseVars = {
5+
appName: "my-app",
6+
prNumber: "123",
7+
branchName: "feature/login-page",
8+
uniqueId: "abc123",
9+
};
10+
11+
test("replaces ${prNumber} variable", () => {
12+
const result = interpolateSubdomainTemplate(
13+
"${prNumber}.previews.example.com",
14+
baseVars,
15+
);
16+
expect(result).toBe("123.previews.example.com");
17+
});
18+
19+
test("replaces ${branchName} with slugified value", () => {
20+
const result = interpolateSubdomainTemplate(
21+
"${branchName}.previews.example.com",
22+
baseVars,
23+
);
24+
expect(result).toBe("feature-login-page.previews.example.com");
25+
});
26+
27+
test("replaces ${appName} variable", () => {
28+
const result = interpolateSubdomainTemplate(
29+
"${appName}-${prNumber}.example.com",
30+
baseVars,
31+
);
32+
expect(result).toBe("my-app-123.example.com");
33+
});
34+
35+
test("replaces ${uniqueId} variable", () => {
36+
const result = interpolateSubdomainTemplate(
37+
"preview-${uniqueId}.example.com",
38+
baseVars,
39+
);
40+
expect(result).toBe("preview-abc123.example.com");
41+
});
42+
43+
test("replaces all variables in a complex template", () => {
44+
const result = interpolateSubdomainTemplate(
45+
"${appName}-pr${prNumber}-${branchName}.example.com",
46+
baseVars,
47+
);
48+
expect(result).toBe("my-app-pr123-feature-login-page.example.com");
49+
});
50+
51+
test("leaves template unchanged when no variables present", () => {
52+
const result = interpolateSubdomainTemplate(
53+
"*.traefik.me",
54+
baseVars,
55+
);
56+
expect(result).toBe("*.traefik.me");
57+
});
58+
59+
test("slugifies branch names with special characters", () => {
60+
const result = interpolateSubdomainTemplate(
61+
"${branchName}.example.com",
62+
{ ...baseVars, branchName: "feat/SOME_THING@v2.0" },
63+
);
64+
expect(result).toBe("feat-some-thing-v2-0.example.com");
65+
});
66+
67+
test("truncates slugified branch to 63 chars for DNS compliance", () => {
68+
const longBranch = "a".repeat(100);
69+
const result = interpolateSubdomainTemplate(
70+
"${branchName}.example.com",
71+
{ ...baseVars, branchName: longBranch },
72+
);
73+
const subdomain = result.split(".")[0]!;
74+
expect(subdomain.length).toBeLessThanOrEqual(63);
75+
});
76+
77+
test("handles multiple occurrences of the same variable", () => {
78+
const result = interpolateSubdomainTemplate(
79+
"${prNumber}-${prNumber}.example.com",
80+
baseVars,
81+
);
82+
expect(result).toBe("123-123.example.com");
83+
});

apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { zodResolver } from "@hookform/resolvers/zod";
22
import { HelpCircle, Plus, Settings2, X } from "lucide-react";
3-
import { useEffect, useState } from "react";
3+
import { useEffect, useMemo, useState } from "react";
44
import { useForm } from "react-hook-form";
55
import { toast } from "sonner";
66
import { z } from "zod";
@@ -100,6 +100,32 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
100100
});
101101

102102
const previewHttps = form.watch("previewHttps");
103+
const watchedWildcard = form.watch("wildcardDomain");
104+
105+
const templatePreview = useMemo(() => {
106+
const template = watchedWildcard || "*.traefik.me";
107+
const appName = data?.appName || "my-app";
108+
const exampleVars: Record<string, string> = {
109+
appName,
110+
prNumber: "42",
111+
branchName: "feature-login",
112+
uniqueId: "a1b2c3",
113+
};
114+
let result = template.replace(
115+
/\$\{(appName|prNumber|branchName|uniqueId)\}/g,
116+
(_match: string, key: string) => exampleVars[key] ?? "",
117+
);
118+
const hasUniqueVar =
119+
template.includes("${prNumber}") ||
120+
template.includes("${branchName}") ||
121+
template.includes("${uniqueId}");
122+
if (!hasUniqueVar) {
123+
result = result.replace("*", `${appName}-a1b2c3`);
124+
} else {
125+
result = result.replace("*", appName);
126+
}
127+
return result;
128+
}, [watchedWildcard, data?.appName]);
103129

104130
useEffect(() => {
105131
setIsEnabled(data?.isPreviewDeploymentsActive || false);
@@ -175,22 +201,44 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
175201
className="grid w-full gap-4"
176202
>
177203
<div className="grid gap-4 lg:grid-cols-2">
178-
<FormField
179-
control={form.control}
180-
name="wildcardDomain"
181-
render={({ field }) => (
182-
<FormItem>
183-
<FormLabel>Wildcard Domain</FormLabel>
184-
<FormControl>
185-
<Input placeholder="*.traefik.me" {...field} />
186-
</FormControl>
187-
<FormMessage />
188-
</FormItem>
189-
)}
190-
/>
191-
<FormField
192-
control={form.control}
193-
name="previewPath"
204+
<FormField
205+
control={form.control}
206+
name="wildcardDomain"
207+
render={({ field }) => (
208+
<FormItem className="lg:col-span-2">
209+
<FormLabel>Preview Domain Template</FormLabel>
210+
<FormControl>
211+
<Input
212+
placeholder="*.traefik.me or ${prNumber}.example.com"
213+
{...field}
214+
/>
215+
</FormControl>
216+
<FormDescription className="flex flex-col gap-1.5">
217+
<span>
218+
Use <code className="text-xs">*.</code> for auto-generated subdomains,
219+
or template variables for custom patterns.
220+
If the template includes{" "}
221+
<code className="text-xs">{"${prNumber}"}</code> or{" "}
222+
<code className="text-xs">{"${branchName}"}</code>,
223+
no random suffix is appended.
224+
</span>
225+
<span className="flex flex-wrap gap-x-3 gap-y-1 text-xs font-mono">
226+
<code>{"${appName}"}</code>
227+
<code>{"${prNumber}"}</code>
228+
<code>{"${branchName}"}</code>
229+
<code>{"${uniqueId}"}</code>
230+
</span>
231+
<span className="text-xs mt-1 px-2 py-1 rounded bg-muted font-mono truncate">
232+
Example: {templatePreview}
233+
</span>
234+
</FormDescription>
235+
<FormMessage />
236+
</FormItem>
237+
)}
238+
/>
239+
<FormField
240+
control={form.control}
241+
name="previewPath"
194242
render={({ field }) => (
195243
<FormItem>
196244
<FormLabel>Preview Path</FormLabel>

0 commit comments

Comments
 (0)