293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Alert,
|
|
Autocomplete,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
CircularProgress,
|
|
Container,
|
|
FormControl,
|
|
Grid,
|
|
InputLabel,
|
|
MenuItem,
|
|
Select,
|
|
Stack,
|
|
TextField,
|
|
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 healthQuery = useQuery({ queryKey: ["health"], queryFn: api.health });
|
|
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 publishers = useMemo(
|
|
() => [...(publishersQuery.data ?? [])].sort((a, b) => a.localeCompare(b)),
|
|
[publishersQuery.data]
|
|
);
|
|
const offers = useMemo(
|
|
() => [...(offersQuery.data ?? [])].sort((a, b) => a.localeCompare(b)),
|
|
[offersQuery.data]
|
|
);
|
|
const skus = useMemo(
|
|
() => [...(skusQuery.data ?? [])].sort((a, b) => a.localeCompare(b)),
|
|
[skusQuery.data]
|
|
);
|
|
const versions = useMemo(
|
|
() => [...(versionsQuery.data ?? [])].sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" })),
|
|
[versionsQuery.data]
|
|
);
|
|
|
|
const containsFilter = (options: string[], inputValue: string) => {
|
|
const query = inputValue.trim().toLowerCase();
|
|
if (!query) {
|
|
return options;
|
|
}
|
|
|
|
return options.filter((option) => option.toLowerCase().includes(query));
|
|
};
|
|
|
|
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;
|
|
const healthError = healthQuery.error instanceof Error ? healthQuery.error.message : "";
|
|
const appNotConfigured = healthError.includes("Missing AZURE_SUBSCRIPTION_ID");
|
|
const locationsError = locationsQuery.error instanceof Error ? locationsQuery.error.message : "Failed to load locations";
|
|
|
|
return (
|
|
<Box sx={{ minHeight: "100vh", py: 4 }}>
|
|
<Container maxWidth="lg">
|
|
<Stack spacing={3}>
|
|
<Typography variant="h4" sx={{ fontWeight: 500 }}>
|
|
Azure Image Chooser
|
|
</Typography>
|
|
<Typography color="text.secondary">
|
|
Select a marketplace image and generate reusable snippets.
|
|
</Typography>
|
|
|
|
{appNotConfigured ? (
|
|
<Alert severity="warning">
|
|
App is not configured. Set AZURE_SUBSCRIPTION_ID (and Azure credentials) in the container start environment, then restart the app.
|
|
</Alert>
|
|
) : null}
|
|
|
|
{loading ? (
|
|
<CircularProgress />
|
|
) : locationsQuery.isError ? (
|
|
<Alert severity={appNotConfigured ? "warning" : "error"}>{locationsError}</Alert>
|
|
) : (
|
|
<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 }}>
|
|
<Autocomplete
|
|
options={publishers}
|
|
value={selection.publisher || null}
|
|
disabled={!selection.location}
|
|
onChange={(_event, value) => setSelection((prev) => ({ ...prev, publisher: value ?? "" }))}
|
|
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
|
|
renderInput={(params) => <TextField {...params} label="Publisher" />}
|
|
/>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12, md: 4 }}>
|
|
<Autocomplete
|
|
options={offers}
|
|
value={selection.offer || null}
|
|
disabled={!selection.publisher}
|
|
onChange={(_event, value) => setSelection((prev) => ({ ...prev, offer: value ?? "" }))}
|
|
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
|
|
renderInput={(params) => <TextField {...params} label="Offer" />}
|
|
/>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12, md: 4 }}>
|
|
<Autocomplete
|
|
options={skus}
|
|
value={selection.sku || null}
|
|
disabled={!selection.offer}
|
|
onChange={(_event, value) => setSelection((prev) => ({ ...prev, sku: value ?? "" }))}
|
|
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
|
|
renderInput={(params) => <TextField {...params} label="SKU" />}
|
|
/>
|
|
</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 }))}
|
|
>
|
|
{versions.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%" }} color="primary" 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: "grey.100", color: "text.primary", borderRadius: 1, border: 1, borderColor: "divider" }}
|
|
>
|
|
{renderedUsage}
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
{skuExport ? (
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="h6">Available SKUs</Typography>
|
|
<Box
|
|
component="pre"
|
|
sx={{ overflowX: "auto", p: 2, bgcolor: "grey.100", color: "text.primary", borderRadius: 1, border: 1, borderColor: "divider" }}
|
|
>
|
|
{skuExport}
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
</Stack>
|
|
</Container>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default App;
|