erp-platform/ui/src/components/developerKit/EntityEditor.tsx

557 lines
24 KiB
TypeScript
Raw Normal View History

2025-08-11 06:34:44 +00:00
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useEntities, EntityFieldType } from '../../contexts/EntityContext'
2025-08-16 19:47:24 +00:00
import {
FaSave,
FaArrowLeft,
FaPlus,
FaTrashAlt,
FaDatabase,
2025-10-30 21:38:51 +00:00
FaCog,
FaTable,
FaColumns,
2025-08-21 14:57:00 +00:00
} from 'react-icons/fa'
2025-10-31 08:30:04 +00:00
import { CustomEntityField } from '@/proxy/developerKit/models'
2025-08-11 06:34:44 +00:00
import { ROUTES_ENUM } from '@/routes/route.constant'
import { useLocalization } from '@/utils/hooks/useLocalization'
2025-10-30 21:38:51 +00:00
import { Formik, Form, Field, FieldProps, FieldArray } from 'formik'
import * as Yup from 'yup'
2025-11-05 09:02:16 +00:00
import { FormItem, Input, Select, Checkbox, FormContainer, Button } from '@/components/ui'
2025-10-30 21:38:51 +00:00
import { SelectBoxOption } from '@/shared/types'
// Validation schema
const validationSchema = Yup.object({
name: Yup.string().required('Entity name is required'),
displayName: Yup.string().required('Display name is required'),
tableName: Yup.string().required('Table name is required'),
description: Yup.string(),
fields: Yup.array()
.of(
Yup.object({
name: Yup.string().required('Field name is required'),
type: Yup.string().required('Field type is required'),
isRequired: Yup.boolean(),
maxLength: Yup.number().nullable(),
isUnique: Yup.boolean(),
2025-10-31 08:30:04 +00:00
defaultValue: Yup.string().notRequired(),
description: Yup.string().notRequired(),
displayOrder: Yup.number().required(),
2025-10-30 21:38:51 +00:00
}),
)
.min(1, 'At least one field is required'),
isActive: Yup.boolean(),
hasAuditFields: Yup.boolean(),
hasSoftDelete: Yup.boolean(),
})
2025-08-11 06:34:44 +00:00
const EntityEditor: React.FC = () => {
const { id } = useParams()
const navigate = useNavigate()
const { translate } = useLocalization()
2025-10-30 21:38:51 +00:00
const { getEntity, addEntity, updateEntity } = useEntities()
2025-08-11 06:34:44 +00:00
const isEditing = !!id
2025-10-30 21:38:51 +00:00
// Initial values for Formik
const [initialValues, setInitialValues] = useState({
name: '',
displayName: '',
tableName: '',
description: '',
fields: [
{
id: crypto.randomUUID(),
entityId: id || '',
name: 'Name',
type: 'string' as EntityFieldType,
isRequired: true,
maxLength: 100,
description: 'Entity name',
2025-10-31 08:30:04 +00:00
displayOrder: 1,
2025-10-30 21:38:51 +00:00
},
] as CustomEntityField[],
isActive: true,
hasAuditFields: true,
hasSoftDelete: true,
})
2025-08-11 06:34:44 +00:00
// Check if migration is applied to disable certain fields
const isMigrationApplied = Boolean(
isEditing && id && getEntity(id)?.migrationStatus === 'applied',
)
useEffect(() => {
if (isEditing && id) {
const entity = getEntity(id)
if (entity) {
2025-10-31 08:30:04 +00:00
// Ensure fields are sorted by displayOrder and normalized to sequential values
const sortedFields = (entity.fields || [])
.slice()
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0))
.map((f, idx) => ({ ...f, displayOrder: f.displayOrder ?? idx + 1 }))
2025-10-30 21:38:51 +00:00
setInitialValues({
name: entity.name,
displayName: entity.displayName,
tableName: entity.tableName,
description: entity.description || '',
2025-10-31 08:30:04 +00:00
fields: sortedFields,
2025-10-30 21:38:51 +00:00
isActive: entity.isActive,
hasAuditFields: entity.hasAuditFields,
hasSoftDelete: entity.hasSoftDelete,
})
2025-08-11 06:34:44 +00:00
}
}
}, [id, isEditing, getEntity])
2025-10-30 21:38:51 +00:00
const handleSubmit = async (values: typeof initialValues, { setSubmitting }: any) => {
2025-08-11 06:34:44 +00:00
try {
2025-10-30 21:38:51 +00:00
const sanitizedFields = values.fields.map((f) => {
2025-10-31 08:30:04 +00:00
// send both `displayOrder` (frontend proxy) and `order` (backend DTO) to be safe
const sanitized: any = {
2025-10-30 21:38:51 +00:00
...(f.id && isEditing ? { id: f.id } : {}),
2025-08-11 06:34:44 +00:00
name: f.name.trim(),
type: f.type,
isRequired: f.isRequired,
maxLength: f.maxLength,
isUnique: f.isUnique || false,
defaultValue: f.defaultValue,
description: f.description,
2025-10-31 08:30:04 +00:00
displayOrder: f.displayOrder,
order: f.displayOrder,
2025-08-11 06:34:44 +00:00
}
return sanitized
})
const entityData = {
2025-10-30 21:38:51 +00:00
name: values.name.trim(),
displayName: values.displayName.trim(),
tableName: values.tableName.trim(),
description: values.description.trim(),
2025-08-11 06:34:44 +00:00
fields: sanitizedFields,
2025-10-30 21:38:51 +00:00
isActive: values.isActive,
hasAuditFields: values.hasAuditFields,
hasSoftDelete: values.hasSoftDelete,
2025-08-11 06:34:44 +00:00
}
if (isEditing && id) {
await updateEntity(id, entityData)
2025-08-11 06:34:44 +00:00
} else {
await addEntity(entityData)
2025-08-11 06:34:44 +00:00
}
navigate(ROUTES_ENUM.protected.saas.developerKit.entities, { replace: true })
2025-08-11 06:34:44 +00:00
} catch (error) {
console.error('Error saving entity:', error)
alert('Failed to save entity. Please try again.')
} finally {
2025-10-30 21:38:51 +00:00
setSubmitting(false)
2025-08-11 06:34:44 +00:00
}
}
const fieldTypes = [
2025-11-05 09:02:16 +00:00
{ value: 'string', label: 'String' },
{ value: 'number', label: 'Number' },
2025-08-11 06:34:44 +00:00
{ value: 'decimal', label: 'Decimal' },
2025-11-05 09:02:16 +00:00
{ value: 'boolean', label: 'Boolean' },
2025-08-11 06:34:44 +00:00
{ value: 'date', label: 'Date' },
2025-11-05 09:02:16 +00:00
{ value: 'guid', label: 'Guid' },
2025-08-11 06:34:44 +00:00
]
return (
2025-10-31 08:30:04 +00:00
<Formik
enableReinitialize
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={handleSubmit}
>
{({ values, touched, errors, isSubmitting, setFieldValue, submitForm, isValid }) => (
<>
{/* Enhanced Header */}
<div className="bg-white shadow border-b border-slate-200 sticky top-0 z-10">
<div className="px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => navigate(ROUTES_ENUM.protected.saas.developerKit.entities)}
className="flex items-center gap-2 text-slate-600 hover:text-blue-600 hover:bg-blue-50 px-3 py-2 rounded-lg transition-all duration-200"
>
<FaArrowLeft className="w-4 h-4" />
<span className="text-sm">
{translate('::App.DeveloperKit.EntityEditor.Back')}
</span>
</button>
<div className="h-6 w-px bg-slate-300"></div>
<div className="flex items-center gap-3">
<div className="bg-gradient-to-r from-green-500 to-blue-600 p-1 rounded">
<FaDatabase className="w-5 h-5 text-white" />
2025-10-30 21:38:51 +00:00
</div>
2025-10-31 08:30:04 +00:00
<div>
<h1 className="text-xl font-bold text-slate-900">
{isEditing
? `${translate('::App.DeveloperKit.EntityEditor.Title.Edit')} - ${values.name || initialValues.name || 'Entity'}`
2025-11-05 06:37:04 +00:00
: translate('::App.DeveloperKit.Entity.CreateEntity')}
2025-10-31 08:30:04 +00:00
</h1>
<p className="text-sm text-slate-600">
{isEditing ? 'Modify your entity' : 'Create a new entity'}
</p>
2025-10-30 21:38:51 +00:00
</div>
2025-08-11 06:34:44 +00:00
</div>
</div>
2025-10-30 21:38:51 +00:00
2025-10-31 08:30:04 +00:00
{/* Save Button in Header */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={submitForm}
disabled={isSubmitting || !isValid}
className="flex items-center gap-1 bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 text-white font-semibold px-2 py-1.5 rounded shadow transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<FaSave className="w-3 h-3" />
{isSubmitting ? translate('::SavingWithThreeDot') : translate('::Save')}
</button>
</div>
</div>
</div>
</div>
2025-11-05 09:02:16 +00:00
<Form className="grid grid-cols-1 lg:grid-cols-4 gap-4 pt-2">
2025-10-31 08:30:04 +00:00
{/* Basic Entity Information */}
<div className="space-y-4 col-span-1">
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-3">
<div className="flex items-center gap-2 mb-4">
<div className="bg-blue-100 p-1.5 rounded-lg">
<FaCog className="w-4 h-4 text-blue-600" />
2025-08-11 06:34:44 +00:00
</div>
2025-10-31 08:30:04 +00:00
<h2 className="text-sm font-semibold text-slate-900">Entity Settings</h2>
2025-08-11 06:34:44 +00:00
</div>
2025-10-30 21:38:51 +00:00
2025-10-31 08:30:04 +00:00
<FormContainer size="sm">
<FormItem
label={translate('::App.DeveloperKit.EntityEditor.EntityName')}
invalid={!!(errors.name && touched.name)}
errorMessage={errors.name as string}
>
<Field name="name">
{({ field }: FieldProps) => (
<Input
{...field}
onBlur={(e) => {
field.onBlur(e)
if (!values.tableName) {
setFieldValue('tableName', values.name + 's')
}
if (!values.displayName) {
setFieldValue('displayName', values.name)
}
}}
disabled={isMigrationApplied}
placeholder="e.g., Product, User, Order"
className="px-2 py-1.5 bg-slate-50 focus:bg-white transition-all duration-200 text-sm h-7"
/>
)}
</Field>
</FormItem>
<FormItem
label={translate('::App.DeveloperKit.EntityEditor.DisplayName')}
invalid={!!(errors.displayName && touched.displayName)}
errorMessage={errors.displayName as string}
>
<Field
name="displayName"
component={Input}
placeholder="e.g., Product, User, Order"
/>
</FormItem>
<FormItem
label={translate('::App.DeveloperKit.EntityEditor.TableName')}
invalid={!!(errors.tableName && touched.tableName)}
errorMessage={errors.tableName as string}
>
<Field
name="tableName"
component={Input}
disabled={isMigrationApplied}
placeholder="e.g., Products, Users, Orders"
/>
</FormItem>
<FormItem
label={translate('::App.DeveloperKit.EntityEditor.Description')}
invalid={!!(errors.description && touched.description)}
errorMessage={errors.description as string}
>
<Field
name="description"
component={Input}
placeholder="Brief description of this entity"
/>
</FormItem>
<FormItem label={translate('::App.DeveloperKit.ComponentEditor.Active')}>
<Field name="isActive" component={Checkbox} />
</FormItem>
<FormItem label={translate('::App.DeveloperKit.EntityEditor.Audit')}>
<Field name="hasAuditFields" component={Checkbox} />
</FormItem>
<FormItem label={translate('::App.DeveloperKit.EntityEditor.SoftDelete')}>
<Field name="hasSoftDelete" component={Checkbox} />
</FormItem>
</FormContainer>
</div>
</div>
{/* Fields Section */}
2025-11-05 09:02:16 +00:00
<div className="space-y-4 col-span-3">
2025-10-31 08:30:04 +00:00
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-3">
<FormContainer size="sm">
2025-10-30 21:38:51 +00:00
<FieldArray name="fields">
{({ push, remove }) => (
<>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1">
2025-10-31 08:30:04 +00:00
<div className="bg-green-100 p-1.5 rounded">
<FaColumns className="w-4 h-4 text-green-600" />
2025-10-30 21:38:51 +00:00
</div>
<h2 className="text-sm font-semibold text-slate-900">
{translate('::App.DeveloperKit.EntityEditor.Fields')}
</h2>
</div>
<button
type="button"
onClick={() =>
push({
id: crypto.randomUUID(),
entityId: id || '',
name: '',
type: 'string',
isRequired: false,
description: '',
2025-10-31 08:30:04 +00:00
// Assign next sequential displayOrder
displayOrder: (values.fields?.length ?? 0) + 1,
2025-10-30 21:38:51 +00:00
})
}
className="flex items-center gap-1 bg-gradient-to-r from-blue-600 to-blue-700 text-white px-2 py-1.5 rounded hover:from-blue-700 hover:to-blue-800 transition-all duration-200 text-sm"
>
<FaPlus className="w-2.5 h-2.5" />
{translate('::App.DeveloperKit.EntityEditor.AddField')}
</button>
</div>
2025-11-05 09:02:16 +00:00
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-2 mb-2">
<div className="col-span-1">Order *</div>
<div className="col-span-2 font-bold">
{translate('::App.DeveloperKit.EntityEditor.FieldName')} *
</div>
<div className="col-span-1 font-bold">
{translate('::App.DeveloperKit.EntityEditor.Type')} *
</div>
<div className="col-span-2 font-bold">
{translate('::App.DeveloperKit.EntityEditor.DefaultValue')}
</div>
<div className="font-bold">
{translate('::App.DeveloperKit.EntityEditor.MaxLength')}
</div>
<div className="col-span-2 font-bold">
{translate('::App.DeveloperKit.EntityEditor.Description')}
</div>
<div className="items-center font-bold">
{translate('::App.DeveloperKit.EntityEditor.Required')}
</div>
<div className="items-center font-bold">
{translate('::App.DeveloperKit.EntityEditor.Unique')}
</div>
</div>
2025-10-31 08:30:04 +00:00
{values.fields.map((field, index) => (
<div key={field.id || `new-${index}`}>
2025-11-05 09:02:16 +00:00
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-2">
<FormItem className="col-span-1">
<Field
type="number"
name={`fields.${index}.displayOrder`}
component={Input}
/>
2025-10-31 08:30:04 +00:00
</FormItem>
<FormItem
invalid={
!!(
errors.fields &&
(errors.fields as any)[index]?.name &&
touched.fields &&
(touched.fields as any)[index]?.name
)
}
errorMessage={(errors.fields as any)?.[index]?.name as string}
className="col-span-2"
>
<Field
name={`fields.${index}.name`}
component={Input}
placeholder="e.g., Name, Email, Age"
/>
</FormItem>
<FormItem
invalid={
!!(
errors.fields &&
(errors.fields as any)[index]?.type &&
touched.fields &&
(touched.fields as any)[index]?.type
)
}
errorMessage={(errors.fields as any)?.[index]?.type as string}
className="col-span-1"
>
<Field name={`fields.${index}.type`}>
{({ field, form }: FieldProps<SelectBoxOption>) => (
<Select
field={field}
form={form}
options={fieldTypes.map((type) => ({
value: type.value,
label: type.label,
}))}
value={fieldTypes
.map((type) => ({ value: type.value, label: type.label }))
.filter((option) => option.value === field.value)}
onChange={(option) =>
form.setFieldValue(field.name, option?.value)
}
2025-10-30 21:38:51 +00:00
/>
2025-10-31 08:30:04 +00:00
)}
</Field>
</FormItem>
<FormItem
invalid={
!!(
errors.fields &&
(errors.fields as any)[index]?.defaultValue &&
touched.fields &&
(touched.fields as any)[index]?.defaultValue
)
}
errorMessage={
(errors.fields as any)?.[index]?.defaultValue as string
}
className="col-span-2"
>
<Field
name={`fields.${index}.defaultValue`}
component={Input}
placeholder="Optional default value"
/>
</FormItem>
2025-11-05 09:02:16 +00:00
<FormItem
invalid={
!!(
errors.fields &&
(errors.fields as any)[index]?.maxLength &&
touched.fields &&
(touched.fields as any)[index]?.maxLength
)
}
errorMessage={(errors.fields as any)?.[index]?.maxLength as string}
>
<Field
name={`fields.${index}.maxLength`}
component={Input}
type="number"
placeholder="e.g., 100"
disabled={field.type !== 'string'}
/>
</FormItem>
2025-10-31 08:30:04 +00:00
<FormItem
invalid={
!!(
errors.fields &&
(errors.fields as any)[index]?.description &&
touched.fields &&
(touched.fields as any)[index]?.description
)
}
errorMessage={
(errors.fields as any)?.[index]?.description as string
}
2025-11-05 09:02:16 +00:00
className="col-span-2"
2025-10-31 08:30:04 +00:00
>
<Field
name={`fields.${index}.description`}
component={Input}
placeholder="Field description"
/>
</FormItem>
2025-11-05 09:02:16 +00:00
<FormItem className="items-center">
2025-10-31 08:30:04 +00:00
<Field name={`fields.${index}.isRequired`} component={Checkbox} />
</FormItem>
2025-11-05 09:02:16 +00:00
<FormItem className="items-center">
2025-10-31 08:30:04 +00:00
<Field name={`fields.${index}.isUnique`} component={Checkbox} />
</FormItem>
2025-11-05 09:02:16 +00:00
<Button
size="xs"
2025-10-31 08:30:04 +00:00
onClick={() => {
remove(index)
const newFields = values.fields ? [...values.fields] : []
newFields.splice(index, 1)
newFields.forEach((f, i) => {
f.displayOrder = i + 1
})
setFieldValue('fields', newFields)
}}
2025-11-05 09:02:16 +00:00
className="!px-0 !py-0 !border-0 text-red-600 hover:text-red-800 rounded transition-all duration-200"
2025-10-31 08:30:04 +00:00
title="Remove field"
>
<FaTrashAlt className="w-5 h-5" />
2025-11-05 09:02:16 +00:00
</Button>
2025-10-30 21:38:51 +00:00
</div>
2025-10-31 08:30:04 +00:00
</div>
))}
{values.fields.length === 0 && (
<div className="text-center py-4 bg-slate-50 rounded border-2 border-dashed border-slate-300">
<FaTable className="w-8 h-8 mx-auto mb-2 text-slate-400" />
<h3 className="text-sm font-medium text-slate-600 mb-1">
{translate('::App.DeveloperKit.EntityEditor.NoFields')}
</h3>
<p className="text-sm text-slate-500">
{translate('::App.DeveloperKit.EntityEditor.NoFieldsDescription')}
</p>
</div>
)}
2025-10-30 21:38:51 +00:00
</>
)}
</FieldArray>
2025-10-31 08:30:04 +00:00
</FormContainer>
</div>
</div>
</Form>
</>
)}
</Formik>
2025-08-11 06:34:44 +00:00
)
}
export default EntityEditor