mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-02 13:45:45 +01:00
Remote path mappings, Client handling improvements (#481)
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
ActionButtonConfig,
|
||||
HeadingFieldConfig,
|
||||
ShowWhenCondition,
|
||||
TableFieldConfig,
|
||||
} from '../../types/settings';
|
||||
import { FieldWrapper } from './shared';
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
OrderableListField,
|
||||
ActionButton,
|
||||
HeadingField,
|
||||
TableField,
|
||||
} from './fields';
|
||||
|
||||
interface SettingsContentProps {
|
||||
@@ -207,6 +209,15 @@ const renderField = (
|
||||
);
|
||||
case 'ActionButton':
|
||||
return <ActionButton field={field as ActionButtonConfig} onAction={onAction} disabled={isDisabled} />;
|
||||
case 'TableField':
|
||||
return (
|
||||
<TableField
|
||||
field={field as TableFieldConfig}
|
||||
value={(value as Record<string, unknown>[]) ?? []}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'HeadingField':
|
||||
return <HeadingField field={field as HeadingFieldConfig} />;
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useMemo } from 'react';
|
||||
import { TableFieldConfig, TableFieldColumn } from '../../../types/settings';
|
||||
import { DropdownList } from '../../DropdownList';
|
||||
|
||||
interface TableFieldProps {
|
||||
field: TableFieldConfig;
|
||||
value: Record<string, unknown>[];
|
||||
onChange: (value: Record<string, unknown>[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function defaultCellValue(column: TableFieldColumn): unknown {
|
||||
if (column.defaultValue !== undefined) {
|
||||
return column.defaultValue;
|
||||
}
|
||||
if (column.type === 'checkbox') {
|
||||
return false;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeRows(rows: Record<string, unknown>[], columns: TableFieldColumn[]): Record<string, unknown>[] {
|
||||
return (rows ?? []).map((row) => {
|
||||
const normalized: Record<string, unknown> = { ...row };
|
||||
for (const col of columns) {
|
||||
if (!(col.key in normalized)) {
|
||||
normalized[col.key] = defaultCellValue(col);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
}
|
||||
|
||||
export const TableField = ({ field, value, onChange, disabled }: TableFieldProps) => {
|
||||
const isDisabled = disabled ?? false;
|
||||
|
||||
const columns = useMemo(() => field.columns ?? [], [field.columns]);
|
||||
const rows = useMemo(() => normalizeRows(value ?? [], columns), [value, columns]);
|
||||
|
||||
const updateCell = (rowIndex: number, key: string, cellValue: unknown) => {
|
||||
const next = rows.map((row, idx) => (idx === rowIndex ? { ...row, [key]: cellValue } : row));
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
const newRow: Record<string, unknown> = {};
|
||||
columns.forEach((col) => {
|
||||
newRow[col.key] = defaultCellValue(col);
|
||||
});
|
||||
onChange([...(rows ?? []), newRow]);
|
||||
};
|
||||
|
||||
const removeRow = (rowIndex: number) => {
|
||||
const next = rows.filter((_, idx) => idx !== rowIndex);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{field.emptyMessage && <p className="text-sm opacity-70">{field.emptyMessage}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
disabled={isDisabled}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium
|
||||
bg-[var(--bg-soft)] border border-[var(--border-muted)]
|
||||
hover:bg-[var(--hover-surface)] transition-colors
|
||||
disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{field.addLabel || 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Use minmax(0, ...) so the grid can shrink inside the settings modal.
|
||||
const gridTemplate = 'sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)_minmax(0,1fr)_auto]';
|
||||
|
||||
return (
|
||||
<div className="space-y-3 min-w-0">
|
||||
<div className={`hidden sm:grid ${gridTemplate} gap-2 px-1 text-xs font-medium opacity-70`}>
|
||||
{columns.map((col) => (
|
||||
<div key={col.key} className="truncate">
|
||||
{col.label}
|
||||
</div>
|
||||
))}
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 min-w-0">
|
||||
{rows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className={`grid grid-cols-1 ${gridTemplate} gap-3 items-start min-w-0`}
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
{columns.map((col) => {
|
||||
const cellValue = row[col.key];
|
||||
|
||||
const mobileLabel = <div className="sm:hidden text-xs font-medium opacity-70">{col.label}</div>;
|
||||
|
||||
if (col.type === 'checkbox') {
|
||||
return (
|
||||
<div key={col.key} className="flex flex-col gap-1 min-w-0">
|
||||
{mobileLabel}
|
||||
<div className="pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(cellValue)}
|
||||
onChange={(e) => updateCell(rowIndex, col.key, e.target.checked)}
|
||||
disabled={isDisabled}
|
||||
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500
|
||||
disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (col.type === 'select') {
|
||||
const options = (col.options ?? []).map((opt) => ({
|
||||
value: String(opt.value),
|
||||
label: opt.label,
|
||||
description: opt.description,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div key={col.key} className="flex flex-col gap-1 min-w-0">
|
||||
{mobileLabel}
|
||||
{isDisabled ? (
|
||||
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
|
||||
{options.find((o) => o.value === String(cellValue ?? ''))?.label || 'Select...'}
|
||||
</div>
|
||||
) : (
|
||||
<DropdownList
|
||||
options={options}
|
||||
value={String(cellValue ?? '')}
|
||||
onChange={(val) => updateCell(rowIndex, col.key, Array.isArray(val) ? val[0] : val)}
|
||||
placeholder={col.placeholder || 'Select...'}
|
||||
widthClassName="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// text/path
|
||||
return (
|
||||
<div key={col.key} className="flex flex-col gap-1 min-w-0">
|
||||
{mobileLabel}
|
||||
<input
|
||||
type="text"
|
||||
value={String(cellValue ?? '')}
|
||||
onChange={(e) => updateCell(rowIndex, col.key, e.target.value)}
|
||||
placeholder={col.placeholder}
|
||||
disabled={isDisabled}
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
|
||||
bg-[var(--bg-soft)] text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
|
||||
disabled:opacity-60 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(rowIndex)}
|
||||
disabled={isDisabled}
|
||||
className="p-2 rounded hover:bg-[var(--hover-surface)]
|
||||
disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
aria-label="Remove row"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full border-t border-[var(--border-muted)] opacity-60" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
disabled={isDisabled}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium
|
||||
bg-[var(--bg-soft)] border border-[var(--border-muted)]
|
||||
hover:bg-[var(--hover-surface)] transition-colors
|
||||
disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{field.addLabel || 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,4 +6,5 @@ export { SelectField } from './SelectField';
|
||||
export { MultiSelectField } from './MultiSelectField';
|
||||
export { OrderableListField } from './OrderableListField';
|
||||
export { ActionButton } from './ActionButton';
|
||||
export { TableField } from './TableField';
|
||||
export { HeadingField } from './HeadingField';
|
||||
|
||||
@@ -37,6 +37,11 @@ function getFieldValue(field: SettingsField): unknown {
|
||||
if (field.type === 'ActionButton' || field.type === 'HeadingField') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (field.type === 'TableField') {
|
||||
return (field as unknown as { value?: unknown }).value ?? [];
|
||||
}
|
||||
|
||||
// All other fields have a value property
|
||||
return field.value ?? '';
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export type FieldType =
|
||||
| 'SelectField'
|
||||
| 'MultiSelectField'
|
||||
| 'OrderableListField'
|
||||
| 'TableField'
|
||||
| 'ActionButton'
|
||||
| 'HeadingField';
|
||||
|
||||
@@ -118,6 +119,31 @@ export interface ActionButtonConfig extends BaseField {
|
||||
style: 'default' | 'primary' | 'danger';
|
||||
}
|
||||
|
||||
export interface TableFieldColumnOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type TableFieldColumnType = 'text' | 'select' | 'checkbox' | 'path';
|
||||
|
||||
export interface TableFieldColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
type: TableFieldColumnType;
|
||||
placeholder?: string;
|
||||
options?: TableFieldColumnOption[];
|
||||
defaultValue?: string | boolean;
|
||||
}
|
||||
|
||||
export interface TableFieldConfig extends BaseField {
|
||||
type: 'TableField';
|
||||
value: Record<string, unknown>[];
|
||||
columns: TableFieldColumn[];
|
||||
addLabel?: string;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export interface HeadingFieldConfig {
|
||||
key: string;
|
||||
type: 'HeadingField';
|
||||
@@ -138,6 +164,7 @@ export type SettingsField =
|
||||
| SelectFieldConfig
|
||||
| MultiSelectFieldConfig
|
||||
| OrderableListFieldConfig
|
||||
| TableFieldConfig
|
||||
| ActionButtonConfig
|
||||
| HeadingFieldConfig;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user