diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index ded514e..b91c582 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -13,20 +13,20 @@ JSON should have strictly the following form: ```json [ - { - "name": "typos", - "file": "path/from/the/root/of/the/repo/file.py", - "line_from": 10, - "line_to": 20, - "description": "Word 'asembly' is written with a typo, you likely meant 'assembly'" - }, - { - "name": "incorrect-comments", - "file": "path/from/the/root/of/the/repo/another.py", - "line_from": 15, - "line_to": 21, - "description": "The comment says that this algorithm works in O(N) but most common path according to numpy documentation is O(N^2)" - } + { + "name": "typos", + "file": "path/from/the/root/of/the/repo/file.py", + "line_from": 10, + "line_to": 20, + "description": "Word 'asembly' is written with a typo, you likely meant 'assembly'" + }, + { + "name": "incorrect-comments", + "file": "path/from/the/root/of/the/repo/another.py", + "line_from": 15, + "line_to": 21, + "description": "The comment says that this algorithm works in O(N) but most common path according to numpy documentation is O(N^2)" + } ] ``` @@ -34,6 +34,11 @@ You MUST adhere to this form because it will be later used by automation to crea If the rule asks for a citation, put it in the `description` field. +To fetch the full diff use +```shell +git fetch origin && git diff origin/master...HEAD +``` + Below are rules you should check when reviewing the code. ### duplicate-code @@ -46,7 +51,7 @@ The comment used for a function, variable, or expression contradicts the content ### misleading-name -The name of a function, method, variable, parameter, or class contradicts what it actually does. Examples of contradictions: a get_* or fetch_* function that mutates state, an is__/has__ name that does not return a boolean, a singular name bound to a collection, a boolean flag whose name implies the opposite polarity of the behavior it controls, or a verb that names a different operation than the one performed. Only flag when the mismatch is visible in the body of the token in the diff or in the code the diff calls. Do not flag names that are merely vague, short, or abbreviated. +The name of a function, method, variable, parameter, or class contradicts what it actually does. Examples of contradictions: a get_* or fetch_* function that mutates state, an is_*/has_* name that does not return a boolean, a singular name bound to a collection, a boolean flag whose name implies the opposite polarity of the behavior it controls, or a verb that names a different operation than the one performed. Only flag when the mismatch is visible in the body of the token in the diff or in the code the diff calls. Do not flag names that are merely vague, short, or abbreviated. ### typos @@ -123,3 +128,7 @@ subprocess/os.system/os.popen runs a shell with concatenated or formatted user/e ### pointless-wrapper A new or changed function or method only forwards to another callable with the same arguments and return value, adding no conversion, validation, defaulting, error handling, or other logic. Only flag when call sites could invoke the inner callable directly, the wrapper does not implement an interface, protocol, or abstract method, and it is not a public re-export of a private or third-party symbol. + +### redundant-parameter + +All callers of a new or changed function, method or class pass the same value for the parameter such that a parameter can be deleted without affecting any behaviour of all callers. diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 54dfb92..95518f1 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -11,6 +11,7 @@ import { MdAdminPanelSettings, MdAccountTree, MdCode, + MdLibraryBooks, MdOpenInNew, MdPublic, MdTableChart, @@ -22,6 +23,8 @@ import { DataCatalogPage, LoginPage, RecordCrossmatchDetailsPage, + ReferenceDetailsPage, + ReferencesPage, SqlQueryPage, TableDetailsPage, TablesPage, @@ -110,6 +113,9 @@ function Layout() { + + + @@ -137,6 +143,11 @@ function App() { } /> } /> } /> + } /> + } + /> } /> } /> } /> diff --git a/apps/admin/src/components/ui/EditableField.tsx b/apps/admin/src/components/ui/EditableField.tsx new file mode 100644 index 0000000..00447a4 --- /dev/null +++ b/apps/admin/src/components/ui/EditableField.tsx @@ -0,0 +1,208 @@ +import classNames from "classnames"; +import { ReactElement, useEffect, useState } from "react"; +import { MdEdit } from "react-icons/md"; +import { formatCaughtError } from "@leda/lib/tap"; +import { FieldInput, type FieldInputConfig } from "./FieldInput"; + +export interface EditableFieldProps { + value: T; + formatValue: (value: T) => string; + parseDraft: (draft: string) => T; + input: FieldInputConfig; + onSave: (value: T) => void | Promise; + editLabel: string; + saving?: boolean; + displayClassName?: string; + inputClassName?: string; + align?: "center" | "start"; + emptyDisplayValue?: string; + renderDisplay?: (value: T) => ReactElement; + trimOnCommit?: boolean; + revertOnError?: boolean; + isEmpty?: (value: T) => boolean; + isUnchanged?: (draft: string, value: T) => boolean; +} + +export interface StringEditableFieldProps extends Omit< + EditableFieldProps, + "formatValue" | "parseDraft" | "input" +> { + input?: FieldInputConfig; +} + +function EditableFieldInner({ + value, + formatValue, + parseDraft, + input, + onSave, + editLabel, + saving = false, + displayClassName, + inputClassName, + align = "center", + emptyDisplayValue, + renderDisplay, + trimOnCommit = true, + revertOnError = true, + isEmpty, + isUnchanged, +}: EditableFieldProps): ReactElement { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(() => formatValue(value)); + const [error, setError] = useState(null); + + function valueIsEmpty(nextValue: T): boolean { + if (isEmpty) { + return isEmpty(nextValue); + } + return !formatValue(nextValue); + } + + function toDisplay(nextValue: T): string { + if (valueIsEmpty(nextValue) && emptyDisplayValue) { + return emptyDisplayValue; + } + return formatValue(nextValue); + } + + useEffect(() => { + if (!editing) { + setDraft(formatValue(value)); + setError(null); + } + }, [value, editing, formatValue]); + + function startEdit(): void { + setDraft(formatValue(value)); + setError(null); + setEditing(true); + } + + function cancelEdit(): void { + setDraft(formatValue(value)); + setError(null); + setEditing(false); + } + + async function handleSave(): Promise { + const nextDraft = trimOnCommit ? draft.trim() : draft; + const changed = isUnchanged + ? !isUnchanged(nextDraft, value) + : nextDraft !== formatValue(value); + + if (!changed) { + setEditing(false); + setError(null); + return; + } + + try { + await onSave(parseDraft(nextDraft)); + setEditing(false); + setError(null); + } catch (err) { + setError(formatCaughtError(err)); + if (revertOnError) { + setDraft(formatValue(value)); + } + } + } + + function defaultRender(nextValue: T): ReactElement { + return ( + + {toDisplay(nextValue)} + + ); + } + + function renderValue(nextValue: T): ReactElement { + if (valueIsEmpty(nextValue) && emptyDisplayValue) { + return defaultRender(nextValue); + } + if (renderDisplay) { + return renderDisplay(nextValue); + } + return defaultRender(nextValue); + } + + if (editing) { + return ( +
+ void handleSave()} + onCancel={cancelEdit} + commitOnChange={input.kind === "select"} + /> + {error ?

{error}

: null} +
+ ); + } + + return ( +
+
{renderValue(value)}
+ +
+ ); +} + +export function EditableField(props: EditableFieldProps): ReactElement; +export function EditableField(props: StringEditableFieldProps): ReactElement; +export function EditableField( + props: EditableFieldProps | StringEditableFieldProps, +): ReactElement { + if ("parseDraft" in props) { + return ; + } + + return ( + v} + parseDraft={(d) => d} + input={props.input ?? { kind: "text" }} + onSave={props.onSave} + editLabel={props.editLabel} + saving={props.saving} + displayClassName={props.displayClassName} + inputClassName={props.inputClassName} + align={props.align} + emptyDisplayValue={props.emptyDisplayValue} + renderDisplay={props.renderDisplay} + trimOnCommit={props.trimOnCommit ?? true} + revertOnError={props.revertOnError ?? true} + isEmpty={props.isEmpty} + isUnchanged={props.isUnchanged} + /> + ); +} diff --git a/apps/admin/src/components/ui/EditableTextField.tsx b/apps/admin/src/components/ui/EditableTextField.tsx deleted file mode 100644 index 69c6b81..0000000 --- a/apps/admin/src/components/ui/EditableTextField.tsx +++ /dev/null @@ -1,109 +0,0 @@ -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/apps/admin/src/components/ui/FieldInput.tsx b/apps/admin/src/components/ui/FieldInput.tsx new file mode 100644 index 0000000..da44e19 --- /dev/null +++ b/apps/admin/src/components/ui/FieldInput.tsx @@ -0,0 +1,332 @@ +import classNames from "classnames"; +import { + KeyboardEvent, + ReactElement, + ReactNode, + useEffect, + useRef, + useState, +} from "react"; +import { SuggestibleInput } from "@leda/lib/ui"; + +const SEARCH_DEBOUNCE_MS = 300; + +export interface FieldOption { + value: string; + label: string; + description?: string | null; +} + +export type FieldInputConfig = + | { kind: "text" } + | { kind: "number" } + | { kind: "textarea"; rows?: number } + | { kind: "json"; rows?: number } + | { kind: "select"; options: { value: unknown; label: string }[] } + | { + kind: "autocomplete"; + loadOptions: (query: string) => Promise; + }; + +export interface FieldInputProps { + input: FieldInputConfig; + value: string; + onChange: (value: string) => void; + disabled?: boolean; + autoFocus?: boolean; + className?: string; + appearance?: "form" | "inline"; + onSave?: () => void; + onCancel?: () => void; + commitOnChange?: boolean; +} + +function controlClassName( + appearance: "form" | "inline", + className?: string, +): string { + return classNames( + appearance === "inline" + ? "w-full bg-transparent border border-border rounded px-2 py-0.5 text-primary flex-1 min-w-0" + : "bg-surface-2 border border-border rounded px-2 py-1 text-primary w-full min-w-0 text-sm", + className, + ); +} + +function defaultTextareaRows(input: FieldInputConfig): number { + if (input.kind === "json") { + return 4; + } + if (input.kind === "textarea") { + return 3; + } + return 3; +} + +function textareaRowsForValue(input: FieldInputConfig, value: string): number { + if (input.kind !== "textarea" && input.kind !== "json") { + return defaultTextareaRows(input); + } + const lineCount = value.split("\n").length; + return Math.max(input.rows ?? defaultTextareaRows(input), lineCount); +} + +function handleSingleLineKeyDown( + event: KeyboardEvent, + onSave?: () => void, + onCancel?: () => void, +): void { + if (event.key === "Escape" && onCancel) { + event.preventDefault(); + onCancel(); + return; + } + if (event.key === "Enter" && onSave) { + event.preventDefault(); + onSave(); + } +} + +function handleMultilineKeyDown( + event: KeyboardEvent, + onSave?: () => void, + onCancel?: () => void, +): void { + if (event.key === "Escape" && onCancel) { + event.preventDefault(); + onCancel(); + return; + } + if (event.key === "Enter" && event.metaKey && onSave) { + event.preventDefault(); + onSave(); + } +} + +interface AutocompleteInputProps { + loadOptions: (query: string) => Promise; + value: string; + onChange: (value: string) => void; + disabled?: boolean; + autoFocus?: boolean; + className?: string; + onSave?: () => void; + onCancel?: () => void; +} + +function AutocompleteInput({ + loadOptions, + value, + onChange, + disabled = false, + autoFocus = false, + className, + onSave, + onCancel, +}: AutocompleteInputProps): ReactElement { + const [optionsError, setOptionsError] = useState(null); + const [options, setOptions] = useState([]); + const debounceRef = useRef | null>(null); + const requestIdRef = useRef(0); + + useEffect( + () => () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + requestIdRef.current += 1; + }, + [], + ); + + function scheduleOptionsLoad(query: string): void { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + debounceRef.current = null; + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + + void (async () => { + try { + const loaded = await loadOptions(query); + if (requestId !== requestIdRef.current) { + return; + } + setOptionsError(null); + setOptions(loaded); + } catch (err) { + if (requestId !== requestIdRef.current) { + return; + } + setOptionsError( + err instanceof Error ? err.message : "Failed to load options", + ); + setOptions([]); + } + })(); + }, SEARCH_DEBOUNCE_MS); + } + + function renderSuggestions(query: string): ReactNode[] { + const needle = query.trim().toLowerCase(); + return options + .filter((option) => { + if (!needle) { + return true; + } + return ( + option.label.toLowerCase().includes(needle) || + option.value.toLowerCase().includes(needle) + ); + }) + .map((option) => ( + + )); + } + + return ( +
+ { + onChange(nextValue); + scheduleOptionsLoad(nextValue); + }} + getSuggestions={renderSuggestions} + disabled={disabled} + autoFocus={autoFocus} + className={className} + onKeyDown={(event) => handleSingleLineKeyDown(event, onSave, onCancel)} + onFocus={() => scheduleOptionsLoad(value)} + /> + {optionsError ? ( +

{optionsError}

+ ) : null} +
+ ); +} + +export function FieldInput({ + input, + value, + onChange, + disabled = false, + autoFocus = false, + className, + appearance = "form", + onSave, + onCancel, + commitOnChange = false, +}: FieldInputProps): ReactElement { + const resolvedClassName = controlClassName(appearance, className); + + if (input.kind === "select") { + return ( + + ); + } + + if (input.kind === "autocomplete") { + return ( + + ); + } + + if (input.kind === "textarea" || input.kind === "json") { + return ( +