556 lines
24 KiB
TypeScript
556 lines
24 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
import { useEntities, EntityFieldType } from '../../contexts/EntityContext'
|
|
import {
|
|
FaSave,
|
|
FaArrowLeft,
|
|
FaPlus,
|
|
FaTrashAlt,
|
|
FaDatabase,
|
|
FaCog,
|
|
FaTable,
|
|
FaColumns,
|
|
} from 'react-icons/fa'
|
|
import { CustomEntityField } from '@/proxy/developerKit/models'
|
|
import { ROUTES_ENUM } from '@/routes/route.constant'
|
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
|
import { Formik, Form, Field, FieldProps, FieldArray } from 'formik'
|
|
import * as Yup from 'yup'
|
|
import { FormItem, Input, Select, Checkbox, FormContainer, Button } from '@/components/ui'
|
|
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(),
|
|
defaultValue: Yup.string().notRequired(),
|
|
description: Yup.string().notRequired(),
|
|
displayOrder: Yup.number().required(),
|
|
}),
|
|
)
|
|
.min(1, 'At least one field is required'),
|
|
isActive: Yup.boolean(),
|
|
hasAuditFields: Yup.boolean(),
|
|
hasSoftDelete: Yup.boolean(),
|
|
})
|
|
|
|
const EntityEditor: React.FC = () => {
|
|
const { id } = useParams()
|
|
const navigate = useNavigate()
|
|
const { translate } = useLocalization()
|
|
|
|
const { getEntity, addEntity, updateEntity } = useEntities()
|
|
|
|
const isEditing = !!id
|
|
|
|
// 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',
|
|
displayOrder: 1,
|
|
},
|
|
] as CustomEntityField[],
|
|
isActive: true,
|
|
hasAuditFields: true,
|
|
hasSoftDelete: true,
|
|
})
|
|
|
|
// 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) {
|
|
// 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 }))
|
|
|
|
setInitialValues({
|
|
name: entity.name,
|
|
displayName: entity.displayName,
|
|
tableName: entity.tableName,
|
|
description: entity.description || '',
|
|
fields: sortedFields,
|
|
isActive: entity.isActive,
|
|
hasAuditFields: entity.hasAuditFields,
|
|
hasSoftDelete: entity.hasSoftDelete,
|
|
})
|
|
}
|
|
}
|
|
}, [id, isEditing, getEntity])
|
|
|
|
const handleSubmit = async (values: typeof initialValues, { setSubmitting }: any) => {
|
|
try {
|
|
const sanitizedFields = values.fields.map((f) => {
|
|
// send both `displayOrder` (frontend proxy) and `order` (backend DTO) to be safe
|
|
const sanitized: any = {
|
|
...(f.id && isEditing ? { id: f.id } : {}),
|
|
name: f.name.trim(),
|
|
type: f.type,
|
|
isRequired: f.isRequired,
|
|
maxLength: f.maxLength,
|
|
isUnique: f.isUnique || false,
|
|
defaultValue: f.defaultValue,
|
|
description: f.description,
|
|
displayOrder: f.displayOrder,
|
|
order: f.displayOrder,
|
|
}
|
|
|
|
return sanitized
|
|
})
|
|
|
|
const entityData = {
|
|
name: values.name.trim(),
|
|
displayName: values.displayName.trim(),
|
|
tableName: values.tableName.trim(),
|
|
description: values.description.trim(),
|
|
fields: sanitizedFields,
|
|
isActive: values.isActive,
|
|
hasAuditFields: values.hasAuditFields,
|
|
hasSoftDelete: values.hasSoftDelete,
|
|
}
|
|
|
|
if (isEditing && id) {
|
|
await updateEntity(id, entityData)
|
|
} else {
|
|
await addEntity(entityData)
|
|
}
|
|
|
|
navigate(ROUTES_ENUM.protected.saas.developerKit.entities, { replace: true })
|
|
} catch (error) {
|
|
console.error('Error saving entity:', error)
|
|
alert('Failed to save entity. Please try again.')
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const fieldTypes = [
|
|
{ value: 'string', label: 'String' },
|
|
{ value: 'number', label: 'Number' },
|
|
{ value: 'decimal', label: 'Decimal' },
|
|
{ value: 'boolean', label: 'Boolean' },
|
|
{ value: 'date', label: 'Date' },
|
|
{ value: 'guid', label: 'Guid' },
|
|
]
|
|
|
|
return (
|
|
<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" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-bold text-slate-900">
|
|
{isEditing
|
|
? `${translate('::App.DeveloperKit.EntityEditor.Title.Edit')} - ${values.name || initialValues.name || 'Entity'}`
|
|
: translate('::App.DeveloperKit.Entity.CreateEntity')}
|
|
</h1>
|
|
<p className="text-sm text-slate-600">
|
|
{isEditing ? 'Modify your entity' : 'Create a new entity'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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>
|
|
|
|
<Form className="grid grid-cols-1 lg:grid-cols-4 gap-4 pt-2">
|
|
{/* 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" />
|
|
</div>
|
|
<h2 className="text-sm font-semibold text-slate-900">Entity Settings</h2>
|
|
</div>
|
|
|
|
<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 */}
|
|
<div className="space-y-4 col-span-3">
|
|
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-3">
|
|
<FormContainer size="sm">
|
|
<FieldArray name="fields">
|
|
{({ push, remove }) => (
|
|
<>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-1">
|
|
<div className="bg-green-100 p-1.5 rounded">
|
|
<FaColumns className="w-4 h-4 text-green-600" />
|
|
</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: '',
|
|
// Assign next sequential displayOrder
|
|
displayOrder: (values.fields?.length ?? 0) + 1,
|
|
})
|
|
}
|
|
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>
|
|
|
|
<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>
|
|
|
|
{values.fields.map((field, index) => (
|
|
<div key={field.id || `new-${index}`}>
|
|
<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}
|
|
/>
|
|
</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)
|
|
}
|
|
/>
|
|
)}
|
|
</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>
|
|
|
|
<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>
|
|
|
|
<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
|
|
}
|
|
className="col-span-2"
|
|
>
|
|
<Field
|
|
name={`fields.${index}.description`}
|
|
component={Input}
|
|
placeholder="Field description"
|
|
/>
|
|
</FormItem>
|
|
|
|
<FormItem className="items-center">
|
|
<Field name={`fields.${index}.isRequired`} component={Checkbox} />
|
|
</FormItem>
|
|
|
|
<FormItem className="items-center">
|
|
<Field name={`fields.${index}.isUnique`} component={Checkbox} />
|
|
</FormItem>
|
|
|
|
<Button
|
|
size="xs"
|
|
onClick={() => {
|
|
remove(index)
|
|
const newFields = values.fields ? [...values.fields] : []
|
|
newFields.splice(index, 1)
|
|
newFields.forEach((f, i) => {
|
|
f.displayOrder = i + 1
|
|
})
|
|
setFieldValue('fields', newFields)
|
|
}}
|
|
className="!px-0 !py-0 !border-0 text-red-600 hover:text-red-800 rounded transition-all duration-200"
|
|
title="Remove field"
|
|
>
|
|
<FaTrashAlt className="w-5 h-5" />
|
|
</Button>
|
|
</div>
|
|
</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>
|
|
)}
|
|
</>
|
|
)}
|
|
</FieldArray>
|
|
</FormContainer>
|
|
</div>
|
|
</div>
|
|
</Form>
|
|
</>
|
|
)}
|
|
</Formik>
|
|
)
|
|
}
|
|
|
|
export default EntityEditor
|