1.0.5 • Published 1 month ago

@team_yumi/dynamic-form v1.0.5

Weekly downloads
-
License
MIT
Repository
github
Last release
1 month ago

@team_yumi/dynamic-form

Una librería avanzada de formularios dinámicos con modal bottom sheet para aplicaciones Ionic React, diseñada específicamente para sistemas de gestión de espacios y encuestas dinámicas.

🎯 ¿Para qué sirve la librería?

@team_yumi/dynamic-form es una solución completa para crear formularios dinámicos e interactivos que permiten:

  • Generar formularios desde configuraciones JSON sin necesidad de hardcodear componentes
  • Cargar formularios dinámicamente desde APIs con validación de estado de usuario
  • Manejar dependencias complejas entre preguntas (mostrar/ocultar según respuestas anteriores)
  • Recopilar respuestas con evidencias (comentarios adicionales, archivos adjuntos)
  • Validar completitud automáticamente antes de envío
  • Proveer experiencia de usuario optimizada con modal bottom sheet responsive
  • Soporte para calificación por estrellas con SVGs personalizados
  • Sistema de estadísticas en tiempo real del progreso del formulario

🚀 Características Principales

  • 📋 Formularios Dinámicos: Crea formularios complejos desde configuraciones JSON
  • 🔄 Carga desde API: Integración con endpoints para formularios dinámicos
  • 📱 Bottom Sheet Modal: Modal nativo de Ionic con soporte para breakpoints
  • 🎯 TypeScript Completo: Tipado estricto para mejor experiencia de desarrollo
  • 🧩 Componentes Modulares: Arquitectura basada en componentes reutilizables
  • 🪝 Hook Personalizado: useFormBottomSheet para manejo de estado simplificado
  • 🔗 Dependencias Inteligentes: Mostrar/ocultar preguntas según respuestas anteriores
  • ⭐ Rating por Estrellas: Componente de calificación con SVGs personalizados
  • 📊 Estadísticas en Tiempo Real: Seguimiento automático del progreso
  • 👤 Gestión de Usuario: Integración con datos de usuario y estado de respuestas
  • 🛡️ Validaciones Robustas: Sistema de validación completo con reglas de negocio

📦 Instalación

npm install @team_yumi/dynamic-form

Dependencias Peer

npm install react react-dom @ionic/react ionicons @team_yumi/ramen axios

🎯 Uso Básico

1. FormBottomSheet con API (Recomendado)

import React, { useEffect } from 'react';
import { IonApp, IonButton } from '@ionic/react';
import { FormBottomSheet, useFormBottomSheet } from '@team_yumi/dynamic-form';

const App = () => {
  const formBottomSheet = useFormBottomSheet({
    onFormComplete: (answers, stats, userData) => {
      console.log('Formulario completado:', {
        answers,
        stats,
        userData,
      });

      // Enviar respuestas a tu API
      submitToAPI(answers, userData);
    },
  });

  // Abrir automáticamente al renderizar
  useEffect(() => {
    formBottomSheet.openForm();
  }, []);

  const submitToAPI = async (answers, userData) => {
    try {
      await fetch('/api/submit-form', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ answers, userData }),
      });
    } catch (error) {
      console.error('Error enviando formulario:', error);
    }
  };

  return (
    <IonApp>
      <IonButton onClick={formBottomSheet.openForm}>Evaluar Experiencia</IonButton>

      <FormBottomSheet
        isOpen={formBottomSheet.isOpen}
        onDidDismiss={formBottomSheet.closeForm}
        // 🆕 Nueva configuración de API
        hostUrl="https://api.example.com"
        sheetEndpoint="/engagement/sheets/active"
        skipEndpoint="/engagement/responses/skip"
        sendEndpoint="/engagement/responses/send"
        apiKey="your-api-key-here"
        // 🆕 Configuración de skip
        skipButtonText="Saltear"
        skipDelayMinutes={30}
        // Configuración visual (opcional - títulos pueden venir del API)
        title="Evaluación de Experiencia" // 🔄 Prioridad sobre API
        subtitle="Tu opinión es importante" // 🔄 Prioridad sobre API
        confirmButtonText="Enviar Evaluación"
        cancelButtonText="Cancelar"
        icon="star-outline"
        size="xl"
        // Callbacks
        onFormComplete={formBottomSheet.handleFormComplete}
        onFormChange={formBottomSheet.handleFormChange}
        onFormSkipped={(userData) => {
          console.log('Usuario saltó el formulario:', userData);
          // Lógica personalizada para cuando se hace skip
        }}
        onShouldNotShow={() => console.log('Formulario ya completado o salteado')}
        // 🆕 Datos del usuario actualizados
        getUserData={() => ({
          name: 'Juan Pérez',
          email: 'juan.perez@example.com',
          store: 'STORE-001',
          role: 'manager',
          businessUnit: 'SM',
          country: 'CO',
          moduleCode: 'SM-001', // 🆕 Nuevo campo requerido
        })}
      />
    </IonApp>
  );
};

2. FormBottomSheet con Datos Estáticos

import {
  FormBottomSheet,
  useFormBottomSheet,
  RamenQuestionType,
  RamenQuestionVisualType,
} from '@team_yumi/dynamic-form';

const StaticFormExample = () => {
  const formBottomSheet = useFormBottomSheet({
    onFormComplete: (answers, stats) => {
      console.log('Respuestas:', answers);
      console.log('Estadísticas:', stats);
    },
  });

  const formData = {
    questions: [
      {
        title: '¿Cómo calificarías nuestro servicio?',
        required: true,
        order: 1,
        type: RamenQuestionType.STAR_RATING,
        code: 'SERVICE_RATING',
        main: true,
        visual_type: RamenQuestionVisualType.ICON,
        alternatives: [
          {
            name: '1 estrella - Muy malo',
            code: '1',
            require_evidence: false,
            require_comments: false,
          },
          {
            name: '2 estrellas - Malo',
            code: '2',
            require_evidence: false,
            require_comments: false,
          },
          {
            name: '3 estrellas - Regular',
            code: '3',
            require_evidence: false,
            require_comments: false,
          },
          {
            name: '4 estrellas - Bueno',
            code: '4',
            require_evidence: false,
            require_comments: false,
          },
          {
            name: '5 estrellas - Excelente',
            code: '5',
            require_evidence: false,
            require_comments: false,
          },
        ],
      },
      {
        title: '¿Qué te gustó más?',
        required: false,
        order: 2,
        type: RamenQuestionType.MULTIPLE_SELECTION,
        code: 'LIKED_MOST',
        main: false,
        visual_type: RamenQuestionVisualType.LIST,
        alternatives: [
          {
            name: 'Atención al cliente',
            code: 'customer_service',
            require_evidence: false,
            require_comments: false,
          },
          {
            name: 'Calidad del producto',
            code: 'product_quality',
            require_evidence: false,
            require_comments: false,
          },
          {
            name: 'Tiempo de respuesta',
            code: 'response_time',
            require_evidence: false,
            require_comments: false,
          },
          { name: 'Precio', code: 'price', require_evidence: false, require_comments: false },
        ],
        dependencies: [
          {
            question: 'SERVICE_RATING',
            answers: ['4', '5'], // Solo se muestra si calificó 4 o 5 estrellas
          },
        ],
      },
      {
        title: 'Comentarios adicionales',
        required: false,
        order: 3,
        type: RamenQuestionType.COMMENTS,
        code: 'ADDITIONAL_COMMENTS',
        main: false,
        visual_type: RamenQuestionVisualType.TEXT,
        alternatives: [],
      },
    ],
    overrideAlternatives: [],
  };

  return (
    <>
      <IonButton onClick={formBottomSheet.openForm}>Abrir Formulario</IonButton>

      <FormBottomSheet
        isOpen={formBottomSheet.isOpen}
        onDidDismiss={formBottomSheet.closeForm}
        formData={formData}
        title="Encuesta de Satisfacción"
        confirmButtonText="Enviar"
        onFormComplete={formBottomSheet.handleFormComplete}
      />
    </>
  );
};

3. InstrumentContainer (Uso Directo)

import {
  InstrumentContainer,
  IRamenForm,
  IRamenAnswer,
  IRamenInstrumentStats,
} from '@team_yumi/dynamic-form';

const DirectFormComponent = () => {
  const [answers, setAnswers] = useState<{ [key: string]: IRamenAnswer }>({});
  const [stats, setStats] = useState<IRamenInstrumentStats>();

  const handleFormChange = (
    newAnswers: { [key: string]: IRamenAnswer },
    newStats: IRamenInstrumentStats,
    question: IRamenQuestion
  ) => {
    setAnswers(newAnswers);
    setStats(newStats);

    console.log(`Pregunta ${question.code} respondida:`, newAnswers[question.code]);
    console.log('Progreso:', `${newStats.totalResolved}/${newStats.total}`);
    console.log('¿Completado?', newStats.finished);
  };

  return (
    <div>
      <h2>
        Progreso: {stats?.totalResolved || 0}/{stats?.total || 0}
      </h2>

      <InstrumentContainer
        questions={formData.questions}
        answers={answers}
        onChangeHandler={handleFormChange}
      />

      <button disabled={!stats?.finished} onClick={() => console.log('Enviar:', answers)}>
        {stats?.finished
          ? 'Enviar Formulario'
          : `Faltan ${stats?.totalRequiredRemain || 0} preguntas`}
      </button>
    </div>
  );
};

📋 API Reference Completa

FormBottomSheet Props

PropTipoRequeridoValor por defectoDescripción
isOpenboolean-Controla si el modal está abierto
onDidDismiss() => void-Callback cuando el modal se cierra
formDataIRamenForm*undefinedConfiguración estática del formulario
hostUrlstring*undefinedURL base del servidor API
sheetEndpointstring*undefinedEndpoint para cargar formularios (ej: /engagement/sheets/active)
skipEndpointstringundefinedEndpoint para saltar formularios (ej: /engagement/responses/skip)
sendEndpointstringundefinedEndpoint para enviar respuestas (ej: /engagement/responses/send)
apiKeystring*undefinedAPI key para autenticación
titlestring'Formulario'Título del modal (prioridad sobre API)
subtitlestringundefinedSubtítulo del modal (prioridad sobre API)
confirmButtonTextstring'Confirmar'Texto del botón de confirmación
cancelButtonTextstring'Cancelar'Texto del botón de cancelación
skipButtonTextstring'Saltear'Texto del botón de skip
skipDelayMinutesnumber30Minutos de retraso para el skip
onFormComplete(answers, stats, userData?) => voidundefinedCallback cuando se completa el formulario
onFormChange(answers, stats, question) => voidundefinedCallback cuando cambia una respuesta
onFormSkipped(userData?) => voidundefinedCallback cuando se hace skip del formulario
onShouldNotShow() => voidundefinedCallback cuando el formulario no debe mostrarse
getUserData() => IUserData \| Promise<IUserData>undefinedFunción para obtener datos del usuario
iconstringundefinedIcono del modal (ej: 'star-outline')
size'xs' \| 's' \| 'm' \| 'l' \| 'xl''l'Tamaño del modal
classNamestringundefinedClase CSS personalizada

*Nota: Debes proporcionar formData O (hostUrl + sheetEndpoint + apiKey), no ambos.

Tipos de Preguntas Disponibles

1. STAR_RATING - Calificación por Estrellas

{
  type: RamenQuestionType.STAR_RATING,
  alternatives: [
    { name: '1 estrella', code: '1' },
    { name: '2 estrellas', code: '2' },
    { name: '3 estrellas', code: '3' },
    { name: '4 estrellas', code: '4' },
    { name: '5 estrellas', code: '5' }
  ]
}

2. MULTIPLE_OPTION - Opción Múltiple (Una selección)

{
  type: RamenQuestionType.MULTIPLE_OPTION,
  alternatives: [
    { name: 'Opción A', code: 'a' },
    { name: 'Opción B', code: 'b' },
    { name: 'Opción C', code: 'c' }
  ]
}

3. MULTIPLE_SELECTION - Selección Múltiple

{
  type: RamenQuestionType.MULTIPLE_SELECTION,
  alternatives: [
    { name: 'Selección 1', code: '1' },
    { name: 'Selección 2', code: '2' },
    { name: 'Selección 3', code: '3' }
  ]
}

4. DICHOTOMIC - Pregunta Dicotómica (Sí/No)

{
  type: RamenQuestionType.DICHOTOMIC,
  alternatives: [
    { name: 'Sí', code: 'yes' },
    { name: 'No', code: 'no' }
  ]
}

5. SHORT_TEXT - Texto Corto

{
  type: RamenQuestionType.SHORT_TEXT,
  alternatives: [] // No requiere alternativas
}

6. COMMENTS - Comentarios Largos

{
  type: RamenQuestionType.COMMENTS,
  alternatives: [] // No requiere alternativas
}

Tipos Visuales

TipoDescripciónMejor para
TEXTCampo de texto simpleSHORT_TEXT, COMMENTS
SELECTDropdown/SelectorMULTIPLE_OPTION con muchas opciones
LISTLista vertical de opcionesMULTIPLE_OPTION, MULTIPLE_SELECTION
ICONIconos clickeablesSTAR_RATING, DICHOTOMIC

useFormBottomSheet Hook

const {
  isOpen, // boolean - Estado del modal
  answers, // object - Respuestas actuales
  stats, // IRamenInstrumentStats - Estadísticas
  openForm, // function - Abrir formulario
  closeForm, // function - Cerrar formulario
  handleFormComplete, // function - Manejar completar
  handleFormChange, // function - Manejar cambios
} = useFormBottomSheet({
  onFormComplete: (answers, stats) => {
    // Tu lógica aquí
  },
  onFormChange: (answers, stats, question) => {
    // Tu lógica aquí
  },
  autoClose: true, // Cerrar automáticamente al completar
});

🔧 Configuración Avanzada

Estructura del Formulario (IRamenForm)

interface IRamenForm {
  questions: IRamenQuestion[];
  overrideAlternatives: IOverrideAlternative[];
}

interface IRamenQuestion {
  title: string; // Título de la pregunta
  required: boolean; // Si es obligatoria
  order: number; // Orden de aparición
  type: RamenQuestionType; // Tipo de pregunta
  code: string; // Identificador único
  main: boolean; // Si es pregunta principal
  visual_type: RamenQuestionVisualType; // Tipo visual
  alternatives: IRamenAlternative[]; // Opciones disponibles
  dependencies?: IRamenDependency[]; // Dependencias (opcional)
  baseAlternativesData?: any[]; // Datos base para alternativas dinámicas
}

interface IRamenAlternative {
  name: string; // Texto mostrado
  code: string; // Valor interno
  require_evidence: boolean; // Si requiere evidencia/archivo
  require_comments: boolean; // Si requiere comentarios adicionales
}

interface IRamenDependency {
  question: string; // Código de la pregunta de la cual depende
  answers: string[]; // Respuestas que activan esta pregunta
  filterable?: boolean; // Si cualquier respuesta activa la pregunta
}

Ejemplo de Dependencias Complejas

const formWithDependencies = {
  questions: [
    {
      title: '¿Visitaste nuestra tienda?',
      code: 'VISITED_STORE',
      type: RamenQuestionType.DICHOTOMIC,
      required: true,
      alternatives: [
        { name: 'Sí', code: 'yes' },
        { name: 'No', code: 'no' },
      ],
    },
    {
      title: '¿Cómo calificarías la tienda?',
      code: 'STORE_RATING',
      type: RamenQuestionType.STAR_RATING,
      required: true,
      dependencies: [
        {
          question: 'VISITED_STORE',
          answers: ['yes'], // Solo se muestra si visitó la tienda
        },
      ],
      alternatives: [
        /* ... */
      ],
    },
    {
      title: '¿Qué podemos mejorar?',
      code: 'IMPROVEMENTS',
      type: RamenQuestionType.MULTIPLE_SELECTION,
      required: false,
      dependencies: [
        {
          question: 'STORE_RATING',
          answers: ['1', '2', '3'], // Solo si calificó 3 estrellas o menos
        },
      ],
      alternatives: [
        { name: 'Atención al cliente', code: 'service' },
        { name: 'Limpieza', code: 'cleanliness' },
        { name: 'Variedad de productos', code: 'variety' },
      ],
    },
  ],
  overrideAlternatives: [],
};

Integración con API

Formato Esperado de la API

[
  {
    "userEngagement": {
      "hasResponse": false,
      "status": null,
      "responseId": null
    },
    "schema": {
      "questions": [
        {
          "title": "¿Cómo calificarías tu experiencia?",
          "required": true,
          "order": 1,
          "type": "STAR_RATING",
          "code": "EXPERIENCE_RATING",
          "main": true,
          "visual_type": "ICON",
          "alternatives": [
            {
              "name": "1 estrella - Muy malo",
              "code": "1",
              "require_evidence": false,
              "require_comments": false
            }
          ]
        }
      ]
    }
  }
]

Headers Requeridos

const headers = {
  apiKey: 'your-api-key-here',
  'Content-Type': 'application/json',
};

🎯 Reglas de Negocio Implementadas

1. Gestión de Estados de Encuesta

La librería implementa un sistema inteligente de gestión de estados con soporte completo para skip:

// Estado de userEngagement actualizado
interface UserEngagement {
  hasResponse: boolean;
  status: 'sent' | 'draft' | null;
  responseId: string | null;
  isSkipped: boolean; // 🆕 Si la respuesta fue saltada
  skipDate: string | null; // 🆕 Fecha cuando se saltó
}

Reglas de Apertura del Formulario:

  • hasResponse: false → Formulario se abre normalmente
  • hasResponse: true, status: 'draft' → Usuario puede continuar completando
  • hasResponse: true, status: 'sent' → Formulario NO se abre (ya completado)
  • 🆕 isSkipped: true, skipDate: > fecha actual → Formulario NO se abre (skip vigente)
  • isSkipped: true, skipDate: null → Formulario SÍ se abre
  • isSkipped: true, skipDate: <= fecha actual → Formulario SÍ se abre (skip expiró)
  • Error en API → Formulario NO se abre silenciosamente

Lógica de Skip:

// El skip es válido si la fecha actual es menor o igual a skipDate
const isSkipValid = (skipDate: string) => {
  const skipDateTime = new Date(skipDate);
  const now = new Date();
  return now <= skipDateTime;
};

2. Sistema de Dependencias

Las preguntas pueden depender de respuestas anteriores:

// Ejemplo: Pregunta que solo aparece si calificó 5 estrellas
{
  dependencies: [
    {
      question: 'RATING',
      answers: ['5'], // Solo si respondió "5"
    },
  ];
}

// Ejemplo: Pregunta que aparece con cualquier respuesta
{
  dependencies: [
    {
      question: 'ANY_QUESTION',
      answers: [],
      filterable: true, // Aparece si hay cualquier respuesta
    },
  ];
}

Comportamiento:

  • Las preguntas dependientes se ocultan automáticamente si no se cumplen las condiciones
  • Las respuestas de preguntas ocultas se resetean automáticamente
  • Solo las preguntas visibles cuentan para el cálculo de totalRequired

3. Validación de Completitud

El sistema calcula automáticamente el progreso:

interface IRamenInstrumentStats {
  total: number; // Total de preguntas
  totalResolved: number; // Preguntas respondidas
  totalRequired: number; // Preguntas requeridas (considerando dependencias)
  totalRequiredResolved: number; // Preguntas requeridas completadas
  finished: boolean; // Si el formulario está completo
  totalRemain: number; // Preguntas pendientes
  totalRequiredRemain: number; // Preguntas requeridas pendientes
}

Reglas de Validación:

  • Una pregunta se considera "resuelta" si tiene un valor válido
  • Para alternativas con require_comments: true, debe haber texto en otherText
  • Para alternativas con require_evidence: true, debe haber archivos en evidences
  • Las preguntas de tipo SHORT_TEXT y COMMENTS se consideran válidas con cualquier texto
  • El formulario está finished cuando totalRequired === totalRequiredResolved

4. Manejo de Evidencias y Comentarios

interface IRamenAnswer {
  value: string | string[]; // Respuesta principal
  otherText?: string; // Comentarios adicionales
  evidences?: any[]; // Archivos/evidencias adjuntas
}

Reglas:

  • Si require_comments: true, otherText es obligatorio
  • Si require_evidence: true, evidences debe tener al menos un elemento
  • Los comentarios y evidencias se validan automáticamente

5. Reseteo Automático en Dependencias

Cuando una pregunta padre cambia y afecta a preguntas dependientes:

// Si cambio la respuesta de RATING de "5" a "3"
// Todas las preguntas que dependían de RATING === "5"
// se ocultan y sus respuestas se resetean automáticamente

6. Gestión de Datos de Usuario

interface IUserData {
  name: string;
  email: string;
  store: string;
  role: string;
  businessUnit: string;
  country: string;
  moduleCode: string; // 🆕 Código del módulo (requerido para skip)
}

Integración:

  • Los datos de usuario se cargan al abrir el formulario
  • Se incluyen automáticamente en onFormComplete
  • Son requeridos para operaciones de skip (POST al skipEndpoint)
  • Útil para auditoría y segmentación de respuestas
  • Se usan para construir automáticamente query parameters en sheetEndpoint

7. 🆕 Sistema de Skip Inteligente

La nueva funcionalidad de skip permite a los usuarios posponer formularios:

Configuración requerida:

<FormBottomSheet
  hostUrl="https://api.example.com"
  sheetEndpoint="/engagement/sheets/active"
  skipEndpoint="/engagement/responses/skip" // 🆕 Endpoint para skip
  skipDelayMinutes={30} // 🆕 Tiempo de retraso en minutos
  onFormSkipped={(userData) => {
    // 🆕 Callback de skip
    console.log('Usuario hizo skip:', userData);
  }}
/>

Payload enviado al skipEndpoint:

{
  "sheetId": "60f7b3b3b3b3b3b3b3b3b3b3",      // ID del formulario
  "userEmail": "juan.perez@company.com",
  "userStore": "STORE-001",
  "userRole": "admin",
  "userName": "Juan Pérez",
  "country": "CO",
  "businessUnit": "SM",
  "moduleCode": "SM-001",
  "skipDelayMinutes": 30                       // Configurado en props
}

Comportamiento del botón Skip:

  • Aparece automáticamente si se configuran hostUrl, skipEndpoint y hay userData
  • Envía POST al skipEndpoint con todos los datos del usuario
  • Cierra automáticamente el modal después del skip exitoso
  • Ejecuta onFormSkipped callback con userData
  • En caso de error, permite cerrar el modal normalmente

8. 🆕 Envío Automático de Respuestas

Cuando se completa el formulario, las respuestas se pueden enviar automáticamente:

Configuración:

<FormBottomSheet
  hostUrl="https://api.example.com"
  sendEndpoint="/engagement/responses/send" // 🆕 Endpoint para respuestas
  onFormComplete={(answers, stats, userData) => {
    console.log('Respuestas enviadas y callback ejecutado');
  }}
/>

Payload enviado al sendEndpoint:

{
  "sheetId": "60f7b3b3b3b3b3b3b3b3b3b3",
  "country": "CO",
  "businessUnit": "SM",
  "moduleCode": "SM-001",
  "userName": "Juan Pérez",
  "userEmail": "juan.perez@company.com",
  "userStore": "STORE-001",
  "userRole": "admin",
  "response": {
    "SPACE_WAS_SET_UP": {
      "value": "NO"
    },
    "PROBLEM": {
      "value": "FALT_ACT"
    },
    "LOCAL_RATING": {
      "value": "3"
    },
    "ADDITIONAL_COMMENTS": {
      "value": "kjkljl"
    }
  },
  "skip": false
}

Flujo de envío:

  1. Usuario completa formulario y presiona "Confirmar"
  2. Si sendEndpoint está configurado → Envío automático al servidor
  3. Si el envío es exitoso → Log de confirmación
  4. Si hay error → Log de error pero continúa el flujo
  5. Ejecuta onFormComplete callback original
  6. Cierra el modal automáticamente

Ventajas:

  • 🔄 Automático: No requiere lógica adicional en el componente padre
  • 🛡️ Resiliente: Continúa funcionando aunque el envío falle
  • 🔧 Opcional: Solo se activa si se configura sendEndpoint
  • 📊 Completo: Incluye todos los datos del usuario y respuestas

9. 🆕 Títulos y Subtítulos Dinámicos

Los títulos del modal se pueden configurar dinámicamente desde el API:

Prioridad de títulos:

  1. Props del componente (máxima prioridad)
  2. Response del sheetEndpoint (si no hay props)
  3. Valor por defecto ('Formulario' para title)

Response del sheetEndpoint puede incluir:

{
  "id": "60f7b3b3b3b3b3b3b3b3b3b3",
  "title": "Evaluación de Experiencia del Cliente",     // 🆕 Título dinámico
  "subtitle": "Tu opinión nos ayuda a mejorar",        // 🆕 Subtítulo dinámico
  "skippable": true,                                   // 🆕 Si el formulario puede ser saltado
  "schema": {
    "questions": [...]
  },
  "userEngagement": {...}
}

Ejemplos de uso:

// ✅ Títulos fijos (prioridad máxima)
<FormBottomSheet
  title="Mi Título Fijo"
  subtitle="Mi Subtítulo Fijo"
  sheetEndpoint="/engagement/sheets/active"
  // Aunque el API devuelva title/subtitle, se usan los props
/>

// ✅ Títulos dinámicos del API
<FormBottomSheet
  sheetEndpoint="/engagement/sheets/active"
  // title y subtitle no especificados
  // Se usan los valores del API response
/>

// ✅ Título fijo + subtítulo dinámico
<FormBottomSheet
  title="Evaluación"
  // subtitle no especificado → usa el del API
  sheetEndpoint="/engagement/sheets/active"
/>

Comportamiento:

  • getFinalTitle(): title (prop) || dynamicTitle (API) || 'Formulario'
  • getFinalSubtitle(): subtitle (prop) || dynamicSubtitle (API) || undefined
  • Los títulos dinámicos se resetean al abrir el modal
  • Se extraen automáticamente del response.data[0] del sheetEndpoint

10. 🆕 Control de Botón Skip (skippable)

El botón "Saltear" se puede controlar dinámicamente desde el API:

Configuración desde sheetEndpoint:

{
  "id": "60f7b3b3b3b3b3b3b3b3b3b3",
  "skippable": false,    // 🆕 false = NO mostrar botón skip
  "schema": {...}
}

Lógica de visualización del botón Skip:

const shouldShowSkipButton =
  hostUrl && // ✅ URL configurada
  skipEndpoint && // ✅ Endpoint configurado
  userData && // ✅ Datos de usuario disponibles
  currentSheetId && // ✅ ID del formulario disponible
  isSkippable; // 🆕 API permite skip

Comportamiento:

  • skippable: true → Botón "Saltear" se muestra (si otras condiciones se cumplen)
  • skippable: false → Botón "Saltear" NO se muestra
  • skippable no definido → Por defecto true (backward compatibility)
  • Reset al abrir modalisSkippable se resetea a true

Ejemplos de uso:

// Formulario NO skippable (ej: formulario obligatorio)
// Response del API:
{
  "skippable": false,
  "title": "Formulario Obligatorio",
  "schema": {...}
}
// Resultado: Solo botones "Confirmar" y "Cancelar"

// Formulario skippable (ej: encuesta opcional)
// Response del API:
{
  "skippable": true,
  "title": "Encuesta Opcional",
  "schema": {...}
}
// Resultado: Botones "Confirmar", "Saltear" y "Cancelar"

Casos de uso típicos:

  • Formularios obligatorios: skippable: false (evaluaciones de desempeño)
  • Encuestas opcionales: skippable: true (feedback del cliente)
  • Formularios temporales: skippable: true con skipDelayMinutes

🎨 Personalización Visual

Estilos del Star Rating

El componente de estrellas incluye estilos personalizados:

.star-rating-container {
  .star {
    &.empty svg {
      color: #f0d06b; // Estrella vacía - amarillo tenue
      stroke: #f0d06b;
      fill: none;
    }

    &.filled svg {
      color: #ffc107; // Estrella llena - dorado
      fill: #ffc107;
      stroke: #ffc107;
    }

    &.hovered svg {
      color: #ffca28; // Estrella hover - amarillo intermedio
      fill: #ffca28;
      stroke: #ffca28;
    }
  }
}

Responsive Design

Los componentes están optimizados para dispositivos móviles:

@media (max-width: 768px) {
  .star-rating-container {
    gap: 0.5rem;

    .star {
      padding: 8px;

      &:hover {
        transform: scale(1.1); // Menor escala en móvil
      }
    }
  }
}

🔧 Nueva Arquitectura de API

Construcción Automática de URLs

La nueva arquitectura separa la configuración para mayor flexibilidad:

// ✅ Nueva forma (recomendada)
<FormBottomSheet
  hostUrl="https://api.company.com" // URL base
  sheetEndpoint="/engagement/sheets/active" // Endpoint para cargar formulario
  skipEndpoint="/engagement/responses/skip" // Endpoint para skip
  sendEndpoint="/engagement/responses/send" // Endpoint para enviar respuestas
  apiKey="your-api-key"
  getUserData={() => ({
    country: 'CO',
    businessUnit: 'SM',
    role: 'admin',
  })}
/>

// 🔄 Construye automáticamente:
// GET: https://api.company.com/engagement/sheets/active?country=CO&businessUnit=SM&role=admin
// POST: https://api.company.com/engagement/responses/skip (cuando se hace skip)
// POST: https://api.company.com/engagement/responses/send (cuando se envían respuestas)

Ventajas:

  • 📝 URLs dinámicas: Se construyen automáticamente con datos del usuario
  • 🔄 Reutilización: Misma configuración para diferentes usuarios/roles
  • 🛡️ Tipo safety: TypeScript valida la estructura
  • 🧩 Modularidad: Separación clara de responsabilidades

Flujo Completo de Datos

sequenceDiagram
    participant U as Usuario
    participant F as FormBottomSheet
    participant A as API Server

    U->>F: Abre formulario
    F->>F: getUserData()
    F->>A: GET {hostUrl}{sheetEndpoint}?country=CO&businessUnit=SM&role=admin
    A->>F: response.data[0] con schema + userEngagement
    F->>F: Verifica isSkipped, skipDate
    F->>U: Muestra formulario (si procede)

    alt Usuario hace skip
        U->>F: Presiona "Saltear"
        F->>A: POST {hostUrl}{skipEndpoint} con skipPayload
        A->>F: 200 OK
        F->>F: onFormSkipped(userData)
        F->>U: Cierra modal
    else Usuario completa formulario
        U->>F: Presiona "Confirmar"
        F->>A: POST {hostUrl}{sendEndpoint} con responsePayload
        A->>F: 200 OK
        F->>F: onFormComplete(answers, stats, userData)
        F->>U: Cierra modal
    end

📊 Ejemplos de Uso Avanzado

Formulario de Evaluación de Local Completo

const LocalEvaluationForm = () => {
  const formBottomSheet = useFormBottomSheet({
    onFormComplete: async (answers, stats, userData) => {
      // Enviar evaluación a la API
      const evaluation = {
        userId: userData?.email,
        store: userData?.store,
        rating: answers['LOCAL_RATING']?.value,
        improvements: answers['IMPROVEMENTS']?.value,
        comments: answers['COMMENTS']?.value,
        completedAt: new Date().toISOString(),
      };

      try {
        await submitEvaluation(evaluation);
        showSuccessMessage('¡Gracias por tu evaluación!');
      } catch (error) {
        showErrorMessage('Error al enviar evaluación');
      }
    },
  });

  return (
    <FormBottomSheet
      isOpen={formBottomSheet.isOpen}
      onDidDismiss={formBottomSheet.closeForm}
      endpoint="https://api.company.com/evaluations/active"
      apiKey={process.env.REACT_APP_API_KEY}
      title="Evaluación de Mi Local"
      subtitle="Tu opinión nos ayuda a mejorar"
      confirmButtonText="Enviar Evaluación"
      cancelButtonText="Ahora no"
      icon="storefront-outline"
      size="xl"
      onFormComplete={formBottomSheet.handleFormComplete}
      onShouldNotShow={() => {
        console.log('Usuario ya evaluó este local');
      }}
      getUserData={() => getAuthenticatedUser()}
    />
  );
};

Integración con Estado Global (Redux/Context)

import { useAppDispatch, useAppSelector } from '../store/hooks';
import { submitFormAnswers, selectFormState } from '../store/formSlice';

const GlobalStateForm = () => {
  const dispatch = useAppDispatch();
  const formState = useAppSelector(selectFormState);

  const formBottomSheet = useFormBottomSheet({
    onFormComplete: (answers, stats, userData) => {
      dispatch(
        submitFormAnswers({
          answers,
          stats,
          userData,
          timestamp: Date.now(),
        })
      );
    },
    onFormChange: (answers, stats, question) => {
      // Guardar progreso en tiempo real
      dispatch(updateFormProgress({ answers, stats }));
    },
  });

  return (
    <FormBottomSheet
      {...formBottomSheet}
      // ... resto de props
    />
  );
};

🔍 Debugging y Troubleshooting

Logs de Debugging

La librería incluye logs útiles para debugging:

// Activar logs detallados
console.log('FormBottomSheet state:', {
  isOpen: formBottomSheet.isOpen,
  answers: formBottomSheet.answers,
  stats: formBottomSheet.stats,
});

Problemas Comunes

  1. El modal no se abre:

    • Verificar que isOpen sea true
    • Revisar si userEngagement.status === 'sent'
    • Comprobar errores en la llamada a la API
  2. Dependencias no funcionan:

    • Verificar que los códigos de pregunta coincidan exactamente
    • Revisar que los valores en answers array sean strings
  3. Validación incorrecta:

    • Verificar require_comments y require_evidence en alternativas
    • Comprobar que las preguntas required estén correctamente marcadas

Archivo de Troubleshooting

Para más detalles, consulta el archivo TROUBLESHOOTING.md incluido en la librería.

🧪 Testing

# Ejecutar tests
npm run test

# Tests con coverage
npm run test:coverage

# Tests en modo watch
npm run test:watch

Ejemplo de Test

import { render, screen, fireEvent } from '@testing-library/react';
import { FormBottomSheet } from '@team_yumi/dynamic-form';

test('debería renderizar el formulario correctamente', () => {
  const mockProps = {
    isOpen: true,
    onDidDismiss: jest.fn(),
    formData: mockFormData,
  };

  render(<FormBottomSheet {...mockProps} />);

  expect(screen.getByText('¿Cómo calificarías nuestro servicio?')).toBeInTheDocument();
});

🚀 Build y Deploy

# Build de la librería
npm run build

# Verificar build
npm run build:check

# Publicar (si tienes permisos)
npm run publish

📈 Roadmap

Próximas Características

  • Conditional Logic Builder: Editor visual para dependencias complejas
  • Form Templates: Plantillas predefinidas para casos comunes
  • Analytics Integration: Métricas automáticas de uso y completitud
  • Offline Support: Soporte para formularios sin conexión
  • Custom Validations: Validaciones personalizadas por pregunta
  • Multi-language: Soporte para múltiples idiomas
  • File Upload: Componente nativo para subida de archivos
  • Conditional Styling: Estilos dinámicos según respuestas

🤝 Contribuir

  1. Fork el repositorio
  2. Crear una branch para tu feature (git checkout -b feature/nueva-caracteristica)
  3. Commit tus cambios (git commit -am 'Agregar nueva característica')
  4. Push a la branch (git push origin feature/nueva-caracteristica)
  5. Crear un Pull Request

📄 Licencia

MIT © Yumi Team - Cencosud X


🆘 Soporte

  • Documentación: Consulta este README y TROUBLESHOOTING.md
  • Issues: Reporta bugs en GitHub Issues
  • Team Contact: Contacta al equipo Yumi para soporte técnico

¡Feliz desarrollo! 🎉

1.0.5

1 month ago

1.0.4

1 month ago

1.0.3

1 month ago

1.0.2

1 month ago

1.0.1

1 month ago

1.0.0

1 month ago