diff --git a/DashAI/back/api/api_v1/endpoints/datasets.py b/DashAI/back/api/api_v1/endpoints/datasets.py index bd3494342..be147ab0d 100644 --- a/DashAI/back/api/api_v1/endpoints/datasets.py +++ b/DashAI/back/api/api_v1/endpoints/datasets.py @@ -6,6 +6,7 @@ import shutil import tempfile import zipfile +from datetime import datetime, timezone from typing import Any, Dict import numpy as np @@ -30,7 +31,11 @@ from DashAI.back.dependencies.database.models import Dataset, ModelSession from DashAI.back.types.inf.type_inference import infer_types from DashAI.back.types.type_validation import validate_multiple_type_changes -from DashAI.back.types.utils import arrow_to_dashai_schema +from DashAI.back.types.utils import ( + arrow_to_dashai_schema, + get_types_from_arrow_metadata, + save_types_in_arrow_metadata, +) logger = logging.getLogger(__name__) router = APIRouter() @@ -841,6 +846,161 @@ async def update_dataset( ) from e +@router.patch("/{dataset_id}/columns/rename") +@inject +async def rename_dataset_column( + dataset_id: int, + params: schemas.DatasetRenameColumnParams, + session_factory: sessionmaker = Depends(lambda: di["session_factory"]), +): + """Rename a column in a dataset. + + Parameters + ---------- + dataset_id : int + ID of the dataset to update. + params : DatasetRenameColumnParams + Parameters containing old_name and new_name for the column. + session_factory : Callable[..., ContextManager[Session]] + A factory that creates a context manager that handles a SQLAlchemy session. + + Returns + ------- + Dict + A dictionary with a success message and updated column types. + """ + with session_factory() as db: + dataset = db.get(Dataset, dataset_id) + if dataset is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Dataset not found" + ) + + if dataset.status != DatasetStatus.FINISHED: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Dataset is not in finished state or being modified", + ) + + # Lock the dataset to prevent concurrent modifications + try: + dataset.set_status_as_started() + db.commit() + except exc.SQLAlchemyError as e: + logger.exception(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error locking dataset for modification", + ) from e + + old_name = params.old_name.strip() + new_name = params.new_name.strip() + if not old_name or not new_name: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Column names cannot be empty", + ) + if old_name == new_name: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="New column name must be different from old name", + ) + + dataset_path = f"{dataset.file_path}/dataset" + arrow_file_path = f"{dataset_path}/data.arrow" + try: + with pa.OSFile(arrow_file_path, "rb") as source: + reader = pa.ipc.open_file(source) + table = reader.read_all() + if old_name not in table.schema.names: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Column '{old_name}' not found in dataset", + ) + if new_name in table.schema.names and new_name != old_name: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Column '{new_name}' already exists", + ) + + column_index = table.schema.get_field_index(old_name) + types_dict = get_types_from_arrow_metadata(table.schema) + new_fields = [] + for i, field in enumerate(table.schema): + if i == column_index: + new_fields.append(pa.field(new_name, field.type, field.nullable)) + else: + new_fields.append(field) + + new_schema = pa.schema(new_fields) + + renamed_table = pa.table( + { + new_name if name == old_name else name: table[name] + for name in table.schema.names + }, + schema=new_schema, + ) + if old_name in types_dict: + types_dict[new_name] = types_dict.pop(old_name) + + types_serialized = {col: types_dict[col].to_string() for col in types_dict} + renamed_table = save_types_in_arrow_metadata( + renamed_table, types_serialized + ) + + with pa.OSFile(arrow_file_path, "wb") as sink: + writer = ipc.new_file(sink, renamed_table.schema) + writer.write_table(renamed_table) + writer.close() + + splits_path = f"{dataset_path}/splits.json" + if os.path.exists(splits_path): + with open(splits_path, "r", encoding="utf-8") as f: + splits_data = json.load(f) + if "column_names" in splits_data: + splits_data["column_names"] = [ + new_name if name == old_name else name + for name in splits_data["column_names"] + ] + if "nan" in splits_data and old_name in splits_data["nan"]: + splits_data["nan"][new_name] = splits_data["nan"].pop(old_name) + with open(splits_path, "w", encoding="utf-8") as f: + json.dump( + splits_data, + f, + indent=2, + sort_keys=True, + ensure_ascii=False, + ) + + dataset.last_modified = datetime.now(timezone.utc) + dataset.set_status_as_finished() + db.commit() + db.refresh(dataset) + updated_columns = get_columns_spec(dataset_path) + return { + "message": f"Column '{old_name}' renamed to '{new_name}' successfully", + "old_name": old_name, + "new_name": new_name, + "columns": updated_columns, + } + except HTTPException: + # Release the lock before re-raising + dataset.set_status_as_finished() + db.commit() + raise + except Exception as e: + # Release the lock and mark as finished on error + dataset.set_status_as_finished() + db.commit() + logger.exception(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error renaming column: {str(e)}", + ) from e + + @router.get("/file/") async def get_dataset_file( path: str, diff --git a/DashAI/back/api/api_v1/schemas/datasets_params.py b/DashAI/back/api/api_v1/schemas/datasets_params.py index b25fe2ce3..f0071203b 100644 --- a/DashAI/back/api/api_v1/schemas/datasets_params.py +++ b/DashAI/back/api/api_v1/schemas/datasets_params.py @@ -26,6 +26,11 @@ class DatasetUpdateParams(BaseModel): name: str = None +class DatasetRenameColumnParams(BaseModel): + old_name: str + new_name: str + + class DatasetUploadFromNotebookParams(BaseModel): name: str diff --git a/DashAI/back/job/dataset_job.py b/DashAI/back/job/dataset_job.py index c31101c1e..e4eba1a69 100644 --- a/DashAI/back/job/dataset_job.py +++ b/DashAI/back/job/dataset_job.py @@ -177,10 +177,36 @@ def run( new_dataset.to_pandas(), method="DashAIPtype" ) - # Cast dataset to inferred types + if "column_renames" in params: + renames = params["column_renames"] + original_names = new_dataset.arrow_table.schema.names + new_names = [renames.get(col, col) for col in original_names] + + if len(new_names) != len(set(new_names)): + duplicate_names = set() + seen = set() + for name in new_names: + if name in seen: + duplicate_names.add(name) + else: + seen.add(name) + msg = ( + "Invalid column_renames: resulting column names " + "contain duplicates: " + f"{sorted(duplicate_names)}" + ) + raise JobError(msg) + + arrow_table = new_dataset.arrow_table.rename_columns(new_names) + new_dataset = new_dataset.__class__( + arrow_table, + splits=new_dataset.splits, + types=new_dataset.types, + ) + schema = {renames.get(col, col): schema[col] for col in schema} + new_dataset = transform_dataset_with_schema(new_dataset, schema) - # Calculate metadata new_dataset.compute_metadata() gc.collect() diff --git a/DashAI/front/src/api/datasets.ts b/DashAI/front/src/api/datasets.ts index 813d645ee..1e1a2ac34 100644 --- a/DashAI/front/src/api/datasets.ts +++ b/DashAI/front/src/api/datasets.ts @@ -79,6 +79,23 @@ export const updateDataset = async ( return response.data; }; +export const renameDatasetColumn = async ( + id: number, + oldName: string, + newName: string, +): Promise<{ + message: string; + old_name: string; + new_name: string; + columns: object; +}> => { + const response = await api.patch(`${datasetEndpoint}/${id}/columns/rename`, { + old_name: oldName, + new_name: newName, + }); + return response.data; +}; + export const deleteDataset = async (id: string): Promise => { const response = await api.delete(`${datasetEndpoint}/${id}`); return response.data; diff --git a/DashAI/front/src/components/DatasetVisualization.jsx b/DashAI/front/src/components/DatasetVisualization.jsx index ee84890c9..40480ce87 100644 --- a/DashAI/front/src/components/DatasetVisualization.jsx +++ b/DashAI/front/src/components/DatasetVisualization.jsx @@ -22,12 +22,8 @@ import { getDatasetInfo, getDatasetFileFiltered, } from "../api/datasets"; -import DatasetTable from "./notebooks/dataset/DatasetTable"; -import { getComponents } from "../api/component"; import { useTourContext } from "./tour/TourProvider"; -import { useSnackbar } from "notistack"; import JobQueueWidget from "./jobs/JobQueueWidget"; -import { getDatasetStatus } from "../utils/datasetStatus"; import { formatDate } from "../pages/results/constants/formatDate"; import Header from "./notebooks/dataset/header/Header"; import Tooltip from "@mui/material/Tooltip"; @@ -92,12 +88,10 @@ export default function DatasetVisualization({ fetchDatasetInfo(); }, [dataset.id, dataset.status]); - // fetchPage compatible with server-side filtering const fetchDatasetPage = useCallback( async (page, pageSize, filterModel) => { if (isProcessing) return { rows: [], total: 0 }; try { - // Use getDatasetFile if no filters, else use getDatasetFileFiltered const hasFilters = filterModel && Array.isArray(filterModel.items) && @@ -122,7 +116,7 @@ export default function DatasetVisualization({ ); const status = dataset.status; - const isProcessing = !(status === 3 || status === 4); // Finished or Error + const isProcessing = !(status === 3 || status === 4); return ( <> diff --git a/DashAI/front/src/components/datasets/DatasetModal.jsx b/DashAI/front/src/components/datasets/DatasetModal.jsx index 06f931a1a..50478c287 100644 --- a/DashAI/front/src/components/datasets/DatasetModal.jsx +++ b/DashAI/front/src/components/datasets/DatasetModal.jsx @@ -115,6 +115,46 @@ function DatasetModal({ open, setOpen, updateDatasets }) { } }; + const handleColumnRename = (oldName, newName) => { + setColumnsSpec((prevSpec) => { + const updatedSpec = { ...prevSpec }; + + // Move the spec from old name to new name + if (updatedSpec[oldName]) { + updatedSpec[newName] = updatedSpec[oldName]; + delete updatedSpec[oldName]; + } + + return updatedSpec; + }); + + // Also update previewData to reflect the new column name + setPreviewData((prevData) => { + if (!prevData || !prevData.schema) return prevData; + + const updatedSchema = { ...prevData.schema }; + if (updatedSchema[oldName]) { + updatedSchema[newName] = updatedSchema[oldName]; + delete updatedSchema[oldName]; + } + + const updatedSample = prevData.sample.map((row) => { + const newRow = { ...row }; + if (oldName in newRow) { + newRow[newName] = newRow[oldName]; + delete newRow[oldName]; + } + return newRow; + }); + + return { + ...prevData, + schema: updatedSchema, + sample: updatedSample, + }; + }); + }; + const handleInferDataTypes = async (methods) => { setLoading(true); const formData = new FormData(); @@ -283,6 +323,7 @@ function DatasetModal({ open, setOpen, updateDatasets }) { setNextEnabled={setNextEnabled} columnsSpec={columnsSpec} setColumnsSpec={setColumnsSpec} + onColumnRename={handleColumnRename} /> )} diff --git a/DashAI/front/src/components/notebooks/dataset/DatasetTable.jsx b/DashAI/front/src/components/notebooks/dataset/DatasetTable.jsx index 04c596361..bb6e72bd0 100644 --- a/DashAI/front/src/components/notebooks/dataset/DatasetTable.jsx +++ b/DashAI/front/src/components/notebooks/dataset/DatasetTable.jsx @@ -13,8 +13,10 @@ import { LinearProgress } from "@mui/material"; import { exportDatasetCsvByPath, getDatasetTypesByFilePath, + renameDatasetColumn, } from "../../../api/datasets"; import { useTranslation } from "react-i18next"; +import EditableColumnHeader from "./EditableColumnHeader"; /** * Props: @@ -25,6 +27,8 @@ import { useTranslation } from "react-i18next"; * - autoHeight?: boolean (default true) * - pageSizeOptions?: number[] (default [5, 10, 25]) * - datasetPath?: string (optional) - Path to dataset for CSV export + * - datasetId?: number (optional) - Dataset ID for column renaming + * - editableColumns?: boolean (default false) - Enable column name editing */ export default function DatasetTable({ fetchPage, @@ -34,6 +38,8 @@ export default function DatasetTable({ autoHeight = true, pageSizeOptions = [5, 10, 25], datasetPath, + datasetId, + editableColumns = false, density = "compact", ...props }) { @@ -77,7 +83,6 @@ export default function DatasetTable({ })); setRows(withIds); - // Siempre usa el total devuelto por el backend para la paginación setRowCount(data?.total ?? withIds.length); } catch (e) { setRows([]); @@ -91,10 +96,8 @@ export default function DatasetTable({ alive = false; }; }, [fetchPage, paginationModel, filterModel, ...deps]); - // Handler for DataGrid filter changes const handleFilterModelChange = useCallback((model) => { setFilterModel((prev) => { - // Si el filtro es igual al anterior, igual resetea la paginación setPaginationModel((m) => ({ ...m, page: 0 })); if (!model || !model.items || model.items.length === 0) { return { items: [] }; @@ -107,6 +110,42 @@ export default function DatasetTable({ setPaginationModel((m) => ({ ...m, page: 0 })); }, deps); + const handleColumnRename = useCallback( + async (oldName, newName) => { + if (!datasetId) { + throw new Error("Dataset ID is required for renaming columns"); + } + + try { + const result = await renameDatasetColumn(datasetId, oldName, newName); + + const { page, pageSize } = paginationModel; + const data = await fetchPage(page, pageSize, filterModel); + const withIds = (data?.rows ?? []).map((r, i) => ({ + id: page * pageSize + i, + ...r, + })); + + setColumnTypes((prevTypes) => { + const newTypes = { ...prevTypes }; + if (newTypes[oldName]) { + newTypes[newName] = newTypes[oldName]; + delete newTypes[oldName]; + } + return newTypes; + }); + + setRows(withIds); + setRowCount(data?.total ?? withIds.length); + + return result; + } catch (error) { + throw error; + } + }, + [datasetId, paginationModel, filterModel, fetchPage], + ); + const columns = useMemo(() => { if (columnsProp?.length) return columnsProp; const first = rows[0]; @@ -135,30 +174,47 @@ export default function DatasetTable({ : "string", minWidth: 120, width: Math.max(120, field.length * 8 + 40), - renderHeader: () => ( -
- - {field} - - + editableColumns && datasetId ? ( + + ) : ( +
- {columnTypes[field]?.type || t("common:unknown")} - -
- ), + + {field} + + + {columnTypes[field]?.type || t("common:unknown")} + +
+ ), })); - }, [rows, columnsProp, columnTypes]); + }, [ + rows, + columnsProp, + columnTypes, + editableColumns, + datasetId, + handleColumnRename, + t, + ]); // Custom CSV Export Button function CsvExportButton() { @@ -291,7 +347,7 @@ export default function DatasetTable({ toolbar: CustomToolbar, loadingOverlay: LinearProgress, }} - columnHeaderHeight={85} + columnHeaderHeight={editableColumns ? 95 : 85} {...props} /> ); diff --git a/DashAI/front/src/components/notebooks/dataset/EditableColumnHeader.jsx b/DashAI/front/src/components/notebooks/dataset/EditableColumnHeader.jsx new file mode 100644 index 000000000..fbb7fa2b9 --- /dev/null +++ b/DashAI/front/src/components/notebooks/dataset/EditableColumnHeader.jsx @@ -0,0 +1,175 @@ +import { useState, useRef, useEffect } from "react"; +import { Typography, TextField, Box, Tooltip } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +/** + * Editable column header component that allows users to rename dataset columns. + * + * @param {string} columnName - Current name of the column + * @param {string} columnType - Type of the column (e.g., "Integer", "Text") + * @param {Function} onRename - Callback function when rename is confirmed (oldName, newName) => Promise + * @param {boolean} disabled - Whether editing is disabled + */ +export default function EditableColumnHeader({ + columnName, + columnType, + onRename, + disabled = false, +}) { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(columnName); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const inputRef = useRef(null); + const { t } = useTranslation(["common"]); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleEditClick = () => { + if (disabled) return; + setEditValue(columnName); + setError(""); + setIsEditing(true); + }; + + const handleCancel = () => { + setEditValue(columnName); + setError(""); + setIsEditing(false); + }; + + const handleConfirm = async () => { + const newName = editValue.trim(); + if (!newName) { + setError(t("common:columnNameEmpty")); + return; + } + if (newName === columnName) { + setIsEditing(false); + return; + } + if (!/^[a-zA-Z0-9_]+$/.test(newName)) { + setError(t("common:columnNameInvalid")); + return; + } + + setIsLoading(true); + setError(""); + + try { + await onRename(columnName, newName); + setIsEditing(false); + } catch (err) { + setError( + err.response?.data?.detail || + err.message || + t("common:errorRenamingColumn"), + ); + } finally { + setIsLoading(false); + } + }; + + const handleKeyDown = (e) => { + if (e.key === "Enter") { + handleConfirm(); + } else if (e.key === "Escape") { + handleCancel(); + } + }; + + if (isEditing) { + return ( + + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleConfirm} + size="small" + error={!!error} + disabled={isLoading} + sx={{ + width: "100%", + "& .MuiInputBase-input": { + fontSize: "0.875rem", + paddingY: 0.5, + textAlign: "center", + }, + }} + /> + {error && ( + + {error} + + )} + + {isLoading ? t("common:loading") : columnType || t("common:unknown")} + + + ); + } + + return ( + + + + {columnName} + + + + {columnType || t("common:unknown")} + + + ); +} diff --git a/DashAI/front/src/components/notebooks/dataset/tabs/OverviewTab.jsx b/DashAI/front/src/components/notebooks/dataset/tabs/OverviewTab.jsx index f98b8d4e0..aea73ee56 100644 --- a/DashAI/front/src/components/notebooks/dataset/tabs/OverviewTab.jsx +++ b/DashAI/front/src/components/notebooks/dataset/tabs/OverviewTab.jsx @@ -59,6 +59,8 @@ const OverviewTab = ({ deps={[dataset.file_path]} initialPageSize={10} datasetPath={dataset.file_path} + datasetId={dataset.id} + editableColumns={true} /> diff --git a/DashAI/front/src/components/notebooks/datasetCreation/ConfigureAndUploadDatasetStep.jsx b/DashAI/front/src/components/notebooks/datasetCreation/ConfigureAndUploadDatasetStep.jsx index 88092e6e9..baf0fa078 100644 --- a/DashAI/front/src/components/notebooks/datasetCreation/ConfigureAndUploadDatasetStep.jsx +++ b/DashAI/front/src/components/notebooks/datasetCreation/ConfigureAndUploadDatasetStep.jsx @@ -1,5 +1,5 @@ -import { useState, useEffect, useCallback } from "react"; -import { Grid, CircularProgress, useTheme } from "@mui/material"; +import { useState, useEffect, useCallback, useRef } from "react"; +import { Grid, CircularProgress } from "@mui/material"; import FormSchemaButtonGroup from "../../shared/FormSchemaButtonGroup"; import Upload from "./Upload"; import { useSnackbar } from "notistack"; @@ -28,7 +28,6 @@ export default function ConfigureAndUploadDatasetStep({ const { enqueueSnackbar } = useSnackbar(); const { t } = useTranslation(["common", "datasets"]); - const theme = useTheme(); useEffect(() => { if (onPreviewError) { @@ -156,8 +155,7 @@ export default function ConfigureAndUploadDatasetStep({ spacing={2} sx={{ width: "100%", - backgroundColor: theme.palette.ui.box, - border: `1px solid ${theme.palette.ui.border}`, + backgroundColor: "#212121", padding: 4, borderRadius: 2, }} diff --git a/DashAI/front/src/components/notebooks/datasetCreation/PreviewDataset.jsx b/DashAI/front/src/components/notebooks/datasetCreation/PreviewDataset.jsx index ba034d6a4..69986ff34 100644 --- a/DashAI/front/src/components/notebooks/datasetCreation/PreviewDataset.jsx +++ b/DashAI/front/src/components/notebooks/datasetCreation/PreviewDataset.jsx @@ -14,12 +14,14 @@ import { useTranslation } from "react-i18next"; * @param {function} onChangeDataset - Callback function when the user wants to change the dataset * @param {function} onPreviewError - Callback function to notify parent of preview errors * @param {function} onTypesChanged - Callback to notify parent when column types change + * @param {function} onColumnRename - Callback to notify parent when columns are renamed (oldName, newName) */ function PreviewDataset({ datasetData, onChangeDataset, onPreviewError, onTypesChanged, + onColumnRename, }) { const theme = useTheme(); const { enqueueSnackbar } = useSnackbar(); @@ -112,6 +114,15 @@ function PreviewDataset({ [enqueueSnackbar, onTypesChanged], ); + const handleColumnRename = useCallback( + (oldName, newName) => { + if (onColumnRename) { + onColumnRename(oldName, newName); + } + }, + [onColumnRename], + ); + return ( @@ -244,6 +256,7 @@ PreviewDataset.propTypes = { onChangeDataset: PropTypes.func, onPreviewError: PropTypes.func, onTypesChanged: PropTypes.func, + onColumnRename: PropTypes.func, }; export default PreviewDataset; diff --git a/DashAI/front/src/components/notebooks/datasetCreation/PreviewDatasetTable.jsx b/DashAI/front/src/components/notebooks/datasetCreation/PreviewDatasetTable.jsx index bcf0ff32b..26d51a9b9 100644 --- a/DashAI/front/src/components/notebooks/datasetCreation/PreviewDatasetTable.jsx +++ b/DashAI/front/src/components/notebooks/datasetCreation/PreviewDatasetTable.jsx @@ -1,9 +1,17 @@ import { useMemo, useState } from "react"; -import { Typography, Select, MenuItem, Box } from "@mui/material"; +import { + Typography, + Select, + MenuItem, + Box, + TextField, + Tooltip, +} from "@mui/material"; import { DataGrid } from "@mui/x-data-grid"; import { TypeChangeValidator } from "./TypeChangeValidator"; +import { useTranslation } from "react-i18next"; +import { useSnackbar } from "notistack"; -// Mapeo de tipos a dtypes por defecto const TYPE_TO_DEFAULT_DTYPE = { Integer: "int64", Float: "float64", @@ -27,6 +35,7 @@ const TYPE_TO_DEFAULT_DTYPE = { * @param {File} file - The uploaded file (needed for validation) * @param {Object} params - Dataloader parameters (needed for validation) * @param {Function} onTypeChange - Callback when types are successfully changed + * @param {Function} onColumnRename - Callback when a column is renamed (oldName, newName) => void */ export default function PreviewDatasetTable({ rows, @@ -34,36 +43,36 @@ export default function PreviewDatasetTable({ file, params, onTypeChange, + onColumnRename, }) { + const { t } = useTranslation(["common"]); + const { enqueueSnackbar } = useSnackbar(); const [showValidator, setShowValidator] = useState(false); const [pendingChanges, setPendingChanges] = useState({}); + const [editingColumn, setEditingColumn] = useState(null); + const [editValue, setEditValue] = useState(""); + const [columnNames, setColumnNames] = useState({}); - // Handler cuando el usuario selecciona un nuevo tipo en el dropdown const handleTypeChangeRequest = (columnName, newType) => { const currentType = columnTypes[columnName]?.type; - // Si no cambió el tipo, no hacer nada if (currentType === newType) { return; } - // Obtener el dtype correcto para el nuevo tipo const newDtype = TYPE_TO_DEFAULT_DTYPE[newType] || "string"; - // Preparar el cambio pendiente con el dtype correcto setPendingChanges({ [columnName]: { current_type: currentType, new_type: newType, - new_dtype: newDtype, // Ya normalizado aquí + new_dtype: newDtype, }, }); - // Mostrar el validador setShowValidator(true); }; - // Handler cuando el usuario confirma los cambios después de la validación const handleConfirmChanges = (changes) => { if (onTypeChange) { onTypeChange(changes); @@ -72,26 +81,93 @@ export default function PreviewDatasetTable({ setPendingChanges({}); }; - // Handler cuando el usuario cancela const handleCancelChanges = () => { setShowValidator(false); setPendingChanges({}); }; - // Crear las columnas del DataGrid + const handleStartEdit = (columnName) => { + setEditingColumn(columnName); + setEditValue(columnNames[columnName] || columnName); + }; + + const handleCancelEdit = () => { + setEditingColumn(null); + setEditValue(""); + }; + + const handleConfirmEdit = (oldName) => { + const newName = editValue.trim(); + + if (!newName) { + enqueueSnackbar(t("common:columnNameCannotBeEmpty"), { + variant: "warning", + }); + handleCancelEdit(); + return; + } + + const columnNameRegex = /^[a-zA-Z0-9_]+$/; + if (!columnNameRegex.test(newName)) { + enqueueSnackbar(t("common:columnNameInvalidCharacters"), { + variant: "warning", + }); + handleCancelEdit(); + return; + } + + if (newName === oldName || newName === (columnNames[oldName] || oldName)) { + handleCancelEdit(); + return; + } + + const allColumnNames = Object.keys(columnTypes).map( + (col) => columnNames[col] || col, + ); + + if (allColumnNames.includes(newName)) { + enqueueSnackbar(t("common:columnNameAlreadyExists"), { + variant: "warning", + }); + handleCancelEdit(); + return; + } + + setColumnNames((prev) => ({ + ...prev, + [oldName]: newName, + })); + + if (onColumnRename) { + onColumnRename(oldName, newName); + } + + handleCancelEdit(); + }; + + const handleKeyDown = (e, columnName) => { + if (e.key === "Enter") { + handleConfirmEdit(columnName); + } else if (e.key === "Escape") { + handleCancelEdit(); + } + }; + const columns = useMemo(() => { if (!rows || rows.length === 0) return []; const firstRow = rows[0]; return Object.keys(firstRow).map((field) => { const columnType = columnTypes[field]; + const displayName = columnNames[field] || field; return { field, - headerName: field, + headerName: displayName, minWidth: 150, flex: 1, - // Custom header con el selector de tipo + sortable: false, + disableColumnMenu: true, renderHeader: () => ( - {/* Nombre de la columna */} - - {field} - + {editingColumn === field ? ( + setEditValue(e.target.value)} + onKeyDown={(e) => handleKeyDown(e, field)} + onBlur={() => handleConfirmEdit(field)} + size="small" + sx={{ + width: "100%", + "& .MuiInputBase-input": { + fontSize: "0.875rem", + paddingY: 0.5, + }, + }} + /> + ) : ( + + handleStartEdit(field)} + sx={{ + fontWeight: 600, + fontSize: "0.875rem", + cursor: "pointer", + transition: "all 0.2s", + "&:hover": { + color: "primary.main", + textDecoration: "underline", + }, + }} + > + {displayName} + + + )} - {/* Selector de tipo */} ), }; }); - }, [rows, columnTypes]); + }, [rows, columnTypes, columnNames, editingColumn, editValue, t]); const rowsWithIds = useMemo(() => { if (!rows) return []; @@ -170,7 +268,6 @@ export default function PreviewDatasetTable({ }} /> - {/* Diálogo de validación */} ); diff --git a/DashAI/front/src/utils/i18n/locales/en/common.json b/DashAI/front/src/utils/i18n/locales/en/common.json index b5f07cf8f..6301961e6 100644 --- a/DashAI/front/src/utils/i18n/locales/en/common.json +++ b/DashAI/front/src/utils/i18n/locales/en/common.json @@ -133,5 +133,12 @@ "validation": "Validation", "value": "Value", "vertical": "Vertical", - "viewDetails": "View Details" + "viewDetails": "View Details", + "renameColumn": "Rename column", + "columnNameEmpty": "Column name cannot be empty", + "columnNameInvalid": "Column name can only contain letters, numbers and underscores", + "columnNameCannotBeEmpty": "Column name cannot be empty", + "columnNameInvalidCharacters": "Column name can only contain letters, numbers and underscores", + "columnNameAlreadyExists": "A column with this name already exists", + "errorRenamingColumn": "Error renaming column" } diff --git a/DashAI/front/src/utils/i18n/locales/es/common.json b/DashAI/front/src/utils/i18n/locales/es/common.json index acf2e15ba..55f4a9f46 100644 --- a/DashAI/front/src/utils/i18n/locales/es/common.json +++ b/DashAI/front/src/utils/i18n/locales/es/common.json @@ -133,5 +133,12 @@ "validation": "Validación", "value": "Valor", "vertical": "Vertical", - "viewDetails": "Ver Detalles" + "viewDetails": "Ver Detalles", + "renameColumn": "Renombrar columna", + "columnNameEmpty": "El nombre de la columna no puede estar vacío", + "columnNameInvalid": "El nombre de la columna solo puede contener letras, números y guiones bajos", + "columnNameCannotBeEmpty": "El nombre de la columna no puede estar vacío", + "columnNameInvalidCharacters": "El nombre de la columna solo puede contener letras, números y guiones bajos", + "columnNameAlreadyExists": "Ya existe una columna con este nombre", + "errorRenamingColumn": "Error al renombrar la columna" }