diff --git a/src/components/common/DataGridRow.tsx b/src/components/common/DataGridRow.tsx new file mode 100644 index 0000000..bb1ce16 --- /dev/null +++ b/src/components/common/DataGridRow.tsx @@ -0,0 +1,166 @@ +import React, { memo, useCallback, useReducer } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { ColumnDef, GridRow, validateCellValue } from '../../utils/gridUtils'; +import { InlineEditing } from '../grid/InlineEditing'; + +interface DataGridRowProps { + row: GridRow; + rowIndex: number; + columns: ColumnDef[]; + columnWidths: number[]; + onRowUpdate?: (rowId: string | number, columnKey: string, value: string) => void; +} + +type RowAction = + | { type: 'START_EDIT'; columnKey: string; draft: string } + | { type: 'UPDATE_DRAFT'; draft: string } + | { type: 'SET_ERROR'; error: string | null } + | { type: 'COMMIT' } + | { type: 'CANCEL' }; + +interface DataGridRowState { + editingColumnKey: string | null; + draft: string; + error: string | null; +} + +function reducer(state: DataGridRowState, action: RowAction): DataGridRowState { + switch (action.type) { + case 'START_EDIT': + return { + editingColumnKey: action.columnKey, + draft: action.draft, + error: null, + }; + + case 'UPDATE_DRAFT': + return { + ...state, + draft: action.draft, + error: null, + }; + + case 'SET_ERROR': + return { + ...state, + error: action.error, + }; + + case 'COMMIT': + case 'CANCEL': + return { + editingColumnKey: null, + draft: '', + error: null, + }; + + default: + return state; + } +} + +const DataGridRowComponent = ({ + row, + rowIndex, + columns, + columnWidths, + onRowUpdate, +}: DataGridRowProps) => { + const [state, dispatch] = useReducer(reducer, { + editingColumnKey: null, + draft: '', + error: null, + }); + + const handleStartEdit = useCallback((columnKey: string, currentValue: unknown) => { + dispatch({ + type: 'START_EDIT', + columnKey, + draft: currentValue == null ? '' : String(currentValue), + }); + }, []); + + const handleChangeDraft = useCallback((draft: string) => { + dispatch({ type: 'UPDATE_DRAFT', draft }); + }, []); + + const handleCommit = useCallback( + (columnKey: string) => { + if (!state.editingColumnKey || state.editingColumnKey !== columnKey) { + return; + } + + const column = columns.find(c => c.key === columnKey); + if (!column) { + return; + } + + const validationError = validateCellValue(state.draft, column as ColumnDef); + if (validationError) { + dispatch({ type: 'SET_ERROR', error: validationError }); + return; + } + + onRowUpdate?.(row.id, columnKey, state.draft); + dispatch({ type: 'COMMIT' }); + }, + [columns, onRowUpdate, row.id, state.draft, state.editingColumnKey] + ); + + const handleCancel = useCallback(() => dispatch({ type: 'CANCEL' }), []); + + const isEvenRow = rowIndex % 2 === 0; + + return ( + + {columns.map((col, idx) => { + const cellIsEditing = state.editingColumnKey === col.key; + + return ( + + handleStartEdit(col.key, row[col.key])} + onChangeDraft={handleChangeDraft} + onCommit={() => handleCommit(col.key)} + onCancel={handleCancel} + /> + + ); + })} + + ); +}; + +function areEqual(prevProps: Readonly, nextProps: Readonly) { + return ( + prevProps.row === nextProps.row && + prevProps.rowIndex === nextProps.rowIndex && + prevProps.columns === nextProps.columns && + prevProps.columnWidths === nextProps.columnWidths && + prevProps.onRowUpdate === nextProps.onRowUpdate + ); +} + +export const DataGridRow = memo(DataGridRowComponent, areEqual); + +const styles = StyleSheet.create({ + dataRow: { + flexDirection: 'row', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#F3F4F6', + }, + dataRowEven: { + backgroundColor: '#FAFAFA', + }, + dataCell: { + borderRightWidth: StyleSheet.hairlineWidth, + borderRightColor: '#E5E7EB', + justifyContent: 'center', + }, +}); diff --git a/src/components/common/SecureWebView.tsx b/src/components/common/SecureWebView.tsx index 3d1fba6..84d2206 100644 --- a/src/components/common/SecureWebView.tsx +++ b/src/components/common/SecureWebView.tsx @@ -1,6 +1,16 @@ -import React from 'react'; +/** + * SecureWebView wraps the native WebView implementation and enforces + * a restrictive default Content Security Policy for embedded content. + * + * Documented usage: + * - No current app screens import WebView directly. + * - SecureWebView is the safe wrapper to use for any future screen that + * loads external or embedded HTML content. + */ +import React from 'react'; import { View, StyleSheet } from 'react-native'; import { WebView, WebViewProps } from 'react-native-webview'; + import { CSP_TRUST_TIERS, TrustTier } from '../../config/security'; interface SecureWebViewProps extends Omit { @@ -9,30 +19,43 @@ interface SecureWebViewProps extends Omit { platformDomain?: string; } -export function SecureWebView({ +export const SecureWebView = ({ source, trustLevel = 'restricted', platformDomain = 'platform.com', style, + originWhitelist, + javaScriptEnabled, + domStorageEnabled, + allowsInlineMediaPlayback, + mediaPlaybackRequiresUserAction, + injectedJavaScriptBeforeContentLoaded, + startInLoadingState, ...props -}: SecureWebViewProps) { +}: SecureWebViewProps) => { const csp = CSP_TRUST_TIERS[trustLevel]; - const injectedJs = "(function(){var m=document.createElement('meta');m.httpEquiv='Content-Security-Policy';m.content='" + csp + "';document.head.insertBefore(m,document.head.firstChild);})();true;"; - const originWhitelist = trustLevel === 'restricted' ? undefined : ['https://*.' + platformDomain, 'https://' + platformDomain]; + const finalOriginWhitelist = originWhitelist ?? ['https://*']; + const injectedJs = + injectedJavaScriptBeforeContentLoaded ?? + "(function(){var m=document.createElement('meta');m.httpEquiv='Content-Security-Policy';m.content='" + + csp + + "';document.head.insertBefore(m,document.head.firstChild);})();true;"; return ( ); -} +}; const styles = StyleSheet.create({ container: { flex: 1 } }); diff --git a/src/components/grid/AdvancedDataGrid.tsx b/src/components/grid/AdvancedDataGrid.tsx index eae9c39..b87079d 100644 --- a/src/components/grid/AdvancedDataGrid.tsx +++ b/src/components/grid/AdvancedDataGrid.tsx @@ -3,28 +3,26 @@ import * as FileSystem from 'expo-file-system'; import { ArrowDown, ArrowUp, ArrowUpDown, Filter, FilterX, Upload } from 'lucide-react-native'; import React, { useCallback, useMemo, useState } from 'react'; import { - ActivityIndicator, - FlatList, - ListRenderItemInfo, - ScrollView, - Share, - StyleSheet, - Text, - TouchableOpacity, - View, + ActivityIndicator, + FlatList, + ListRenderItemInfo, + Platform, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, } from 'react-native'; import { GridExporter } from './GridExporter'; import { GridFiltering } from './GridFiltering'; -import { InlineEditing } from './InlineEditing'; import { useDataGrid, UseDataGridOptions } from '../../hooks/useDataGrid'; import { batchImportCSV, BatchProgress } from '../../services/batchDataProcessor'; -import { ColumnDef, ExportFormat, GridRow, SortConfig, SortDirection } from '../../utils/gridUtils'; +import { ColumnDef, GridRow, SortConfig, SortDirection } from '../../utils/gridUtils'; +import { DataGridRow } from '../common/DataGridRow'; import { ErrorBoundary } from '../common/ErrorBoundary'; import { Skeleton } from '../ui/Skeleton'; -// Import for document picking (web and native) - // ─── Public API ─────────────────────────────────────────────────────────────── /** @@ -77,11 +75,10 @@ export const AdvancedDataGrid = ({ emptyMessage = 'No data to display', testID, onImport, + onRowUpdate, ...gridOptions }: AdvancedDataGridProps) => { - const grid = useDataGrid(rows, columns, gridOptions); - const [importProgress, setImportProgress] = useState(null); - const [activeImport, setActiveImport] = useState(false); + const grid = useDataGrid(rows, columns, { ...gridOptions, onRowUpdate }); const { paginatedRows, @@ -94,12 +91,6 @@ export const AdvancedDataGrid = ({ page, pageSize, goToPage, - editingCell, - editError, - startEditing, - updateDraft, - commitEdit, - cancelEditing, exportDataAsync, } = grid; @@ -111,30 +102,15 @@ export const AdvancedDataGrid = ({ // ── Row renderer (memoized to avoid re-renders on unrelated state changes) ─ const renderRow = useCallback( ({ item, index }: ListRenderItemInfo) => ( - []} columnWidths={columnWidths} - editingCell={editingCell} - editError={editError} - onStartEdit={startEditing} - onChangeDraft={updateDraft} - onCommit={commitEdit} - onCancel={cancelEditing} + onRowUpdate={onRowUpdate} /> ), - [ - columns, - columnWidths, - editingCell, - editError, - startEditing, - updateDraft, - commitEdit, - cancelEditing, - ] + [columns, columnWidths, onRowUpdate] ); const keyExtractor = useCallback((item: T) => String(item.id), []); @@ -159,11 +135,18 @@ export const AdvancedDataGrid = ({ hasFilters={hasFilters} onClearFilters={clearAllFilters} showExporter={showExporter} + showImporter={!!onImport} + onImportComplete={onImport} onExport={exportDataAsync} /> {/* ── Scrollable grid area ─────────────────────────────────────────── */} - + {/* Column headers */} ({ }} > {columnWidths.map((_cw, j) => ( - + ))} ))} @@ -252,9 +231,8 @@ interface GridToolbarProps { onClearFilters: () => void; showExporter: boolean; onExport: ReturnType['exportDataAsync']; - showImporter: boolean; - onImport: (onProgress?: (progress: BatchProgress) => void) => Promise; - onImportComplete: (newRows: GridRow[]) => void; + showImporter?: boolean; + onImportComplete?: (newRows: GridRow[]) => void; } const GridToolbar = ({ @@ -264,39 +242,11 @@ const GridToolbar = ({ showExporter, onExport, showImporter, - onImport, onImportComplete, }: GridToolbarProps) => { const [activeType, setActiveType] = useState<'export' | 'import' | null>(null); const [progress, setProgress] = useState(null); - const handleExport = useCallback( - async (format: ExportFormat) => { - if (activeType !== null) return; - - setActiveType('export'); - setProgress({ processed: 0, total: 0, percent: 0, phase: 'queued' }); - try { - const data = await onExport(format, setProgress); - - await Share.share({ - message: data, - title: `Export data as ${LABEL[format]}`, - }); - } catch (err) { - // Sharing cancelled by the user produces a rejection — treat it silently. - const message = err instanceof Error ? err.message : String(err); - if (!message.toLowerCase().includes('cancel')) { - logger.error('[GridToolbar] Share failed:', err); - } - } finally { - setActiveType(null); - setProgress(null); - } - }, - [activeType, onExport] - ); - const handleImport = useCallback(async () => { if (activeType !== null) return; @@ -308,13 +258,11 @@ const GridToolbar = ({ // Also allow .csv extension }); - if (!result.cancelled && result.assets && result.assets[0]) { + if (!result.canceled && result.assets && result.assets[0]) { const file = result.assets[0]; let csvString = ''; if (file.uri) { - // For web, we can use fetch - // For native, we can use FileSystem.readAsStringAsync - if (__WEB__) { + if (Platform.OS === 'web') { const response = await fetch(file.uri); csvString = await response.text(); } else { @@ -329,7 +277,9 @@ const GridToolbar = ({ useWorker: true, }); - onImportComplete(newRows); + if (onImportComplete) { + onImportComplete(newRows); + } } } catch (err) { console.error('[GridToolbar] Import failed:', err); @@ -363,9 +313,7 @@ const GridToolbar = ({ )} - {showExporter && ( - - )} + {showExporter && } {showImporter && ( { return ; }; -// ─── DataRow ────────────────────────────────────────────────────────────────── - -interface DataRowProps { - row: T; - rowIndex: number; - columns: ColumnDef[]; - columnWidths: number[]; - editingCell: ReturnType['editingCell']; - editError: string | null; - onStartEdit: (rowId: string | number, columnKey: string, currentValue: unknown) => void; - onChangeDraft: (value: string) => void; - onCommit: () => void; - onCancel: () => void; -} - -const DataRow = ({ - row, - rowIndex, - columns, - columnWidths, - editingCell, - editError, - onStartEdit, - onChangeDraft, - onCommit, - onCancel, -}: DataRowProps) => { - const isEvenRow = rowIndex % 2 === 0; - - return ( - - {columns.map((col, idx) => { - const cellIsEditing = editingCell?.rowId === row.id && editingCell?.columnKey === col.key; - - return ( - - onStartEdit(row.id, col.key, row[col.key])} - onChangeDraft={onChangeDraft} - onCommit={onCommit} - onCancel={onCancel} - /> - - ); - })} - - ); -}; +// DataRow has been moved to its own memoized component in common/DataGridRow. // ─── PaginationBar ──────────────────────────────────────────────────────────── @@ -746,6 +642,41 @@ const styles = StyleSheet.create({ pageBtnDisabled: { backgroundColor: 'transparent', }, + btn: { + paddingHorizontal: 10, + paddingVertical: 8, + borderRadius: 6, + borderWidth: 1, + borderColor: '#19c3e6', + alignItems: 'center', + justifyContent: 'center', + }, + btnDisabled: { + borderColor: '#D1D5DB', + }, + progressWrap: { + minWidth: 90, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginLeft: 2, + }, + progressTrack: { + width: 56, + height: 4, + borderRadius: 2, + overflow: 'hidden', + backgroundColor: '#E5E7EB', + }, + progressFill: { + height: '100%', + backgroundColor: '#19c3e6', + }, + progressText: { + fontSize: 12, + color: '#6B7280', + fontWeight: '500', + }, pageBtnText: { fontSize: 14, color: '#374151',