feat(templates): importar CSV/Excel con group, question y help
El importador de plantillas solo parseaba name/label/type/required/options/ min/max/step, dejando fuera group (sección), question (pregunta corta) y help (ayuda) que el builder manual sí soporta. - Nuevo orden de columnas: group,name,label,question,type,required,options,min,max,step,help - parseRows() rellena las 11 claves (coincide con addField()). - readFileRows() ya no descarta filas por la 1ª columna (ahora es `group`, que puede ir vacía); filtra por "fila con algún contenido" y parseRows descarta las que no tengan `name`. - CSV de ejemplo actualizado (con BOM UTF-8) y con filas que muestran group/ question/help/select. - Modal de importación: texto de columnas y tabla de preview muestran Grupo y Pregunta. Tests: nuevo test csv_import_parses_group_question_and_help. Suite 95 passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -163,9 +163,11 @@ class GlobalTemplateManager extends Component
|
||||
public function downloadExampleCsv()
|
||||
{
|
||||
$headers = ['Content-Type' => 'text/csv'];
|
||||
$csv = "name,label,type,required,options,min,max,step\n"
|
||||
. "altura,Altura (m),decimal,1,,0,100,0.1\n"
|
||||
. "ok,¿OK?,boolean,1,,,,\n";
|
||||
$csv = "\xEF\xBB\xBF" // BOM UTF-8 (para que Excel respete los acentos)
|
||||
. "group,name,label,question,type,required,options,min,max,step,help\n"
|
||||
. "Dimensiones,altura,Altura (m),¿Cumple la altura de proyecto?,decimal,1,,0,100,0.1,Medir con flexómetro\n"
|
||||
. "Dimensiones,material,Material,,select,1,Hormigón|Acero|Madera,,,,\n"
|
||||
. "Acabados,ok,¿Acabado correcto?,,boolean,1,,,,,\n";
|
||||
return response()->streamDownload(fn () => print($csv), 'plantilla_ejemplo.csv', $headers);
|
||||
}
|
||||
|
||||
@@ -217,12 +219,17 @@ class GlobalTemplateManager extends Component
|
||||
$ext = strtolower($this->importFile->getClientOriginalExtension());
|
||||
$path = $this->importFile->getRealPath();
|
||||
|
||||
// Fila no vacía = tiene al menos una celda con contenido (no filtramos por
|
||||
// la primera columna, que ahora es `group` y puede ir vacía). parseRows()
|
||||
// ya descarta las filas sin `name`.
|
||||
$notEmpty = fn ($r) => count(array_filter((array) $r, fn ($c) => trim((string) $c) !== '')) > 0;
|
||||
|
||||
if ($ext === 'xlsx' || $ext === 'xls') {
|
||||
$spreadsheet = IOFactory::load($path);
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$rows = $sheet->toArray(null, true, true, false);
|
||||
array_shift($rows);
|
||||
return array_filter($rows, fn ($r) => !empty($r[0]));
|
||||
return array_values(array_filter($rows, $notEmpty));
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
@@ -231,7 +238,7 @@ class GlobalTemplateManager extends Component
|
||||
if ($bom !== "\xEF\xBB\xBF") rewind($handle);
|
||||
fgetcsv($handle);
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
if (!empty($row[0])) $rows[] = $row;
|
||||
if ($notEmpty($row)) $rows[] = $row;
|
||||
}
|
||||
fclose($handle);
|
||||
return $rows;
|
||||
@@ -239,21 +246,26 @@ class GlobalTemplateManager extends Component
|
||||
|
||||
private function parseRows(array $rows): array
|
||||
{
|
||||
// Orden de columnas:
|
||||
// group, name, label, question, type, required, options, min, max, step, help
|
||||
$fields = [];
|
||||
foreach ($rows as $row) {
|
||||
$row = array_values((array) $row);
|
||||
$rawName = trim($row[0] ?? '');
|
||||
$rawName = trim($row[1] ?? '');
|
||||
if ($rawName === '') continue;
|
||||
|
||||
$fields[] = [
|
||||
'group' => trim($row[0] ?? ''),
|
||||
'name' => $this->slugify($rawName),
|
||||
'label' => trim($row[1] ?? $rawName),
|
||||
'type' => $this->normalizeType($row[2] ?? 'text'),
|
||||
'required' => in_array(strtolower(trim($row[3] ?? '0')), ['1', 'si', 'sí', 'yes', 'true']),
|
||||
'options' => trim($row[4] ?? ''),
|
||||
'min' => ($row[5] ?? '') !== '' ? $row[5] : null,
|
||||
'max' => ($row[6] ?? '') !== '' ? $row[6] : null,
|
||||
'step' => ($row[7] ?? '') !== '' ? $row[7] : null,
|
||||
'label' => trim($row[2] ?? '') ?: $rawName,
|
||||
'question' => trim($row[3] ?? ''),
|
||||
'type' => $this->normalizeType($row[4] ?? 'text'),
|
||||
'required' => in_array(strtolower(trim($row[5] ?? '0')), ['1', 'si', 'sí', 'yes', 'true']),
|
||||
'options' => trim($row[6] ?? ''),
|
||||
'min' => ($row[7] ?? '') !== '' ? $row[7] : null,
|
||||
'max' => ($row[8] ?? '') !== '' ? $row[8] : null,
|
||||
'step' => ($row[9] ?? '') !== '' ? $row[9] : null,
|
||||
'help' => trim($row[10] ?? ''),
|
||||
];
|
||||
}
|
||||
return $fields;
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<p class="text-xs text-base-content/60">
|
||||
{{ __('Columns: name, label, type, required, options, min, max, step') }}.
|
||||
{{ __('Columns') }}: <code class="text-[11px]">group, name, label, question, type, required, options, min, max, step, help</code>.
|
||||
<button type="button" wire:click="downloadExampleCsv" class="link link-primary">{{ __('Download example CSV') }}</button>
|
||||
</p>
|
||||
<div class="form-control">
|
||||
@@ -144,10 +144,17 @@
|
||||
<p class="text-sm font-medium mb-2">{{ count($importPreviewFields) }} {{ __('fields detected') }}:</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-xs">
|
||||
<thead><tr><th>{{ __('Label') }}</th><th>{{ __('Name') }}</th><th>{{ __('Type') }}</th><th>{{ __('Required') }}</th></tr></thead>
|
||||
<thead><tr><th>{{ __('Group') }}</th><th>{{ __('Label') }}</th><th>{{ __('Question') }}</th><th>{{ __('Name') }}</th><th>{{ __('Type') }}</th><th>{{ __('Required') }}</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach($importPreviewFields as $f)
|
||||
<tr><td>{{ $f['label'] }}</td><td class="font-mono text-xs">{{ $f['name'] }}</td><td><span class="badge badge-ghost badge-sm">{{ $f['type'] }}</span></td><td>{{ !empty($f['required']) ? '✓' : '' }}</td></tr>
|
||||
<tr>
|
||||
<td>{{ $f['group'] ?? '' }}</td>
|
||||
<td>{{ $f['label'] }}</td>
|
||||
<td class="text-xs text-base-content/70">{{ $f['question'] ?? '' }}</td>
|
||||
<td class="font-mono text-xs">{{ $f['name'] }}</td>
|
||||
<td><span class="badge badge-ghost badge-sm">{{ $f['type'] }}</span></td>
|
||||
<td>{{ !empty($f['required']) ? '✓' : '' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Models\Phase;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Tests\TestCase;
|
||||
@@ -116,6 +117,43 @@ class GlobalTemplatesTest extends TestCase
|
||||
->assertSet('editingTemplate', $tpl->id);
|
||||
}
|
||||
|
||||
public function test_csv_import_parses_group_question_and_help(): void
|
||||
{
|
||||
Permission::findOrCreate('manage templates');
|
||||
$admin = User::factory()->create();
|
||||
$admin->givePermissionTo('manage templates');
|
||||
|
||||
$csv = "group,name,label,question,type,required,options,min,max,step,help\n"
|
||||
. "Dimensiones,altura,Altura (m),¿Cumple la cota?,decimal,1,,0,100,0.1,Medir con flexómetro\n"
|
||||
. "Acabados,ok,¿Acabado?,,boolean,0,,,,,\n";
|
||||
$file = UploadedFile::fake()->createWithContent('plantilla.csv', $csv);
|
||||
|
||||
$cmp = Livewire::actingAs($admin)
|
||||
->test(GlobalTemplateManager::class)
|
||||
->set('importTemplateName', 'Importada')
|
||||
->set('importFile', $file)
|
||||
->call('parseImportFile')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$fields = $cmp->get('importPreviewFields');
|
||||
$this->assertCount(2, $fields);
|
||||
$this->assertEquals('Dimensiones', $fields[0]['group']);
|
||||
$this->assertEquals('altura', $fields[0]['name']);
|
||||
$this->assertEquals('¿Cumple la cota?', $fields[0]['question']);
|
||||
$this->assertEquals('Medir con flexómetro', $fields[0]['help']);
|
||||
$this->assertEquals('decimal', $fields[0]['type']);
|
||||
$this->assertTrue($fields[0]['required']);
|
||||
|
||||
// Fila sin group (columna 1 vacía) NO debe descartarse
|
||||
$this->assertEquals('Acabados', $fields[1]['group']);
|
||||
$this->assertEquals('ok', $fields[1]['name']);
|
||||
|
||||
$cmp->call('confirmImportFile');
|
||||
$tpl = InspectionTemplate::where('name', 'Importada')->first();
|
||||
$this->assertNotNull($tpl);
|
||||
$this->assertEquals('¿Cumple la cota?', $tpl->fields[0]['question']);
|
||||
}
|
||||
|
||||
public function test_map_template_selector_only_shows_assigned_templates(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
Reference in New Issue
Block a user