feat: support packer config

This commit is contained in:
ReaJason
2026-02-26 22:55:45 +08:00
parent 0d3b56de63
commit 9243070126
85 changed files with 2410 additions and 658 deletions
@@ -0,0 +1,147 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
} from "@/components/ui/combobox";
import type { NormalizedPackerCategory } from "@/lib/packer-schema";
type PackerComboboxProps = {
categories: NormalizedPackerCategory[];
value?: string;
onValueChange: (value: string) => void;
placeholder?: string;
emptyText?: string;
disabled?: boolean;
};
type PreparedPacker = {
name: string;
label: string;
searchText: string;
};
type PreparedCategory = {
name: string;
label: string;
packers: PreparedPacker[];
};
export function PackerCombobox({
categories,
value,
onValueChange,
placeholder,
emptyText,
disabled,
}: Readonly<PackerComboboxProps>) {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const preparedCategories = useMemo<PreparedCategory[]>(
() =>
categories.map((category) => {
const categoryLabel = t(category.name, { defaultValue: category.name });
return {
name: category.name,
label: categoryLabel,
packers: category.packers.map((packer) => {
const packerLabel = t(packer.name, { defaultValue: packer.name });
return {
name: packer.name,
label: packerLabel,
searchText:
`${packer.name} ${packerLabel} ${category.name} ${categoryLabel}`.toLowerCase(),
};
}),
};
}),
[categories, t],
);
const selectedLabel = useMemo(() => {
if (!value) {
return "";
}
for (const category of preparedCategories) {
const found = category.packers.find((packer) => packer.name === value);
if (found) {
return found.label;
}
}
return value;
}, [preparedCategories, value]);
const filteredCategories = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return preparedCategories;
}
return preparedCategories
.map((category) => ({
...category,
packers: category.packers.filter((packer) =>
packer.searchText.includes(normalizedQuery),
),
}))
.filter((category) => category.packers.length > 0);
}, [preparedCategories, query]);
const resolvedPlaceholder =
placeholder ?? t("selectPacker", { defaultValue: "Select packer" });
return (
<Combobox
value={value ?? null}
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
setQuery("");
}}
inputValue={open ? query : selectedLabel}
onInputValueChange={(nextValue) => {
if (open) {
setQuery(nextValue);
}
}}
onValueChange={(nextValue) => {
if (typeof nextValue === "string") {
onValueChange(nextValue);
}
setOpen(false);
setQuery("");
}}
autoComplete="none"
autoHighlight={true}
>
<ComboboxInput
className="w-full"
placeholder={resolvedPlaceholder}
disabled={disabled}
showClear={false}
/>
<ComboboxContent>
<ComboboxList>
{filteredCategories.map((category) => (
<ComboboxGroup key={category.name}>
<ComboboxLabel>{category.label}</ComboboxLabel>
{category.packers.map((packer) => (
<ComboboxItem key={packer.name} value={packer.name}>
<span className="truncate" title={packer.label}>
{packer.label}
</span>
</ComboboxItem>
))}
</ComboboxGroup>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
@@ -0,0 +1,205 @@
import {
Controller,
type FieldValues,
type UseFormReturn,
} from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type { PackerSchemaField } from "@/types/memshell";
type Props<T extends FieldValues> = {
form: UseFormReturn<T>;
fields: PackerSchemaField[];
baseName?: string;
};
export function PackerCustomConfigFields<T extends FieldValues>({
form,
fields,
baseName = "packerCustomConfig",
}: Readonly<Props<T>>) {
const { t } = useTranslation("common");
if (fields.length === 0) {
return null;
}
const supportedFields = fields.filter((field) =>
["BOOLEAN", "STRING", "ENUM", "INTEGER"].includes(field.type),
);
if (supportedFields.length === 0) {
return null;
}
const getFieldDescription = (schemaField: PackerSchemaField) => {
if (!schemaField.description && !schemaField.descriptionI18nKey) {
return undefined;
}
if (!schemaField.descriptionI18nKey) {
return schemaField.description;
}
return t(schemaField.descriptionI18nKey, {
defaultValue: schemaField.description ?? schemaField.descriptionI18nKey,
});
};
return (
<Field className="mt-2 gap-1">
<FieldLabel>
{t("packerParams", { defaultValue: "Packer Params" })}
</FieldLabel>
{supportedFields.map((schemaField) => {
const fieldName = `${baseName}.${schemaField.key}` as any;
const fieldDescription = getFieldDescription(schemaField);
return (
<Controller
key={schemaField.key}
control={form.control}
name={fieldName}
render={({ field }) => {
switch (schemaField.type) {
case "BOOLEAN":
return (
<Field orientation="horizontal">
<Switch
id={fieldName}
checked={Boolean(field.value)}
onCheckedChange={field.onChange}
/>
<FieldContent>
<FieldLabel htmlFor={fieldName}>
{schemaField.key}
</FieldLabel>
{fieldDescription ? (
<FieldDescription>
{fieldDescription}
</FieldDescription>
) : null}
</FieldContent>
</Field>
);
case "ENUM":
return (
<Field orientation="vertical">
<FieldContent>
<FieldLabel htmlFor={fieldName}>
{schemaField.key}
</FieldLabel>
<Select
value={
typeof field.value === "string"
? field.value
: undefined
}
onValueChange={field.onChange}
>
<SelectTrigger id={fieldName}>
<SelectValue
data-placeholder={t("placeholders.select")}
/>
</SelectTrigger>
<SelectContent>
{(schemaField.options ?? []).map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.label || option.value}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldDescription ? (
<FieldDescription>
{fieldDescription}
</FieldDescription>
) : null}
</FieldContent>
</Field>
);
case "INTEGER":
return (
<Field orientation="vertical">
<FieldContent>
<FieldLabel htmlFor={fieldName}>
{schemaField.key}
</FieldLabel>
<Input
id={fieldName}
type="number"
step={1}
value={
typeof field.value === "number"
? String(field.value)
: ""
}
onChange={(event) => {
const raw = event.target.value;
if (raw === "") {
field.onChange(undefined);
return;
}
const parsed = Number.parseInt(raw, 10);
field.onChange(
Number.isFinite(parsed) ? parsed : undefined,
);
}}
/>
{fieldDescription ? (
<FieldDescription>
{fieldDescription}
</FieldDescription>
) : null}
</FieldContent>
</Field>
);
case "STRING":
return (
<Field orientation="vertical">
<FieldContent>
<FieldLabel htmlFor={fieldName}>
{schemaField.key}
</FieldLabel>
<Input
id={fieldName}
type="text"
value={
typeof field.value === "string" ? field.value : ""
}
onChange={field.onChange}
/>
{fieldDescription ? (
<FieldDescription>
{fieldDescription}
</FieldDescription>
) : null}
</FieldContent>
</Field>
);
default:
return <></>;
}
}}
/>
);
})}
</Field>
);
}