diff --git a/src/api.ts b/src/api.ts index c77c3e1..8d744f9 100644 --- a/src/api.ts +++ b/src/api.ts @@ -300,6 +300,13 @@ export async function getAllDoctypesFromLocal(): Promise< } const parsed = parseJson(value); if (parsed?.payload) { + // Validate that payload has the expected structure + if (!parsed.payload.data) { + return; + } + if (!parsed.payload.fields) { + return; + } result[normalizeDoctypeName(parsed.name)] = parsed.payload; } }); @@ -352,6 +359,7 @@ const defaultFetcher = async (name: string): Promise => { const response = await getDoctypeByName({ path: { form_name: name }, }); + const data = (response as any)?.data?.data ?? (response as any)?.data ?? @@ -359,6 +367,42 @@ const defaultFetcher = async (name: string): Promise => { if (!data) { throw new Error(`Doctype response missing data for ${name}`); } + + // Validate that we have fields array + if (!data.fields || !Array.isArray(data.fields)) { + throw new Error(`Invalid doctype structure for ${name}: missing fields array`); + } + + // Check if data object exists, if not try to create it from top-level properties + if (!data.data || typeof data.data !== 'object') { + // Look for doctype metadata at the top level + const doctypeMetadata: any = {}; + const metadataFields = [ + 'name', 'creation', 'modified', 'modified_by', 'owner', 'docStatus', + 'idx', 'issingle', 'istable', 'editable_grid', 'track_changes', 'module', + 'autoname', 'name_case', 'sort_field', 'sort_order', 'readonly', 'in_create', + 'allow_copy', 'allow_rename', 'allow_import', 'hide_toolbar', 'track_seen', + 'max_attachments', 'document_type', 'engine', 'is_submittable', + 'show_name_in_global_search', 'custom', 'beta', 'has_web_view', + 'allow_guest_to_view', 'qick_entry', 'is_tree', 'track_views', + 'all_events_in_timeline', 'allow_auto_repeat', 'show_preview_popup', + 'email_append_to', 'index_web_pages_for_search', 'docType' + ]; + + metadataFields.forEach(field => { + if (data[field] !== undefined) { + doctypeMetadata[field] = data[field]; + } + }); + + // If we found metadata, create the nested structure + if (Object.keys(doctypeMetadata).length > 0) { + data.data = doctypeMetadata; + } else { + throw new Error(`Invalid doctype structure for ${name}: missing data object`); + } + } + return data as DocType; }; @@ -594,6 +638,7 @@ export function extractFields(docType: DocType): RawField[] { print_hide: field.print_hide, report_hide: field.report_hide, depends_on: field.depends_on, + reqd: field.reqd, })); } diff --git a/src/app/components/DatePicker.tsx b/src/app/components/DatePicker.tsx index 429e5b7..4c9fd7c 100644 --- a/src/app/components/DatePicker.tsx +++ b/src/app/components/DatePicker.tsx @@ -1,18 +1,19 @@ +import DateTimePicker, { + DateTimePickerAndroid, + type DateTimePickerEvent, +} from '@react-native-community/datetimepicker'; +import { Calendar } from 'lucide-react-native'; import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { - View, - Text, - TouchableOpacity, Modal, Platform, StyleSheet, + Text, + TouchableOpacity, + View, } from 'react-native'; -import DateTimePicker, { - DateTimePickerAndroid, - type DateTimePickerEvent, -} from '@react-native-community/datetimepicker'; import { useTheme } from '../../context/ThemeContext'; -import { useTranslation } from 'react-i18next'; interface DatePickerProps { value?: string; @@ -129,6 +130,7 @@ const DatePicker: React.FC = ({ > {value ? formatDisplayDate(value) : placeholder} + {Platform.OS === 'ios' && datePickerVisible && ( diff --git a/src/app/components/LinkDropdown.tsx b/src/app/components/LinkDropdown.tsx index 535f1dd..9238cbf 100644 --- a/src/app/components/LinkDropdown.tsx +++ b/src/app/components/LinkDropdown.tsx @@ -1,17 +1,17 @@ +import { ChevronDown } from 'lucide-react-native'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { - View, - Text, - TouchableOpacity, - ScrollView, ActivityIndicator, + ScrollView, + Text, TextInput, + TouchableOpacity, + View, } from 'react-native'; -import { ChevronDown } from 'lucide-react-native'; -import { useTheme } from '../../context/ThemeContext'; +import { getLinkOptionsFromLocal, saveLinkOptionsToLocal } from '../../api'; import { useNetwork } from '../../context/NetworkProvider'; +import { useTheme } from '../../context/ThemeContext'; import { getLinkOptions } from '../../lib/hey-api/client/sdk.gen'; -import { getLinkOptionsFromLocal, saveLinkOptionsToLocal } from '../../api'; type LinkDropdownProps = { doctype: string; // linked doctype to fetch options for @@ -41,27 +41,9 @@ const LinkDropdown: React.FC = ({ const hasLoadedRef = useRef(false); const containerStyle = { - position: 'relative' as const, zIndex: containerZIndex, }; - const dropdownStyle = { - position: 'absolute' as const, - top: 45, - left: 0, - right: 0, - zIndex: 2000, - backgroundColor: theme.dropdownBg, - borderWidth: 1.5, - borderColor: theme.border, - borderRadius: 8, - shadowColor: theme.shadow, - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.25, - shadowRadius: 8, - elevation: 20, - }; - const filteredOptions = useMemo(() => { if (!searchTerm.trim()) { return allOptions; @@ -75,13 +57,8 @@ const LinkDropdown: React.FC = ({ [filteredOptions] ); - const dropdownMaxHeight = Math.min( - Math.max(displayOptions.length, 4) * 48 + 56, - 480 - ); - const scrollViewStyle = { - maxHeight: dropdownMaxHeight - 56, + maxHeight: 250, }; const normalizedDoctype = useMemo(() => (doctype || '').trim(), [doctype]); @@ -257,7 +234,7 @@ const LinkDropdown: React.FC = ({ return ( = ({ {value || placeholder} = ({ {isOpen && ( - + {loading ? ( @@ -298,13 +290,14 @@ const LinkDropdown: React.FC = ({ ) : ( <> - + setSearchTerm(text)} @@ -323,7 +316,7 @@ const LinkDropdown: React.FC = ({ return ( = ({ optIndex < displayOptions.length - 1 ? theme.border : undefined, + borderBottomWidth: optIndex < displayOptions.length - 1 ? 0.5 : 0, }} onPress={() => { onValueChange(trimmedOption); @@ -341,6 +335,7 @@ const LinkDropdown: React.FC = ({ style={{ color: theme.text, fontWeight, + fontSize: 15, }} > {trimmedOption} diff --git a/src/app/components/SelectDropdown.tsx b/src/app/components/SelectDropdown.tsx index cd4bead..4c1ea22 100644 --- a/src/app/components/SelectDropdown.tsx +++ b/src/app/components/SelectDropdown.tsx @@ -1,5 +1,5 @@ import { ChevronDown } from 'lucide-react-native'; -import React, { useEffect } from 'react'; +import React from 'react'; import { ScrollView, Text, TouchableOpacity, View } from 'react-native'; import { useTheme } from '../../context/ThemeContext'; @@ -11,7 +11,6 @@ interface SelectDropdownProps { isOpen: boolean; onToggle: () => void; containerZIndex?: number; - dependsOn?: string; formData?: Record; } @@ -23,88 +22,39 @@ const SelectDropdown: React.FC = ({ isOpen, onToggle, containerZIndex, - dependsOn, - formData, }) => { const { theme } = useTheme(); const containerStyle = { - position: 'relative' as const, zIndex: containerZIndex, }; - const dropdownStyle = { - position: 'absolute' as const, - top: 45, - left: 0, - right: 0, - zIndex: 2000, - backgroundColor: theme.dropdownBg, - borderWidth: 1.5, - borderColor: theme.border, - borderRadius: 8, - shadowColor: theme.shadow, - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.25, - shadowRadius: 8, - elevation: 20, - maxHeight: 250, - }; - const scrollViewStyle = { maxHeight: 250, }; - // 🔥 Compute disabled state - const isDisabled = React.useMemo(() => { - if (!dependsOn) return false; - - if (dependsOn.startsWith('eval:doc.')) { - const regex = /^eval:doc\.([a-zA-Z0-9_]+)\s*==\s*["'](.+)["']$/; - const match = dependsOn.match(regex); - - if (match && formData) { - const [_, fieldName, expectedValue] = match; - return formData[fieldName] !== expectedValue; - } - return true; - } - - return false; - }, [dependsOn, formData]); - - // 🔄 Reset value when it becomes disabled - useEffect(() => { - if (isDisabled && value !== "") { - onValueChange(""); - } - }, [isDisabled, value]); - return ( {/* Dropdown Toggle Button */} { - if (!isDisabled) onToggle(); // prevent opening when disabled + backgroundColor: theme.background, }} + onPress={onToggle} > {value || placeholder} = ({ /> - {/* Dropdown Options - Always render structure, only open if isOpen */} + {/* Pushes content down when open */} {isOpen && ( - + {options.length > 0 ? ( options.map((option: string, optIndex: number) => { @@ -124,22 +89,22 @@ const SelectDropdown: React.FC = ({ return ( { - if (!isDisabled) onValueChange(trimmedOption); - }} + onPress={() => onValueChange(trimmedOption)} > {trimmedOption} diff --git a/src/app/components/fields/CheckboxInput.tsx b/src/app/components/fields/CheckboxInput.tsx new file mode 100644 index 0000000..7d8886c --- /dev/null +++ b/src/app/components/fields/CheckboxInput.tsx @@ -0,0 +1,51 @@ +import { Check } from 'lucide-react-native'; +import React from 'react'; +import { Text, TouchableOpacity, View } from 'react-native'; +import { useTheme } from '../../../context/ThemeContext'; + +interface CheckboxInputProps { + value?: number | boolean; + onValueChange?: (value: number) => void; + label?: string; +} + +const CheckboxInput: React.FC = ({ + value, + onValueChange, + label, +}) => { + const { theme } = useTheme(); + const isChecked = value === 1 || value === true; + + const handleToggle = () => { + if (onValueChange) { + onValueChange(isChecked ? 0 : 1); + } + }; + + return ( + + + {isChecked && } + + {label && ( + + {label} + + )} + + ); +}; + +export default CheckboxInput; diff --git a/src/app/components/fields/CurrencyInput.tsx b/src/app/components/fields/CurrencyInput.tsx new file mode 100644 index 0000000..e386417 --- /dev/null +++ b/src/app/components/fields/CurrencyInput.tsx @@ -0,0 +1,73 @@ +import React, { useState } from 'react'; +import { TextInput as RNTextInput, Text, TextInputProps, View } from 'react-native'; +import { useTheme } from '../../../context/ThemeContext'; + +interface CurrencyInputProps extends Omit { + className?: string; + value?: string; + onChangeText?: (text: string) => void; +} + +const formatCurrency = (value: string): string => { + // Remove all non-digit characters + const numericValue = value.replace(/[^0-9]/g, ''); + + if (!numericValue) return ''; + + // Add commas for Indian number system (lakhs and crores) + const number = parseInt(numericValue, 10); + return number.toLocaleString('en-IN'); +}; + +const CurrencyInput: React.FC = ({ + className = "h-[40px] w-full rotate-0 rounded-md border pb-2.5 pl-3 pr-3 pt-2.5 opacity-100", + value, + onChangeText, + ...props +}) => { + const { theme } = useTheme(); + const [displayValue, setDisplayValue] = useState(formatCurrency(value || '')); + + const handleTextChange = (text: string) => { + // Remove commas to get raw numeric value + const rawValue = text.replace(/,/g, ''); + + // Format for display + const formatted = formatCurrency(rawValue); + setDisplayValue(formatted); + + // Pass raw numeric value to parent + if (onChangeText) { + onChangeText(rawValue); + } + }; + + // Update display value when prop value changes + React.useEffect(() => { + setDisplayValue(formatCurrency(value || '')); + }, [value]); + + return ( + + + ₹ + + + + ); +}; + +export default CurrencyInput; diff --git a/src/app/components/fields/HeadingText.tsx b/src/app/components/fields/HeadingText.tsx new file mode 100644 index 0000000..cf912a5 --- /dev/null +++ b/src/app/components/fields/HeadingText.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { useTheme } from '../../../context/ThemeContext'; + +interface HeadingTextProps { + label: string; +} + +const HeadingText: React.FC = ({ label }) => { + const { theme } = useTheme(); + + return ( + + {label} + + ); +}; + +export default HeadingText; diff --git a/src/app/components/fields/PhoneInput.tsx b/src/app/components/fields/PhoneInput.tsx new file mode 100644 index 0000000..2381ea6 --- /dev/null +++ b/src/app/components/fields/PhoneInput.tsx @@ -0,0 +1,146 @@ +import React, { useState } from 'react'; +import { + TextInput as RNTextInput, + TextInputProps, + View +} from 'react-native'; +import { useTheme } from '../../../context/ThemeContext'; + +interface PhoneInputProps extends Omit { + className?: string; + value?: string; + onChangeText?: (text: string) => void; +} + + +const parsePhoneValue = (input?: string) => { + // If value is already in +{countrycode}-{phonenumber} format, split it + if (input && input.startsWith('+') && input.includes('-')) { + const [country, number] = input.split('-'); + return { + countryCode: country.replace('+', ''), + phoneNumber: number || '', + }; + } + // If only 10 digits, treat as Indian number + if (input && /^[0-9]{10}$/.test(input)) { + return { + countryCode: '91', + phoneNumber: input, + }; + } + // Fallback + return { + countryCode: '91', + phoneNumber: '', + }; +}; + +const PhoneInput: React.FC = ({ + className = "h-[40px] w-full rotate-0 rounded-md border pb-2.5 pl-3 pr-3 pt-2.5 opacity-100", + value, + onChangeText, + ...props +}) => { + const { theme } = useTheme(); + // Parse value into country code and phone number + const parsed = parsePhoneValue(value); + const [countryCode, setCountryCode] = useState(parsed.countryCode); + const [phoneNumber, setPhoneNumber] = useState(parsed.phoneNumber); + + React.useEffect(() => { + const parsed = parsePhoneValue(value); + setCountryCode(parsed.countryCode); + setPhoneNumber(parsed.phoneNumber); + }, [value]); + + + // Always emit in +{countrycode}-{phonenumber} format, even if incomplete + const emitPhoneValue = (cc: string, pn: string) => { + // Always emit in +{countrycode}-{phonenumber} format + const formatted = `+${cc || ''}-${pn || ''}`; + onChangeText && onChangeText(formatted); + }; + + const handlePhoneChange = (text: string) => { + let numeric = text.replace(/[^0-9]/g, ''); + if (numeric.length > 10) { + numeric = numeric.slice(0, 10); + } + setPhoneNumber(numeric); + emitPhoneValue(countryCode, numeric); + }; + + const handleCountryCodeChange = (text: string) => { + let numeric = text.replace(/[^0-9]/g, ''); + if (numeric.length > 4) { + numeric = numeric.slice(0, 4); + } + setCountryCode(numeric); + emitPhoneValue(numeric, phoneNumber); + }; + + return ( + + + + { + let numeric = text.replace(/[^0-9]/g, ''); + if (numeric.length > 4) numeric = numeric.slice(0, 4); + setCountryCode(numeric); + emitPhoneValue(numeric, phoneNumber); + }} + maxLength={5} + /> + + + + ); +}; + +export default PhoneInput; diff --git a/src/app/components/fields/SectionBreak.tsx b/src/app/components/fields/SectionBreak.tsx new file mode 100644 index 0000000..baef566 --- /dev/null +++ b/src/app/components/fields/SectionBreak.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Text, View } from 'react-native'; +import { useTheme } from '../../../context/ThemeContext'; + +interface SectionBreakProps { + label?: string; +} + +const SectionBreak: React.FC = ({ label }) => { + const { theme } = useTheme(); + + if (!label || label.trim() === '') { + return ( + + + + ); + } + + return ( + + + + {label} + + + ); +}; + +export default SectionBreak; diff --git a/src/app/screens/files/Forms.tsx b/src/app/screens/files/Forms.tsx index 0b722d3..9bfc021 100644 --- a/src/app/screens/files/Forms.tsx +++ b/src/app/screens/files/Forms.tsx @@ -753,13 +753,14 @@ function Forms() { return ( { console.log( 'Navigating to PreviewForm with formId:', @@ -784,16 +785,19 @@ function Forms() { handleSubmitSingleForm(formData)} > - {t('formsScreen.submitForm')} - ); diff --git a/src/app/screens/files/PreviewForm.tsx b/src/app/screens/files/PreviewForm.tsx index 13af6a7..9fe3fb6 100644 --- a/src/app/screens/files/PreviewForm.tsx +++ b/src/app/screens/files/PreviewForm.tsx @@ -1,30 +1,35 @@ +import { FormStackParamList } from '@/app/navigation/FormStackParamList'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { ArrowLeft } from 'lucide-react-native'; import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Modal, - TouchableOpacity, - View, + ScrollView, Text, TextInput, - ScrollView, + TouchableOpacity, + View, } from 'react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { extractFields, getDocTypeFromLocal } from '../../../api'; +import { useTheme } from '../../../context/ThemeContext'; +import { usePendingFormsExport } from '../../../hooks/usePendingFormsExport'; +import { RawField, SubmissionItem } from '../../../types'; +import DatePicker from '../../components/DatePicker'; import LanguageControl from '../../components/LanguageControl'; -import SelectDropdown from '../../components/SelectDropdown'; import LinkDropdown from '../../components/LinkDropdown'; -import DatePicker from '../../components/DatePicker'; +import SelectDropdown from '../../components/SelectDropdown'; import TableField from '../../components/TableField'; -import { useTranslation } from 'react-i18next'; -import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; -import { SubmissionItem, RawField } from '../../../types'; +import CheckboxInput from '../../components/fields/CheckboxInput'; +import CurrencyInput from '../../components/fields/CurrencyInput'; +import HeadingText from '../../components/fields/HeadingText'; +import PhoneInput from '../../components/fields/PhoneInput'; +import SectionBreak from '../../components/fields/SectionBreak'; import { getQueue, removeFromQueue } from '../../pendingQueue'; -import { FormStackParamList } from '@/app/navigation/FormStackParamList'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { useTheme } from '../../../context/ThemeContext'; -import { extractFields, getDocTypeFromLocal } from '../../../api'; -import { usePendingFormsExport } from '../../../hooks/usePendingFormsExport'; type PreviewFormRouteProp = RouteProp; type PreviewFormNavigationProp = NativeStackNavigationProp< @@ -373,300 +378,211 @@ function PreviewForm() { 'Link', 'Date', 'Table', + 'Check', + 'Phone', + 'Currency', + 'Heading', + 'Section Break', + ]; + const fieldsToRender = formFields.length > 0 ? formFields - .filter(field => { - // Skip if hidden, print_hide, or report_hide is true (value is 1 or truthy) - if (field.hidden || field.print_hide || field.report_hide) { - return false; - } - return allowedFieldTypes.includes(field.fieldtype || 'Data'); - }) - .map(field => ({ - fieldname: field.fieldname, - label: - field.label || - field.fieldname.charAt(0).toUpperCase() + - field.fieldname.slice(1).replace(/([A-Z])/g, ' $1'), - fieldtype: field.fieldtype || 'Data', - options: field.options, - value: formData[field.fieldname], - })) - : Object.keys(formData).map(key => ({ - fieldname: key, + .filter(field => { + // Skip if hidden, print_hide, or report_hide is true (value is 1 or truthy) + if (field.hidden || field.print_hide || field.report_hide) { + return false; + } + return allowedFieldTypes.includes(field.fieldtype || 'Data'); + }) + .map(field => ({ + fieldname: field.fieldname, label: - key.charAt(0).toUpperCase() + - key.slice(1).replace(/([A-Z])/g, ' $1'), - fieldtype: typeof formData[key] === 'boolean' ? 'Check' : 'Data', - options: undefined, - value: formData[key], - })); + field.label || + field.fieldname.charAt(0).toUpperCase() + + field.fieldname.slice(1).replace(/([A-Z])/g, ' $1'), + fieldtype: field.fieldtype || 'Data', + options: field.options, + value: formData[field.fieldname], + })) + : Object.keys(formData).map(key => ({ + fieldname: key, + label: + key.charAt(0).toUpperCase() + + key.slice(1).replace(/([A-Z])/g, ' $1'), + fieldtype: typeof formData[key] === 'boolean' ? 'Check' : 'Data', + options: undefined, + value: formData[key], + })); // Helper function to render field based on type const renderField = (field: any, index: number = 0) => { - const { fieldname, label, fieldtype, options, value } = field; + const { fieldname, label, fieldtype, options, value, reqd } = field; const isOpen = dropdownStates[fieldname] || false; + const isRequired = reqd === 1; - switch (fieldtype) { - case 'Select': - if (options) { - const optionsList = options - .split('\n') - .filter((opt: string) => opt.trim()); - - return ( - - - {label} - - handleChange(fieldname, val)} - placeholder={t('formDetail.selectPlaceholder', { - label: label, - })} - isOpen={isOpen} - onToggle={() => toggleDropdown(fieldname)} - containerZIndex={1000 - index} - /> - - ); - } - // Fallback to text input if no options - return ( - - - {label} - - handleChange(fieldname, text)} - editable={true} - /> - - ); - - case 'Link': - if (options) { - return ( - - - {label} - - handleChange(fieldname, val)} - placeholder={t('formDetail.selectPlaceholder', { - label: label, - })} - isOpen={isOpen} - onToggle={() => toggleDropdown(fieldname)} - containerZIndex={1000 - index} - /> - - ); - } - // Fallback to text input if no doctype - return ( - - - {label} - - handleChange(fieldname, text)} - editable={true} - /> - - ); - - case 'Date': - return ( - - - {label} - - handleChange(fieldname, val)} - placeholder={t('formDetail.selectPlaceholder', { - label: label, - })} - /> - - ); - - case 'Check': - const checkBoxStyle = { - backgroundColor: value ? theme.buttonBackground : 'transparent', - borderColor: value ? theme.buttonBackground : theme.border, - }; - return ( - - handleChange(fieldname, !value)} - > - - {value && ( - - ✓ - - )} - - - {label} - - - - ); - - case 'Text': - return ( - - - {label} - - handleChange(fieldname, text)} - multiline={true} - textAlignVertical="top" - editable={true} - /> - - ); + // SectionBreak and Heading do not need label above + if (fieldtype === 'Section Break') { + return ; + } + if (fieldtype === 'Heading') { + return ; + } - case 'Table': { - const tableSchema = tableSchemas[fieldname]; - return ( - - - {label} - - - // @ts-ignore - (navigation as any).navigate('TableRowEditor', { - fieldname, - tableDoctype: (options as string) || '', - title: label, - index: rowIndex, - initialRow: - Array.isArray(value) && value[rowIndex] - ? value[rowIndex] - : null, - schema: tableSchema || undefined, - }) + return ( + + {/* Field label and required asterisk */} + {fieldtype !== 'Check' && ( + + {label} + {isRequired && *} + + )} + + {(() => { + switch (fieldtype) { + case 'Select': + if (options) { + const optionsList = options + .split('\n') + .filter((opt: string) => opt.trim()); + return ( + handleChange(fieldname, val)} + placeholder={t('formDetail.selectPlaceholder', { label })} + isOpen={isOpen} + onToggle={() => toggleDropdown(fieldname)} + containerZIndex={1000 - index} + formData={formData} + /> + ); } - onDeleteRow={rowIndex => { - const current = Array.isArray(value) - ? [...(value as any[])] - : []; - if (rowIndex >= 0 && rowIndex < current.length) { - current.splice(rowIndex, 1); - handleChange( - fieldname, - current as unknown as string | boolean - ); - } - }} - /> - - ); - } - - default: - // Default to regular text input - return ( - - - {label} - - handleChange(fieldname, text)} - editable={true} - /> - - ); - } + return null; + case 'Link': + if (options) { + return ( + handleChange(fieldname, val)} + placeholder={t('formDetail.selectPlaceholder', { label })} + isOpen={isOpen} + onToggle={() => toggleDropdown(fieldname)} + containerZIndex={1000 - index} + /> + ); + } + return null; + case 'Date': + return ( + handleChange(fieldname, val)} + placeholder={t('formDetail.selectPlaceholder', { label })} + /> + ); + case 'Table': { + const tableSchema = tableSchemas[fieldname]; + return ( + + (navigation as any).navigate('TableRowEditor', { + fieldname, + tableDoctype: (options as string) || '', + title: label, + index: rowIndex, + initialRow: + Array.isArray(value) && value[rowIndex] + ? value[rowIndex] + : null, + schema: tableSchema || undefined, + }) + } + onDeleteRow={rowIndex => { + const current = Array.isArray(value) + ? [...(value as any[])] + : []; + if (rowIndex >= 0 && rowIndex < current.length) { + current.splice(rowIndex, 1); + handleChange( + fieldname, + current as unknown as string | boolean + ); + } + }} + /> + ); + } + case 'Currency': + return ( + handleChange(fieldname, text)} + /> + ); + case 'Phone': + return ( + handleChange(fieldname, text)} + /> + ); + case 'Check': + return ( + handleChange(fieldname, val)} + label={label} + /> + ); + case 'Text': + return ( + handleChange(fieldname, text)} + multiline={true} + textAlignVertical="top" + editable={true} + /> + ); + default: + return ( + handleChange(fieldname, text)} + editable={true} + /> + ); + } + })()} + + ); }; return ( diff --git a/src/app/screens/home/FormDetail.tsx b/src/app/screens/home/FormDetail.tsx index a374644..16c058a 100644 --- a/src/app/screens/home/FormDetail.tsx +++ b/src/app/screens/home/FormDetail.tsx @@ -26,11 +26,21 @@ import { useNetwork } from '../../../context/NetworkProvider'; import { useTheme } from '../../../context/ThemeContext'; import generateSchemaHash from '../../../helper/hashFunction'; import { RawField } from '../../../types'; +import { + formatFloatToFixed, + validateFloatInput, + validateIntegerInput, +} from '../../../utils/fieldValidation'; import DatePicker from '../../components/DatePicker'; import LanguageControl from '../../components/LanguageControl'; import LinkDropdown from '../../components/LinkDropdown'; import SelectDropdown from '../../components/SelectDropdown'; import TableField from '../../components/TableField'; +import CheckboxInput from '../../components/fields/CheckboxInput'; +import CurrencyInput from '../../components/fields/CurrencyInput'; +import HeadingText from '../../components/fields/HeadingText'; +import PhoneInput from '../../components/fields/PhoneInput'; +import SectionBreak from '../../components/fields/SectionBreak'; import { enqueue } from '../../pendingQueue'; type FormDetailRouteProp = RouteProp; @@ -63,15 +73,70 @@ const FormDetail: React.FC = ({ navigation }) => { const isFieldEnabled = useCallback((field: RawField) => { if (!field.depends_on) return true; - if (field.depends_on.startsWith('eval:doc.')) { - const regex = /^eval:doc\.([a-zA-Z0-9_]+)\s*==\s*["'](.+)["']$/; - const match = field.depends_on.match(regex); - if (match) { - const [_, fieldName, expectedValue] = match; - return formData[fieldName] === expectedValue; + if (field.depends_on.startsWith('eval:')) { + try { + // Remove 'eval:' prefix + let expression = field.depends_on.substring(5).trim(); + + // Replace 'doc.' with actual formData values + // First, find all unique field references + const fieldMatches = expression.matchAll(/doc\.([a-zA-Z0-9_]+)/g); + const fieldReplacements: Record = {}; + + for (const match of fieldMatches) { + const fieldName = match[1]; + const fieldValue = formData[fieldName]; + // Store the value to replace later + if (!fieldReplacements[fieldName]) { + fieldReplacements[fieldName] = fieldValue || ''; + } + } + + // Now evaluate by splitting on OR conditions + const orConditions = expression.split('||').map(cond => cond.trim()); + + // Check if any OR condition is true + const result = orConditions.some(condition => { + // Split by AND operator (&&) + const andConditions = condition.split('&&').map(cond => cond.trim()); + + // All AND conditions must be true + return andConditions.every(andCond => { + // Match pattern: doc.fieldname == "value" + const regex = /doc\.([a-zA-Z0-9_]+)\s*==\s*["']([^"']*)["']/; + const match = andCond.match(regex); + + if (match) { + const [_, fieldName, expectedValue] = match; + const actualValue = formData[fieldName]; + const matches = actualValue === expectedValue; + + // Debug logging + console.log('Depends_on check:', { + fieldLabel: field.label, + condition: andCond, + fieldName, + expectedValue, + actualValue, + matches + }); + + return matches; + } + + console.log('Depends_on regex no match:', andCond); + return false; + }); + }); + + console.log('Final result for', field.label, ':', result); + return result; + } catch (error) { + console.error('Error evaluating depends_on:', field.depends_on, error); + return false; } - return false; } + return true; }, [formData]); @@ -114,6 +179,12 @@ const FormDetail: React.FC = ({ navigation }) => { 'Link', 'Date', 'Table', + 'Check', + 'Phone', + 'Currency', + 'Heading', + 'Section Break', + ].includes(field.fieldtype); }); @@ -144,14 +215,21 @@ const FormDetail: React.FC = ({ navigation }) => { return; } - // Only check fields that are enabled (not disabled by depends_on) + // Only check fields that are enabled (not disabled by depends_on) AND required const missingFields = fields.filter(field => { + // Skip display-only fields (Section Break, Heading) + const isDisplayOnly = field.fieldtype === 'Section Break' || field.fieldtype === 'Heading'; + if (isDisplayOnly) { + return false; + } + const isEnabled = isFieldEnabled(field); + const isRequired = field.reqd === 1; const isEmpty = !formData[field.fieldname] || formData[field.fieldname].toString().trim() === ''; - - // Only report as missing if the field is enabled AND empty - return isEnabled && isEmpty; + + // Only report as missing if the field is enabled AND required AND empty + return isEnabled && isRequired && isEmpty; }); if (missingFields.length > 0) { @@ -165,10 +243,6 @@ const FormDetail: React.FC = ({ navigation }) => { return; } - if (Object.keys(formData).length === 0) { - Alert.alert(t('common.error'), t('formDetail.noData')); - return; - } setConfirmModalVisible(true); }; @@ -190,9 +264,8 @@ const FormDetail: React.FC = ({ navigation }) => { data: formData, schemaHash, status: 'pending' as 'pending' | 'submitted' | 'failed', - is_submittable: doctype.data?.is_submittable ?? 0, + is_submittable: doctype.data.is_submittable }; - setLoading(true); setConfirmModalVisible(false); try { @@ -386,6 +459,10 @@ const FormDetail: React.FC = ({ navigation }) => { {fields.map((field, index) => { + // Check if field should be visible based on depends_on + const isEnabled = isFieldEnabled(field); + if (!isEnabled) return null; + const isSelectField = field.fieldtype === 'Select' && field.options; const optionsList = @@ -401,6 +478,12 @@ const FormDetail: React.FC = ({ navigation }) => { const selectedValue = formData[field.fieldname]; const isNumericField = field.fieldtype === 'Int' || field.fieldtype === 'Float'; + const isCurrencyField = field.fieldtype === 'Currency'; + const isPhoneField = field.fieldtype === 'Phone'; + const isCheckField = field.fieldtype === 'Check'; + const isHeading = field.fieldtype === 'Heading'; + const isSectionBreak = field.fieldtype === 'Section Break'; + const isRequired = field.reqd === 1; return ( = ({ navigation }) => { className="mb-4" style={{ zIndex: 1000 - index }} > - - {field.label || field.fieldname} - - {isSelectField ? ( + {!isHeading && !isSectionBreak && !isCheckField && ( + + {field.label} + {isRequired && *} + + )} + {isSectionBreak ? ( + + ) : isHeading ? ( + + ) : isSelectField ? ( handleChange(field.fieldname, value) } placeholder={t('formDetail.selectPlaceholder', { - label: field.label || field.fieldname, + label: field.label, })} isOpen={isOpen} onToggle={() => toggleDropdown(field.fieldname)} @@ -438,7 +527,7 @@ const FormDetail: React.FC = ({ navigation }) => { handleChange(field.fieldname, value) } placeholder={t('formDetail.selectPlaceholder', { - label: field.label || field.fieldname, + label: field.label, })} isOpen={isOpen} onToggle={() => toggleDropdown(field.fieldname)} @@ -451,7 +540,7 @@ const FormDetail: React.FC = ({ navigation }) => { handleChange(field.fieldname, value) } placeholder={t('formDetail.selectPlaceholder', { - label: field.label || field.fieldname, + label: field.label, })} /> ) : isTableField ? ( @@ -461,14 +550,14 @@ const FormDetail: React.FC = ({ navigation }) => { (navigation as any).navigate('TableRowEditor', { fieldname: field.fieldname, tableDoctype: (field.options as string) || '', - title: field.label || field.fieldname, + title: field.label, }) } onEditRow={rowIndex => (navigation as any).navigate('TableRowEditor', { fieldname: field.fieldname, tableDoctype: (field.options as string) || '', - title: field.label || field.fieldname, + title: field.label, index: rowIndex, initialRow: Array.isArray(selectedValue) && @@ -495,6 +584,34 @@ const FormDetail: React.FC = ({ navigation }) => { } }} /> + ) : isCurrencyField ? ( + + handleChange(field.fieldname, text) + } + /> + ) : isPhoneField ? ( + + handleChange(field.fieldname, text) + } + /> + ) : isCheckField ? ( + + handleChange(field.fieldname, value) + } + label={field.label || t('formDetail.checkboxLabel')} + /> ) : ( = ({ navigation }) => { color: theme.text, }} placeholder={t('formDetail.enterPlaceholder', { - label: field.label || field.fieldname, + label: field.label, })} placeholderTextColor={theme.subtext} value={formData[field.fieldname] || ''} keyboardType={isNumericField ? 'numeric' : 'default'} - onChangeText={text => - handleChange(field.fieldname, text) - } + onChangeText={text => { + if (field.fieldtype === 'Int') { + handleChange(field.fieldname, validateIntegerInput(text)); + } else if (field.fieldtype === 'Float') { + handleChange(field.fieldname, validateFloatInput(text)); + } else { + handleChange(field.fieldname, text); + } + }} + onBlur={() => { + if (field.fieldtype === 'Float' && formData[field.fieldname]) { + handleChange( + field.fieldname, + formatFloatToFixed(formData[field.fieldname]) + ); + } + }} /> )} diff --git a/src/app/screens/home/FormsList.tsx b/src/app/screens/home/FormsList.tsx index 472f831..ea77756 100644 --- a/src/app/screens/home/FormsList.tsx +++ b/src/app/screens/home/FormsList.tsx @@ -37,6 +37,8 @@ export interface FormItem { const additionalDoctype = [ 'Combating Malnutrition Basic Data', 'PGS Peer Appraisal Basic Data', + 'Nutri Garden Household Nutrition Survey Tool', + 'Data Registers Farmer Transition to NF', 'Testing DocType', ]; @@ -177,10 +179,14 @@ const FormsList = () => { }); }} > - + {item.name} - + {isConnected && (itemState.isDownloading ? ( diff --git a/src/helper/hashFunction.ts b/src/helper/hashFunction.ts index bf37a94..35e2f20 100644 --- a/src/helper/hashFunction.ts +++ b/src/helper/hashFunction.ts @@ -1,22 +1,70 @@ -import SHA256 from 'crypto-js/sha256'; -import encHex from 'crypto-js/enc-hex'; import { RawField } from '@/types'; +import encHex from 'crypto-js/enc-hex'; +import SHA256 from 'crypto-js/sha256'; + +const LAYOUT_FIELD_TYPES = new Set([ + 'Section Break', + 'Column Break', + 'HTML', +]); + +function normalizeFieldname( + name: string, + allNames: string[] +): string { + const base = name.replace(/\d+$/, ''); + if (base !== name && allNames.filter(n => n === base).length === 1) { + return base; + } + return name; +} + +function normalizeOptions( + fieldtype: string, + options: unknown +): string { + if (!options) return ''; -function generateSchemaHash(fields: RawField[]): string { - const simplifiedFields = fields.map(f => ({ - fieldname: f.fieldname, - fieldtype: f.fieldtype, - options: f.options || '', - })); + const str = String(options).trim(); - const sorted = simplifiedFields.sort((a, b) => - a.fieldname.localeCompare(b.fieldname) + if (fieldtype === 'Select') { + return str + .split('\n') + .map(s => s.trim()) + .filter(Boolean) + .join('\n'); + } + + return str; +} + +function generateSchemaHash( + fields: RawField[] +):string { + + const allNames = fields.map(f => f.fieldname || ''); + + const simplified = fields + .filter(f => !LAYOUT_FIELD_TYPES.has(f.fieldtype)) + .map(f => ({ + fieldname: normalizeFieldname(f.fieldname, allNames), + fieldtype: f.fieldtype, + options: normalizeOptions(f.fieldtype, f.options), + })); + + simplified.sort((a, b) => + a.fieldname.localeCompare(b.fieldname) || + a.fieldtype.localeCompare(b.fieldtype) || + a.options.localeCompare(b.options) ); - const concatStr = sorted + const concatStr = simplified .map(f => `${f.fieldname}:${f.fieldtype}:${f.options}`) .join('|'); - return SHA256(concatStr).toString(encHex); + const hash = SHA256(concatStr).toString(encHex); + + return hash; } + export default generateSchemaHash; diff --git a/src/types.ts b/src/types.ts index 3060624..1d3a37d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,7 @@ export type RawField = { print_hide?: number; report_hide?: number; depends_on?: string; + reqd:number; }; export interface DocType { data: Data; diff --git a/src/utils/fieldValidation.ts b/src/utils/fieldValidation.ts new file mode 100644 index 0000000..6f5d80b --- /dev/null +++ b/src/utils/fieldValidation.ts @@ -0,0 +1,44 @@ +/** + * Validates and formats integer input + * Removes decimals and non-numeric characters, allows negative numbers + */ +export const validateIntegerInput = (text: string): string => { + // Only allow integers (no decimals or other characters) + const integerOnly = text.replace(/[^0-9-]/g, ''); + // Ensure minus sign only appears at the start + const validInteger = integerOnly.replace(/(?!^)-/g, ''); + return validInteger; +}; + +/** + * Validates and formats float input during typing + * Allows up to 3 decimal places, handles negative numbers + */ +export const validateFloatInput = (text: string): string => { + // Remove non-numeric characters except decimal point and minus + let cleaned = text.replace(/[^0-9.-]/g, ''); + // Ensure minus sign only at start + cleaned = cleaned.replace(/(?!^)-/g, ''); + // Ensure only one decimal point + const parts = cleaned.split('.'); + if (parts.length > 2) { + cleaned = parts[0] + '.' + parts.slice(1).join(''); + } + // Limit to 3 decimal places + if (parts.length === 2 && parts[1].length > 3) { + cleaned = parts[0] + '.' + parts[1].substring(0, 3); + } + return cleaned; +}; + +/** + * Formats float value to exactly 3 decimal places + * Called when user finishes editing (onBlur) + */ +export const formatFloatToFixed = (value: string): string => { + const numValue = parseFloat(value); + if (!isNaN(numValue)) { + return numValue.toFixed(3); + } + return value; +};