Skip to content

Commit 1afd1f2

Browse files
committed
feat: added new templates and created postcreate actions for app templates
1 parent aa00934 commit 1afd1f2

56 files changed

Lines changed: 3601 additions & 149 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import { allTemplates, appTemplates, databaseTemplates } from '@/shared/templates/all.templates';
2+
import { AppTemplateModel } from '@/shared/model/app-template.model';
3+
import https from 'https';
4+
import http from 'http';
5+
6+
describe('Template Icons', () => {
7+
describe('Icon URL Validation', () => {
8+
const isValidUrl = (urlString: string): boolean => {
9+
try {
10+
const url = new URL(urlString);
11+
return url.protocol === 'http:' || url.protocol === 'https:';
12+
} catch {
13+
return false;
14+
}
15+
};
16+
17+
const checkTemplateIcon = (template: AppTemplateModel) => {
18+
const { name, iconName } = template;
19+
20+
// Check if iconName exists
21+
expect(iconName).toBeDefined();
22+
expect(typeof iconName).toBe('string');
23+
24+
if (!iconName) return;
25+
26+
// If it's a URL (starts with http:// or https://)
27+
if (iconName.startsWith('http://') || iconName.startsWith('https://')) {
28+
// Should be a valid URL
29+
expect(isValidUrl(iconName)).toBe(true);
30+
31+
// Should use https for security (warn if http)
32+
if (iconName.startsWith('http://')) {
33+
console.warn(`⚠️ Template "${name}" uses HTTP instead of HTTPS: ${iconName}`);
34+
}
35+
36+
// Should have a valid file extension for images or be from known CDN/repos
37+
const hasValidExtension = /\.(svg|png|jpg|jpeg|gif|ico|webp)$/i.test(iconName);
38+
const isFromTrustedSource =
39+
iconName.includes('github.com') ||
40+
iconName.includes('githubusercontent.com') ||
41+
iconName.includes('raw.githubusercontent.com') ||
42+
iconName.includes('cdn.jsdelivr.net') ||
43+
iconName.includes('cdn.simpleicons.org') ||
44+
iconName.includes('codeberg.org') ||
45+
iconName.includes('hub.docker.com') ||
46+
iconName.includes('redis.io') ||
47+
iconName.includes('jenkins.io') ||
48+
iconName.includes('sonarsource.com') ||
49+
iconName.includes('nodered.org') ||
50+
iconName.includes('plausible.io') ||
51+
iconName.includes('www.adminer.org');
52+
53+
if (!hasValidExtension && !isFromTrustedSource) {
54+
console.error(`❌ Template "${name}" has invalid icon URL: ${iconName}`);
55+
}
56+
57+
expect(hasValidExtension || isFromTrustedSource).toBe(true);
58+
} else {
59+
// If it's not a URL, it should be a filename
60+
expect(iconName.length).toBeGreaterThan(0);
61+
expect(iconName).toMatch(/\.(svg|png|jpg|jpeg|gif|ico|webp)$/i);
62+
}
63+
};
64+
65+
test('All database templates should have valid icon URLs', () => {
66+
databaseTemplates.forEach(template => {
67+
checkTemplateIcon(template);
68+
});
69+
});
70+
71+
test('All app templates should have valid icon URLs', () => {
72+
appTemplates.forEach(template => {
73+
checkTemplateIcon(template);
74+
});
75+
});
76+
77+
test('No duplicate template names', () => {
78+
const names = allTemplates.map(t => t.name);
79+
const uniqueNames = new Set(names);
80+
expect(names.length).toBe(uniqueNames.size);
81+
});
82+
83+
test('All templates should have non-empty names', () => {
84+
allTemplates.forEach(template => {
85+
expect(template.name).toBeDefined();
86+
expect(template.name.length).toBeGreaterThan(0);
87+
});
88+
});
89+
});
90+
91+
describe('URL Format Validation', () => {
92+
test('All URL-based icons should use valid protocols', () => {
93+
const urlTemplates = allTemplates.filter(t =>
94+
t.iconName?.startsWith('http://') || t.iconName?.startsWith('https://')
95+
);
96+
97+
urlTemplates.forEach(template => {
98+
expect(
99+
template.iconName?.startsWith('http://') ||
100+
template.iconName?.startsWith('https://')
101+
).toBe(true);
102+
});
103+
});
104+
105+
test('URL-based icons should not have spaces', () => {
106+
const urlTemplates = allTemplates.filter(t =>
107+
t.iconName?.startsWith('http://') || t.iconName?.startsWith('https://')
108+
);
109+
110+
urlTemplates.forEach(template => {
111+
expect(template.iconName).not.toContain(' ');
112+
});
113+
});
114+
115+
test('URL-based icons should not have line breaks', () => {
116+
const urlTemplates = allTemplates.filter(t =>
117+
t.iconName?.startsWith('http://') || t.iconName?.startsWith('https://')
118+
);
119+
120+
urlTemplates.forEach(template => {
121+
expect(template.iconName).not.toContain('\n');
122+
expect(template.iconName).not.toContain('\r');
123+
});
124+
});
125+
});
126+
127+
describe('Template Structure', () => {
128+
test('All templates should have at least one template configuration', () => {
129+
allTemplates.forEach(template => {
130+
expect(template.templates).toBeDefined();
131+
expect(Array.isArray(template.templates)).toBe(true);
132+
expect(template.templates.length).toBeGreaterThan(0);
133+
});
134+
});
135+
136+
test('All template configurations should have required fields', () => {
137+
allTemplates.forEach(template => {
138+
template.templates.forEach((config, index) => {
139+
expect(config.inputSettings).toBeDefined();
140+
expect(config.appModel).toBeDefined();
141+
expect(config.appDomains).toBeDefined();
142+
expect(config.appVolumes).toBeDefined();
143+
expect(config.appFileMounts).toBeDefined();
144+
expect(config.appPorts).toBeDefined();
145+
});
146+
});
147+
});
148+
});
149+
150+
describe('Icon URL Accessibility Summary', () => {
151+
test('Generate summary of icon sources', () => {
152+
const urlTemplates = allTemplates.filter(t =>
153+
t.iconName?.startsWith('http://') || t.iconName?.startsWith('https://')
154+
);
155+
156+
const sources: { [key: string]: number } = {};
157+
158+
urlTemplates.forEach(template => {
159+
if (template.iconName) {
160+
const url = new URL(template.iconName);
161+
const hostname = url.hostname;
162+
sources[hostname] = (sources[hostname] || 0) + 1;
163+
}
164+
});
165+
166+
console.log('\n📊 Icon URL Sources Summary:');
167+
Object.entries(sources)
168+
.sort((a, b) => b[1] - a[1])
169+
.forEach(([source, count]) => {
170+
console.log(` ${source}: ${count} template(s)`);
171+
});
172+
173+
console.log(`\n✅ Total templates with URL icons: ${urlTemplates.length}`);
174+
console.log(`📁 Total templates with local icons: ${allTemplates.length - urlTemplates.length}`);
175+
console.log(`📦 Total templates: ${allTemplates.length}`);
176+
177+
expect(urlTemplates.length).toBeGreaterThan(0);
178+
});
179+
});
180+
181+
describe('Icon URL Accessibility (HTTP Fetch)', () => {
182+
test('All URL-based icons should be accessible via HTTP', async () => {
183+
const urlTemplates = allTemplates.filter(t =>
184+
t.iconName?.startsWith('http://') || t.iconName?.startsWith('https://')
185+
);
186+
187+
const failedUrls: { name: string; url: string; error: string }[] = [];
188+
const successfulUrls: string[] = [];
189+
190+
console.log('\n🔍 Testing HTTP accessibility for icon URLs...\n');
191+
192+
// Helper function to make HEAD request
193+
const testUrl = (url: string): Promise<{ statusCode: number; statusMessage: string }> => {
194+
return new Promise((resolve, reject) => {
195+
const urlObj = new URL(url);
196+
const client = urlObj.protocol === 'https:' ? https : http;
197+
198+
const options = {
199+
method: 'HEAD',
200+
headers: {
201+
'User-Agent': 'Mozilla/5.0 (compatible; QuickStack-IconTest/1.0)',
202+
},
203+
timeout: 10000, // 10 second timeout per request
204+
};
205+
206+
const req = client.request(url, options, (res) => {
207+
resolve({
208+
statusCode: res.statusCode || 0,
209+
statusMessage: res.statusMessage || ''
210+
});
211+
});
212+
213+
req.on('error', (error) => {
214+
reject(error);
215+
});
216+
217+
req.on('timeout', () => {
218+
req.destroy();
219+
reject(new Error('Request timeout'));
220+
});
221+
222+
req.end();
223+
});
224+
};
225+
226+
for (const template of urlTemplates) {
227+
if (!template.iconName) continue;
228+
229+
try {
230+
const { statusCode, statusMessage } = await testUrl(template.iconName);
231+
232+
if (statusCode >= 200 && statusCode < 400) {
233+
successfulUrls.push(template.iconName);
234+
console.log(` ✅ ${template.name}: ${statusCode}`);
235+
} else {
236+
failedUrls.push({
237+
name: template.name,
238+
url: template.iconName,
239+
error: `HTTP ${statusCode} ${statusMessage}`
240+
});
241+
console.error(` ❌ ${template.name}: ${statusCode} ${statusMessage}`);
242+
}
243+
} catch (error) {
244+
const errorMessage = error instanceof Error ? error.message : String(error);
245+
failedUrls.push({
246+
name: template.name,
247+
url: template.iconName,
248+
error: errorMessage
249+
});
250+
console.error(` ❌ ${template.name}: ${errorMessage}`);
251+
}
252+
253+
// Add a small delay to avoid rate limiting
254+
await new Promise(resolve => setTimeout(resolve, 100));
255+
}
256+
257+
console.log(`\n📊 Results:`);
258+
console.log(` ✅ Successful: ${successfulUrls.length}`);
259+
console.log(` ❌ Failed: ${failedUrls.length}`);
260+
261+
if (failedUrls.length > 0) {
262+
console.error('\n❌ Failed URLs that need to be replaced:');
263+
failedUrls.forEach(({ name, url, error }) => {
264+
console.error(` - ${name}:`);
265+
console.error(` URL: ${url}`);
266+
console.error(` Error: ${error}`);
267+
});
268+
}
269+
270+
expect(failedUrls.length).toBe(0);
271+
}, 60000); // 60 second timeout for all fetches
272+
});
273+
});

src/app/project/[projectId]/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export const createAppFromTemplate = async (prevState: any, inputData: AppTempla
4343
throw new ServiceException('Please fill out all required fields.');
4444
}
4545
await appTemplateService.createAppFromTemplate(projectId, validatedData);
46-
return new SuccessActionResult(undefined, "App created successfully.");
46+
return new SuccessActionResult(undefined, "");
4747
});
4848

4949
export const deleteApp = async (appId: string) =>

src/app/project/[projectId]/choose-template-dialog.tsx

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { AppTemplateModel } from "@/shared/model/app-template.model"
66
import { allTemplates, appTemplates, databaseTemplates } from "@/shared/templates/all.templates"
77
import CreateTemplateAppSetupDialog from "./create-template-app-setup-dialog"
88
import { ScrollArea } from "@/components/ui/scroll-area";
9+
import { Input } from "@/components/ui/input";
10+
import { Search } from "lucide-react";
911

1012

1113

@@ -22,19 +24,25 @@ export default function ChooseTemplateDialog({
2224
const [isOpen, setIsOpen] = useState<boolean>(false);
2325
const [chosenAppTemplate, setChosenAppTemplate] = useState<AppTemplateModel | undefined>(undefined);
2426
const [displayedTemplates, setDisplayedTemplates] = useState<AppTemplateModel[]>([]);
27+
const [searchQuery, setSearchQuery] = useState<string>("");
2528

2629
useEffect(() => {
2730
if (templateType) {
2831
setIsOpen(true);
32+
setSearchQuery("");
2933
}
3034
if (templateType === 'database') {
31-
setDisplayedTemplates(databaseTemplates);
35+
setDisplayedTemplates(databaseTemplates.sort((a, b) => a.name.localeCompare(b.name)));
3236
}
3337
if (templateType === 'template') {
34-
setDisplayedTemplates(appTemplates);
38+
setDisplayedTemplates(appTemplates.sort((a, b) => a.name.localeCompare(b.name)));
3539
}
3640
}, [templateType]);
3741

42+
const filteredTemplates = displayedTemplates.filter(template =>
43+
template.name.toLowerCase().includes(searchQuery.toLowerCase())
44+
);
45+
3846
return (
3947
<>
4048
<CreateTemplateAppSetupDialog appTemplate={chosenAppTemplate} projectId={projectId}
@@ -55,19 +63,34 @@ export default function ChooseTemplateDialog({
5563
Choose a Template you want to deploy.
5664
</DialogDescription>
5765
</DialogHeader>
58-
<ScrollArea className="max-h-[70vh]">
66+
<div className="relative mb-4">
67+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
68+
<Input
69+
type="text"
70+
placeholder="Search templates..."
71+
value={searchQuery}
72+
onChange={(e) => setSearchQuery(e.target.value)}
73+
className="pl-10"
74+
/>
75+
</div>
76+
<ScrollArea className="max-h-[60vh]">
5977
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 px-1">
60-
{displayedTemplates.map((template) => (
61-
<div key={template.name}
62-
className="h-42 grid grid-cols-1 gap-4 items-center bg-white rounded-md p-4 border border-gray-200 text-center hover:bg-slate-50 active:bg-slate-100 transition-all cursor-pointer"
63-
onClick={() => {
64-
setIsOpen(false);
65-
setChosenAppTemplate(template);
66-
}} >
67-
{template.iconName && <img src={`/template-icons/${template.iconName}`} className="h-10 mx-auto" />}
68-
<h3 className="text-lg font-semibold">{template.name}</h3>
69-
</div>
70-
))}
78+
{filteredTemplates.map((template) => {
79+
const isUrl = template.iconName?.startsWith('http://') || template.iconName?.startsWith('https://');
80+
const iconSrc = template.iconName ? (isUrl ? template.iconName : `/template-icons/${template.iconName}`) : undefined;
81+
82+
return (
83+
<div key={template.name}
84+
className="h-42 grid grid-cols-1 gap-4 items-center bg-white rounded-md p-4 border border-gray-200 text-center hover:bg-slate-50 active:bg-slate-100 transition-all cursor-pointer"
85+
onClick={() => {
86+
setIsOpen(false);
87+
setChosenAppTemplate(template);
88+
}} >
89+
{iconSrc && <img src={iconSrc} className="h-10 mx-auto" />}
90+
<h3 className="text-lg font-semibold">{template.name}</h3>
91+
</div>
92+
);
93+
})}
7194
</div>
7295
</ScrollArea>
7396
</DialogContent>

src/app/project/app/[appId]/credentials/db-crendentials.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,21 @@ export default function DbCredentials({
3737
<CardContent>
3838
{!databaseCredentials ? <FullLoadingSpinner /> : <>
3939
<div className="grid grid-cols-2 gap-4">
40-
<CopyInputField
40+
{!!databaseCredentials?.databaseName && <> <CopyInputField
4141
label="Database Name"
4242
value={databaseCredentials?.databaseName || ''} />
4343

44-
<div></div>
44+
<div></div>
45+
</>}
4546

46-
<CopyInputField
47+
{!!databaseCredentials?.username && <CopyInputField
4748
label="Username"
48-
value={databaseCredentials?.username || ''} />
49+
value={databaseCredentials?.username || ''} />}
4950

50-
<CopyInputField
51+
{!!databaseCredentials?.password && <CopyInputField
5152
label="Password"
5253
secret={true}
53-
value={databaseCredentials?.password || ''} />
54-
54+
value={databaseCredentials?.password || ''} />}
5555

5656
<CopyInputField
5757
label="Internal Hostname"

0 commit comments

Comments
 (0)