feat: import and export tickets as CSV
Every ticket of a project can be downloaded as CSV, and a CSV exported from TaskStream or from Jira can be imported: types, statuses, priorities and people are matched by id or name, the rest falls back with a warning, and parents are rebuilt. The settings page previews an import before it creates anything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import { NgTemplateOutlet } from '@angular/common';
|
||||
import { PiIconComponent, PiNoteComponent } from '@pi/ui';
|
||||
import { GitIntegrationComponent } from '../ui/git-integration';
|
||||
import { WebhooksComponent } from '../ui/webhooks';
|
||||
import { TransferComponent } from '../ui/transfer';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
ProjectSettings,
|
||||
@@ -55,6 +56,7 @@ function idFrom(name: string): string {
|
||||
imports: [
|
||||
GitIntegrationComponent,
|
||||
WebhooksComponent,
|
||||
TransferComponent,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
CdkDragHandle,
|
||||
@@ -471,6 +473,7 @@ function idFrom(name: string): string {
|
||||
[projectKey]="project.key"
|
||||
/>
|
||||
<ts-webhooks [projectId]="project.id" />
|
||||
<ts-transfer [projectId]="project.id" [projectKey]="project.key" />
|
||||
}
|
||||
|
||||
@if (facade.isOwner() && facade.project(); as project) {
|
||||
|
||||
213
libs/web/tracker/presenters/src/lib/ui/transfer.ts
Normal file
213
libs/web/tracker/presenters/src/lib/ui/transfer.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { PiActionButtonComponent, PiFileUploadComponent } from '@pi/ui';
|
||||
import { ToastService } from '@task-stream/web/shared/ui';
|
||||
import { ImportReport, TransferGateway } from '@task-stream/web/tracker/domain';
|
||||
|
||||
/** Settings card: export every ticket as CSV, or import a CSV (ours or Jira's). */
|
||||
@Component({
|
||||
selector: 'ts-transfer',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiActionButtonComponent, PiFileUploadComponent],
|
||||
template: `
|
||||
<section
|
||||
class="ts-panel ts-block ts-transfer"
|
||||
aria-labelledby="transfer-title"
|
||||
>
|
||||
<div>
|
||||
<h3 id="transfer-title">Importar y exportar</h3>
|
||||
<p class="ts-muted">
|
||||
Exporta todos los tickets a CSV, o crea tickets desde un CSV de
|
||||
TaskStream o una exportación CSV de Jira (hasta 2000 filas). Antes de
|
||||
importar verás qué se va a crear.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
[text]="exporting() ? 'Exportando…' : 'Exportar CSV'"
|
||||
[disabled]="exporting()"
|
||||
(execute)="exportCsv()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<pi-file-upload
|
||||
label="CSV para importar"
|
||||
hint="Exportación de TaskStream o de Jira («Exportar → CSV (todos los campos)»)"
|
||||
accept=".csv,text/csv"
|
||||
[multiple]="false"
|
||||
[maxFiles]="1"
|
||||
[maxSizeBytes]="maxBytes"
|
||||
[(value)]="files"
|
||||
(valueChange)="preview()"
|
||||
/>
|
||||
|
||||
@if (report(); as plan) {
|
||||
<div class="ts-transfer__report" role="status">
|
||||
<p>
|
||||
@if (plan.dryRun) {
|
||||
Formato {{ plan.format === 'jira' ? 'Jira' : 'TaskStream' }}: se
|
||||
crearán <strong>{{ plan.rows }}</strong> tickets.
|
||||
} @else {
|
||||
Creados <strong>{{ plan.created }}</strong> tickets
|
||||
@if (plan.keys.length) {
|
||||
({{ plan.keys[0] }}
|
||||
@if (plan.keys.length > 1) {
|
||||
… {{ plan.keys[plan.keys.length - 1] }}
|
||||
}
|
||||
).
|
||||
}
|
||||
}
|
||||
</p>
|
||||
@if (plan.skipped.length) {
|
||||
<p class="ts-transfer__bad">
|
||||
{{ plan.skipped.length }} fila(s) fuera:
|
||||
{{ lines(plan.skipped, 'reason') }}
|
||||
</p>
|
||||
}
|
||||
@if (plan.warnings.length) {
|
||||
<details>
|
||||
<summary>{{ plan.warnings.length }} aviso(s)</summary>
|
||||
<ul>
|
||||
@for (warning of plan.warnings.slice(0, 30); track $index) {
|
||||
<li>Línea {{ warning.line }}: {{ warning.message }}</li>
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
}
|
||||
@if (plan.dryRun && plan.rows) {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
[text]="
|
||||
importing()
|
||||
? 'Importando…'
|
||||
: 'Importar ' + plan.rows + ' tickets'
|
||||
"
|
||||
[disabled]="importing()"
|
||||
(execute)="importCsv()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
.ts-transfer {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
}
|
||||
.ts-transfer__report {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: color-mix(in srgb, var(--ts-brand) 8%, transparent);
|
||||
p,
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
ul {
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
.ts-transfer__bad {
|
||||
color: var(--ts-c-red, var(--ts-ink));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class TransferComponent {
|
||||
readonly projectId = input.required<string>();
|
||||
readonly projectKey = input.required<string>();
|
||||
|
||||
private readonly gateway = inject(TransferGateway);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly maxBytes = 5 * 1024 * 1024;
|
||||
protected readonly files = signal<File[]>([]);
|
||||
protected readonly report = signal<ImportReport | null>(null);
|
||||
protected readonly exporting = signal(false);
|
||||
protected readonly importing = signal(false);
|
||||
|
||||
protected lines(
|
||||
items: { line: number; reason?: string }[],
|
||||
field: 'reason',
|
||||
): string {
|
||||
return items
|
||||
.slice(0, 5)
|
||||
.map((i) => `línea ${i.line} (${i[field]})`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
protected exportCsv(): void {
|
||||
this.exporting.set(true);
|
||||
this.gateway
|
||||
.exportCsv(this.projectId())
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${this.projectKey()}-tickets.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
this.exporting.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.exporting.set(false);
|
||||
this.toast.error('No se pudo exportar');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A dry run as soon as a file is chosen. */
|
||||
protected preview(): void {
|
||||
const [file] = this.files();
|
||||
this.report.set(null);
|
||||
if (!file) return;
|
||||
this.run(file, true);
|
||||
}
|
||||
|
||||
protected importCsv(): void {
|
||||
const [file] = this.files();
|
||||
if (file) this.run(file, false);
|
||||
}
|
||||
|
||||
private run(file: File, dryRun: boolean): void {
|
||||
this.importing.set(!dryRun);
|
||||
this.gateway
|
||||
.importCsv(this.projectId(), file, dryRun)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (report) => {
|
||||
this.report.set(report);
|
||||
this.importing.set(false);
|
||||
if (!dryRun) {
|
||||
this.files.set([]);
|
||||
this.toast.success(`${report.created} tickets importados`);
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
this.importing.set(false);
|
||||
const message = (error as { error?: { message?: string } })?.error
|
||||
?.message;
|
||||
this.toast.error(message || 'No se pudo leer el CSV');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user