Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,184 @@
import { useTranslation } from "react-i18next";
import { useRef, useState } from "react";
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
import JsonPropertyForm from "./JsonPropertyForm";
import TrashIcon from "~/components/ui/icons/TrashIcon";
import { JsonPropertyDto } from "~/modules/jsonProperties/dtos/JsonPropertyTypeDto";
import OrderListButtons from "~/components/ui/sort/OrderListButtons";
import JsonPropertyTypeIcon from "./JsonPropertyTypeIcon";
import PencilIcon from "~/components/ui/icons/PencilIcon";
import NewFieldIcon from "~/components/ui/icons/NewFieldIcon";
import { useSubmit } from "react-router";
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
interface Props {
items: JsonPropertyDto[];
}
export default function JsonPropertiesTable({ items }: Props) {
const { t } = useTranslation();
const submit = useSubmit();
const confirmDelete = useRef<RefConfirmModal>(null);
const [selectedProperty, setSelectedProperty] = useState<JsonPropertyDto | null | undefined>(undefined);
function onDelete(item: JsonPropertyDto) {
confirmDelete.current?.setValue(item);
confirmDelete.current?.show(t("shared.confirmDelete"), t("shared.delete"), t("shared.cancel"), t("shared.warningCannotUndo"));
}
function onDeleteConfirmed(item: JsonPropertyDto) {
const form = new FormData();
form.set("action", "properties-delete");
form.set("name", item.name);
submit(form, {
method: "post",
});
}
return (
<div className="space-y-1">
{/* <div className="flex justify-between space-x-2">
<label className="text-xs font-medium">{t("models.jsonProperty.plural")}</label>
<button
type="button"
className="bg-secondary hover:bg-secondary hover:text-secondary-foreground hover:border-border text-muted-foreground rounded-md border border-transparent p-1 px-2 text-sm font-medium"
onClick={() => setSelectedProperty(null)}
>
{t("shared.add")}
</button>
</div> */}
{items.map((item, idx) => {
return <input key={idx} type="hidden" name="properties[]" value={JSON.stringify(item)} readOnly hidden />;
})}
<div className="space-y-1">
{items.map((item, idx) => {
return (
<div key={item.name} className="border-border bg-background rounded-md border px-4 py-1 shadow-2xs">
<div className="flex items-center justify-between space-x-2">
<div className="flex items-center space-x-2 truncate">
<div className=" flex items-center space-x-3 truncate">
<div className="hidden shrink-0 sm:flex">
<OrderListButtons
index={idx}
items={items.map((f, idx) => ({ ...f, id: f.name, order: idx }))}
editable={true}
actionName="properties-sort"
/>
</div>
<div className="flex items-center space-x-2">
<JsonPropertyTypeIcon type={item.type} className="text-muted-foreground h-4 w-4" />
</div>
<div className="text-foreground flex items-center space-x-2 truncate text-sm">
<div className="flex items-baseline space-x-1 truncate">
<div className="flex flex-col">
<div>
{item.title}
{item.required && <span className="text-red-500">*</span>}
</div>
<div className="text-muted-foreground text-xs">
{item.name}
{["select", "multiselect"].includes(item.type) && "[]"}
{["range-date", "range-number"].includes(item.type) && ` (${t("models.jsonProperty.badges.range")})`}
</div>
</div>
{/* {item.type === PropertyType.FORMULA && <div className="truncate italic text-muted-foreground">({item.formula})</div>} */}
{["select", "multiselect"].includes(item.type) && (
<div className="text-muted-foreground truncate text-xs">
{t("shared.options")}: [{!item.options || item.options.length === 0 ? "" : item.options?.map((f) => f.value).join(", ")}]
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex shrink-0 space-x-1">
<div className="flex items-center space-x-1 truncate p-1">
<button
type="button"
onClick={() => setSelectedProperty(item)}
className="hover:bg-secondary/90 focus:bg-secondary/90 group flex items-center rounded-md border border-transparent p-2 focus:outline-hidden focus:ring-2 focus:ring-gray-400 focus:ring-offset-1"
// onClick={() => update(idx, item)}
>
<PencilIcon className="group-hover:text-muted-foreground h-4 w-4 text-gray-300" />
</button>
<button
type="button"
className="hover:bg-secondary/90 focus:bg-secondary/90 group flex items-center rounded-md border border-transparent p-2 focus:outline-hidden focus:ring-2 focus:ring-gray-400 focus:ring-offset-1"
onClick={() => onDelete(item)}
>
<TrashIcon className="group-hover:text-muted-foreground h-4 w-4 text-gray-300" />
</button>
</div>
</div>
</div>
</div>
);
})}
<div>
<button
type="button"
onClick={() => setSelectedProperty(null)}
className="focus:ring-ring border-border hover:border-border relative block w-full rounded-lg border-2 border-dashed px-12 py-3 text-center focus:outline-hidden focus:ring-2 focus:ring-offset-2"
>
<NewFieldIcon className="text-muted-foreground mx-auto h-4" />
<span className="mt-1 block text-xs font-medium">{t("models.jsonProperty.actions.add")}</span>
</button>
</div>
</div>
{/* <TableSimple
items={items}
emptyState={{ title: t("shared.noCustomProperties"), description: "" }}
actions={[
{
title: t("shared.edit"),
onClick: (_, item) => setSelectedProperty(item),
},
{
title: <TrashIcon className="text-muted-foreground group-hover:text-foreground h-4 w-4" />,
onClick: (_, item) => onChange(items.filter((i) => i !== item)),
},
]}
headers={[
{
name: "type",
title: t("models.jsonProperty.fields.type"),
value: (item) => <JsonPropertyTypeIcon type={item.type} className="h-4 w-4 text-muted-foreground" />,
},
{
name: "name",
title: t("models.jsonProperty.fields.name"),
className: "w-full",
value: (item) => (
<div className="flex flex-col">
<div className="font-medium">
{item.title} <span className="text-muted-foreground text-xs">({item.name})</span>
</div>
</div>
),
},
{
name: "required",
title: t("shared.required"),
value: (item) => (item.required ? <CheckIcon className="text-muted-foreground h-4 w-4" /> : <XIcon className="text-muted-foreground h-4 w-4" />),
},
]}
/> */}
<SlideOverWideEmpty
title={selectedProperty === null ? t("shared.new") : t("shared.edit")}
open={selectedProperty !== undefined}
onClose={() => setSelectedProperty(undefined)}
size="md"
>
{selectedProperty !== undefined && <JsonPropertyForm item={selectedProperty} onClose={() => setSelectedProperty(undefined)} />}
</SlideOverWideEmpty>
<ConfirmModal ref={confirmDelete} destructive onYes={onDeleteConfirmed} />
</div>
);
}
@@ -0,0 +1,329 @@
import { useEffect, useRef, useState } from "react";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import StringUtils from "~/utils/shared/StringUtils";
import TableSimple from "~/components/ui/tables/TableSimple";
import { useTranslation } from "react-i18next";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
import InputSelect from "~/components/ui/input/InputSelect";
import { Form, useFetcher } from "react-router";
import { JsonPropertyDto, JsonPropertyType, JsonPropertyTypes } from "~/modules/jsonProperties/dtos/JsonPropertyTypeDto";
import InputCheckbox from "~/components/ui/input/InputCheckbox";
// import {toast} from "sonner";
import toast from "react-hot-toast";
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
import { Button } from "~/components/ui/button";
import TrashIcon from "~/components/ui/icons/TrashIcon";
import PencilIcon from "~/components/ui/icons/PencilIcon";
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
interface Props {
item: JsonPropertyDto | null;
// onUpdate?: (item: JsonPropertyDto) => void;
// onAdd?: (item: JsonPropertyDto) => void;
onClose: () => void;
}
export default function JsonPropertyForm({ item, onClose }: Props) {
const { t } = useTranslation();
const fetcher = useFetcher<{ error?: string; success?: string }>();
const confirmDelete = useRef<RefConfirmModal>(null);
const [selectedOption, setSelectedOption] = useState<{ value: string; name: string } | null>();
const [name, setName] = useState<string>(item ? item.name : "");
const [title, setTitle] = useState<string>(item ? item.title : "");
const [type, setType] = useState<JsonPropertyType>(item ? (item.type as any) : "string");
const [options, setOptions] = useState<{ value: string; name: string }[]>(item?.options || []);
const [isRequired, setIsRequired] = useState<boolean>(item ? item.required : false);
useEffect(() => {
if (fetcher.data?.success) {
toast.success(fetcher.data.success);
onClose();
} else if (fetcher.data?.error) {
toast.error(fetcher.data.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher.data]);
const mainInput = useRef<RefInputText>(null);
useEffect(() => {
setTimeout(() => {
mainInput.current?.input.current?.focus();
}, 100);
}, []);
useEffect(() => {
if (!item) {
if (title.includes(".")) {
const keys = title.split(".");
setName(StringUtils.toCamelCase(keys[keys.length - 1].toLowerCase()));
} else {
setName(StringUtils.toCamelCase(title.toLowerCase()));
}
}
}, [item, title, type]);
function onAccept() {
if (item === null) {
const form = new FormData();
form.append("action", "properties-add");
form.set(
"property",
JSON.stringify({
name,
title,
type,
required: isRequired,
options: type === "select" || type === "multiselect" ? options : null,
})
);
fetcher.submit(form, {
method: "post",
});
// if (onAdd) {
// onAdd({
// name,
// title,
// type,
// required: isRequired,
// options: type === "select" || type === "multiselect" ? options : null,
// });
// }
} else {
const form = new FormData();
form.append("action", "properties-edit");
form.set("name", item.name);
form.set(
"property",
JSON.stringify({
...item,
name,
title,
type,
options: type === "select" || type === "multiselect" ? options : null,
required: isRequired,
})
);
fetcher.submit(form, {
method: "post",
});
// if (onUpdate) {
// onUpdate({
// ...item,
// name,
// title,
// type,
// options: type === "select" || type === "multiselect" ? options : null,
// required: isRequired,
// });
// }
}
}
function onSubmit(e: React.FormEvent) {
e.preventDefault();
onAccept();
}
function onDelete(item: JsonPropertyDto) {
confirmDelete.current?.setValue(item);
confirmDelete.current?.show(t("shared.confirmDelete"), t("shared.delete"), t("shared.cancel"), t("shared.warningCannotUndo"));
}
function onDeleteConfirmed(item: JsonPropertyDto) {
const form = new FormData();
form.set("action", "properties-delete");
form.set("name", item.name);
fetcher.submit(form, {
method: "post",
});
}
return (
<div>
<Form onSubmit={onSubmit}>
<div className="mb-4 space-y-2">
<div>
<InputText ref={mainInput} name="title" title={t("models.jsonProperty.title")} value={title} setValue={setTitle} required />
</div>
<div>
<InputText name="name" title={t("models.jsonProperty.name")} value={name} setValue={setName} required />
</div>
<div>
<InputSelect
name="type"
title={t("models.jsonProperty.type")}
value={type}
setValue={(e) => setType(e as JsonPropertyType)}
options={JsonPropertyTypes.map((property) => ({
name: t(`models.jsonProperty.types.${property.value}`),
value: property.value,
}))}
required
/>
</div>
{(type === "select" || type === "multiselect") && (
<div className="space-y-1">
{/* Caption for an options table, not a single form control. */}
<div className="text-xs font-medium">{t("models.jsonProperty.options")}</div>
<div>
<TableSimple
items={options}
actions={[
{
title: t("shared.edit"),
onClick: (_, i) => setSelectedOption(i),
renderTitle: (_) => <PencilIcon className="group-hover:text-muted-foreground h-4 w-4 text-gray-300" />,
},
{
title: t("shared.remove"),
onClick: (_, i) => setOptions((prev) => prev.filter((o) => o.value !== i.value)),
renderTitle: (_) => <TrashIcon className="text-muted-foreground group-hover:text-foreground h-4 w-4" />,
},
]}
headers={[
{
name: "name",
title: t("shared.name"),
value: (item) => item.name,
},
{
name: "value",
title: t("shared.value"),
className: "w-full",
value: (item) => item.value,
},
]}
/>
<div className="mt-2">
<Button type="button" variant="outline" onClick={() => setSelectedOption(null)}>
{t("shared.add")}
</Button>
</div>
</div>
</div>
)}
<div>
<InputCheckbox name="required" title={t("models.jsonProperty.required")} value={isRequired} setValue={setIsRequired} />
</div>
</div>
<div className="border-border mt-3 flex justify-between border-t pt-3">
<div>
{item && (
<ButtonSecondary destructive onClick={() => onDelete(item)}>
{t("shared.delete")}
</ButtonSecondary>
)}
</div>
<div className="flex items-center space-x-2">
<ButtonSecondary onClick={onClose}>{t("shared.back")}</ButtonSecondary>
<ButtonPrimary type="submit">{t("shared.accept")}</ButtonPrimary>
</div>
</div>
</Form>
<SlideOverWideEmpty
title={selectedOption === null ? t("shared.new") : selectedOption ? t("shared.edit") : ""}
open={selectedOption !== undefined}
onClose={() => setSelectedOption(undefined)}
size="sm"
>
{selectedOption !== undefined && (
<PropertyOptionForm
item={selectedOption}
onClose={() => setSelectedOption(undefined)}
onUpdate={(option) => {
if (selectedOption) {
// onChange(items.map((item) => (item === selectedProperty ? property : item)));
setOptions(options.map((o) => (o === selectedOption ? option : o)));
} else {
setOptions([...options, option]);
}
setSelectedOption(undefined);
}}
onAdd={(option) => {
setOptions([...options, option]);
setSelectedOption(undefined);
}}
/>
)}
</SlideOverWideEmpty>
<ConfirmModal ref={confirmDelete} destructive onYes={onDeleteConfirmed} />
</div>
);
}
function PropertyOptionForm({
item,
onUpdate,
onAdd,
onClose,
}: {
item: { name: string; value: string } | null;
onUpdate: (option: { name: string; value: string }) => void;
onAdd: (option: { name: string; value: string }) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState<string>(item ? item.name : "");
const [value, setValue] = useState<string>(item ? item.value : "");
const mainInput = useRef<RefInputText>(null);
useEffect(() => {
setTimeout(() => {
mainInput.current?.input.current?.focus();
}, 100);
}, []);
useEffect(() => {
if (!item) {
if (name.includes(".")) {
const keys = name.split(".");
setValue(StringUtils.toCamelCase(keys[keys.length - 1].toLowerCase()));
} else {
setValue(StringUtils.toCamelCase(name.toLowerCase()));
}
}
}, [item, name]);
function onAccept() {
if (item === null) {
onAdd({
name,
value,
});
} else {
onUpdate({
...item,
name,
value,
});
}
}
function onSubmit(e: React.FormEvent) {
e.preventDefault();
onAccept();
}
return (
<Form onSubmit={onSubmit} className="space-y-2">
<div>
<InputText ref={mainInput} name="name" title={t("shared.name")} value={name} setValue={setName} required />
</div>
<div>
<InputText name="value" title={t("shared.value")} value={value} setValue={setValue} required />
</div>
<div className="flex justify-between">
<div>
<ButtonSecondary onClick={onClose}>{t("shared.back")}</ButtonSecondary>
</div>
<ButtonPrimary type="submit">{t("shared.accept")}</ButtonPrimary>
</div>
</Form>
);
}
@@ -0,0 +1,30 @@
import { PropertyType } from "~/application/enums/entities/PropertyType";
import PropertyBadge from "~/components/entities/properties/PropertyBadge";
import { JsonPropertyType } from "../dtos/JsonPropertyTypeDto";
import { useTranslation } from "react-i18next";
import { FileTextIcon } from "~/components/ui/icons/vendor";
export default function JsonPropertyTypeIcon({ type, className }: { type: JsonPropertyType; className?: string }) {
const { t } = useTranslation();
switch (type) {
case "string":
return <PropertyBadge type={PropertyType.TEXT} className={className} />;
case "number":
return <PropertyBadge type={PropertyType.NUMBER} className={className} />;
case "boolean":
return <PropertyBadge type={PropertyType.BOOLEAN} className={className} />;
case "date":
return <PropertyBadge type={PropertyType.DATE} className={className} />;
case "image":
return <PropertyBadge type={PropertyType.MEDIA} className={className} />;
case "select":
return <PropertyBadge type={PropertyType.SELECT} className={className} />;
case "multiselect":
return <PropertyBadge type={PropertyType.MULTI_SELECT} className={className} />;
case "wysiwyg":
case "monaco":
case "content":
return <FileTextIcon className={className} />;
}
return <div className="font-medium">{t("models.jsonProperty.types." + type)}</div>;
}
@@ -0,0 +1,64 @@
import DateCell from "~/components/ui/dates/DateCell";
import { JsonValue } from "../dtos/JsonPropertiesValuesDto";
import { JsonPropertyDto } from "../dtos/JsonPropertyTypeDto";
import CheckIcon from "~/components/ui/icons/CheckIcon";
import XIcon from "~/components/ui/icons/XIcon";
export default function JsonPropertyValueCell({ property, value }: { property: JsonPropertyDto; value: JsonValue | undefined }) {
if (value === null || value === undefined) {
return null;
}
switch (property.type) {
case "string":
return value as string;
case "number":
return value as number;
case "boolean":
const booleanValue = value === undefined ? undefined : value === "true" || value === true || value === 1 || value === "1";
if (value === undefined || value === null) {
return null;
}
if (booleanValue) {
return <CheckIcon className="h-4 w-4" />;
} else {
return <XIcon className="h-4 w-4" />;
}
case "image":
if (!value) {
return null;
}
return <img src={value as string} alt="" className="h-8 w-8 object-cover" />;
case "date":
if (!value) {
return null;
}
try {
const date = new Date(value as string);
return <div>{<DateCell date={date} />}</div>;
} catch (e) {
return <div className="text-muted-foreground">{value?.toString()}</div>;
}
case "select":
if (!property.options) {
return value as string;
} else {
const option = property.options.find((o) => o.value === value);
return option?.name || (value as string);
}
case "multiselect":
if (!Array.isArray(value)) {
return null;
}
if (!property.options) {
return <div className="text-muted-foreground">{value.join(", ")}</div>;
} else {
const values = value.map((v) => {
const option = property.options?.find((o) => o.value === v);
return option?.name || v;
});
return values.join(", ");
}
default:
return value as string;
}
}
@@ -0,0 +1,285 @@
import InputText from "~/components/ui/input/InputText";
import { JsonPropertyDto } from "../dtos/JsonPropertyTypeDto";
import { JsonPropertiesValuesDto, JsonValue } from "../dtos/JsonPropertiesValuesDto";
import InputNumber from "~/components/ui/input/InputNumber";
import InputCheckbox from "~/components/ui/input/InputCheckbox";
import InputDate from "~/components/ui/input/InputDate";
import InputImage from "~/components/ui/input/InputImage";
import InputSelect from "~/components/ui/input/InputSelect";
import InputCombobox from "~/components/ui/input/InputCombobox";
import { useTranslation } from "react-i18next";
import { Fragment, lazy, Suspense, useState } from "react";
import clsx from "clsx";
import InputGroup from "~/components/ui/forms/InputGroup";
const NovelEditor = lazy(() => import("~/modules/novel/ui/editor"));
export default function JsonPropertyValuesInput({
prefix = "attributes",
properties,
attributes,
}: {
prefix?: string;
properties: JsonPropertyDto[] | JsonValue | null;
attributes: JsonPropertiesValuesDto | null;
}) {
let propertiesObj = properties as JsonPropertyDto[] | null;
if (!propertiesObj) {
return null;
}
const groups: { name: string; properties: JsonPropertyDto[] }[] = [];
propertiesObj.forEach((property) => {
let group = groups.find((g) => g.name === property.group);
if (!group) {
group = { name: property.group || "", properties: [] };
groups.push(group);
}
group.properties.push(property);
});
return (
<Fragment>
{groups.map((group, idx) => {
return (
<Fragment key={idx}>
{group.name ? (
<InputGroup title={group.name} className="space-y-2">
<GroupInputs prefix={prefix} properties={group.properties} attributes={attributes} />
</InputGroup>
) : (
<GroupInputs prefix={prefix} properties={group.properties} attributes={attributes} />
)}
</Fragment>
);
})}
</Fragment>
);
}
function GroupInputs({ prefix, properties, attributes }: { prefix: string; properties: JsonPropertyDto[]; attributes: JsonPropertiesValuesDto | null }) {
let propertiesObj = properties as JsonPropertyDto[] | null;
return (
<Fragment>
{propertiesObj?.map((property) => {
return (
<div key={property.name}>
<JsonPropertyInput prefix={prefix} property={property} attributes={attributes} />
</div>
);
})}
</Fragment>
);
}
function JsonPropertyInput({ prefix, property, attributes }: { prefix: string; property: JsonPropertyDto; attributes: JsonPropertiesValuesDto | null }) {
const { t } = useTranslation();
let value: JsonValue | undefined = attributes ? attributes[property.name] : undefined;
switch (property.type) {
case "string":
let stringValue = value === undefined ? undefined : (value as string);
const defaultStringValue = property.defaultValue === undefined ? undefined : (property.defaultValue as string);
if (stringValue === undefined && defaultStringValue !== undefined) {
stringValue = defaultStringValue;
}
return (
<div>
<InputText name={`${prefix}[${property.name}]`} title={t(property.title)} defaultValue={stringValue} required={property.required} />
</div>
);
case "number": {
let numberValue = value === undefined ? undefined : (value as number);
const defaultNumberValue = property.defaultValue === undefined ? undefined : (property.defaultValue as number);
if (numberValue === undefined && defaultNumberValue !== undefined) {
numberValue = defaultNumberValue;
}
return (
<div>
<InputNumber name={`${prefix}[${property.name}]`} title={t(property.title)} defaultValue={numberValue} required={property.required} />
</div>
);
}
case "boolean": {
let booleanValue = value === undefined ? undefined : value === "true" || value === true || value === 1 || value === "1";
const defaultBooleanValue =
property.defaultValue === undefined
? undefined
: property.defaultValue === "true" || property.defaultValue === true || property.defaultValue === 1 || property.defaultValue === "1";
if (booleanValue === undefined && defaultBooleanValue !== undefined) {
booleanValue = defaultBooleanValue;
}
return (
<div>
<InputCheckbox name={`${prefix}[${property.name}]`} title={t(property.title)} value={booleanValue} required={property.required} />
</div>
);
}
case "date": {
let dateValue = !value ? undefined : new Date(value as string);
const defaultDateValue = property.defaultValue === undefined ? undefined : new Date(property.defaultValue as string);
if (dateValue === undefined && defaultDateValue !== undefined) {
dateValue = defaultDateValue;
}
return (
<div>
<InputDate name={`${prefix}[${property.name}]`} title={t(property.title)} defaultValue={dateValue} required={property.required} />
</div>
);
}
case "image": {
let imageValue = value === undefined ? undefined : (value as string);
const defaultImageValue = property.defaultValue === undefined ? undefined : (property.defaultValue as string);
if (imageValue === undefined && defaultImageValue !== undefined) {
imageValue = defaultImageValue;
}
return (
<div>
<InputImage name={`${prefix}[${property.name}]`} title={t(property.title)} defaultValue={imageValue} required={property.required} />
</div>
);
}
case "select": {
let stringValue = value === undefined ? undefined : (value as string);
const defaultStringValue = property.defaultValue === undefined ? undefined : (property.defaultValue as string);
if (stringValue === undefined && defaultStringValue !== undefined) {
stringValue = defaultStringValue;
}
return (
<div>
<InputSelect
name={`${prefix}[${property.name}]`}
title={t(property.title)}
defaultValue={stringValue}
required={property.required}
options={property.options?.filter((f) => f.value) || []}
placeholder={`${t("shared.select")}...`}
/>
</div>
);
}
case "multiselect": {
let arrValue = value === undefined ? [] : (value as Array<string>);
const defaultArrValue = property.defaultValue === undefined ? [] : (property.defaultValue as Array<string>);
if (!value && defaultArrValue.length > 0) {
arrValue = defaultArrValue;
}
return <JsonMultiSelectInput prefix={prefix} property={property} initial={arrValue} />;
}
case "wysiwyg": {
let stringValue = value === undefined ? undefined : (value as string);
const defaultStringValue = property.defaultValue === undefined ? undefined : (property.defaultValue as string);
if (stringValue === undefined && defaultStringValue !== undefined) {
stringValue = defaultStringValue;
}
return (
<div>
<InputText
name={`${prefix}[${property.name}]`}
title={t(property.title)}
defaultValue={stringValue}
required={property.required}
editor="wysiwyg"
editorSize="md"
/>
</div>
);
}
case "monaco": {
let stringValue = value === undefined ? undefined : (value as string);
const defaultStringValue = property.defaultValue === undefined ? undefined : (property.defaultValue as string);
if (stringValue === undefined && defaultStringValue !== undefined) {
stringValue = defaultStringValue;
}
return (
<div>
<InputText
name={`${prefix}[${property.name}]`}
title={t(property.title)}
defaultValue={stringValue}
required={property.required}
editor="monaco"
editorLanguage="markdown"
editorSize="md"
/>
</div>
);
}
case "content": {
let stringValue = value === undefined ? undefined : (value as string);
const defaultStringValue = property.defaultValue === undefined ? undefined : (property.defaultValue as string);
if (stringValue === undefined && defaultStringValue !== undefined) {
stringValue = defaultStringValue;
}
return <ContentForm prefix={prefix} property={property} value={stringValue} />;
}
default:
return null;
}
}
function JsonMultiSelectInput({ prefix, property, initial }: { prefix: string; property: JsonPropertyDto; initial: string[] }) {
const { t } = useTranslation();
const [actualValue, setActualValue] = useState<(string | number)[]>(initial);
return (
<div>
{actualValue?.map((item, idx) => {
return <input key={idx} type="hidden" name={`${prefix}[${property.name}][]`} value={item} />;
})}
<InputCombobox
title={t(property.title)}
value={actualValue}
onChange={setActualValue}
required={property.required}
options={property.options?.filter((f) => f.value) || []}
withSearch={false}
/>
</div>
);
}
function ContentForm({ prefix, property, value }: { prefix: string; property: JsonPropertyDto; value: string | undefined }) {
const { t } = useTranslation();
const [content, setContent] = useState(value);
const [contentType, setContentType] = useState("wysiwyg");
return (
<div className="space-y-2">
<div className="grid gap-3">
<div className="space-y-2">
<div className="flex justify-between space-x-2">
{/* <label className="text-sm font-medium text-muted-foreground">Article</label> */}
<div className="flex items-center space-x-1">
<button type="button" onClick={() => setContentType("wysiwyg")} className="text-muted-foreground text-xs hover:underline">
<div className={clsx(contentType === "wysiwyg" ? "font-bold" : "")}>{t("components.editor.modes.wysiwyg")}</div>
</button>
<div>•</div>
<button type="button" onClick={() => setContentType("markdown")} className="text-muted-foreground text-xs hover:underline">
<div className={clsx(contentType === "markdown" ? "font-bold" : "")}>{t("components.editor.modes.markdown")}</div>
</button>
</div>
</div>
<input name="contentType" value={contentType} readOnly hidden />
{contentType === "wysiwyg" ? (
<div className="h-[calc(100vh-320px)] overflow-y-auto">
<input type="hidden" name="content" value={content} hidden readOnly />
<Suspense fallback={null}>
<NovelEditor content={content} onChange={(e) => setContent(e.html ?? "")} usingLocalStorage={false} />
</Suspense>
</div>
) : contentType === "markdown" ? (
<InputText
className="col-span-12 h-[calc(100vh-320px)] overflow-y-auto"
rows={6}
editor="monaco"
editorLanguage="markdown"
editorTheme="vs-dark"
editorFontSize={14}
name="content"
value={content}
setValue={(e) => setContent(e.toString())}
/>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,18 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const sourcePath = path.resolve(process.cwd(), "apps/web/app/modules/jsonProperties/components/JsonPropertiesTable.tsx");
describe("json properties table i18n", () => {
it("uses semantic translation keys for property badges", () => {
const source = fs.readFileSync(sourcePath, "utf8");
expect(source).toContain("models.jsonProperty.badges.range");
expect(source).toContain("models.jsonProperty.fields.type");
expect(source).toContain("models.jsonProperty.fields.name");
expect(source).not.toContain('" (range)"');
expect(source).not.toContain('title: "Type"');
expect(source).not.toContain('title: "Name"');
});
});