AI scaffolded NodeJS version of the App.
This commit is contained in:
12
app-new/frontend/index.html
Normal file
12
app-new/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Azure Image Chooser</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4355
app-new/frontend/package-lock.json
generated
Normal file
4355
app-new/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
app-new/frontend/package.json
Normal file
31
app-new/frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "azure-image-chooser-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/material": "^9.0.0",
|
||||
"@tanstack/react-query": "^5.99.2",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"jsdom": "^27.0.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.8",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
267
app-new/frontend/src/App.tsx
Normal file
267
app-new/frontend/src/App.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Container,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "./api";
|
||||
import type { SelectionState } from "./types";
|
||||
|
||||
const EMPTY_SELECTION: SelectionState = {
|
||||
location: "",
|
||||
publisher: "",
|
||||
offer: "",
|
||||
sku: "",
|
||||
version: ""
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const [selection, setSelection] = useState<SelectionState>(EMPTY_SELECTION);
|
||||
const [templateFile, setTemplateFile] = useState("");
|
||||
const [renderedUsage, setRenderedUsage] = useState("");
|
||||
const [skuExport, setSkuExport] = useState("");
|
||||
const [renderError, setRenderError] = useState("");
|
||||
|
||||
const locationsQuery = useQuery({ queryKey: ["locations"], queryFn: api.locations });
|
||||
const publishersQuery = useQuery({
|
||||
queryKey: ["publishers", selection.location],
|
||||
queryFn: () => api.publishers(selection.location),
|
||||
enabled: Boolean(selection.location)
|
||||
});
|
||||
const offersQuery = useQuery({
|
||||
queryKey: ["offers", selection.location, selection.publisher],
|
||||
queryFn: () => api.offers(selection.location, selection.publisher),
|
||||
enabled: Boolean(selection.location && selection.publisher)
|
||||
});
|
||||
const skusQuery = useQuery({
|
||||
queryKey: ["skus", selection.location, selection.publisher, selection.offer],
|
||||
queryFn: () => api.skus(selection.location, selection.publisher, selection.offer),
|
||||
enabled: Boolean(selection.location && selection.publisher && selection.offer)
|
||||
});
|
||||
const versionsQuery = useQuery({
|
||||
queryKey: ["versions", selection.location, selection.publisher, selection.offer, selection.sku],
|
||||
queryFn: () => api.versions(selection.location, selection.publisher, selection.offer, selection.sku),
|
||||
enabled: Boolean(selection.location && selection.publisher && selection.offer && selection.sku)
|
||||
});
|
||||
const templatesQuery = useQuery({ queryKey: ["templates"], queryFn: api.templates });
|
||||
|
||||
useEffect(() => {
|
||||
setSelection((previous) => ({ ...previous, publisher: "", offer: "", sku: "", version: "" }));
|
||||
setRenderedUsage("");
|
||||
setSkuExport("");
|
||||
}, [selection.location]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelection((previous) => ({ ...previous, offer: "", sku: "", version: "" }));
|
||||
setRenderedUsage("");
|
||||
setSkuExport("");
|
||||
}, [selection.publisher]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelection((previous) => ({ ...previous, sku: "", version: "" }));
|
||||
setRenderedUsage("");
|
||||
}, [selection.offer]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelection((previous) => ({ ...previous, version: "" }));
|
||||
setRenderedUsage("");
|
||||
}, [selection.sku]);
|
||||
|
||||
const canRender = useMemo(
|
||||
() => Boolean(selection.location && selection.publisher && selection.offer && selection.sku && selection.version && templateFile),
|
||||
[selection, templateFile]
|
||||
);
|
||||
|
||||
const onRender = async () => {
|
||||
setRenderError("");
|
||||
try {
|
||||
const [usage, skuBlock] = await Promise.all([
|
||||
api.render(templateFile, selection),
|
||||
api.skuExport(selection.location, selection.publisher, selection.offer)
|
||||
]);
|
||||
setRenderedUsage(usage);
|
||||
setSkuExport(skuBlock);
|
||||
} catch (error) {
|
||||
setRenderError(error instanceof Error ? error.message : "Unexpected render error");
|
||||
}
|
||||
};
|
||||
|
||||
const loading = locationsQuery.isLoading || templatesQuery.isLoading;
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: "100vh", py: 6 }}>
|
||||
<Container maxWidth="lg">
|
||||
<Stack spacing={3}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 700 }}>
|
||||
Azure Image Chooser
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Select a marketplace image and generate reusable snippets.
|
||||
</Typography>
|
||||
|
||||
{loading ? (
|
||||
<CircularProgress />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="location-label">Location</InputLabel>
|
||||
<Select
|
||||
labelId="location-label"
|
||||
label="Location"
|
||||
value={selection.location}
|
||||
onChange={(event) => setSelection((prev) => ({ ...prev, location: event.target.value }))}
|
||||
>
|
||||
{(locationsQuery.data ?? []).map((location) => (
|
||||
<MenuItem key={location.name} value={location.name}>
|
||||
{location.displayName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<FormControl fullWidth disabled={!selection.location}>
|
||||
<InputLabel id="publisher-label">Publisher</InputLabel>
|
||||
<Select
|
||||
labelId="publisher-label"
|
||||
label="Publisher"
|
||||
value={selection.publisher}
|
||||
onChange={(event) => setSelection((prev) => ({ ...prev, publisher: event.target.value }))}
|
||||
>
|
||||
{(publishersQuery.data ?? []).map((publisher) => (
|
||||
<MenuItem key={publisher} value={publisher}>
|
||||
{publisher}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<FormControl fullWidth disabled={!selection.publisher}>
|
||||
<InputLabel id="offer-label">Offer</InputLabel>
|
||||
<Select
|
||||
labelId="offer-label"
|
||||
label="Offer"
|
||||
value={selection.offer}
|
||||
onChange={(event) => setSelection((prev) => ({ ...prev, offer: event.target.value }))}
|
||||
>
|
||||
{(offersQuery.data ?? []).map((offer) => (
|
||||
<MenuItem key={offer} value={offer}>
|
||||
{offer}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<FormControl fullWidth disabled={!selection.offer}>
|
||||
<InputLabel id="sku-label">SKU</InputLabel>
|
||||
<Select
|
||||
labelId="sku-label"
|
||||
label="SKU"
|
||||
value={selection.sku}
|
||||
onChange={(event) => setSelection((prev) => ({ ...prev, sku: event.target.value }))}
|
||||
>
|
||||
{(skusQuery.data ?? []).map((sku) => (
|
||||
<MenuItem key={sku} value={sku}>
|
||||
{sku}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<FormControl fullWidth disabled={!selection.sku}>
|
||||
<InputLabel id="version-label">Version</InputLabel>
|
||||
<Select
|
||||
labelId="version-label"
|
||||
label="Version"
|
||||
value={selection.version}
|
||||
onChange={(event) => setSelection((prev) => ({ ...prev, version: event.target.value }))}
|
||||
>
|
||||
{(versionsQuery.data ?? []).map((version) => (
|
||||
<MenuItem key={version} value={version}>
|
||||
{version}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="template-label">Usage scenario</InputLabel>
|
||||
<Select
|
||||
labelId="template-label"
|
||||
label="Usage scenario"
|
||||
value={templateFile}
|
||||
onChange={(event) => setTemplateFile(event.target.value)}
|
||||
>
|
||||
{(templatesQuery.data ?? []).map((template) => (
|
||||
<MenuItem key={template.file} value={template.file}>
|
||||
{template.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Button fullWidth variant="contained" sx={{ height: "100%" }} onClick={onRender} disabled={!canRender}>
|
||||
Generate usage
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{renderError ? <Alert severity="error">{renderError}</Alert> : null}
|
||||
|
||||
{renderedUsage ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">Usage snippet</Typography>
|
||||
<Box component="pre" sx={{ overflowX: "auto", p: 2, bgcolor: "#0f172a", color: "#e2e8f0", borderRadius: 2 }}>
|
||||
{renderedUsage}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{skuExport ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">Available SKUs</Typography>
|
||||
<Box component="pre" sx={{ overflowX: "auto", p: 2, bgcolor: "#111827", color: "#d1fae5", borderRadius: 2 }}>
|
||||
{skuExport}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
46
app-new/frontend/src/api.ts
Normal file
46
app-new/frontend/src/api.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { LocationOption, SelectionState, UsageTemplate } from "./types";
|
||||
|
||||
const json = async <T>(path: string): Promise<T> => {
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed for ${path}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
};
|
||||
|
||||
export const api = {
|
||||
locations: () => json<LocationOption[]>("/api/locations"),
|
||||
publishers: (location: string) => json<string[]>(`/api/publishers?location=${encodeURIComponent(location)}`),
|
||||
offers: (location: string, publisher: string) =>
|
||||
json<string[]>(`/api/offers?location=${encodeURIComponent(location)}&publisher=${encodeURIComponent(publisher)}`),
|
||||
skus: (location: string, publisher: string, offer: string) =>
|
||||
json<string[]>(
|
||||
`/api/skus?location=${encodeURIComponent(location)}&publisher=${encodeURIComponent(publisher)}&offer=${encodeURIComponent(offer)}`
|
||||
),
|
||||
versions: (location: string, publisher: string, offer: string, sku: string) =>
|
||||
json<string[]>(
|
||||
`/api/versions?location=${encodeURIComponent(location)}&publisher=${encodeURIComponent(publisher)}&offer=${encodeURIComponent(offer)}&sku=${encodeURIComponent(sku)}`
|
||||
),
|
||||
templates: () => json<UsageTemplate[]>("/api/templates"),
|
||||
render: async (templateFile: string, selection: SelectionState): Promise<string> => {
|
||||
const response = await fetch("/api/render", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ templateFile, selection })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to render template");
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { rendered: string };
|
||||
return payload.rendered;
|
||||
},
|
||||
skuExport: async (location: string, publisher: string, offer: string): Promise<string> => {
|
||||
const payload = await json<{ rendered: string }>(
|
||||
`/api/sku-export?location=${encodeURIComponent(location)}&publisher=${encodeURIComponent(publisher)}&offer=${encodeURIComponent(offer)}`
|
||||
);
|
||||
return payload.rendered;
|
||||
}
|
||||
};
|
||||
33
app-new/frontend/src/main.tsx
Normal file
33
app-new/frontend/src/main.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { CssBaseline, ThemeProvider, createTheme } from "@mui/material";
|
||||
import App from "./App";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: "light",
|
||||
primary: { main: "#00695f" },
|
||||
secondary: { main: "#0d47a1" },
|
||||
background: {
|
||||
default: "#f4f8f7",
|
||||
paper: "#ffffff"
|
||||
}
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12
|
||||
}
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
18
app-new/frontend/src/types.ts
Normal file
18
app-new/frontend/src/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type LocationOption = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type UsageTemplate = {
|
||||
label: string;
|
||||
language: string;
|
||||
file: string;
|
||||
};
|
||||
|
||||
export type SelectionState = {
|
||||
location: string;
|
||||
publisher: string;
|
||||
offer: string;
|
||||
sku: string;
|
||||
version: string;
|
||||
};
|
||||
48
app-new/frontend/test/app.test.tsx
Normal file
48
app-new/frontend/test/app.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import App from "../src/App";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
const createResponse = (payload: unknown) =>
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
|
||||
describe("App", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/locations") {
|
||||
return Promise.resolve(createResponse([{ name: "westeurope", displayName: "West Europe" }]));
|
||||
}
|
||||
|
||||
if (url === "/api/templates") {
|
||||
return Promise.resolve(createResponse([{ label: "Azure CLI", language: "shell", file: "shell.tpl" }]));
|
||||
}
|
||||
|
||||
return Promise.resolve(createResponse([]));
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("renders application heading", async () => {
|
||||
const client = new QueryClient();
|
||||
const theme = createTheme();
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Azure Image Chooser")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
1
app-new/frontend/test/setup.ts
Normal file
1
app-new/frontend/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
15
app-new/frontend/tsconfig.json
Normal file
15
app-new/frontend/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["vite/client", "vitest/globals"]
|
||||
},
|
||||
"include": ["src", "test", "vite.config.ts"]
|
||||
}
|
||||
19
app-new/frontend/vite.config.ts
Normal file
19
app-new/frontend/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "../../dist/frontend",
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3000",
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
8
app-new/frontend/vitest.config.ts
Normal file
8
app-new/frontend/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./test/setup.ts"]
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user