Angular Material File Upload With Scan and Read States

Posted on
ngular Material File Upload With Scanning and Reading States for Every File

Angular Material has no built-in file upload component. You build one from mat-button, a hidden file input, mat-list and mat-progress-bar, and use a client like filestack-js to send the files. This guide adds scanning and reading states to every row.

A user of your expense app drags four files into the upload list: a receipt, an invoice, a PDF and a photo. Every progress bar fills, and Submit still says the files aren’t ready. The list has nothing more to show, because the upload finished and the work that follows it hasn’t.

This Angular Material file upload shows that work. Two Filestack features run on every file in a workflow attached at upload: Virus Detection checks the file for malware, and OCR reads the text out of every clean image. We built a mat-list queue around them, with a Node server that signs the upload and reports each file’s workflow status, so every row moves through Uploading, Scanning, Reading and Ready, or stops at Rejected. The upload runs on filestack-js, the client under our Angular file upload SDK, so Angular Material draws the list.

Key takeaways

  • The upload promise resolves when the file is stored. The scan and the text extraction run after it, in a workflow attached through the workflows upload option.
  • file.workflows holds one entry per workflow, keyed by workflow ID, and its jobid is what the app polls.
  • The job reads InProgress until both tasks finish, and results gains each task’s output as it completes.
  • The OCR task runs only when the scan reports infected as false, so an infected file is never read.
  • The scan leaves an infected file in storage, so the server deletes it and the list blocks Submit until the row is removed.
An Angular Material file upload queue with five files. Each row moves from Uploading to Scanning, the photo moves to Reading, four rows reach Ready and eicar.com stops at Rejected with a Remove button
An Angular Material file upload queue with five files. Each row moves from Uploading to Scanning, the photo moves to Reading, four rows reach Ready and eicar.com stops at Rejected with a Remove button

 

Why an Angular Material file upload at 100% isn’t done

A progress bar measures bytes sent. When the last byte lands, filestack-js resolves the upload with the stored file, and a workflow attached to that upload starts on it. In our example, the virus scan takes about ten seconds per file, small receipt or 12 MB photo alike, and OCR follows it. An Angular Material file upload that stops at 100% leaves those seconds blank, and Submit has nothing to wait on.

There are four common ways to cover that gap:

Option What you run Where it fits
Accept at 100%, scan later A queue, ClamAV and Tesseract on your servers, and a way to pull a file back after the user moved on Files nobody acts on right away
Scan inside your upload endpoint Your API receives the file, scans it and reads it before answering Small files, and an API that can hold the request open
A cloud storage trigger S3 events into Amazon GuardDuty Malware Protection for S3 and Textract, and status written to your database An app already built on AWS
A Filestack workflow at upload One workflow with Virus Detection and OCR, and a status call per file You want the scan, the text and a status for each file without running either engine

The rest of this post builds the last row.

Create the scan and read workflow

The workflow is two tasks, the scan first and the text extraction second, with a condition on the second. It’s one of our file processing workflows, built in the console at console.filestack.com:

  • Open Workflows, click Create New and select Build from scratch.
  • Click Select the first task, open the Intelligence tab and pick virus_detection. Name it virus_scan and click Save.
  • Click the add button under virus_scan, open Intelligence and pick ocr. Name it ocr.
  • Click Add new condition, type infected in Path, leave Equal to, type false in Value and click Save.
  • Name the workflow and copy its Workflow Id.

Under the editor, the console sums it up as “Scan for viruses, if ‘infected’ equals ‘false’ then apply ocr”:

The console's workflow editor with a virus_detection task named virus_scan, followed by an ocr task that runs when infected equals false
The console’s workflow editor with a virus_detection task named virus_scan, followed by an ocr task that runs when infected equals false

 

The task names are the keys each result arrives under, so the server reads results.virus_scan and results.ocr. The verdict is an infected boolean and an infections_list of detection names. The virus detection setup post covers running the same scan on files you already store.

OCR reads images. For a PDF, the ocr entry carries {"error": "unsupported mimetype"} and the job still finishes, so a clean PDF reaches Ready without text. The OCR response reference describes the blocks, lines and words, each with a four-point bounding_box, that sit next to the flat text field.

Sign the upload and report job status on the server

Turn on application security under Configuration, Security, Policy & Signature in the console, and the same card shows the app secret. The server signs the browser’s upload policy and every status call with it, so the secret never reaches the page. Our Node server has no dependencies and needs Node 20.6 or later for --env-file. .env:

FILESTACK_API_KEY=YOUR_API_KEY
FILESTACK_APP_SECRET=YOUR_APP_SECRET    # Configuration > Security > Policy & Signature
FILESTACK_WORKFLOW_ID=YOUR_WORKFLOW_ID  # the scan and read workflow

server.mjs answers two routes. /api/upload-config returns a policy for the browser, and /api/jobs/:jobid turns a workflow status into the five fields the list needs:

// Signs upload policies and reports each file's workflow status to the Angular app.
import { createServer } from 'node:http';
import { createHmac } from 'node:crypto';

const { FILESTACK_API_KEY, FILESTACK_APP_SECRET, FILESTACK_WORKFLOW_ID, PORT = '3000' } = process.env;

// Sign a policy with the app secret. Keep the = padding.
function sign(policy) {
  const encoded = Buffer.from(JSON.stringify(policy))
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_');
  const signature = createHmac('sha256', FILESTACK_APP_SECRET).update(encoded).digest('hex');
  return { policy: encoded, signature };
}

const inSeconds = (s) => Math.floor(Date.now() / 1000) + s;

function uploadConfig() {
  const security = sign({
    call: ['pick', 'store', 'read', 'convert', 'runWorkflow'],
    expiry: inSeconds(3600),
  });
  return { apikey: FILESTACK_API_KEY, workflowId: FILESTACK_WORKFLOW_ID, ...security };
}

const removed = new Set();

async function deleteFile(handle) {
  const { policy, signature } = sign({ call: ['remove'], handle, expiry: inSeconds(60) });
  const params = new URLSearchParams({ key: FILESTACK_API_KEY, policy, signature });
  const res = await fetch(`https://www.filestackapi.com/api/file/${handle}?${params}`, { method: 'DELETE' });
  if (!res.ok) throw new Error(`delete ${handle}: ${res.status}`);
}

async function jobStatus(jobid) {
  const { policy, signature } = sign({
    call: ['read', 'convert', 'runWorkflow'],
    expiry: inSeconds(300),
  });
  const res = await fetch(
    `https://cdn.filestackcontent.com/${FILESTACK_API_KEY}/security=p:${policy},s:${signature}` +
      `/workflow_status=job_id:${jobid}`,
  );
  if (!res.ok) throw new Error(`workflow_status ${res.status}: ${await res.text()}`);
  const job = await res.json();

  const scan = job.results?.virus_scan?.data;
  const ocr = job.results?.ocr;
  if (scan?.infected && !removed.has(jobid)) {
    removed.add(jobid);
    await deleteFile(job.sources[0]); // the scan leaves the file in storage
    console.log(`removed ${job.sources[0]}: ${scan.infections_list.join(', ')}`);
  }
  return {
    status: job.status, // InProgress, Finished or Failed
    scanned: Boolean(scan),
    infected: scan?.infected ?? false,
    infections: scan?.infections_list ?? [],
    text: ocr?.data?.text ?? null,
    ocrError: ocr?.error ?? null,
  };
}

createServer(async (req, res) => {
  const send = (code, body) => {
    res.writeHead(code, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(body));
  };
  try {
    if (req.url === '/api/upload-config') return send(200, uploadConfig());
    const match = req.url.match(/^\/api\/jobs\/([0-9a-f-]{36})$/);
    if (match) return send(200, await jobStatus(match[1]));
    send(404, { error: 'not found' });
  } catch (err) {
    console.error(err.message);
    send(502, { error: err.message });
  }
}).listen(Number(PORT), () => console.log(`API on http://localhost:${PORT}`));

The upload policy carries runWorkflow because the upload starts the workflow. The workflow’s tasks open the stored file, so it also needs read with convert. The status call signs the same three. Start it:

node --env-file=.env server.mjs

It prints:

API on http://localhost:3000

Right after an upload, the status call returns the job with no results yet:

{
  "createdAt": "2026-11-09T06:54:06.000576334Z",
  "jobid": "c6ac87f2-f2d4-4db0-b78a-8969ad768c28",
  "sources": ["B7dfPILnQM6xwQiaLlup"],
  "status": "InProgress",
  "updatedAt": "2026-11-09T06:54:06.075628506Z",
  "workflow": "de9ea60b-36aa-4b2e-9f68-1ed35de1ab8d"
}

Once both tasks finish, status is Finished and results holds both outputs. This is the receipt, with the OCR output trimmed to its flat text:

{
  "jobid": "c6ac87f2-f2d4-4db0-b78a-8969ad768c28",
  "results": {
    "ocr": {
      "data": {
        "page_height": 1200,
        "page_width": 800,
        "text": "KITSUNE NOODLE BAR\nSTANDARD14 Harbour Lane, Portview\nReceipt 004817 2026-09-28 19:42\n...",
        "text_area_percentage": 10.256354166666666
      }
    },
    "virus_scan": { "data": { "infected": false, "infections_list": [] } }
  },
  "status": "Finished",
  "ttl": 172800
}

Between the two, a job can read InProgress with only virus_scan in results. The server reports that as scanned: true, which is the Reading state.

Join the Filestack developer community on Discord

Add Angular Material and filestack-js

The sample uses Angular 22 with Angular Material 22 and filestack-js 4.3.9. For a new project, generate it and add the two libraries:

npx @angular/cli@22 new expense-uploads --defaults --style=css --ssr=false
cd expense-uploads
npm install @angular/material @angular/cdk filestack-js

Add a Material theme to the styles array in angular.json, ahead of your own stylesheet:

"styles": [
  "@angular/material/prebuilt-themes/azure-blue.css",
  "src/styles.css"
]

Then send /api requests from ng serve to the Node server. proxy.conf.json:

{
  "/api": { "target": "http://localhost:3000", "secure": false }
}

Point serve.options.proxyConfig at proxy.conf.json in angular.json. The Angular SDK v4 release covers the picker component, which draws its own interface. Here the Material list is the interface, so the component calls filestack-js directly, the same client our JavaScript file upload SDK documents.

Hold per-file state in signals

The queue is a standalone Angular Material file upload component. Each row is one object in a signal, and canSubmit is computed from all of them. The top of upload-queue.ts declares the six stages and what the server’s status route returns:

import { Component, computed, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatListModule } from '@angular/material/list';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import * as filestack from 'filestack-js';

type Stage = 'uploading' | 'scanning' | 'reading' | 'ready' | 'rejected' | 'failed';

interface QueuedFile {
  id: number;
  name: string;
  stage: Stage;
  progress: number;
  detail: string;
}

interface JobStatus {
  status: 'InProgress' | 'Finished' | 'Failed';
  scanned: boolean;
  infected: boolean;
  infections: string[];
  text: string | null;
  ocrError: string | null;
}

const LABELS: Record<Stage, string> = {
  uploading: 'Uploading',
  scanning: 'Scanning',
  reading: 'Reading',
  ready: 'Ready',
  rejected: 'Rejected',
  failed: 'Failed',
};

const POLL_MS = 2000;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

Submit stays disabled until every row is Ready, so a rejected or failed row has to be removed first.

Upload each file and poll its workflow status

The component uploads every picked file at once, moves each row to Scanning when its upload resolves, and polls that file’s job every two seconds. The rest of upload-queue.ts:

@Component({
  selector: 'app-upload-queue',
  imports: [MatButtonModule, MatChipsModule, MatListModule, MatProgressBarModule],
  templateUrl: './upload-queue.html',
  styleUrl: './upload-queue.css',
})
export class UploadQueue {
  protected readonly labels = LABELS;
  protected readonly files = signal<QueuedFile[]>([]);
  protected readonly canSubmit = computed(
    () => this.files().length > 0 && this.files().every((f) => f.stage === 'ready'),
  );
  private nextId = 0;

  async add(list: FileList | null | undefined) {
    const picked = Array.from(list ?? []); // copy now, the input is cleared right after
    if (!picked.length) return;
    const config = await fetch('/api/upload-config').then((r) => r.json());
    const client = filestack.init(config.apikey, {
      security: { policy: config.policy, signature: config.signature },
    });
    for (const file of picked) this.upload(client, file, config.workflowId);
  }

  onDrop(event: DragEvent) {
    event.preventDefault();
    this.add(event.dataTransfer?.files);
  }

  remove(id: number) {
    this.files.update((list) => list.filter((f) => f.id !== id));
  }

  private patch(id: number, change: Partial<QueuedFile>) {
    this.files.update((list) => list.map((f) => (f.id === id ? { ...f, ...change } : f)));
  }

  private async upload(client: filestack.Client, file: File, workflowId: string) {
    const id = this.nextId++;
    this.files.update((list) => [
      ...list,
      { id, name: file.name, stage: 'uploading', progress: 0, detail: '' },
    ]);
    try {
      const stored = await client.upload(
        file,
        { onProgress: (e: { totalPercent: number }) => this.patch(id, { progress: e.totalPercent }) },
        { filename: file.name, workflows: [workflowId] },
      );
      const run = stored.workflows?.[workflowId];
      if (!run?.jobid) throw new Error(run?.error ?? 'The workflow did not start');
      this.patch(id, { stage: 'scanning', progress: 100, detail: 'Checking for malware' });
      await this.watch(id, run.jobid);
    } catch (err) {
      this.patch(id, { stage: 'failed', detail: err instanceof Error ? err.message : String(err) });
    }
  }

  private async watch(id: number, jobid: string) {
    for (;;) {
      await sleep(POLL_MS);
      const res = await fetch(`/api/jobs/${jobid}`);
      if (!res.ok) continue; // try again on the next tick
      const job: JobStatus = await res.json();

      if (job.infected) {
        return this.patch(id, { stage: 'rejected', detail: job.infections.join(', ') });
      }
      if (job.status === 'Failed') {
        return this.patch(id, { stage: 'failed', detail: 'The workflow failed' });
      }
      if (job.status === 'Finished') {
        const lines = job.text?.split('\n').filter(Boolean).length ?? 0;
        return this.patch(id, {
          stage: 'ready',
          detail: job.ocrError
            ? `Clean, not read (${job.ocrError})`
            : `Clean, ${lines} ${lines === 1 ? 'line' : 'lines'} of text read`,
        });
      }
      if (job.scanned) this.patch(id, { stage: 'reading', detail: 'Clean, reading text' });
    }
  }
}

When a workflow can’t start, the upload still resolves, and its workflow entry has an empty jobid and an error such as policy error: the signature has expired. upload turns that into a Failed row instead of polling a job that doesn’t exist.

add copies the FileList before its first await. The template clears the input right after calling add, and that empties the same FileList object, so reading it after the policy request returns no files.

The order of the checks in watch is the order of the states. An infected verdict ends the row at Rejected whatever else the job says. Finished ends it at Ready. Otherwise, a scan result without an OCR result means Reading, and no scan result means the row stays on Scanning. With OCR that finishes inside one polling interval, a row moves from Scanning straight to Ready.

Show each state with chips and progress bars

The template draws one mat-list-item per file, a chip for the stage, and a mat-progress-bar under the row while there’s work in progress. upload-queue.html:

<div class="drop" (dragover)="$event.preventDefault()" (drop)="onDrop($event)">
  <button mat-stroked-button (click)="picker.click()">Choose files</button>
  <span>or drop them here</span>
  <input #picker type="file" multiple hidden (change)="add(picker.files); picker.value = ''" />
</div>

<mat-list>
  @for (file of files(); track file.id) {
    <mat-list-item [class]="file.stage">
      <span matListItemTitle>{{ file.name }}</span>
      <span matListItemLine>
        {{ file.detail }}
        @if (file.stage === 'rejected' || file.stage === 'failed') {
          <button mat-button (click)="remove(file.id)">Remove</button>
        }
      </span>
      <mat-chip-set matListItemMeta>
        <mat-chip [disableRipple]="true">{{ labels[file.stage] }}</mat-chip>
      </mat-chip-set>
    </mat-list-item>
    @if (file.stage === 'uploading') {
      <mat-progress-bar mode="determinate" [value]="file.progress" />
    } @else if (file.stage === 'scanning' || file.stage === 'reading') {
      <mat-progress-bar mode="indeterminate" />
    }
  }
</mat-list>

<button mat-flat-button [disabled]="!canSubmit()">Submit</button>

While a file uploads, its bar fills with totalPercent. Once it’s stored, the bar switches to indeterminate, because the scan reports no percentage. upload-queue.css colors each chip by the row’s stage class and gives the two-line rows room for the chip:

:host { display: block; max-width: 640px; margin: 32px auto; }
.drop { display: flex; gap: 12px; align-items: center; padding: 24px; border: 2px dashed #c4c7c5; border-radius: 12px; }
mat-list { margin: 16px 0; }
mat-list-item { --mat-list-list-item-two-line-container-height: 80px; }
mat-chip-set { align-self: center; }
.ready mat-chip { --mat-chip-label-text-color: #1b5e20; --mat-chip-outline-color: #66bb6a; }
.rejected mat-chip, .failed mat-chip { --mat-chip-label-text-color: #b71c1c; --mat-chip-outline-color: #e57373; }
.scanning mat-chip, .reading mat-chip { --mat-chip-label-text-color: #0d47a1; --mat-chip-outline-color: #64b5f6; }

Render <app-upload-queue /> from your root component. With the photo still being read, the list looks like this:

The Angular Material file upload list mid-run. The photo is Reading under an indeterminate bar. The two images and the PDF are Ready. eicar.com is Rejected with a Remove button, and Submit is disabled
The Angular Material file upload list mid-run. The photo is Reading under an indeterminate bar. The two images and the PDF are Ready. eicar.com is Rejected with a Remove button, and Submit is disabled

 

Reject infected files in the list

Rejection happens in two places, and both follow the verdict from our virus detection for uploads. In the browser, an infected: true verdict puts the row at Rejected, shows the detection name and keeps Submit disabled. On the server, the same verdict deletes the file with a remove policy for that one handle, because the scan leaves the file in storage. The removed set makes the delete run once per job, however many times the row polls.

Run the server and the dev server side by side:

node --env-file=.env server.mjs
npx ng serve

ng serve prints Local: http://localhost:4200/. Open it and choose the five files. The EICAR test file is a string antivirus engines agree to detect, so it exercises the Rejected path without real malware:

echo WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo= | base64 -d > eicar.com

When eicar.com is rejected, the server prints its handle and the detection name:

API on http://localhost:3000
removed PKW08bukTlej5D7pbma7: content.malicious.eicar-test-signature

That handle’s CDN URL returns 404 from then on. Click Remove on the row and Submit unlocks:

The finished Angular Material file upload list after removing eicar.com. Four rows are Ready and the Submit button is enabled
The finished Angular Material file upload list after removing eicar.com. Four rows are Ready and the Submit button is enabled

 

The scan, the delete and Submit together are one form of secure file uploads: no file reaches the form until it has a clean verdict.

When a row stalls or fails

Symptom Cause Fix
The list stays empty after choosing files The FileList was read after the input was cleared Copy it with Array.from before the first await, as add does
A row goes to Failed with policy error: the signature is not valid The policy wasn’t signed with this application’s secret Check FILESTACK_APP_SECRET against Policy & Signature in the console
A row goes to Failed with policy error: the signature has expired The page has held the same policy past its one-hour expiry Fetch a fresh /api/upload-config before each batch, as add does
A row stays on Scanning and the server logs workflow_status 404 ... job not found The job ID belongs to another application, or the job is older than the workflow’s TTL of 172,800 seconds Use the API key the upload used, and read results within the TTL
Rows reach Ready with no lines of text read The OCR task is missing or named something other than ocr Match the task name in the console to the key the server reads
A clean PDF shows not read (unsupported mimetype) OCR reads images Convert the PDF’s pages to images first, or treat a clean PDF as ready
ng build warns that the initial bundle exceeded its 500 kB budget filestack-js is part of the initial bundle Raise the initial budget in angular.json, or lazy-load the route that holds the queue

Angular Material file upload FAQ

Does Angular Material have a file upload component?

No. Angular Material has no built-in file upload component. You build one from mat-button, a hidden file input, mat-list and mat-progress-bar, and use a client like filestack-js to send the files.

Why does my Angular Material upload show 100% but the file isn’t ready?

A progress bar measures bytes sent. When the last byte lands the upload resolves, but a workflow attached to that upload is still running. In this example the virus scan takes about ten seconds per file and OCR follows it, so the row needs states after 100% to show that work.

How do I show a scanning state in an Angular Material upload list?

Move the row to Scanning when the upload promise resolves, swap the determinate mat-progress-bar for an indeterminate one because the scan reports no percentage, and poll that file’s workflow job. A job with no scan result yet keeps the row on Scanning.

How do I show a reading (OCR) state per file in Angular Material?

Poll the job and read its results. When virus_scan has returned but the job is still InProgress, the OCR task is running, so the server reports scanned: true and the row moves to Reading. When the job reaches Finished, the row becomes Ready.

What’s still yours

The queue, the server and the workflow run as they are. In your app, replace the line count with whatever the form needs from the OCR API output, such as the total on a receipt, and send the stored handles with the form when Submit is clicked. Polling suits a form that someone is watching. For files that finish after the user has left, the fs.workflow webhook delivers the same results to your server for every job. We include Virus Detection and OCR on the Start, Grow and Scale plans.

 

 

Read More →