Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/components/entities/views/EntityViewForm.tsx
T

723 lines
31 KiB
TypeScript

import { Property } from "@toolctrl/db/prisma-browser";
import clsx from "clsx";
import { useRef, useState, useEffect, Fragment } from "react";
import { useTranslation } from "react-i18next";
import Constants from "~/application/Constants";
import { PropertyType } from "~/application/enums/entities/PropertyType";
import { ViewFilterCondition } from "~/application/enums/entities/ViewFilterCondition";
import InfoBanner from "~/components/ui/banners/InfoBanner";
import FormGroup from "~/components/ui/forms/FormGroup";
import InputGroup from "~/components/ui/forms/InputGroup";
import InputCheckboxInline from "~/components/ui/input/InputCheckboxInline";
import InputNumber from "~/components/ui/input/InputNumber";
import InputRadioGroupCards from "~/components/ui/input/InputRadioGroupCards";
import InputSelect from "~/components/ui/input/InputSelect";
import InputSelector from "~/components/ui/input/InputSelector";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import OrderIndexButtons from "~/components/ui/sort/OrderIndexButtons";
import CollapsibleRow from "~/components/ui/tables/CollapsibleRow";
import { EntityViewLayoutTypes } from "~/modules/rows/dtos/EntityViewLayoutType";
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
import { EntityViewWithDetails } from "~/utils/db/entities/entityViews.db.server";
import OrderHelper from "~/utils/helpers/OrderHelper";
import RowColumnsHelper from "~/utils/helpers/RowColumnsHelper";
import { updateItemByIdx } from "~/utils/shared/ObjectUtils";
import StringUtils from "~/utils/shared/StringUtils";
import EntityViewLayoutBadge from "./EntityViewLayoutBadge";
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
import RowHelper from "~/utils/helpers/RowHelper";
import RowsList from "../rows/RowsList";
import { defaultDisplayProperties } from "~/utils/helpers/PropertyHelper";
import { translateEntityTitlePlural, translatePropertyTitle, translateSelectOptionLabel } from "~/utils/i18n/translateLabel";
interface Props {
entity: EntityWithDetails | undefined;
item?: EntityViewWithDetails | null;
canDelete?: boolean;
onClose?: () => void;
actionNames?: {
create: string;
update: string;
delete: string;
};
tenantId: string | null;
userId: string | null;
isSystem: boolean;
showViewType: boolean;
}
export default function EntityViewForm({
entity,
item,
canDelete,
onClose,
actionNames,
isSystem,
tenantId,
userId,
showViewType,
}: Props & { entity: EntityWithDetails }) {
const { t, i18n } = useTranslation();
const inputName = useRef<RefInputText>(null);
const [possibleProperties, setPossibleProperties] = useState<{ propertyId: string | null; name: string | null; order: number; title?: string }[]>(
item?.properties ?? []
);
const [fakeItems, setFakeItems] = useState<RowWithDetails[]>([]);
const [layout, setLayout] = useState<"table" | "board" | "grid" | "card">("table");
const [name, setName] = useState("");
const [title, setTitle] = useState("");
const [order, setOrder] = useState<number | undefined>(item?.order ?? OrderHelper.getNextOrder(entity.views));
const [pageSize, setPageSize] = useState<number | undefined>(item?.pageSize ?? Constants.DEFAULT_PAGE_SIZE);
const [isDefault, setIsDefault] = useState(item?.isDefault ?? OrderHelper.getNextOrder(entity.views) === 1);
const [properties, setProperties] = useState<{ propertyId: string | null; name: string | null; order: number; title?: string }[]>([]);
const [filters, setFilters] = useState<{ name: string; condition: string; value: string; match: string }[]>(item?.filters ?? []);
// const [sort, setSort] = useState<{ name: string; asc: boolean; order: number }[]>(item?.sort ?? []);
// Board
const [groupBy, setGroupBy] = useState<string | number | undefined>(item?.groupByPropertyId ? "byProperty" : undefined);
const [groupByPropertyId, setGroupByPropertyId] = useState<string | number | undefined>(item?.groupByPropertyId ?? undefined);
const [groupByOptions, setGroupByOptions] = useState<{ name: string; value: string }[]>([]);
const [selectProperties, setSelectProperties] = useState<Property[]>([]);
// Grid
const [gridColumns, setGridColumns] = useState<number | undefined>(item?.gridColumns || 0);
const [gridColumnsSm, setGridColumnsSm] = useState<number | undefined>(item?.gridColumnsSm || 0);
const [gridColumnsMd, setGridColumnsMd] = useState<number | undefined>(item?.gridColumnsMd || 0);
const [gridColumnsLg, setGridColumnsLg] = useState<number | undefined>(item?.gridColumnsLg || 0);
const [gridColumnsXl, setGridColumnsXl] = useState<number | undefined>(item?.gridColumnsXl || 0);
const [gridColumns2xl, setGridColumns2xl] = useState<number | undefined>(item?.gridColumns2xl || 0);
const [gridGap, setGridGap] = useState(item?.gridGap ?? "sm");
const [filterByProperties, setFilterByProperties] = useState<{ name: string; value: string }[]>([]);
useEffect(() => {
if (!item && isSystem) {
setName("system-" + entity.slug);
setTitle(t("models.view.systemTitle", { entity: translateEntityTitlePlural(t, i18n, entity) }));
setLayout("card");
} else if (item) {
setName(item.name);
setTitle(item.title);
setLayout(item.layout as "table" | "board" | "grid" | "card");
}
}, [entity, isSystem, item, t]);
useEffect(() => {
const items: RowWithDetails[] = Array.from({ length: 10 }).map((_, idx) => {
let nonDefaultProperties = properties.filter((p) => !defaultDisplayProperties.find((dp) => dp.name === p.name));
const item: RowWithDetails = {
values: nonDefaultProperties.map(({ name }) => {
const property = entity.properties.find((p) => p.name === name);
if (!property) return null;
return RowHelper.getFakePropertyValue({ property, t, idx: idx + 1 });
}),
folio: idx + 1,
createdAt: new Date(),
createdByUser: {
email: "preview@example.invalid",
firstName: "Preview",
lastName: "User",
},
} as RowWithDetails;
return item;
});
setFakeItems(items);
}, [entity.properties, properties, t]);
useEffect(() => {
const possibleProperties = RowColumnsHelper.getDefaultEntityColumns(entity).map((i, idx) => {
const property = entity.properties.find((p) => p.name === i.name);
return {
propertyId: property?.id ?? null,
name: i.name,
title: i.title,
order: idx + 1,
};
});
setPossibleProperties(possibleProperties);
}, [entity]);
// Initial selected properties
useEffect(() => {
if (item) {
setProperties(item.properties);
} else {
setProperties(
RowColumnsHelper.getDefaultEntityColumns(entity)
.filter((f) => f.visible)
.map((i, idx) => {
return {
propertyId: null,
name: i.name,
title: i.title,
order: idx + 1,
};
})
);
}
}, [entity, item]);
useEffect(() => {
setTimeout(() => {
inputName.current?.input.current?.focus();
}, 100);
const selectProperties = entity.properties.filter((f) => [PropertyType.SELECT, PropertyType.MULTI_SELECT].includes(f.type));
setGroupByOptions([
{
name: t("models.view.property"),
value: "byProperty",
},
]);
setSelectProperties(selectProperties);
let filterByProperties = entity.properties
.filter((f) => f.type === PropertyType.TEXT || f.type === PropertyType.SELECT || f.type === PropertyType.BOOLEAN)
.map((property) => {
return {
name: translatePropertyTitle(t, i18n, entity.name, property),
value: property.name,
};
});
if (entity.hasTags) {
filterByProperties = [...filterByProperties, { name: t("models.row.tags"), value: "tags" }];
}
setFilterByProperties(filterByProperties);
}, [entity, t]);
function getPropertyByName(name: string) {
return entity.properties.find((f) => f.name === name);
}
function getPropertyConditionsByName(name: string) {
if (name === "tags") {
return [
{ value: ViewFilterCondition.equals, name: t("entities.conditions.equals") },
{ value: ViewFilterCondition.contains, name: t("entities.conditions.contains") },
{ value: ViewFilterCondition.notIn, name: t("entities.conditions.notIn") },
];
} else {
const property = getPropertyByName(name);
if (property?.type === PropertyType.BOOLEAN) {
return [
{ value: ViewFilterCondition.equals, name: t("entities.conditions.equals") },
// { value: ViewFilterCondition.notIn, name: t("entities.conditions.notIn") },
];
} else if (property?.type === PropertyType.TEXT || property?.type === PropertyType.SELECT) {
return [
{ value: ViewFilterCondition.equals, name: t("entities.conditions.equals") },
{ value: ViewFilterCondition.contains, name: t("entities.conditions.contains") },
{ value: ViewFilterCondition.lt, name: t("entities.conditions.lt") },
{ value: ViewFilterCondition.lte, name: t("entities.conditions.lte") },
{ value: ViewFilterCondition.gt, name: t("entities.conditions.gt") },
{ value: ViewFilterCondition.gte, name: t("entities.conditions.gte") },
{ value: ViewFilterCondition.startsWith, name: t("entities.conditions.startsWith") },
{ value: ViewFilterCondition.endsWith, name: t("entities.conditions.endsWith") },
{ value: ViewFilterCondition.in, name: t("entities.conditions.in") },
{ value: ViewFilterCondition.notIn, name: t("entities.conditions.notIn") },
];
}
}
return [];
}
function getPropertyTitle(name: string) {
return RowColumnsHelper.getDefaultEntityColumns(entity).find((f) => f.name === name)?.title ?? "";
}
function getTypeTitle() {
if (isSystem) {
return t("models.view.typeSummary", { type: t("models.view.types.system") });
} else if (!tenantId && !userId) {
return t("models.view.typeSummary", { type: t("models.view.types.default") });
} else if (tenantId && !userId) {
return t("models.view.typeSummary", { type: t("models.view.types.tenant") });
} else if (tenantId && userId) {
return t("models.view.typeSummary", { type: t("models.view.types.user") });
}
return t("models.view.typeSummary", { type: t("models.view.types.tenant") });
}
function getTypeDescription() {
if (isSystem) {
return t("models.view.typeDescriptions.system");
} else if (!tenantId && !userId) {
return t("models.view.typeDescriptions.default");
} else if (tenantId && !userId) {
return t("models.view.typeDescriptions.tenant");
} else if (tenantId && userId) {
return t("models.view.typeDescriptions.user");
}
return t("models.view.typeDescriptions.tenant");
}
return (
<FormGroup id={item?.id} onCancel={onClose} editing={true} canDelete={canDelete} actionNames={actionNames} className="text-foreground">
<input type="hidden" name="id" value={item?.id} hidden readOnly />
<input type="hidden" name="entityId" value={entity.id} hidden readOnly />
<input type="hidden" name="isSystem" value={isSystem ? "true" : "false"} hidden readOnly />
<input type="hidden" name="tenantId" value={tenantId ?? undefined} hidden readOnly />
<input type="hidden" name="userId" value={userId ?? undefined} hidden readOnly />
{showViewType && <InfoBanner title={getTypeTitle()} text={getTypeDescription()} />}
<InputGroup title={t("models.view.object")}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<InputRadioGroupCards
display="name"
columns={EntityViewLayoutTypes.length}
className="sm:col-span-12"
name="layout"
title={t("models.view.layout")}
value={layout}
onChange={(e) => setLayout((e?.toString() ?? "table") as "table" | "board" | "grid" | "card")}
options={EntityViewLayoutTypes.map((f) => {
return {
name: t(f.labelKey),
value: f.value,
renderName: (
<div className="flex items-center space-x-2">
<EntityViewLayoutBadge layout={f.value} className="text-muted-foreground h-3 w-3" />
<div>{t(f.labelKey)}</div>
</div>
),
};
})}
/>
<InputText
autoFocus
className={clsx(isSystem ? "sm:col-span-6" : "sm:col-span-3")}
name="name"
title={t("models.entity.name")}
value={name}
setValue={(e) => setName(e.toString().toLowerCase())}
autoComplete="off"
required
// lowercase
onBlur={() => {
if (!item) {
let title = StringUtils.capitalize(name.toLowerCase());
// replace - occurrences with spaces
title = title.replace(/-/g, " ");
setTitle(title);
}
}}
/>
<InputText
className={clsx(isSystem ? "sm:col-span-6" : "sm:col-span-3")}
name="title"
title={t("models.entity.title")}
value={title}
setValue={setTitle}
autoComplete="off"
required
withTranslation={true}
/>
{!isSystem && (
<Fragment>
<InputNumber className="sm:col-span-3" name="pageSize" title={t("models.view.pageSize")} value={pageSize} setValue={setPageSize} min={1} max={500} required />
<InputNumber
className="sm:col-span-3"
name="order"
title={t("models.entity.order")}
value={order}
setValue={setOrder}
disabled={!item}
min={1}
max={99}
required
/>
<InputCheckboxInline className="sm:col-span-12" name="isDefault" title={t("models.view.isDefault")} value={isDefault} setValue={setIsDefault} />
</Fragment>
)}
</div>
</InputGroup>
{layout === "board" && (
<InputGroup title={t("models.view.board")}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<>
<InputSelect className="sm:col-span-6" name="groupBy" title={t("models.view.groupBy")} value={groupBy} setValue={setGroupBy} options={groupByOptions} />
<InputSelect
className="sm:col-span-6"
name="groupByPropertyId"
title={t("models.view.property")}
value={groupByPropertyId}
setValue={setGroupByPropertyId}
options={selectProperties.map((item) => {
return {
name: t(item.title),
value: item.id,
};
})}
/>
</>
</div>
</InputGroup>
)}
{layout === "grid" && (
<InputGroup title={t("models.view.grid")}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-7">
<InputNumber name="gridColumns" title={t("models.view.columns")} value={gridColumns} setValue={setGridColumns} min={0} max={12} required />
<InputNumber name="gridColumnsSm" title="sm" value={gridColumnsSm} setValue={setGridColumnsSm} min={0} max={12} required />
<InputNumber name="gridColumnsMd" title="md" value={gridColumnsMd} setValue={setGridColumnsMd} min={0} max={12} required />
<InputNumber name="gridColumnsLg" title="lg" value={gridColumnsLg} setValue={setGridColumnsLg} min={0} max={12} required />
<InputNumber name="gridColumnsXl" title="xl" value={gridColumnsXl} setValue={setGridColumnsXl} min={0} max={12} required />
<InputNumber name="gridColumns2xl" title="2xl" value={gridColumns2xl} setValue={setGridColumns2xl} min={0} max={12} required />
<InputSelect
name="gridGap"
title={t("models.view.gap")}
value={gridGap}
setValue={(e) => setGridGap(e?.toString() ?? "sm")}
options={[
{ name: "xs", value: "xs" },
{ name: "sm", value: "sm" },
{ name: "md", value: "md" },
{ name: "lg", value: "lg" },
{ name: "xl", value: "xl" },
]}
/>
</div>
</InputGroup>
)}
<div>
<div className="divide-border divide-y">
{properties.map((item, idx) => (
<input key={idx} type="text" name="properties[]" readOnly hidden value={JSON.stringify(item)} />
))}
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<div className="flex items-baseline space-x-1">
<div className="text-sm font-medium">{t("shared.all")}</div>
<div className="flex items-center space-x-1">
<button
type="button"
className="text-muted-foreground text-xs"
onClick={() => setProperties(possibleProperties)}
disabled={properties.length === possibleProperties.length}
>
({t("shared.selectAll")})
</button>
</div>
</div>
<div className="border-border h-64 space-y-1 overflow-y-scroll rounded-md border-2 border-dashed p-2">
{possibleProperties.map((item, idx) => (
<div key={item.name}>
<InputCheckboxInline
name={"properties[" + idx + "].propertyId"}
title={
<div className="flex items-baseline space-x-1 truncate text-sm">
<div className={clsx("truncate", item.name?.includes(".") ? "text-muted-foreground" : "text-foreground font-medium")}>
{item.title ? t(item.title) : item.name}
</div>
<div
className={clsx(
"truncate text-xs font-normal",
item.name?.includes(".") ? "text-muted-foreground italic" : "text-foreground font-bold"
)}
>
({item?.name})
</div>
</div>
}
value={properties.find((f) => f.name === item.name) !== undefined}
setValue={(e) => {
if (e) {
setProperties([
...properties,
{ propertyId: item.propertyId, name: item.name, title: item.title, order: OrderHelper.getNextOrder(properties) },
]);
} else {
setProperties(properties.filter((f) => f.name !== item.name));
}
}}
/>
</div>
))}
</div>
</div>
<div className="space-y-1">
<div className="flex items-baseline space-x-1">
<div className="text-sm font-medium">{t("models.view.propertiesDisplayed")}</div>
<button type="button" className="text-muted-foreground text-xs" onClick={() => setProperties([])} disabled={properties.length === 0}>
({t("shared.clear")})
</button>
</div>
<div className="border-border h-64 space-y-1 overflow-y-scroll rounded-md border-2 border-dashed p-2">
{properties
.sort((a, b) => a.order - b.order)
.map((item, idx) => (
<div key={idx} className="flex items-baseline space-x-1 text-sm">
<div className="shrink-0">
<OrderIndexButtons
idx={idx}
items={properties.map((item, itemIdx) => {
return {
order: item.order,
idx: itemIdx,
};
})}
onChange={(newOrders) => {
setProperties(
properties.map((item, itemIdx) => {
const newOrder = newOrders.find((f) => f.idx === itemIdx);
if (newOrder) {
return {
...item,
order: newOrder.order,
};
} else {
return item;
}
})
);
}}
/>
</div>
<div className={clsx("truncate", item.name?.includes(".") ? "text-muted-foreground" : "text-foreground font-medium")}>
{item.title ? t(item.title) : t(getPropertyTitle(item.name ?? ""))}{" "}
<span className={clsx("text-xs font-normal", item.name?.includes(".") ? "text-muted-foreground italic" : "text-foreground font-bold")}>
({item?.name})
</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
{!isSystem && (
<Fragment>
<InputGroup title={t("models.view.filters")}>
{filters.map((item, idx) => (
<input key={idx} type="text" name="filters[]" readOnly hidden value={JSON.stringify(item)} />
))}
<div className="mb-2 space-y-2">
{filters.map((item, idx) => (
<CollapsibleRow
className="bg-secondary"
initial={true}
key={idx}
title={`${item.name} (${item.condition}) ${item.value}`}
value={
<div className="flex items-center space-x-1 text-sm">
<div className="font-medium">{item.name}</div>
<div className="text-muted-foreground font-light">{item.condition}</div>
<div className="">{item.value}</div>
</div>
}
onRemove={() => setFilters(filters.filter((_, i) => i !== idx))}
>
<div className="grid grid-cols-12 gap-3">
<div className="sm:col-span-2">
<InputSelector
withSearch={false}
name={"filters[" + idx + "].match"}
title={t("models.view.match")}
value={item.match}
setValue={(e) =>
updateItemByIdx(filters, setFilters, idx, {
match: e,
})
}
options={[
{ name: t("models.view.matchOperators.and"), value: "and" },
{ name: t("models.view.matchOperators.or"), value: "or" },
]}
/>
</div>
<div className="sm:col-span-4">
<InputSelector
withSearch={false}
name={"filters[" + idx + "].propertyId"}
title={t("models.view.property")}
value={item.name}
setValue={(e) =>
updateItemByIdx(filters, setFilters, idx, {
name: e,
})
}
options={filterByProperties}
/>
</div>
<div className="sm:col-span-3">
<InputSelector
withSearch={false}
name={"filters[" + idx + "].condition"}
title={t("shared.condition")}
value={item.condition}
setValue={(e) =>
updateItemByIdx(filters, setFilters, idx, {
condition: e,
})
}
options={getPropertyConditionsByName(item.name)}
/>
</div>
<Fragment>
{getPropertyByName(item.name)?.type === PropertyType.BOOLEAN ? (
<div className="sm:col-span-3">
<InputSelector
withSearch={false}
name={"filters[" + idx + "].value"}
title={t("shared.value")}
value={item.value}
setValue={(e) => updateItemByIdx(filters, setFilters, idx, { value: e })}
options={[
{ name: t("shared.true"), value: "true" },
{ name: t("shared.false"), value: "false" },
]}
/>
</div>
) : getPropertyByName(item.name)?.type === PropertyType.SELECT ? (
<div className="sm:col-span-3">
<InputSelector
withSearch={false}
name={"filters[" + idx + "].value"}
title={t("shared.value")}
value={item.value}
setValue={(e) => updateItemByIdx(filters, setFilters, idx, { value: e })}
options={
getPropertyByName(item.name)?.options.map((i) => {
return {
name: translateSelectOptionLabel(t, i18n, entity.name, item.name, i),
value: i.value,
};
}) ?? []
}
/>
</div>
) : (
<div className="sm:col-span-3">
<InputText
name={"filters[" + idx + "].value"}
title={t("shared.value")}
value={item.value}
setValue={(e) => updateItemByIdx(filters, setFilters, idx, { value: e })}
/>
</div>
)}
</Fragment>
</div>
</CollapsibleRow>
))}
</div>
<button
type="button"
onClick={() => {
const firstProperty = filterByProperties.length > 0 ? filterByProperties[0].value : "";
setFilters([
...filters,
{
name: firstProperty,
condition: "equals",
value: "",
match: "and",
},
]);
}}
className="border-border hover:border-border relative block w-full rounded-lg border-2 border-dashed p-4 text-center focus:outline-hidden focus:ring-2 focus:ring-gray-500"
>
<span className="text-muted-foreground block text-xs font-normal">
{filters.length === 0 ? t("models.view.noFilters") : t("models.view.addFilter")}
</span>
</button>
</InputGroup>
{/* <InputGroup title="Sort">
{sort.map((item, idx) => (
<input key={idx} type="text" name="sort[]" readOnly hidden value={JSON.stringify(item)} />
))}
<div className="space-y-2 mb-2">
{sort
.sort((a, b) => a.order - b.order)
.map((item, idx) => (
<CollapsibleRow
className="bg-secondary"
key={idx}
initial={true}
title={`${item.name} (${item.asc ? "asc" : "desc"})`}
value={
<div className="flex space-x-1 items-center text-sm">
<div className="font-medium">{item.name}</div>
<div className="font-light text-muted-foreground">{item.asc ? "asc" : "desc"}</div>
</div>
}
onRemove={() => setSort(sort.filter((_, i) => i !== idx))}
>
<div className="grid grid-cols-2 gap-3">
<InputSelector
withSearch={false}
name={"sort[" + idx + "].name"}
title="Property"
value={item.name}
setValue={(e) => updateItemByIdx(sort, setSort, idx, { name: e })}
options={entity.properties.map((property) => {
return {
name: translatePropertyTitle(t, i18n, entity.name, property),
value: property.name,
};
})}
/>
<InputSelect
name={"sort[" + idx + "].order"}
title="Order"
value={item.asc ? "asc" : "desc"}
setValue={(e) => updateItemByIdx(sort, setSort, idx, { asc: e?.toString() === "asc" })}
options={[
{
name: "Ascending",
value: "asc",
},
{
name: "Descending",
value: "desc",
},
]}
/>
</div>
</CollapsibleRow>
))}
</div>
<button
type="button"
onClick={() =>
setSort([...sort, { name: entity.properties.find((f) => !f.isDefault)?.name ?? "", asc: true, order: OrderHelper.getNextOrder(sort) }])
}
className="relative block w-full border-2 border-border border-dashed rounded-lg p-4 text-center hover:border-border focus:outline-hidden focus:ring-2 focus:ring-gray-500"
>
<span className="block text-xs font-normal text-muted-foreground">{filters.length === 0 ? "No sort fields" : "Add sort"}</span>
</button>
</InputGroup> */}
</Fragment>
)}
<InputGroup title={t("shared.preview")}>
<div className="space-y-2">
<RowsList
view={layout}
entity={entity}
items={fakeItems}
columns={properties.map((p) => ({
name: p.name ?? "",
title: p.title ?? "",
visible: true,
}))}
/>
</div>
</InputGroup>
</FormGroup>
);
}