diff --git a/src/components/core/EditableTextField.tsx b/src/components/core/EditableTextField.tsx new file mode 100644 index 0000000..22d8447 --- /dev/null +++ b/src/components/core/EditableTextField.tsx @@ -0,0 +1,109 @@ +import classNames from "classnames"; +import { KeyboardEvent, ReactElement, useEffect, useState } from "react"; +import { MdEdit } from "react-icons/md"; + +interface EditableTextFieldProps { + value: string; + onCommit: (value: string) => void | Promise; + renderDisplay?: (value: string) => ReactElement; + editLabel: string; + saving?: boolean; + inputClassName?: string; + displayClassName?: string; +} + +export function EditableTextField({ + value, + onCommit, + renderDisplay, + editLabel, + saving = false, + inputClassName, + displayClassName, +}: EditableTextFieldProps): ReactElement { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); + + useEffect(() => { + if (!editing) { + setDraft(value); + } + }, [value, editing]); + + function startEdit(): void { + setDraft(value); + setEditing(true); + } + + function cancelEdit(): void { + setDraft(value); + setEditing(false); + } + + async function handleCommit(): Promise { + const trimmed = draft.trim(); + if (trimmed === value) { + setEditing(false); + return; + } + + try { + await onCommit(trimmed); + setEditing(false); + } catch { + setDraft(value); + } + } + + function handleKeyDown(event: KeyboardEvent): void { + if (event.key === "Enter") { + event.preventDefault(); + void handleCommit(); + } + if (event.key === "Escape") { + event.preventDefault(); + cancelEdit(); + } + } + + function defaultRender(displayValue: string): ReactElement { + return {displayValue}; + } + + if (editing) { + return ( + setDraft(event.target.value)} + onKeyDown={handleKeyDown} + disabled={saving} + className={classNames( + "w-full bg-transparent border border-border rounded px-2 py-0.5 text-primary flex-1 min-w-0", + inputClassName, + )} + autoFocus + onClick={(event) => event.stopPropagation()} + /> + ); + } + + return ( +
+
+ {(renderDisplay ?? defaultRender)(value)} +
+ +
+ ); +} diff --git a/src/pages/TableDetails.tsx b/src/pages/TableDetails.tsx index f3d5f8d..291becc 100644 --- a/src/pages/TableDetails.tsx +++ b/src/pages/TableDetails.tsx @@ -1,11 +1,5 @@ import classNames from "classnames"; -import { - KeyboardEvent, - ReactElement, - useEffect, - useMemo, - useState, -} from "react"; +import { ReactElement, useEffect, useMemo, useState } from "react"; import { Bibliography, DataType, @@ -14,7 +8,7 @@ import { } from "../clients/admin/types.gen"; import { getTable, patchTable } from "../clients/admin/sdk.gen"; import { useNavigate, useParams } from "react-router-dom"; -import { MdEdit } from "react-icons/md"; +import { EditableTextField } from "../components/core/EditableTextField"; import { CellPrimitive, Column, @@ -131,6 +125,25 @@ function renderColumnName(name: CellPrimitive): ReactElement { ); } +type ColumnMetadataField = "description" | "unit" | "ucd"; + +interface MetadataCellDisplayProps { + value: string | null | undefined; + renderDisplay?: (value: string) => ReactElement; +} + +function MetadataCellDisplay(props: MetadataCellDisplayProps): ReactElement { + if (props.value) { + return props.renderDisplay ? ( + props.renderDisplay(props.value) + ) : ( + {props.value} + ); + } + + return ; +} + interface TableMetaProps { tableName: string; table: GetTableResponse; @@ -141,14 +154,6 @@ interface TableMetaProps { function TableMeta(props: TableMetaProps): ReactElement { const navigate = useNavigate(); const canEdit = isLoggedIn(); - const [editingName, setEditingName] = useState(false); - const [editingDescription, setEditingDescription] = useState(false); - const showNameEdit = canEdit && !editingDescription; - const showSlugEdit = canEdit && !editingName; - const [draftName, setDraftName] = useState(props.tableName); - const [draftDescription, setDraftDescription] = useState( - props.table.description, - ); const [savingField, setSavingField] = useState< "name" | "description" | "datatype" | null >(null); @@ -177,34 +182,14 @@ function TableMeta(props: TableMetaProps): ReactElement { onSuccess(); } catch (err) { setPatchError(`${err}`); + throw err; } finally { setSavingField(null); } } - useEffect(() => { - if (!editingName) { - setDraftName(props.tableName); - } - }, [props.tableName, editingName]); - - useEffect(() => { - if (!editingDescription) { - setDraftDescription(props.table.description); - } - }, [props.table.description, editingDescription]); - - async function commitName(): Promise { - const trimmed = draftName.trim(); + async function commitSlug(trimmed: string): Promise { if (!trimmed) { - setDraftName(props.tableName); - setEditingName(false); - setPatchError(null); - return; - } - if (trimmed === props.tableName) { - setEditingName(false); - setPatchError(null); return; } await runTablePatch( @@ -213,61 +198,21 @@ function TableMeta(props: TableMetaProps): ReactElement { table_name: props.tableName, new_table_name: trimmed, }, - () => { - setEditingName(false); - navigate(`/table/${encodeURIComponent(trimmed)}`); - }, + () => navigate(`/table/${encodeURIComponent(trimmed)}`), ); } - async function commitDescription(): Promise { - const trimmed = draftDescription.trim(); - if (trimmed === props.table.description) { - setEditingDescription(false); - setPatchError(null); - return; - } + async function commitDescription(trimmed: string): Promise { await runTablePatch( "description", { table_name: props.tableName, description: trimmed, }, - () => { - setEditingDescription(false); - props.onAfterPatch(); - }, + () => props.onAfterPatch(), ); } - function handleNameKeyDown(event: KeyboardEvent): void { - if (event.key === "Enter") { - event.preventDefault(); - void commitName(); - } - if (event.key === "Escape") { - event.preventDefault(); - setDraftName(props.tableName); - setEditingName(false); - setPatchError(null); - } - } - - function handleDescriptionKeyDown( - event: KeyboardEvent, - ): void { - if (event.key === "Enter") { - event.preventDefault(); - void commitDescription(); - } - if (event.key === "Escape") { - event.preventDefault(); - setDraftDescription(props.table.description); - setEditingDescription(false); - setPatchError(null); - } - } - async function commitDatatype(next: DataType): Promise { const current = asDataType(props.table.meta.datatype); if (next === current) { @@ -303,66 +248,30 @@ function TableMeta(props: TableMetaProps): ReactElement { return ( -
- {editingDescription ? ( - setDraftDescription(event.target.value)} - onKeyDown={handleDescriptionKeyDown} - disabled={savingField === "description"} - className="bg-transparent border border-border rounded px-2 py-0.5 flex-1 min-w-0 text-primary" - autoFocus - /> - ) : ( - {props.table.description} - )} - {showNameEdit && ( - - )} -
+ {canEdit ? ( + + ) : ( + {props.table.description} + )}
-
- {editingName ? ( - setDraftName(event.target.value)} - onKeyDown={handleNameKeyDown} - disabled={savingField === "name"} - className="font-mono bg-transparent border border-border rounded px-2 py-0.5 flex-1 min-w-0" - autoFocus - /> - ) : ( - - {props.tableName} - - )} - {showSlugEdit && ( - - )} -
+ {canEdit ? ( + + ) : ( + {props.tableName} + )}
{props.table.id} @@ -545,6 +454,7 @@ function CatalogProgressCard({ interface ColumnInfoProps { tableName: string; table: GetTableResponse; + onAfterPatch: () => void; } const COLUMN_SELECT_KEY = ""; @@ -567,10 +477,16 @@ function columnMatchesSearch( function ColumnInfo(props: ColumnInfoProps): ReactElement { const navigate = useNavigate(); + const canEdit = isLoggedIn(); const [query, setQuery] = useState(""); const [selectedColumns, setSelectedColumns] = useState>( () => new Set(), ); + const [saving, setSaving] = useState<{ + columnName: string; + field: ColumnMetadataField; + } | null>(null); + const [patchError, setPatchError] = useState(null); useEffect(() => { setSelectedColumns(new Set()); @@ -595,6 +511,68 @@ function ColumnInfo(props: ColumnInfoProps): ReactElement { }); } + async function commitColumnMetadata( + columnName: string, + field: ColumnMetadataField, + trimmed: string, + ): Promise { + setPatchError(null); + setSaving({ columnName, field }); + try { + const response = await patchTable({ + client: adminClient, + body: { + table_name: props.tableName, + columns: { + [columnName]: { + [field]: trimmed === "" ? null : trimmed, + }, + }, + }, + }); + if (response.error) { + throw new Error(JSON.stringify(response.error)); + } + props.onAfterPatch(); + } catch (err) { + setPatchError(`${err}`); + throw err; + } finally { + setSaving(null); + } + } + + function renderMetadataCell( + columnName: string, + field: ColumnMetadataField, + value: string | null | undefined, + renderDisplay?: (displayValue: string) => ReactElement, + ): ReactElement { + if (!canEdit) { + return ( + + ); + } + + const isSaving = + saving?.columnName === columnName && saving.field === field; + + return ( + ( + + )} + onCommit={(trimmed) => commitColumnMetadata(columnName, field, trimmed)} + /> + ); + } + const selectedColumnInfo = useMemo( () => props.table.column_info.filter((col) => selectedColumns.has(col.name)), @@ -641,7 +619,6 @@ function ColumnInfo(props: ColumnInfoProps): ReactElement { { name: "Unit" }, { name: "UCD", - renderCell: renderUCD, hint: (

Unified Content Descriptor. Describes astronomical quantities in a @@ -665,22 +642,19 @@ function ColumnInfo(props: ColumnInfoProps): ReactElement { props.table.column_info .filter((col) => columnMatchesSearch(col, query)) .forEach((col) => { - const colValue: Record = { + values.push({ [COLUMN_SELECT_KEY]: col.name, Name: col.name, - }; - - if (col.description) { - colValue.Description = col.description; - } - if (col.unit) { - colValue.Unit = col.unit; - } - if (col.ucd) { - colValue.UCD = col.ucd; - } - - values.push(colValue); + Description: renderMetadataCell( + col.name, + "description", + col.description, + ), + Unit: renderMetadataCell(col.name, "unit", col.unit), + UCD: renderMetadataCell(col.name, "ucd", col.ucd, (ucd) => + renderUCD(ucd), + ), + }); }); return ( @@ -692,6 +666,9 @@ function ColumnInfo(props: ColumnInfoProps): ReactElement { placeholder="Search column by name, description, or UCD" /> + {patchError ? ( +

{patchError}

+ ) : null}
); @@ -755,7 +732,11 @@ export function TableDetailsPage(): ReactElement { /> ) : null} - + setRefreshKey((key) => key + 1)} + /> ); }