Skip to content

Commit a3ca6bc

Browse files
feat(file-dropzone): add validator input and validation fixes #588
1 parent 56d860a commit a3ca6bc

6 files changed

Lines changed: 297 additions & 73 deletions

File tree

‎skills/tedi-angular/references/components.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ Both entry points declare `[tedi-floating-button]`. The Community component is
210210
- **`tedi-file-dropzone` validates itself.** It registers on `NG_VALIDATORS`, so a `formControl` bound
211211
to it fails with a `rejectedFiles` error (carrying the offending files) while any rejected file is
212212
still listed — you don't add a validator for that. `Validators.required` alone would pass on a list
213-
of visibly broken files, since `keepRejectedFiles` keeps them in the value. Same shape as Angular
213+
of visibly broken files whenever `keepRejectedFiles` is on and keeps them in the value. Same shape as Angular
214214
Material's datepicker: the constraint comes from the component's own inputs (`accept`, `maxSize`), so
215215
the component owns the rule. No `registerOnValidatorChange` is needed — `validate()` is a pure
216216
function of the control value, which Angular already revalidates on every change.
@@ -219,11 +219,13 @@ Both entry points declare `[tedi-floating-button]`. The Community component is
219219
URL revoked without diffing the two arrays.
220220
- **`tedi-file-dropzone` selects files, it does not upload them.** It validates against `accept` and
221221
`maxSize` and hands you the list; the upload, and setting each file's `isLoading` / `isValid` as it
222-
progresses, is yours. `keepRejectedFiles` decides what happens to a file that fails `accept` or
223-
`maxSize`: on (the default) keeps it in the list with its reason in that file's `error`, leaving the
224-
dropzone's own border neutral — so the form value holds it with `isValid: false` and you must filter
225-
on `isValid` before uploading. Off discards it instead and summarises every rejection in one message
226-
under the dropzone, which also paints the border red. `error` is writable, so a failure the upload
222+
progresses, is yours. A `validator` input adds a rule of your own on top, run only once the
223+
built-in restrictions pass; the reason it returns lands on that file's `error` and joins the summary,
224+
with the files it rejected named after it. `keepRejectedFiles` decides what happens to a file that
225+
fails `accept`, `maxSize` or `validator`: off (the default) discards it and summarises every rejection in one message under the
226+
dropzone, which paints the border red. On keeps it in the list with its reason in that file's
227+
`error`, leaving the border neutral — so the form value holds it with `isValid: false` and you must
228+
filter on `isValid` before uploading. `error` is writable, so a failure the upload
227229
came back with lands on the file it belongs to.
228230
- **Selecting a file that is already listed is skipped**, matched on name, size and mtime — so two
229231
different files sharing a name still both land. Project an `*tediFileDropzoneFile` template to

‎src/tedi/components/form/file-dropzone/file-dropzone.component.html‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
[multiple]="multiple()"
3939
[disabled]="isDisabled()"
4040
(change)="handleSelection($event)"
41+
(cancel)="handleDialogCancel()"
4142
/>
4243

4344
@if (rejectionError(); as error) {

‎src/tedi/components/form/file-dropzone/file-dropzone.component.spec.ts‎

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,94 @@ describe("FileDropzoneComponent", () => {
516516
expect(text(".tedi-attachment__feedback")).toBe("Vale formaat");
517517
});
518518

519+
const noSpaces = (file: File) =>
520+
file.name.includes(" ") ? "Failinimes ei tohi olla tühikuid." : null;
521+
522+
it("rejects a file through a validator of the consumer's own", () => {
523+
fixture.componentRef.setInput("multiple", true);
524+
fixture.componentRef.setInput("keepRejectedFiles", true);
525+
fixture.componentRef.setInput("validator", noSpaces);
526+
fixture.detectChanges();
527+
528+
selectFiles(fixture, [makeFile("my scan.pdf")]);
529+
530+
expect(component.files()[0].isValid).toBe(false);
531+
expect(component.files()[0].error).toBe(
532+
"Failinimes ei tohi olla tühikuid.",
533+
);
534+
});
535+
536+
it("clears the summary once the last file is removed", () => {
537+
fixture.componentRef.setInput("accept", ".pdf");
538+
fixture.componentRef.setInput("multiple", true);
539+
fixture.detectChanges();
540+
541+
selectFiles(fixture, [makeFile("ok.pdf")]);
542+
selectFiles(fixture, [makeFile("bad.txt", 100, "text/plain")]);
543+
expect(text("tedi-feedback-text")).toContain("extension-rejected");
544+
545+
(
546+
fixture.debugElement.query(By.css(".tedi-attachment__actions button"))
547+
.nativeElement as HTMLButtonElement
548+
).click();
549+
fixture.detectChanges();
550+
551+
expect(component.files()).toEqual([]);
552+
expect(text("tedi-feedback-text")).not.toContain("extension-rejected");
553+
});
554+
555+
it("keeps the summary while other files are still listed", () => {
556+
fixture.componentRef.setInput("accept", ".pdf");
557+
fixture.componentRef.setInput("multiple", true);
558+
fixture.detectChanges();
559+
560+
selectFiles(fixture, [makeFile("a.pdf"), makeFile("b.pdf")]);
561+
selectFiles(fixture, [makeFile("bad.txt", 100, "text/plain")]);
562+
563+
(
564+
fixture.debugElement.query(By.css(".tedi-attachment__actions button"))
565+
.nativeElement as HTMLButtonElement
566+
).click();
567+
fixture.detectChanges();
568+
569+
expect(component.files().length).toBe(1);
570+
expect(text("tedi-feedback-text")).toContain("extension-rejected");
571+
});
572+
573+
it("names every file a custom rule rejected in one message", () => {
574+
fixture.componentRef.setInput("multiple", true);
575+
fixture.componentRef.setInput("validator", noSpaces);
576+
fixture.detectChanges();
577+
578+
selectFiles(fixture, [makeFile("my scan.pdf"), makeFile("a b.pdf")]);
579+
580+
expect(text("tedi-feedback-text")).toContain(
581+
"Failinimes ei tohi olla tühikuid: 'my scan.pdf', 'a b.pdf'",
582+
);
583+
});
584+
585+
it("leaves the validator unrun on a file the restrictions already reject", () => {
586+
const validator = jest.fn(noSpaces);
587+
fixture.componentRef.setInput("accept", ".pdf");
588+
fixture.componentRef.setInput("validator", validator);
589+
fixture.detectChanges();
590+
591+
selectFiles(fixture, [makeFile("bad name.txt", 100, "text/plain")]);
592+
593+
expect(validator).not.toHaveBeenCalled();
594+
});
595+
596+
it("accepts a file its validator returns nothing for", () => {
597+
fixture.componentRef.setInput("multiple", true);
598+
fixture.componentRef.setInput("validator", noSpaces);
599+
fixture.detectChanges();
600+
601+
selectFiles(fixture, [makeFile("korras.pdf")]);
602+
603+
expect(component.files()[0].isValid).toBe(true);
604+
expect(component.files()[0].error).toBeUndefined();
605+
});
606+
519607
it("reports every restriction a file breaks, not just the first", () => {
520608
fixture.componentRef.setInput("accept", ".pdf");
521609
fixture.componentRef.setInput("maxSize", 50);
@@ -567,6 +655,7 @@ describe("FileDropzoneComponent", () => {
567655
it("leaves the border alone when rejected files carry their own reason", () => {
568656
fixture.componentRef.setInput("accept", ".pdf");
569657
fixture.componentRef.setInput("multiple", true);
658+
fixture.componentRef.setInput("keepRejectedFiles", true);
570659
fixture.detectChanges();
571660

572661
selectFiles(fixture, [makeFile("bad.txt", 100, "text/plain")]);
@@ -1019,6 +1108,7 @@ describe("FileDropzoneComponent", () => {
10191108
[formControl]="control"
10201109
accept=".pdf"
10211110
multiple
1111+
keepRejectedFiles
10221112
/>`,
10231113
})
10241114
class ReactiveFormsHostComponent {
@@ -1057,11 +1147,37 @@ describe("FileDropzoneComponent with reactive forms", () => {
10571147
]);
10581148
});
10591149

1060-
it("marks the control touched on blur", () => {
1150+
it("marks the control touched when the user moves on from the zone", () => {
1151+
const hasFocus = jest.spyOn(document, "hasFocus").mockReturnValue(true);
1152+
10611153
expect(host.control.touched).toBe(false);
10621154
zoneOf(fixture).dispatchEvent(new Event("blur"));
10631155
fixture.detectChanges();
10641156

1157+
expect(host.control.touched).toBe(true);
1158+
hasFocus.mockRestore();
1159+
});
1160+
1161+
it("ignores the blur that opening the file dialog causes", () => {
1162+
const hasFocus = jest.spyOn(document, "hasFocus").mockReturnValue(false);
1163+
1164+
zoneOf(fixture).dispatchEvent(new Event("blur"));
1165+
fixture.detectChanges();
1166+
1167+
expect(host.control.touched).toBe(false);
1168+
hasFocus.mockRestore();
1169+
});
1170+
1171+
it("marks the control touched when the dialog is dismissed", () => {
1172+
fileInput(fixture).dispatchEvent(new Event("cancel"));
1173+
fixture.detectChanges();
1174+
1175+
expect(host.control.touched).toBe(true);
1176+
});
1177+
1178+
it("marks the control touched when files are picked", () => {
1179+
selectFiles(fixture, [makeFile("report.pdf")]);
1180+
10651181
expect(host.control.touched).toBe(true);
10661182
});
10671183

0 commit comments

Comments
 (0)