/** * Attribute Form Page * Add/Edit Attribute form with clean, minimal SaaS-style design matching ActivityForm */ import React, { useState, useEffect, useMemo } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Save, Loader2, Edit2, Plus, Trash2 } from "lucide-react"; import { __ } from "../lib/i18n"; import { useToast } from "../components/ui/toast"; import { generateSlug } from "../lib/slug"; import { apiClient } from "../lib/api-client"; import { ajaxService } from "../lib/api-client"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Select } from "../components/ui/select"; import { PageHeader } from "../components/common/PageHeader"; import { Card, CardContent, CardHeader, CardTitle, } from "../components/ui/card"; import { ConditionalRender } from "../components/ui/conditional-render"; import { Switch } from "../components/ui/switch"; import { IconPicker } from "../components/ui/icon-picker"; import type { IconPickerValue } from "../lib/icon-picker-types"; type FieldOptionRow = { label: string; value: string }; function parseFieldOptionRows(raw: unknown): FieldOptionRow[] { if (Array.isArray(raw)) { return raw.map((o: { label?: string; value?: string }) => ({ label: String(o?.label ?? ""), value: String(o?.value ?? ""), })); } if (typeof raw === "string" && raw.trim()) { try { const p = JSON.parse(raw) as unknown; if (Array.isArray(p)) { return p.map((o: { label?: string; value?: string }) => ({ label: String(o?.label ?? ""), value: String(o?.value ?? ""), })); } } catch { return []; } } return []; } /** REST may return the entity at the root or nested under `data`. */ function unwrapAttributePayload(raw: unknown): Record { if (raw == null || typeof raw !== "object") { return {}; } const o = raw as Record; if ("id" in o) { return o; } const inner = o.data; if ( inner && typeof inner === "object" && !Array.isArray(inner) && "id" in (inner as object) ) { return inner as Record; } return {}; } /** Map API / DB icon field to IconPicker state (must keep `provider` for Font Awesome). */ function iconFromAttributeApiRaw(raw: unknown): IconPickerValue | null { if (raw == null || raw === "") { return null; } let ic: unknown = raw; if (typeof ic === "string") { const t = ic.trim(); if (t.startsWith("{")) { try { ic = JSON.parse(t) as unknown; } catch { return t ? { type: "icon", value: t, provider: "yatra" } : null; } } else if (t !== "") { return { type: "icon", value: t, provider: "yatra" }; } else { return null; } } if (typeof ic !== "object" || ic === null || Array.isArray(ic)) { return null; } const io = ic as { type?: string; value?: unknown; provider?: string }; const val = io.value !== undefined && io.value !== null ? String(io.value) : ""; if (io.type === "image") { if (!val) { return null; } return { type: "image", value: val }; } if (val === "") { return null; } const p = io.provider; const provider = p === "fa-solid" || p === "fa-regular" || p === "yatra" ? p : "yatra"; return { type: "icon", value: val, provider }; } type AttributeStatusUi = "publish" | "draft" | "trash"; function normalizeAttributeStatusForForm(raw: unknown): AttributeStatusUi { const s = typeof raw === "string" ? raw.trim().toLowerCase() : ""; if (s === "publish" || s === "draft" || s === "trash") { return s; } return "publish"; } interface AttributeFormData { name: string; slug: string; description: string; icon: IconPickerValue | null; field_type: string; field_option_rows: FieldOptionRow[]; default_value: string; placeholder: string; required: boolean; validation_rules: string; display_order: number; show_on_frontend: boolean; show_in_filters: boolean; filter_type: string; searchable: boolean; status: string; } const AttributeForm: React.FC = () => { const queryClient = useQueryClient(); const { showToast } = useToast(); const [formData, setFormData] = useState({ name: "", slug: "", description: "", icon: null, field_type: "text_field", field_option_rows: [], default_value: "", placeholder: "", required: false, validation_rules: "", display_order: 0, show_on_frontend: true, show_in_filters: false, filter_type: "dropdown", searchable: false, status: "publish", }); const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [isSlugEditable, setIsSlugEditable] = useState(false); // Get action and id from URL const action = useMemo(() => { const params = new URLSearchParams(window.location.search); return params.get("action") || "create"; }, []); const attributeId = useMemo(() => { const params = new URLSearchParams(window.location.search); return params.get("id"); }, []); const isEditMode = action === "edit" && attributeId; const fieldTypeOptions = [ { value: "text_field", label: "Text Field" }, { value: "textarea", label: "Textarea" }, { value: "number", label: "Number" }, { value: "email", label: "Email" }, { value: "url", label: "URL" }, { value: "date", label: "Date" }, { value: "time", label: "Time" }, { value: "color", label: "Color" }, { value: "select", label: "Select Dropdown" }, { value: "radio", label: "Radio Buttons" }, { value: "checkbox", label: "Checkbox" }, { value: "file", label: "File Upload" }, ]; // Handle field changes const handleFieldChange = (field: keyof AttributeFormData, value: any) => { if (field === "field_type") { setFormData((prev) => { const nextNeeds = value === "select" || value === "radio" || value === "checkbox"; const wasNeeds = prev.field_type === "select" || prev.field_type === "radio" || prev.field_type === "checkbox"; let rows = prev.field_option_rows; if (nextNeeds && !wasNeeds && rows.length === 0) { rows = [{ label: "", value: "" }]; } return { ...prev, field_type: value, field_option_rows: rows }; }); if (errors.field_type) { setErrors((prev) => ({ ...prev, field_type: "" })); } if (errors.field_options) { setErrors((prev) => ({ ...prev, field_options: "" })); } return; } setFormData((prev) => ({ ...prev, [field]: value })); // Auto-generate slug from name only in ADD mode (not in EDIT mode) // In EDIT mode, slug only changes if user explicitly edits it if (field === "name" && !isEditMode && !isSlugEditable) { setFormData((prev) => ({ ...prev, name: value, slug: generateSlug(value), })); } // Clear error for this field if (errors[field]) { setErrors((prev) => ({ ...prev, [field]: "" })); } }; const updateOptionRow = ( index: number, key: "label" | "value", val: string, ) => { setFormData((prev) => { const next = [...prev.field_option_rows]; next[index] = { ...next[index], [key]: val }; return { ...prev, field_option_rows: next }; }); if (errors.field_options) { setErrors((prev) => ({ ...prev, field_options: "" })); } }; const addOptionRow = () => { setFormData((prev) => ({ ...prev, field_option_rows: [...prev.field_option_rows, { label: "", value: "" }], })); }; const removeOptionRow = (index: number) => { setFormData((prev) => ({ ...prev, field_option_rows: prev.field_option_rows.filter((_, i) => i !== index), })); }; // Generate unique slug with numeric suffix if needed const generateUniqueSlug = async (baseSlug: string): Promise => { try { // Check if base slug exists const response = await apiClient.get( `/attributes/check-slug?slug=${encodeURIComponent(baseSlug)}${isEditMode && attributeId ? `&exclude_id=${attributeId}` : ""}`, ); if (response.data.exists && response.data.suggested_slug) { return response.data.suggested_slug; } return baseSlug; } catch (error) { // If API fails, fallback to client-side generation let slug = baseSlug; let counter = 1; while (counter <= 100) { // Prevent infinite loop const testSlug = counter === 1 ? baseSlug : `${baseSlug}-${counter}`; // For now, just return the testSlug (in a real implementation, you'd check against existing slugs) // This is a fallback when the API is unavailable if (counter === 1) { return testSlug; // Return base slug on first attempt } slug = testSlug; counter++; } return slug; } }; // Toggle slug editability const toggleSlugEdit = () => { if (isSlugEditable) { // If disabling edit, regenerate slug from name const newSlug = generateSlug(formData.name); setFormData((prev) => ({ ...prev, slug: newSlug })); } setIsSlugEditable(!isSlugEditable); }; // Validate form const validateForm = (): boolean => { const newErrors: Record = {}; if (!formData.name.trim()) { newErrors.name = __("Name is required", "yatra"); } if (!formData.field_type) { newErrors.field_type = __("Field type is required", "yatra"); } // Validate field options for select/radio/checkbox (repeater rows) if ( formData.field_type === "select" || formData.field_type === "radio" || formData.field_type === "checkbox" ) { const built = formData.field_option_rows .map((r) => { const label = r.label.trim(); const value = r.value.trim() || (label ? generateSlug(label) : ""); return { label, value }; }) .filter((r) => r.label !== "" && r.value !== ""); if (built.length === 0) { newErrors.field_options = __( "Add at least one option with a label (value can be left blank to auto-generate from the label).", "yatra", ); } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; // Load attribute data for editing const { data: attribute, isLoading: isLoadingAttribute, error: attributeError, } = useQuery({ queryKey: ["attribute", attributeId], queryFn: async () => { if (!attributeId) return null; const response = await apiClient.get(`/attributes/${attributeId}`); return response; }, enabled: Boolean(isEditMode), retry: 2, retryDelay: 1000, }); // Direct database query to bypass caching issues (fallback only) const fetchDirectDatabaseValues = async (attributeId: number) => { try { const data = await ajaxService.post("yatra_get_attribute_direct", { attribute_id: attributeId, nonce: window.yatraAdmin?.nonce || "", }); if (data?.success && data?.data) { return data.data; } } catch (error) { console.warn("Direct database query failed", { error, attributeId }); } return null; }; useEffect(() => { if (!attribute || !attributeId) { return; } const fromRest = unwrapAttributePayload(attribute); fetchDirectDatabaseValues(Number(attributeId) || 0).then((directData) => { const direct = directData && typeof directData === "object" ? (directData as Record) : {}; const finalAttribute = { ...fromRest, ...direct }; const convertedData: AttributeFormData = { name: String(finalAttribute.name ?? ""), slug: String(finalAttribute.slug ?? ""), description: String(finalAttribute.description ?? ""), icon: iconFromAttributeApiRaw(finalAttribute.icon), field_type: String(finalAttribute.field_type ?? "text_field"), field_option_rows: (() => { const rows = parseFieldOptionRows(finalAttribute.field_options); return rows.length > 0 ? rows : ["select", "radio", "checkbox"].includes( String(finalAttribute.field_type ?? ""), ) ? [{ label: "", value: "" }] : []; })(), default_value: String(finalAttribute.default_value ?? ""), placeholder: String(finalAttribute.placeholder ?? ""), required: finalAttribute.required === "1" || finalAttribute.required === 1 || finalAttribute.required === true, validation_rules: String(finalAttribute.validation_rules ?? ""), display_order: Number(finalAttribute.display_order) || 0, show_on_frontend: finalAttribute.show_on_frontend === "1" || finalAttribute.show_on_frontend === 1 || finalAttribute.show_on_frontend === true, show_in_filters: finalAttribute.show_in_filters === "1" || finalAttribute.show_in_filters === 1 || finalAttribute.show_in_filters === true, filter_type: String(finalAttribute.filter_type ?? "dropdown"), searchable: finalAttribute.searchable === "1" || finalAttribute.searchable === 1 || finalAttribute.searchable === true, status: normalizeAttributeStatusForForm(finalAttribute.status), }; setFormData(convertedData); }); }, [attribute, attributeId]); // Handle attribute loading error useEffect(() => { if (attributeError) { showToast(__("Failed to load attribute data", "yatra"), "error"); } }, [attributeError, showToast]); // Create/Update mutation const saveMutation = useMutation({ mutationFn: async (data: AttributeFormData) => { let slug = data.slug.trim(); // For new attributes, ensure slug is unique if (!isEditMode) { slug = await generateUniqueSlug(slug); } const needsOptionRows = data.field_type === "select" || data.field_type === "radio" || data.field_type === "checkbox"; const fieldOptionsPayload = needsOptionRows ? data.field_option_rows .map((r) => { const label = r.label.trim(); const value = r.value.trim() || (label ? generateSlug(label) : ""); return { label, value }; }) .filter((r) => r.label !== "" && r.value !== "") : []; const payload: any = { name: data.name.trim(), slug: slug, description: data.description.trim(), icon: data.icon, field_type: data.field_type, default_value: data.default_value.trim(), placeholder: data.placeholder.trim(), required: Boolean(data.required), validation_rules: data.validation_rules.trim(), display_order: Number(data.display_order), show_on_frontend: Boolean(data.show_on_frontend), show_in_filters: Boolean(data.show_in_filters), filter_type: data.filter_type, searchable: Boolean(data.searchable), status: normalizeAttributeStatusForForm(data.status), }; if (needsOptionRows) { payload.field_options = fieldOptionsPayload; } // Debug: Log the payload being sent // If slug was manually edited, add flag to preserve it if (isEditMode && isSlugEditable) { payload.preserve_slug = true; } try { if (isEditMode && attributeId) { const response = await apiClient.put( `/attributes/${attributeId}`, payload, ); return response; } else { const response = await apiClient.post("/attributes", payload); return response; } } catch (error: any) { // Handle specific API errors throw error; } }, onSuccess: (response) => { setIsSubmitting(false); queryClient.invalidateQueries({ queryKey: ["attributes"] }); queryClient.invalidateQueries({ queryKey: ["attributes-stats"] }); if (attributeId) { queryClient.invalidateQueries({ queryKey: ["attribute", attributeId] }); } showToast( isEditMode ? __("Attribute updated successfully", "yatra") : __("Attribute created successfully", "yatra"), "success", ); // Redirect logic if (!isEditMode) { // Redirect to edit page after creation // Try different possible response structures const newId = response?.data?.id || response?.id || response?.data?.data?.id; if (newId) { setTimeout(() => { window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=attributes&action=edit&id=${newId}`; }, 1000); } else { console.error( "AttributeForm - Could not extract ID from response for redirect", ); } } }, onError: (error: any) => { const errorMessage = error?.message || __("An error occurred while saving the attribute", "yatra"); showToast(errorMessage, "error"); setIsSubmitting(false); }, }); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!validateForm()) { return; } setIsSubmitting(true); saveMutation.mutate(formData); }; const handleCancel = () => { window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=attributes`; }; // Show loading skeleton if (isLoadingAttribute) { return (
); } // Show error state if attribute loading failed if (attributeError && isEditMode) { return (
{__("Back", "yatra")} } />
{__( "Failed to load attribute data. The attribute may not exist or there might be a server error.", "yatra", )}
); } return (
{__("Back", "yatra")} } />
{/* Main Form Fields */}
{/* Basic Information */} {__("Basic Information", "yatra")} {/* Name */}
handleFieldChange("name", e.target.value) } placeholder={__("Enter attribute name", "yatra")} className={errors.name ? "border-red-500" : ""} /> {errors.name && (

{errors.name}

)}
{/* Slug */}
handleFieldChange("slug", e.target.value) } placeholder={__("attribute-slug", "yatra")} disabled={!isSlugEditable} className={`flex-1 ${errors.slug ? "border-red-500" : ""}`} />
{isSlugEditable ? (

{__( "Click the save icon to preserve your custom slug.", "yatra", )}

) : (

{__( "Auto-generated from name. Click edit icon to customize.", "yatra", )}

)}
{/* Description */}