From 36235460fe0d440403567caa2858783eb09c35f5 Mon Sep 17 00:00:00 2001 From: Ulises Mariscal Date: Tue, 22 Sep 2026 15:54:23 -0600 Subject: [PATCH 1/6] feat(tree): add type-aware AddFieldDialog for nested field creation --- .../components/tree/AddFieldDialog.tsx | 365 ++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 src/features/collections/components/tree/AddFieldDialog.tsx diff --git a/src/features/collections/components/tree/AddFieldDialog.tsx b/src/features/collections/components/tree/AddFieldDialog.tsx new file mode 100644 index 0000000..784be7b --- /dev/null +++ b/src/features/collections/components/tree/AddFieldDialog.tsx @@ -0,0 +1,365 @@ +import React, { useEffect, useState } from 'react'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + InputLabel, + MenuItem, + Select, + TextField, + Typography, +} from '@mui/material'; +import { FirestoreValue } from '../../../../shared/utils/firestoreUtils'; +import { formatDateForDateTimeLocal } from '../../../../shared/utils/dateUtils'; +import { MONOSPACE_FONT_FAMILY } from '../../../../shared/utils/constants'; + +export type AddFieldType = + 'String' | 'Integer' | 'Number' | 'Boolean' | 'Null' | 'Timestamp' | 'GeoPoint' | 'Array' | 'Map' | 'Reference'; + +const FIELD_TYPES: AddFieldType[] = [ + 'String', + 'Integer', + 'Number', + 'Boolean', + 'Null', + 'Timestamp', + 'GeoPoint', + 'Array', + 'Map', + 'Reference', +]; + +interface AddFieldDialogProps { + open: boolean; + parentPathLabel: string; + existingKeys: string[]; + onClose: () => void; + onSubmit: (fieldName: string, value: FirestoreValue) => void; +} + +const defaultTimestampLocal = (): string => { + try { + const formatted = formatDateForDateTimeLocal(new Date()); + if (formatted) return formatted.slice(0, 16); + } catch { + // fall through to ISO fallback + } + return new Date().toISOString().slice(0, 16); +}; + +/** + * AddFieldDialog Component + * Type-aware dialog for adding a field to a document or nested map in Tree view + */ +const AddFieldDialog: React.FC = ({ open, parentPathLabel, existingKeys, onClose, onSubmit }) => { + const [fieldName, setFieldName] = useState(''); + const [fieldType, setFieldType] = useState('String'); + const [stringValue, setStringValue] = useState(''); + const [numberValue, setNumberValue] = useState('0'); + const [boolValue, setBoolValue] = useState('true'); + const [timestampValue, setTimestampValue] = useState(defaultTimestampLocal()); + const [latValue, setLatValue] = useState('0'); + const [lngValue, setLngValue] = useState('0'); + const [jsonValue, setJsonValue] = useState('[]'); + const [referenceValue, setReferenceValue] = useState(''); + + useEffect(() => { + if (open) { + setFieldName(''); + setFieldType('String'); + setStringValue(''); + setNumberValue('0'); + setBoolValue('true'); + setTimestampValue(defaultTimestampLocal()); + setLatValue('0'); + setLngValue('0'); + setJsonValue('[]'); + setReferenceValue(''); + } + }, [open]); + + useEffect(() => { + if (fieldType === 'Array') setJsonValue('[]'); + if (fieldType === 'Map') setJsonValue('{}'); + }, [fieldType]); + + const trimmedName = fieldName.trim(); + const nameError = !trimmedName + ? 'Field name is required' + : trimmedName.includes('.') + ? 'Field name cannot contain dots' + : existingKeys.includes(trimmedName) + ? 'Field already exists' + : ''; + + const jsonError = (() => { + if (fieldType !== 'Array' && fieldType !== 'Map') return ''; + try { + const parsed = JSON.parse(jsonValue); + if (fieldType === 'Array' && !Array.isArray(parsed)) return 'Must be a valid JSON array'; + if (fieldType === 'Map' && (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))) + return 'Must be a valid JSON object'; + return ''; + } catch { + return 'Invalid JSON'; + } + })(); + + const geoError = (() => { + if (fieldType !== 'GeoPoint') return ''; + if (latValue.trim() === '' || lngValue.trim() === '' || isNaN(Number(latValue)) || isNaN(Number(lngValue))) + return 'Latitude and longitude must be numbers'; + return ''; + })(); + + const numberError = (() => { + if (fieldType !== 'Integer' && fieldType !== 'Number') return ''; + if (numberValue.trim() === '' || isNaN(Number(numberValue))) return 'Must be a valid number'; + return ''; + })(); + + const isValid = !nameError && !jsonError && !geoError && !numberError; + + const buildValue = (): FirestoreValue => { + switch (fieldType) { + case 'String': + return stringValue; + case 'Integer': + return Math.trunc(Number(numberValue)); + case 'Number': + return Number(numberValue); + case 'Boolean': + return boolValue === 'true'; + case 'Null': + return null; + case 'Timestamp': { + const date = new Date(timestampValue); + return { _seconds: Math.floor(date.getTime() / 1000), _nanoseconds: 0 }; + } + case 'GeoPoint': + return { _latitude: Number(latValue), _longitude: Number(lngValue) }; + case 'Array': + case 'Map': + return JSON.parse(jsonValue) as FirestoreValue; + case 'Reference': + return referenceValue; + default: + return stringValue; + } + }; + + const handleSave = () => { + if (!isValid) return; + onSubmit(trimmedName, buildValue()); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSave(); + } + }; + + return ( + + + Add Field + + {parentPathLabel} + + + + + Type: {fieldType} + + setFieldName(e.target.value)} + onKeyDown={handleKeyDown} + error={Boolean(nameError)} + helperText={nameError || 'Name for the new field'} + sx={{ mt: 1 }} + /> + + Type + + + + {fieldType === 'String' && ( + setStringValue(e.target.value)} + onKeyDown={handleKeyDown} + sx={{ + mt: 2, + '& .MuiInputBase-input': { fontFamily: MONOSPACE_FONT_FAMILY, fontSize: '0.9rem' }, + }} + /> + )} + {(fieldType === 'Integer' || fieldType === 'Number') && ( + setNumberValue(e.target.value)} + onKeyDown={handleKeyDown} + error={Boolean(numberError)} + helperText={numberError || undefined} + sx={{ + mt: 2, + '& .MuiInputBase-input': { fontFamily: MONOSPACE_FONT_FAMILY, fontSize: '0.9rem' }, + }} + /> + )} + {fieldType === 'Boolean' && ( + + Value + + + )} + {fieldType === 'Null' && ( + + No value needed — saves as null. + + )} + {fieldType === 'Timestamp' && ( + setTimestampValue(e.target.value)} + onKeyDown={handleKeyDown} + InputLabelProps={{ shrink: true }} + sx={{ + mt: 2, + '& .MuiInputBase-input': { fontFamily: MONOSPACE_FONT_FAMILY, fontSize: '0.9rem' }, + }} + /> + )} + {fieldType === 'GeoPoint' && ( + <> + setLatValue(e.target.value)} + onKeyDown={handleKeyDown} + sx={{ + mt: 2, + '& .MuiInputBase-input': { fontFamily: MONOSPACE_FONT_FAMILY, fontSize: '0.9rem' }, + }} + /> + setLngValue(e.target.value)} + onKeyDown={handleKeyDown} + sx={{ + mt: 2, + '& .MuiInputBase-input': { fontFamily: MONOSPACE_FONT_FAMILY, fontSize: '0.9rem' }, + }} + /> + {geoError && ( + + {geoError} + + )} + + )} + {(fieldType === 'Array' || fieldType === 'Map') && ( + setJsonValue(e.target.value)} + onKeyDown={handleKeyDown} + error={Boolean(jsonError)} + helperText={ + jsonError || (fieldType === 'Array' ? 'JSON array, e.g. ["a", 1]' : 'JSON object, e.g. {"k": "v"}') + } + placeholder={fieldType === 'Array' ? '["item1", "item2"]' : '{"key": "value"}'} + sx={{ + mt: 2, + '& .MuiInputBase-input': { + fontFamily: MONOSPACE_FONT_FAMILY, + fontSize: '0.9rem', + lineHeight: 1.5, + }, + }} + /> + )} + {fieldType === 'Reference' && ( + setReferenceValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="projects/p/databases/d/documents/c/doc" + sx={{ + mt: 2, + '& .MuiInputBase-input': { fontFamily: MONOSPACE_FONT_FAMILY, fontSize: '0.9rem' }, + }} + /> + )} + + Press Ctrl+Enter to save, Escape to cancel + + + + + + + + ); +}; + +export default AddFieldDialog; From a22afac672ed64eda4c00feebdb7b19c320f333f Mon Sep 17 00:00:00 2001 From: Ulises Mariscal Date: Tue, 22 Sep 2026 15:57:11 -0600 Subject: [PATCH 2/6] feat(tree): wire nested add-field plumbing from dialog to updateDocument --- odd/tasks/tree-add-fields.md | 44 ++++++++++ .../collections/components/CollectionTab.tsx | 80 +++++++++++++++++++ .../collections/components/TreeView.tsx | 4 + .../components/tree/TreeContext.ts | 1 + .../components/tree/TreeNodeRow.tsx | 31 ++++++- .../collections/services/documentService.ts | 41 ++++++++++ 6 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 odd/tasks/tree-add-fields.md diff --git a/odd/tasks/tree-add-fields.md b/odd/tasks/tree-add-fields.md new file mode 100644 index 0000000..e684f74 --- /dev/null +++ b/odd/tasks/tree-add-fields.md @@ -0,0 +1,44 @@ +# Feature: Add fields from Tree view — type-aware + +- **Feature id**: `tree-add-fields` +- **Branch**: `feat/add-fields` (already checked out, base `34923d6`) +- **Scope**: Documento + Maps anidados (user choice 2026-09-22). No Array indices. +- **Status**: in_progress + +## Objective + +Permitir agregar fields en Tree view especificando el tipo de valor, de forma intuitiva y alineada a la convención del proyecto. + +## Why + +JSON permite agregar pero no es intuitivo; Table solo edita top-level. Tree modela cada field anidado con path `docId.field.nested`. + +## Conventions to reuse (English artifacts) + +- Types: `getValueType` in `src/shared/utils/firestoreUtils.ts` → String, Integer, Number, Boolean, Null, Timestamp, GeoPoint, Array, Map (+ Reference as string passthrough). +- Colors: `getTypeColor(type, isDark)`. +- Dialogs: MUI `Dialog/Title/Content/Actions` like `AddDocumentDialog.tsx` + `EditDialog.tsx` (Ctrl+Enter to save, `MONOSPACE_FONT_FAMILY`, Type badge). +- Date input: `datetime-local` like `TreeNodeRow.tsx:144` + `DatePopover.tsx`, helpers in `dateUtils.ts` (`formatDateForDateTimeLocal`, `isIsoDateString`). +- Tree plumbing: `TreeContext.ts` + `TreeNodeRow.tsx` + `TreeView.tsx` + handlers in `CollectionTab.tsx` (`onCellEdit/onCellSave`, `updateDocument` thunk in `collectionSlice.ts`). +- Service: `documentService.ts` (`transformValueForSave`, `prepareUpdateData` top-level only) — add nested-path variant. + +## Tasks + +- [x] TD-1 `AddFieldDialog` type-aware component (`src/features/collections/components/tree/AddFieldDialog.tsx`): props `open, parentPathLabel, existingKeys, onClose, onSubmit(fieldName, value)`; field-name TextField with duplicate/empty validation; MUI Select of types String,Integer,Number,Boolean,Null,Timestamp,GeoPoint,Array,Map,Reference; per-type input (TextField / number / Select True-False / datetime-local / lat,lng / JSON multiline for Array,Map with JSON.parse + error / Reference string); default values per type; Ctrl+Enter saves. Reuse `MONOSPACE_FONT_FAMILY`, Type badge style from EditDialog. +- [x] TD-2 Nested add plumbing: `documentService.prepareAddData(docDataRecord, parentPath, fieldName, value)` (dot-path set, reject duplicates/empty, no Array indices); `TreeContext` += `onAddField(docId, parentPath, docData, docCollectionPath)`; `CollectionTab` implements it (opens dialog, on submit builds new data, dispatches `updateDocument`, refreshes, shows message); `TreeView` forwards through context; `TreeNodeRow` shows hover `Add` IconButton (AddIcon) on Document rows and Map rows → calls `onAddField` with nested path. +- [ ] TD-3 Verification: `pnpm typecheck`, `pnpm test`, `pnpm lint` (changed files), `pnpm format:check`; record per-task tier/outcome. TDD mode: off (no sdd-init capabilities found, no runner configured) → ordinary functional checks. + +## Acceptance + +- Expand doc or Map → hover reveals Add action → dialog pide nombre + tipo + valor adaptado → Save crea el field y refresca el tree. +- Nombre vacío/duplicado bloquea con helper error; JSON inválido bloquea; Timestamp persiste como `{_seconds,_nanoseconds}` vía helpers existentes. +- Array indices never offer Add (consistent with delete rule). + +## Verification evidence + +- TD-1 (commit 3623546): `pnpm typecheck` clean; `pnpm test` 12 files / 89 tests passed; scoped lint + prettier check on AddFieldDialog clean. +- TD-3 / TD-2 checks: `pnpm typecheck` clean; `pnpm test` 12 files / 89 tests passed; scoped lint + prettier check on CollectionTab, TreeView, TreeNodeRow, TreeContext, documentService, AddFieldDialog clean. + +## Next step + +- Implement TD-1 → TD-2 → TD-3 in order; one work-unit commit per task. diff --git a/src/features/collections/components/CollectionTab.tsx b/src/features/collections/components/CollectionTab.tsx index ad030df..eeedfef 100644 --- a/src/features/collections/components/CollectionTab.tsx +++ b/src/features/collections/components/CollectionTab.tsx @@ -75,6 +75,8 @@ import TreeView from './TreeView'; import JsonView from './JsonView'; import { useTreeSubcollections } from '../hooks/useTreeSubcollections'; import CreateDocumentDialog from './CreateDocumentDialog'; +import AddFieldDialog from './tree/AddFieldDialog'; +import { documentService } from '../services/documentService'; import SettingsDialog from '../../../app/components/SettingsDialog'; type ViewMode = SettingsState['defaultViewType']; @@ -400,6 +402,12 @@ const CollectionTab: React.FC = ({ const [selectedRows, setSelectedRows] = useState([]); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); + const [addFieldTarget, setAddFieldTarget] = useState<{ + docId: string; + parentPath: string; + docData: DocumentData; + collectionPath?: string; + } | null>(null); const [collectionPathInput, setCollectionPathInput] = useState(documentPath || collectionPath); useEffect(() => { @@ -698,6 +706,68 @@ const CollectionTab: React.FC = ({ } }, [selectedRows, project, collectionPath, firestoreDatabaseId, showMessage, dispatch, showError]); + // Add Field Handlers (Tree view) + const handleAddField = useCallback( + (docId: string, parentPath: string, docData: DocumentData, docCollectionPath?: string) => { + setAddFieldTarget({ docId, parentPath, docData, collectionPath: docCollectionPath }); + }, + [], + ); + + const handleAddFieldSubmit = useCallback( + async (fieldName: string, value: FirestoreValue) => { + if (!addFieldTarget) return; + const targetCollectionPath = addFieldTarget.collectionPath ?? collectionPath; + const docRecord = addFieldTarget.docData as unknown as Record; + const result = documentService.prepareAddData(docRecord, addFieldTarget.parentPath, fieldName, value); + if ('error' in result) { + showMessage?.(result.error, 'error'); + return; + } + try { + await dispatch( + updateDocument({ + project, + collection: targetCollectionPath, + docId: addFieldTarget.docId, + docData: result.data as DocumentData, + firestoreDatabaseId, + }), + ).unwrap(); + const location = addFieldTarget.parentPath + ? `${addFieldTarget.docId}.${addFieldTarget.parentPath}.${fieldName}` + : `${addFieldTarget.docId}.${fieldName}`; + showMessage?.(`Added field ${location}`, 'success'); + if (targetCollectionPath !== collectionPath) { + refreshDocuments(targetCollectionPath); + } + setAddFieldTarget(null); + } catch (error) { + showError(error); + } + }, + [addFieldTarget, collectionPath, dispatch, project, firestoreDatabaseId, refreshDocuments, showMessage, showError], + ); + + const addFieldExistingKeys = (() => { + if (!addFieldTarget) return [] as string[]; + let parent: unknown = addFieldTarget.docData as unknown; + if (addFieldTarget.parentPath) { + for (const segment of addFieldTarget.parentPath.split('.')) { + if (parent === null || typeof parent !== 'object' || Array.isArray(parent)) return []; + parent = (parent as Record)[segment]; + } + } + if (parent === null || typeof parent !== 'object' || Array.isArray(parent)) return []; + return Object.keys(parent as Record); + })(); + + const addFieldParentLabel = addFieldTarget + ? addFieldTarget.parentPath + ? `${addFieldTarget.docId}.${addFieldTarget.parentPath}` + : addFieldTarget.docId + : ''; + // Type utility wrappers for child components const getType = useCallback((value: FirestoreValue) => getValueType(value), []); const formatValue = useCallback((value: FirestoreValue, type: string) => formatDisplayValue(value, type), []); @@ -859,6 +929,7 @@ const CollectionTab: React.FC = ({ handleCellSave(); }} // Explicitly call handleCellSave onCellKeyDown={handleCellKeyDown} + onAddField={handleAddField} getType={getType} getTypeColor={getColor} formatValue={formatValue} @@ -894,6 +965,15 @@ const CollectionTab: React.FC = ({ onCreate={handleCreateDocument} /> + {/* Add Field Dialog (Tree view) */} + setAddFieldTarget(null)} + onSubmit={handleAddFieldSubmit} + /> + {/* Settings Dialog */} setSettingsDialogOpen(false)} /> diff --git a/src/features/collections/components/TreeView.tsx b/src/features/collections/components/TreeView.tsx index 23bb5b4..60ba022 100644 --- a/src/features/collections/components/TreeView.tsx +++ b/src/features/collections/components/TreeView.tsx @@ -22,6 +22,7 @@ interface TreeViewProps { ) => void; onCellSave: () => void; onCellKeyDown: (e: React.KeyboardEvent) => void; + onAddField: (docId: string, parentPath: string, docData: DocumentData, docCollectionPath?: string) => void; getType: (value: FirestoreValue) => string; getTypeColor: (type: string, isDark: boolean) => string; formatValue: (value: FirestoreValue, type: string) => string; @@ -43,6 +44,7 @@ const TreeView: React.FC = ({ onCellEdit, onCellSave, onCellKeyDown, + onAddField, getType, getTypeColor, formatValue, @@ -121,6 +123,7 @@ const TreeView: React.FC = ({ onCellEdit, onCellSave, onCellKeyDown, + onAddField, getType, getTypeColor, formatValue, @@ -142,6 +145,7 @@ const TreeView: React.FC = ({ onCellEdit, onCellSave, onCellKeyDown, + onAddField, getType, getTypeColor, formatValue, diff --git a/src/features/collections/components/tree/TreeContext.ts b/src/features/collections/components/tree/TreeContext.ts index c360511..fad2a7a 100644 --- a/src/features/collections/components/tree/TreeContext.ts +++ b/src/features/collections/components/tree/TreeContext.ts @@ -28,6 +28,7 @@ export interface TreeContextValue { ) => void; onCellSave: () => void; onCellKeyDown: (e: React.KeyboardEvent) => void; + onAddField: (docId: string, parentPath: string, docData: DocumentData, docCollectionPath?: string) => void; getType: (value: FirestoreValue) => string; getTypeColor: (type: string, isDark: boolean) => string; formatValue: (value: FirestoreValue, type: string) => string; diff --git a/src/features/collections/components/tree/TreeNodeRow.tsx b/src/features/collections/components/tree/TreeNodeRow.tsx index f370301..db99fb6 100644 --- a/src/features/collections/components/tree/TreeNodeRow.tsx +++ b/src/features/collections/components/tree/TreeNodeRow.tsx @@ -5,6 +5,7 @@ import { ChevronRight as ChevronRightIcon, Storage as CollectionIcon, Description as DocumentIcon, + Add as AddIcon, } from '@mui/icons-material'; import { FirestoreValue } from '../../../../shared/utils/firestoreUtils'; import { @@ -54,6 +55,7 @@ const TreeNodeRow: React.FC = ({ onCellEdit, onCellSave, onCellKeyDown, + onAddField, getType, getTypeColor, formatValue, @@ -66,6 +68,21 @@ const TreeNodeRow: React.FC = ({ const nodeType = isCollection ? 'Collection' : isDoc ? 'Document' : getType(value); const isExpandable = isCollection || isDoc || nodeType === 'Array' || nodeType === 'Map'; + const isMapRow = !isCollection && !isDoc && nodeType === 'Map'; + const showAdd = isDoc || isMapRow; + const parentPath = (() => { + if (isDoc) return ''; + if (!isMapRow || !docId) return ''; + const prefix = docCollectionPath ? `${docCollectionPath}/${docId}.` : null; + if (prefix && path.startsWith(prefix)) return path.slice(prefix.length); + return nodeKey; + })(); + + const handleAddField = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!docId || !docData) return; + onAddField(docId, parentPath, docData, docCollectionPath); + }; const isExpanded = expandedNodes[path]; const displayValue = isExpandable ? '' : formatValue(value, nodeType); const isEditing = @@ -100,7 +117,7 @@ const TreeNodeRow: React.FC = ({ return ( <> - + = ({ > {nodeKey} + {showAdd && ( + + + + )} diff --git a/src/features/collections/services/documentService.ts b/src/features/collections/services/documentService.ts index f833893..7da56a3 100644 --- a/src/features/collections/services/documentService.ts +++ b/src/features/collections/services/documentService.ts @@ -112,6 +112,47 @@ export const documentService = { const transformedValue = this.transformValueForSave(oldValue as FirestoreValue, newValue); return { ...doc.data, [field]: transformedValue }; }, + + /** + * Prepare document data with a new field added at a nested dot-path. + * @param docData - Current document fields + * @param parentPath - Dot-path of the parent object relative to the doc ("" for doc root) + * @param fieldName - New field name (single segment, no dots) + * @param value - Value for the new field + * @returns { data } on success or { error } describing why the add was rejected + */ + prepareAddData( + docData: Record, + parentPath: string, + fieldName: string, + value: FirestoreValue, + ): { data: Record } | { error: string } { + const name = fieldName.trim(); + if (!name) return { error: 'Field name is required' }; + if (name.includes('.')) return { error: 'Field name cannot contain dots' }; + + let cloned: Record; + try { + cloned = JSON.parse(JSON.stringify(docData ?? {})) as Record; + } catch { + return { error: 'Document data is not plain serializable data' }; + } + + let parent: unknown = cloned; + if (parentPath) { + for (const segment of parentPath.split('.')) { + if (Array.isArray(parent)) return { error: 'Cannot add fields inside an Array' }; + if (parent === null || typeof parent !== 'object') return { error: 'Parent path does not exist' }; + parent = (parent as Record)[segment]; + } + } + if (Array.isArray(parent)) return { error: 'Cannot add fields inside an Array' }; + if (parent === null || typeof parent !== 'object') return { error: 'Parent path does not exist' }; + const parentObj = parent as Record; + if (Object.prototype.hasOwnProperty.call(parentObj, name)) return { error: 'Field already exists' }; + parentObj[name] = value; + return { data: cloned }; + }, }; export default documentService; From 97e32516571f0d57886b8d05df5e8cca65229258 Mon Sep 17 00:00:00 2001 From: Ulises Mariscal Date: Tue, 22 Sep 2026 15:57:25 -0600 Subject: [PATCH 3/6] docs(tree-add-fields): record verification evidence and close TD-1/TD-2 --- odd/tasks/tree-add-fields.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odd/tasks/tree-add-fields.md b/odd/tasks/tree-add-fields.md index e684f74..b2c0575 100644 --- a/odd/tasks/tree-add-fields.md +++ b/odd/tasks/tree-add-fields.md @@ -37,7 +37,7 @@ JSON permite agregar pero no es intuitivo; Table solo edita top-level. Tree mode ## Verification evidence - TD-1 (commit 3623546): `pnpm typecheck` clean; `pnpm test` 12 files / 89 tests passed; scoped lint + prettier check on AddFieldDialog clean. -- TD-3 / TD-2 checks: `pnpm typecheck` clean; `pnpm test` 12 files / 89 tests passed; scoped lint + prettier check on CollectionTab, TreeView, TreeNodeRow, TreeContext, documentService, AddFieldDialog clean. +- TD-2 (commit a22afac): `pnpm typecheck` clean; `pnpm test` 12 files / 89 tests passed; scoped lint + prettier check on CollectionTab, TreeView, TreeNodeRow, TreeContext, documentService clean. ## Next step From faac5e2e609da3dc8c4d1e6c0ecb2c48367e51f3 Mon Sep 17 00:00:00 2001 From: Ulises Mariscal Date: Tue, 22 Sep 2026 16:05:14 -0600 Subject: [PATCH 4/6] fix(tree): harden nested add-field parent path and clone --- .../collections/components/tree/TreeNodeRow.tsx | 6 ++++++ src/features/collections/services/documentService.ts | 11 +++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/features/collections/components/tree/TreeNodeRow.tsx b/src/features/collections/components/tree/TreeNodeRow.tsx index db99fb6..2ced84f 100644 --- a/src/features/collections/components/tree/TreeNodeRow.tsx +++ b/src/features/collections/components/tree/TreeNodeRow.tsx @@ -75,6 +75,12 @@ const TreeNodeRow: React.FC = ({ if (!isMapRow || !docId) return ''; const prefix = docCollectionPath ? `${docCollectionPath}/${docId}.` : null; if (prefix && path.startsWith(prefix)) return path.slice(prefix.length); + // Fallback when the collection path is unavailable: strip up to the + // document boundary instead of returning the bare key, so deeply nested + // maps still resolve to their full relative parent path. + const marker = `${docId}.`; + const idx = path.indexOf(marker); + if (idx >= 0) return path.slice(idx + marker.length); return nodeKey; })(); diff --git a/src/features/collections/services/documentService.ts b/src/features/collections/services/documentService.ts index 7da56a3..50f94c6 100644 --- a/src/features/collections/services/documentService.ts +++ b/src/features/collections/services/documentService.ts @@ -133,9 +133,16 @@ export const documentService = { let cloned: Record; try { - cloned = JSON.parse(JSON.stringify(docData ?? {})) as Record; + cloned = + typeof structuredClone === 'function' + ? (structuredClone(docData ?? {}) as Record) + : (JSON.parse(JSON.stringify(docData ?? {})) as Record); } catch { - return { error: 'Document data is not plain serializable data' }; + try { + cloned = JSON.parse(JSON.stringify(docData ?? {})) as Record; + } catch { + return { error: 'Document data is not plain serializable data' }; + } } let parent: unknown = cloned; From 0207f8b74c4ba0f975ca6b272a6026220ce00bfe Mon Sep 17 00:00:00 2001 From: Ulises Mariscal Date: Tue, 22 Sep 2026 16:06:37 -0600 Subject: [PATCH 5/6] fix(tree): guard invalid timestamp in AddFieldDialog --- .../collections/components/tree/AddFieldDialog.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/features/collections/components/tree/AddFieldDialog.tsx b/src/features/collections/components/tree/AddFieldDialog.tsx index 784be7b..18b558d 100644 --- a/src/features/collections/components/tree/AddFieldDialog.tsx +++ b/src/features/collections/components/tree/AddFieldDialog.tsx @@ -121,7 +121,14 @@ const AddFieldDialog: React.FC = ({ open, parentPathLabel, return ''; })(); - const isValid = !nameError && !jsonError && !geoError && !numberError; + const timestampError = (() => { + if (fieldType !== 'Timestamp') return ''; + if (timestampValue.trim() === '' || isNaN(new Date(timestampValue).getTime())) + return 'Must be a valid date and time'; + return ''; + })(); + + const isValid = !nameError && !jsonError && !geoError && !numberError && !timestampError; const buildValue = (): FirestoreValue => { switch (fieldType) { @@ -270,6 +277,8 @@ const AddFieldDialog: React.FC = ({ open, parentPathLabel, onChange={(e) => setTimestampValue(e.target.value)} onKeyDown={handleKeyDown} InputLabelProps={{ shrink: true }} + error={Boolean(timestampError)} + helperText={timestampError || undefined} sx={{ mt: 2, '& .MuiInputBase-input': { fontFamily: MONOSPACE_FONT_FAMILY, fontSize: '0.9rem' }, From 8f52b92f67769397c62e179d8a507697b2821f36 Mon Sep 17 00:00:00 2001 From: Ulises Mariscal Date: Tue, 22 Sep 2026 16:07:15 -0600 Subject: [PATCH 6/6] docs(tree-add-fields): record verification, review escalation, and R3-001 fix --- odd/tasks/tree-add-fields.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/odd/tasks/tree-add-fields.md b/odd/tasks/tree-add-fields.md index b2c0575..11fceb9 100644 --- a/odd/tasks/tree-add-fields.md +++ b/odd/tasks/tree-add-fields.md @@ -3,7 +3,7 @@ - **Feature id**: `tree-add-fields` - **Branch**: `feat/add-fields` (already checked out, base `34923d6`) - **Scope**: Documento + Maps anidados (user choice 2026-09-22). No Array indices. -- **Status**: in_progress +- **Status**: implemented + verified; native review ESCALATED (terminal stop, see below) ## Objective @@ -26,19 +26,28 @@ JSON permite agregar pero no es intuitivo; Table solo edita top-level. Tree mode - [x] TD-1 `AddFieldDialog` type-aware component (`src/features/collections/components/tree/AddFieldDialog.tsx`): props `open, parentPathLabel, existingKeys, onClose, onSubmit(fieldName, value)`; field-name TextField with duplicate/empty validation; MUI Select of types String,Integer,Number,Boolean,Null,Timestamp,GeoPoint,Array,Map,Reference; per-type input (TextField / number / Select True-False / datetime-local / lat,lng / JSON multiline for Array,Map with JSON.parse + error / Reference string); default values per type; Ctrl+Enter saves. Reuse `MONOSPACE_FONT_FAMILY`, Type badge style from EditDialog. - [x] TD-2 Nested add plumbing: `documentService.prepareAddData(docDataRecord, parentPath, fieldName, value)` (dot-path set, reject duplicates/empty, no Array indices); `TreeContext` += `onAddField(docId, parentPath, docData, docCollectionPath)`; `CollectionTab` implements it (opens dialog, on submit builds new data, dispatches `updateDocument`, refreshes, shows message); `TreeView` forwards through context; `TreeNodeRow` shows hover `Add` IconButton (AddIcon) on Document rows and Map rows → calls `onAddField` with nested path. -- [ ] TD-3 Verification: `pnpm typecheck`, `pnpm test`, `pnpm lint` (changed files), `pnpm format:check`; record per-task tier/outcome. TDD mode: off (no sdd-init capabilities found, no runner configured) → ordinary functional checks. +- [x] TD-3 Verification: `pnpm typecheck` clean, `pnpm test` 12 files / 89 passed, scoped lint + format clean (writer + parent spot-check 2026-09-22). TDD off (no sdd-init capabilities on file). ## Acceptance - Expand doc or Map → hover reveals Add action → dialog pide nombre + tipo + valor adaptado → Save crea el field y refresca el tree. -- Nombre vacío/duplicado bloquea con helper error; JSON inválido bloquea; Timestamp persiste como `{_seconds,_nanoseconds}` vía helpers existentes. +- Nombre vacío/duplicado/puntos bloquea con helper error; JSON inválido y fecha inválida bloquean; Timestamp persiste como `{_seconds,_nanoseconds}` vía helpers existentes. - Array indices never offer Add (consistent with delete rule). ## Verification evidence - TD-1 (commit 3623546): `pnpm typecheck` clean; `pnpm test` 12 files / 89 tests passed; scoped lint + prettier check on AddFieldDialog clean. - TD-2 (commit a22afac): `pnpm typecheck` clean; `pnpm test` 12 files / 89 tests passed; scoped lint + prettier check on CollectionTab, TreeView, TreeNodeRow, TreeContext, documentService clean. +- Correction `faac5e2` (R3-002 parentPath fallback, R3-003 structuredClone): typecheck/test/scoped lint+format clean. +- R3-001 fix `0207f8b` (timestamp invalid-date guard): typecheck/test/scoped lint+format clean. + +## Native review (RDD on, global) — ESCALATED, terminal + +- Assess on slice (`--base-ref 34923d6 --committed-only`): `risk: medium` (`executable_change` in CollectionTab), 566 changed lines. +- Lineage `review-55ef78428f190c9d`, one lens `review-reliability`. Reviewer: 3 findings (R3-001 BLOCKER timestamp NaN, R3-002/R3-003 CRITICAL). Refuter corroborated. +- Bounded correction `faac5e2` fixed R3-002 + R3-003 only. Validator rejected: R3-001 unaddressed → state `escalated`, then terminal `stop/native_stop_required`. +- R3-001 fixed afterwards as ordinary work (`0207f8b`), outside the review transaction. Review outcome stays informational only — push/PR/merge remain user decisions under ordinary repository policy. ## Next step -- Implement TD-1 → TD-2 → TD-3 in order; one work-unit commit per task. +- Delivery decision owned by the user: push / open PR / merge (slice is ~590 lines, over the 400-line budget — needs `size:exception` or split per repo policy).