Skip to content

Commit 1a8f3e7

Browse files
Copilothotlong
andcommitted
feat: implement L2 features - UndoManager batch ops, kanban lane persistence, record nav keyboard, import preview, thread resolution
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 6af05aa commit 1a8f3e7

4 files changed

Lines changed: 85 additions & 18 deletions

File tree

apps/console/src/components/RecordDetailView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
3636
const { user } = useAuth();
3737
const [isLoading, setIsLoading] = useState(true);
3838
const [comments, setComments] = useState<Comment[]>([]);
39+
const [threadResolved, setThreadResolved] = useState(false);
3940
const objectDef = objects.find((o: any) => o.name === objectName);
4041

4142
const currentUser = user
@@ -164,6 +165,8 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
164165
onAddComment={handleAddComment}
165166
onDeleteComment={handleDeleteComment}
166167
onReaction={handleReaction}
168+
resolved={threadResolved}
169+
onResolve={setThreadResolved}
167170
/>
168171
</div>
169172
</div>

packages/plugin-detail/src/DetailView.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,26 @@ export const DetailView: React.FC<DetailViewProps> = ({
201201
setEditedValues(prev => ({ ...prev, [field]: value }));
202202
}, []);
203203

204+
// Keyboard shortcuts for prev/next record navigation (← / →)
205+
React.useEffect(() => {
206+
if (!schema.recordNavigation) return;
207+
const nav = schema.recordNavigation;
208+
const handler = (e: KeyboardEvent) => {
209+
// Skip when focus is inside an input, textarea, or contenteditable
210+
const tag = (e.target as HTMLElement)?.tagName;
211+
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
212+
if (e.key === 'ArrowLeft' && nav.currentIndex > 0) {
213+
e.preventDefault();
214+
nav.onNavigate(nav.recordIds[nav.currentIndex - 1]);
215+
} else if (e.key === 'ArrowRight' && nav.currentIndex < nav.recordIds.length - 1) {
216+
e.preventDefault();
217+
nav.onNavigate(nav.recordIds[nav.currentIndex + 1]);
218+
}
219+
};
220+
document.addEventListener('keydown', handler);
221+
return () => document.removeEventListener('keydown', handler);
222+
}, [schema.recordNavigation]);
223+
204224
if (loading || schema.loading) {
205225
return (
206226
<div className={cn('space-y-4', className)}>

packages/plugin-grid/src/ImportWizard.tsx

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface ImportWizardProps {
2121
onCancel?: () => void;
2222
open?: boolean;
2323
onOpenChange?: (open: boolean) => void;
24+
/** Error handling strategy: 'skip' skips invalid rows, 'stop' aborts on first error. @default 'skip' */
25+
onErrorMode?: 'skip' | 'stop';
2426
}
2527

2628
export interface ImportResult {
@@ -201,37 +203,61 @@ const StepMapping: React.FC<{
201203
);
202204
};
203205

204-
// Step 3: Preview & Import
206+
// Step 3: Preview & Import (shows first 10 rows with per-row validation errors)
205207
const StepPreview: React.FC<{
206208
headers: string[]; rows: string[][]; mapping: Record<number, string>; fields: ImportWizardProps['fields'];
207209
}> = ({ headers, rows, mapping, fields }) => {
208210
const mappedCols = useMemo(() =>
209211
Object.entries(mapping).map(([idx, fieldName]) => ({
210212
csvIdx: Number(idx), header: headers[Number(idx)], field: fields.find((f) => f.name === fieldName)!,
211213
})), [mapping, headers, fields]);
212-
const previewRows = rows.slice(0, 5);
214+
const previewRows = rows.slice(0, 10);
215+
216+
const rowValidations = useMemo(() => previewRows.map((row, rIdx) => {
217+
const errs: Record<number, string> = {};
218+
for (const col of mappedCols) {
219+
const raw = row[col.csvIdx] ?? '';
220+
if (col.field.required && !raw) errs[col.csvIdx] = 'Required';
221+
else if (raw && !validateValue(raw, col.field.type)) errs[col.csvIdx] = `Invalid ${col.field.type}`;
222+
}
223+
return errs;
224+
}), [previewRows, mappedCols]);
225+
226+
const errorCount = rowValidations.filter(e => Object.keys(e).length > 0).length;
213227

214228
return (
215229
<div className="max-h-[360px] overflow-auto">
230+
{errorCount > 0 && (
231+
<p className="mb-2 flex items-center gap-1 text-xs text-destructive">
232+
<AlertCircle className="h-3.5 w-3.5" /> {errorCount} row(s) with errors in preview
233+
</p>
234+
)}
216235
<Table>
217236
<TableHeader>
218237
<TableRow>
238+
<TableHead className="w-12">#</TableHead>
219239
{mappedCols.map((col) => <TableHead key={col.csvIdx}>{col.field.label}</TableHead>)}
220240
</TableRow>
221241
</TableHeader>
222242
<TableBody>
223-
{previewRows.map((row, rIdx) => (
224-
<TableRow key={rIdx}>
225-
{mappedCols.map((col) => {
226-
const value = row[col.csvIdx] ?? '';
227-
return (
228-
<TableCell key={col.csvIdx} className={cn(!validateValue(value, col.field.type) && 'text-destructive')}>
229-
{value}
230-
</TableCell>
231-
);
232-
})}
233-
</TableRow>
234-
))}
243+
{previewRows.map((row, rIdx) => {
244+
const errs = rowValidations[rIdx];
245+
const hasError = Object.keys(errs).length > 0;
246+
return (
247+
<TableRow key={rIdx} className={cn(hasError && 'bg-destructive/5')}>
248+
<TableCell className="text-xs text-muted-foreground">{rIdx + 1}</TableCell>
249+
{mappedCols.map((col) => {
250+
const value = row[col.csvIdx] ?? '';
251+
const cellErr = errs[col.csvIdx];
252+
return (
253+
<TableCell key={col.csvIdx} className={cn(cellErr && 'text-destructive')} title={cellErr}>
254+
{value || <span className="text-muted-foreground/50"></span>}
255+
</TableCell>
256+
);
257+
})}
258+
</TableRow>
259+
);
260+
})}
235261
</TableBody>
236262
</Table>
237263
<p className="mt-2 text-xs text-muted-foreground">Showing {previewRows.length} of {rows.length} rows</p>
@@ -241,7 +267,7 @@ const StepPreview: React.FC<{
241267

242268
// Main wizard component
243269
export const ImportWizard: React.FC<ImportWizardProps> = ({
244-
objectName, objectLabel, fields, dataSource, onComplete, onCancel, open, onOpenChange,
270+
objectName, objectLabel, fields, dataSource, onComplete, onCancel, open, onOpenChange, onErrorMode = 'skip',
245271
}) => {
246272
const [step, setStep] = useState<WizardStep>('upload');
247273
const [headers, setHeaders] = useState<string[]>([]);
@@ -274,19 +300,21 @@ export const ImportWizard: React.FC<ImportWizardProps> = ({
274300
if (rowErrors.length > 0) {
275301
skippedRows++;
276302
errors.push(...rowErrors);
303+
if (onErrorMode === 'stop') break;
277304
} else {
278305
try { if (dataSource?.create) await dataSource.create(objectName, record); importedRows++; }
279306
catch (err) {
280307
skippedRows++;
281308
const msg = err instanceof Error ? err.message : 'Failed to create record';
282309
errors.push({ row: i + 1, field: '', message: msg });
310+
if (onErrorMode === 'stop') break;
283311
}
284312
}
285313
setProgress(Math.round(((i + 1) / rows.length) * 100));
286314
}
287315
const importResult: ImportResult = { totalRows: rows.length, importedRows, skippedRows, errors };
288316
setResult(importResult); setImporting(false); onComplete?.(importResult);
289-
}, [rows, mapping, fields, dataSource, objectName, onComplete]);
317+
}, [rows, mapping, fields, dataSource, objectName, onComplete, onErrorMode]);
290318

291319
const reset = useCallback(() => {
292320
setStep('upload'); setHeaders([]); setRows([]); setMapping({}); setProgress(0); setResult(null);

packages/plugin-kanban/src/KanbanImpl.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,20 @@ export default function KanbanBoard({ columns, onCardMove, onCardClick, classNam
317317

318318
function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, quickAdd, onQuickAdd, coverImageField, conditionalFormatting, swimlaneField }: KanbanBoardProps & { dnd: ReturnType<typeof useDnd> | null }) {
319319
const [activeCard, setActiveCard] = React.useState<KanbanCard | null>(null)
320-
const [collapsedLanes, setCollapsedLanes] = React.useState<Set<string>>(new Set())
320+
321+
// Persist collapsed swimlane state per swimlaneField
322+
const storageKey = swimlaneField ? `objectui:kanban-collapsed:${swimlaneField}` : null
323+
const [collapsedLanes, setCollapsedLanes] = React.useState<Set<string>>(() => {
324+
if (!storageKey) return new Set()
325+
try {
326+
const stored = localStorage.getItem(storageKey)
327+
if (stored) {
328+
const parsed = JSON.parse(stored)
329+
if (Array.isArray(parsed)) return new Set(parsed.filter((v): v is string => typeof v === 'string'))
330+
}
331+
} catch { /* ignore corrupt data */ }
332+
return new Set()
333+
})
321334

322335
// Ensure we always have valid columns with cards array
323336
const safeColumns = React.useMemo(() => {
@@ -350,9 +363,12 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
350363
const next = new Set(prev)
351364
if (next.has(lane)) next.delete(lane)
352365
else next.add(lane)
366+
if (storageKey) {
367+
try { localStorage.setItem(storageKey, JSON.stringify([...next])) } catch { /* quota exceeded */ }
368+
}
353369
return next
354370
})
355-
}, [])
371+
}, [storageKey])
356372

357373
const sensors = useSensors(
358374
useSensor(PointerSensor, {

0 commit comments

Comments
 (0)