feat: AdvancedTable Fases 7-8 - Lookup & Callbacks

Fase 7 - Lookup & Auto-fill:
- performLookupAndAutoFill() en commitEdit
- Busca en DataStore.lookup() al editar columna lookup
- Auto-rellena columnas según auto_fill_columns mapping
- Indicador visual "?" en header para columnas lookup
- Campo lookup_success en AdvancedTableResult

Fase 8 - Callbacks + Debounce:
- invokeCallbacks() con sistema de debounce (150ms default)
- on_row_selected: al cambiar selección
- on_cell_changed: al confirmar edición
- on_active_row_changed: al cambiar de fila (para paneles detalle)
- Campos last_callback_time_ms, last_notified_row en state

Tests: 373/373 (+3 nuevos)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-17 17:26:53 +01:00
parent 2dccddeab0
commit 6287231cee
2 changed files with 188 additions and 0 deletions

View file

@ -189,9 +189,61 @@ pub fn advancedTableRect(
}
}
// Phase 8: Invoke callbacks
invokeCallbacks(ctx, table_state, table_schema, &result);
return result;
}
// =============================================================================
// Callback System (Phase 8)
// =============================================================================
fn invokeCallbacks(
ctx: *Context,
table_state: *AdvancedTableState,
table_schema: *const TableSchema,
result: *AdvancedTableResult,
) void {
const config = table_schema.config;
const current_time = ctx.current_time_ms;
// Check debounce
const time_since_last = current_time -| table_state.last_callback_time_ms;
const debounce_ok = time_since_last >= config.callback_debounce_ms;
// on_row_selected: called when selection changes (with debounce)
if (result.selection_changed and debounce_ok) {
if (table_schema.on_row_selected) |callback| {
if (table_state.selected_row >= 0) {
const row_idx: usize = @intCast(table_state.selected_row);
if (table_state.getRowConst(row_idx)) |row| {
callback(row_idx, row);
table_state.last_callback_time_ms = current_time;
}
}
}
}
// on_active_row_changed: called when moving to a different row (for loading detail panels)
// Only fires once per row change, not on every frame
if (table_state.selected_row != table_state.last_notified_row) {
if (table_schema.on_active_row_changed) |callback| {
if (table_state.selected_row >= 0) {
const new_row_idx: usize = @intCast(table_state.selected_row);
if (table_state.getRowConst(new_row_idx)) |row| {
const old_row: ?usize = if (table_state.last_notified_row >= 0)
@intCast(table_state.last_notified_row)
else
null;
callback(old_row, new_row_idx, row);
}
}
}
table_state.last_notified_row = table_state.selected_row;
}
}
// =============================================================================
// Internal Rendering
// =============================================================================
@ -244,6 +296,12 @@ fn drawHeader(
const text_y = header_y + @as(i32, @intCast((config.header_height - 8) / 2));
ctx.pushCommand(Command.text(col_x + 4, text_y, col.title, colors.header_fg));
// Draw lookup indicator (Phase 7c) - small "?" icon
if (col.hasLookup()) {
const lookup_x = col_x + @as(i32, @intCast(col.width)) - 24;
ctx.pushCommand(Command.text(lookup_x, text_y, "?", Style.Color.primary));
}
// Draw sort indicator
if (table_state.sort_column == @as(i32, @intCast(idx))) {
const indicator_x = col_x + @as(i32, @intCast(col.width)) - 12;
@ -861,6 +919,65 @@ fn commitEdit(
row.set(col_def.name, new_value) catch {};
table_state.markDirty(row_idx);
result.cell_edited = true;
// Lookup & Auto-fill (Phase 7)
if (col_def.hasLookup()) {
performLookupAndAutoFill(table_state, table_schema, row, col_def, new_value, result);
}
// Call on_cell_changed callback (Phase 8)
if (table_schema.on_cell_changed) |callback| {
const old_value = table_state.original_value orelse CellValue{ .null_val = {} };
callback(row_idx, col_idx, old_value, new_value);
}
}
}
/// Perform lookup in related table and auto-fill columns
fn performLookupAndAutoFill(
table_state: *AdvancedTableState,
table_schema: *const TableSchema,
row: *Row,
col_def: *const ColumnDef,
lookup_value: CellValue,
result: *AdvancedTableResult,
) void {
// Need DataStore to perform lookup
const data_store = table_schema.data_store orelse return;
// Get lookup configuration
const lookup_table = col_def.lookup_table orelse return;
const lookup_key = col_def.lookup_key_column orelse return;
// Perform lookup
const lookup_result = data_store.lookup(
lookup_table,
lookup_key,
lookup_value,
table_state.allocator,
) catch return;
// If lookup found a match, auto-fill related columns
if (lookup_result) |lookup_row| {
defer {
// Clean up the lookup row after use
var mutable_lookup = lookup_row;
mutable_lookup.deinit();
}
// Auto-fill columns based on mapping
if (col_def.auto_fill_columns) |mappings| {
for (mappings) |mapping| {
const source_value = lookup_row.get(mapping.source_field);
if (!source_value.isEmpty()) {
row.set(mapping.target_column, source_value) catch {};
}
}
}
result.lookup_success = true;
} else {
result.lookup_success = false;
}
}
@ -1020,3 +1137,61 @@ test "blendColor" {
try std.testing.expectEqual(@as(u8, 127), gray.g);
try std.testing.expectEqual(@as(u8, 127), gray.b);
}
test "AdvancedTableResult lookup_success field" {
var result = AdvancedTableResult{};
// Default is null (no lookup attempted)
try std.testing.expect(result.lookup_success == null);
// Can be set to true (lookup found)
result.lookup_success = true;
try std.testing.expect(result.lookup_success.? == true);
// Can be set to false (lookup not found)
result.lookup_success = false;
try std.testing.expect(result.lookup_success.? == false);
}
test "AdvancedTableState callback fields" {
var table_state = AdvancedTableState.init(std.testing.allocator);
defer table_state.deinit();
// Initial state
try std.testing.expectEqual(@as(u64, 0), table_state.last_callback_time_ms);
try std.testing.expectEqual(@as(i32, -1), table_state.last_notified_row);
// Can be updated
table_state.last_callback_time_ms = 1000;
table_state.last_notified_row = 5;
try std.testing.expectEqual(@as(u64, 1000), table_state.last_callback_time_ms);
try std.testing.expectEqual(@as(i32, 5), table_state.last_notified_row);
}
test "ColumnDef hasLookup" {
// Column without lookup
const col_no_lookup = ColumnDef{
.name = "test",
.title = "Test",
};
try std.testing.expect(!col_no_lookup.hasLookup());
// Column with lookup enabled but no config
const col_partial = ColumnDef{
.name = "test",
.title = "Test",
.enable_lookup = true,
};
try std.testing.expect(!col_partial.hasLookup()); // Missing table/key
// Column with full lookup config
const col_full = ColumnDef{
.name = "test",
.title = "Test",
.enable_lookup = true,
.lookup_table = "other_table",
.lookup_key_column = "id",
};
try std.testing.expect(col_full.hasLookup());
}

View file

@ -124,6 +124,16 @@ pub const AdvancedTableState = struct {
/// Does this table have focus
focused: bool = false,
// =========================================================================
// Callbacks & Debounce (Phase 8)
// =========================================================================
/// Last time a callback was invoked (for debouncing)
last_callback_time_ms: u64 = 0,
/// Last row that triggered on_active_row_changed (to avoid duplicate calls)
last_notified_row: i32 = -1,
// =========================================================================
// Lifecycle
// =========================================================================
@ -671,6 +681,9 @@ pub const AdvancedTableResult = struct {
crud_action: ?CRUDAction = null,
crud_success: bool = true,
// Lookup (Phase 7)
lookup_success: ?bool = null, // null = no lookup, true = found, false = not found
// Focus
clicked: bool = false,
};