Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions odd/tasks/tree-add-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 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**: implemented + verified; native review ESCALATED (terminal stop, see below)

## 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.
- [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/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

- 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).
80 changes: 80 additions & 0 deletions src/features/collections/components/CollectionTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -400,6 +402,12 @@ const CollectionTab: React.FC<CollectionTabProps> = ({
const [selectedRows, setSelectedRows] = useState<string[]>([]);
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(() => {
Expand Down Expand Up @@ -698,6 +706,68 @@ const CollectionTab: React.FC<CollectionTabProps> = ({
}
}, [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<string, FirestoreValue>;
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<string, FirestoreValue>)[segment];
}
}
if (parent === null || typeof parent !== 'object' || Array.isArray(parent)) return [];
return Object.keys(parent as Record<string, FirestoreValue>);
})();

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), []);
Expand Down Expand Up @@ -859,6 +929,7 @@ const CollectionTab: React.FC<CollectionTabProps> = ({
handleCellSave();
}} // Explicitly call handleCellSave
onCellKeyDown={handleCellKeyDown}
onAddField={handleAddField}
getType={getType}
getTypeColor={getColor}
formatValue={formatValue}
Expand Down Expand Up @@ -894,6 +965,15 @@ const CollectionTab: React.FC<CollectionTabProps> = ({
onCreate={handleCreateDocument}
/>

{/* Add Field Dialog (Tree view) */}
<AddFieldDialog
open={Boolean(addFieldTarget)}
parentPathLabel={addFieldParentLabel}
existingKeys={addFieldExistingKeys}
onClose={() => setAddFieldTarget(null)}
onSubmit={handleAddFieldSubmit}
/>

{/* Settings Dialog */}
<SettingsDialog open={settingsDialogOpen} onClose={() => setSettingsDialogOpen(false)} />

Expand Down
4 changes: 4 additions & 0 deletions src/features/collections/components/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -43,6 +44,7 @@ const TreeView: React.FC<TreeViewProps> = ({
onCellEdit,
onCellSave,
onCellKeyDown,
onAddField,
getType,
getTypeColor,
formatValue,
Expand Down Expand Up @@ -121,6 +123,7 @@ const TreeView: React.FC<TreeViewProps> = ({
onCellEdit,
onCellSave,
onCellKeyDown,
onAddField,
getType,
getTypeColor,
formatValue,
Expand All @@ -142,6 +145,7 @@ const TreeView: React.FC<TreeViewProps> = ({
onCellEdit,
onCellSave,
onCellKeyDown,
onAddField,
getType,
getTypeColor,
formatValue,
Expand Down
Loading
Loading