mad-gantt — CRUD host + form
Par host (gantt) + form (drawer): mapeamento por convenção e ops parciais ganttUpsertRecord/ganttPatchRecord.
Documenta o par host (gantt) + form (drawer): mapeamento de tarefa por
convenção (sem ganttMap() no model), form->save($record, $extra) e as
ops parciais que atualizam só a barra alterada (sem reload do gráfico).
Spec completa do componente: gantt-builder.md.
O round-trip gantt ↔ form
┌─ BLADE (render) ──────────────────────────────────────────────┐
│ <mad-gantt model="GanttTask"> │ ← field-mapping nas
│ <mad-gantt-column field="name" tree /> ┐ field+source │ sub-tags <column>
│ <mad-gantt-column field="start" hidden /> ... ┘ = mapping │ (fonte ÚNICA, só do render)
│ on-task-click="onTaskClick" on-task-update="onTaskUpdate"> │ ← wiring evento JS → PHP do host
└────────────────────────────────────────────────────────────────┘
│ clique numa barra ▲ salvou no form
▼ │
HOST.onTaskClick(id) FORM.onSave()
→ MadResponse::open(Form, ['id'=>id], 'onEdit') → ganttUpsertRecord($task)
│ SÓ o id cruza │ o model volta (shape por convenção)
▼ │
FORM.onEdit(string $id) ── recarrega GanttTask::find($id), fill() ──┘
| Cruza a fronteira | Direção | Carga |
|---|---|---|
onTaskClick → MadResponse::open(...,'onEdit') |
gantt → form | só o id |
ganttUpsertRecord($task) |
form → gantt | o model Eloquent (shape por convenção) |
field-mapping (colunas field/source) |
— | nunca cruza — fica na tag |
O form não recebe o field-mapping da tag (declarado nas colunas
field/source). Ele recarrega aGanttTaskporide, no upsert, usa a convenção deMadGantt::recordToTask(). Por isso umganttMap()no model seria redundante.
onEdit(string $id) — carregar pra edição
PK slug (string) → recebe o id via MadResponse::open(..., 'onEdit').
fill() mapeia coluna→campo por nome; o $extra ajusta só o que a UI guarda
diferente do banco.
public function onEdit(string $id): void // PK slug → string (não int)
{
$task = GanttTask::findOrFail($id); // leitura não precisa de transação
$this->editingId = (string) $task->id;
// fill() mapeia colunas → campos por nome; $extra ajusta só o que
// diverge da UI (progress fração 0..1 → %, milestone bool → switch).
$this->form->fill($task, [
'progress' => (float) $task->progress * 100,
'milestone' => $task->milestone ? '1' : '0',
]);
}
fill($data, $extra): preenche pelos dados; o $extra sobrescreve (aqui:
conversões banco→UI). Nada de gantt-específico — é MadForm puro.
onSave() — persistir + atualizar só a barra
use Illuminate\Support\Facades\DB;
public function onSave(): MadResponse
{
try {
$data = $this->form->getData();
$this->form->validate(GanttTask::rules($this->editingId));
$task = DB::transaction(function () use ($data) {
$isNew = $this->editingId === '';
$task = $isNew ? new GanttTask() : GanttTask::findOrFail($this->editingId);
// form->save() faz fillRecord + save(); $extra cobre só o que diverge
// da UI (progress 0..100 → 0..1, null, slug da PK numa criação).
$pct = (float) ($data->progress ?? 0);
$extra = [
'progress' => max(0, min(1, $pct > 1 ? $pct / 100 : $pct)),
'milestone' => (!empty($data->milestone) && $data->milestone !== '0') ? 1 : 0,
'owner_id' => trim((string) ($data->owner_id ?? '')) ?: null,
'parent_id' => trim((string) ($data->parent_id ?? '')) ?: null,
];
if ($isNew) {
$extra['id'] = $this->generateUniqueId(GanttTask::class, (string) $data->name);
}
$this->form->save($task, $extra);
return $task;
});
$msg = ($this->editingId !== '' ? 'Tarefa atualizada' : 'Tarefa criada') . " ({$task->id})";
// Partial: insere/atualiza só essa barra (shape via convenção do
// recordToTask); deps re-roteiam sozinhas no recompute.
return (new MadResponse())
->toast($msg, 'success')
->closeDrawer()
->ganttUpsertRecord($task);
} catch (MadValidationException $e) {
return $e->asModal();
} catch (Throwable $e) {
return MadMessage::error('Erro ao salvar', $e->getMessage());
}
}
Dois detalhes centrais:
-
form->save($task, $extra)—$extra(literal ouClosure) é aplicado após o preenchimento dos campos do form e antes dosave()do Eloquent. As colunas que batem com os campos do form (name,code,phase_id,start,end) entram sozinhas; o$extrasó normaliza o resto. Sem atribuição linha a linha. -
->ganttUpsertRecord($task)— sem$map. Mapeia o model pelorecordToTaskna convenção e devolve a barra pro gantt (opgantt_upsert_task) sem reload.
onDelete() — apaga tarefa + filhos + deps
public function onDelete(): MadResponse
{
if ($this->editingId === '') return MadToast::warning('Nada para excluir');
$id = $this->editingId;
try {
$children = DB::transaction(function () use ($id) {
$ids = $this->collectDescendantIds(GanttTask::class, $id, 'parent_id'); // root + descendentes
// Remove dependências que tocam qualquer um dos ids
GanttDependency::query()
->where(function ($q) use ($ids) {
$q->whereIn('from_id', $ids)->orWhereIn('to_id', $ids);
})
->delete();
GanttTask::whereIn('id', $ids)->delete();
return array_values(array_filter($ids, fn ($x) => $x !== $id));
});
return (new MadResponse())
->toast("Removida ({$id})", 'success')
->closeDrawer()
->ganttRemoveTask($id, $children); // tira a barra + filhos + deps no client
} catch (Throwable $e) {
return MadMessage::error('Erro', $e->getMessage());
}
}
collectDescendantIds() e generateUniqueId() moram no MadComponent base
(rodam puro Eloquent, sem transação obrigatória) — reutilizáveis por qualquer
CRUD hierárquico, não só gantt. Ver
helpers no <mad-detail-form>/field-list
para outros padrões de manipulação de árvore.
Lado do host — drag e dependência (não passam pelo form)
// Drag move/resize → persiste e move só a barra
public function onTaskUpdate(string $task_id, string $start, string $end, string $mode = ''): MadResponse
{
$task = GanttTask::find($task_id);
if (!$task) {
return MadToast::error("Tarefa {$task_id} não encontrada");
}
$task->start = $start;
$task->end = $end;
$task->save();
return (new MadResponse())
->ganttPatchRecord($task) // ou ->ganttPatchTask($task_id, $start, $end)
->toast("Reagendada {$task_id}", 'info');
}
// Click numa barra → abre o form (só o id cruza)
public function onTaskClick(string $task_id): MadResponse
{
return MadResponse::open('GanttTaskForm', ['id' => $task_id], 'onEdit');
}
Helpers do framework
| Método | Onde | Assinatura | Faz |
|---|---|---|---|
recordToTask |
MadGantt (static) |
recordToTask($record, $map = []) |
Model Eloquent → shape JS. $map vazio = convenção. Fonte única do mapeamento (render usa via mapTask). |
ganttUpsertRecord |
MadResponse |
ganttUpsertRecord($record, $map = [], $selector = '.mad-gantt') |
Upsert de barra a partir do model (usa recordToTask). |
ganttPatchRecord |
MadResponse |
ganttPatchRecord($record, $map = [], $selector = '.mad-gantt') |
Só datas (id/start/end) a partir do model. |
save |
MadForm |
save(object $record, array|Closure $extra = []) |
fillRecord + aplica $extra (literal/Closure) + $record->save() + hooks pós-store (uploads, etc). |
$extra do save/fill aceita literal ou Closure fn($atual, $record) => $novo:
$this->form->save($task, [
'progress' => fn ($v) => $v / 100, // Closure: lê o valor atual
'milestone' => (!empty($data->milestone)) ? 1 : 0, // literal
]);
save() também aceita uma Closure única fn($record) que recebe o model
já preenchido e o muta direto — útil pra lógica imperativa cross-campo:
$this->form->save($task, function ($task) use ($data) {
$task->progress = $task->progress > 1 ? $task->progress / 100 : $task->progress;
$task->owner_id = trim((string) $task->owner_id) ?: null;
if (empty($task->id)) {
$task->id = $this->generateUniqueId(GanttTask::class, (string) $data->name);
}
});
Convenção defaultTaskMap — quando NÃO precisa de map
recordToTask/ganttUpsertRecord sem $map usam:
| role (chave JS) | coluna |
|---|---|
id |
id |
name |
name |
start |
start |
end |
end |
progress |
progress |
parentId |
parent_id |
owner |
owner_id |
code |
code |
milestone |
milestone |
phase |
phase_id (emitida sob o nome da coluna — casa com cfg.phaseField) |
Colunas no padrão → ->ganttUpsertRecord($task) e pronto. Colunas fora do
padrão → map inline no call (espelha o que a tag declara):
->ganttUpsertRecord($t, ['name' => 'titulo', 'start' => 'dt_inicio', 'end' => 'dt_fim'])
A
progressna barra usa a unidade do banco (fração 0..1). OrecordToTasknão escala — a conversão pra % é só do form ($extra).
O que foi removido — e por quê
toJsTask()(no form) — versões antigas montavam o shape JS à mão, espelhando omapTaskdo render. Duplicação pura → substituído porrecordToTask(uma fonte só, usada pelos dois lados).ganttMap()no model — declarava role→coluna no model. Mas isso é o que os atributos da tag já fazem (name-field,start-field, …). Como o form recarrega o model poride usa convenção no upsert, o map não é necessário na maioria dos casos.
NUNCA fazer
// ERRADO: montar o shape JS à mão (volta o toJsTask)
return (new MadResponse())->ganttUpsertTask([
'id' => $t->id, 'name' => $t->name, 'start' => $t->start, /* ... */
]);
// CERTO: deixa o recordToTask cuidar
return (new MadResponse())->ganttUpsertRecord($t);
// ERRADO: recriar um ganttMap() no model só pra isso
public static function ganttMap(): array { return [...]; }
// CERTO: convenção (defaultTaskMap) ou map inline no call quando fugir do padrão
// ERRADO: atribuir campo a campo o que o fillRecord já resolve
$task->name = $data->name; $task->code = $data->code; $task->start = $data->start; // ...
// CERTO: save() + $extra só pro que diverge da UI
$this->form->save($task, ['progress' => $pct / 100, 'milestone' => $ms]);
// ERRADO: full reload depois de salvar (perde scroll/zoom/filtro/expand)
return (new MadResponse())->script('location.reload()');
// CERTO: op parcial
return (new MadResponse())->closeDrawer()->ganttUpsertRecord($task);
// ERRADO: esperar o form "herdar" os atributos da tag (gantt e form são
// componentes/requests separados — não há estado compartilhado)
// CERTO: o form recarrega o model por id (onEdit) e usa convenção no upsert