Uploading images to S3 in Ember.js – Part 3: Extracting a form component

10 March 2022

By the end of Part 2 of this series, we could create bands with a name and image whose size was validated to not be above a specific limit.

What's sorely missing from our implementation is that we can't edit existing bands, so we have no way to fix our errors or upload a band image later. We prepared the necessary work by having a dedicated page for editing bands and linking to it from the Details page.

In this part, we'll make managing the creation and editing of a band possible and maintainable.

Extract a form component

As we don't want to copy the form code for creating a new band and editing an existing one, we'll start by extracting the code we currently have in the controller and the related template into a component.

Let's start by creating a BandForm component, with a component class:

1$ ember g component band-form --with-component-class

The extraction should move the UI-related pieces from the controller to the new component but leave the saveBand action, and the logic around warning (or not) about unsaved changes as those belong in the invoking context.

That means our component will look like this:

 1// app/components/band-form.js
 2import Component from '@glimmer/component';
 3import { action } from '@ember/object';
 4import { tracked } from '@glimmer/tracking';
 5import fetch from 'fetch';
 6
 7const MAX_IMAGE_SIZE_MB = 1;
 8const MAX_IMAGE_SIZE = MAX_IMAGE_SIZE_MB * 1024 * 1024;
 9
10export default class BandFormComponent extends Component {
11  @tracked name;
12  @tracked imagePreviewSrc;
13  imageToUpload;
14
15  @tracked validationError;
16
17  @action
18  updateName(event) {
19    this.name = event.target.value;
20  }
21
22  @action
23  didUploadImage(event) {
24    this.validationError = '';
25    let [file] = event.target.files;
26    if (file.size > MAX_IMAGE_SIZE) {
27      this.validationError = `Image should be smaller than ${MAX_IMAGE_SIZE_MB}MB.`;
28      return;
29    }
30    this.imageToUpload = file;
31    this.imagePreviewSrc = URL.createObjectURL(file);
32  }
33
34  @action
35  async saveBand(event) {
36    event.preventDefault();
37    let response = await fetch('/presign-aws-request', {
38      method: 'POST',
39    });
40    let { url, url_fields: urlFields } = await response.json();
41    let bandProperties = {
42      name: this.name,
43    };
44
45    let formData = new FormData();
46    for (let field in urlFields) {
47      formData.append(field, urlFields[field]);
48    }
49    formData.append('file', this.imageToUpload);
50
51    let imageUploadResponse = await fetch(url, {
52      method: 'POST',
53      body: formData,
54    });
55
56    if (imageUploadResponse.ok) {
57      bandProperties['image-url'] = imageUploadResponse.headers.get('Location');
58      URL.revokeObjectURL(this.imageToUpload);
59      this.imageToUpload = null;
60    }
61
62    return await this.args.onSave(bandProperties);
63  }
64}

(I renamed the this.saveBand to this.save in the component because the Band suffix is redundant in this context.)

While the controller code is reduced to the following:

 1// app/controllers/bands/new.js
 2import Controller from '@ember/controller';
 3import { action } from '@ember/object';
 4import { inject as service } from '@ember/service';
 5
 6export default class BandsNewController extends Controller {
 7  @service catalog;
 8  @service router;
 9
10  constructor() {
11    super(...arguments);
12    this.router.on('routeWillChange', (transition) => {
13      if (transition.isAborted) {
14        return;
15      }
16      if (this.confirmedLeave) {
17        return;
18      }
19      if (transition.from?.name === 'bands.new') {
20        if (this.name) {
21          let leave = window.confirm('You have unsaved changes. Are you sure?');
22          if (leave) {
23            this.confirmedLeave = true;
24          } else {
25            transition.abort();
26          }
27        }
28      }
29    });
30  }
31
32  G@action
33  async saveBand(properties) {
34    let band = await this.catalog.create('band', properties);
35    this.confirmedLeave = true;
36    this.router.transitionTo('bands.band.songs', band.id);
37  }
38}

The saveBand action now only contains what's specific to saving a band in this context. All the image-related URL fetching, composing the payload, and validating now live in the component where it will get reused when editing a band.

Consequently, the component template, band-form.hbs, is 99% identical to the template belonging to the controller, app/templates/bands/new.hbs. The only difference is that we call this.save instead of this.saveBand when submitting the form due to the name change I mentioned earlier.

The only thing remaining in the controller template is rendering the component:

1{{!-- app/templates/bands/new.hbs --}}
2<h3 class="text-lg leading-6 font-medium text-gray-100">
3  New band
4</h3>
5
6<BandForm
7  @onSave={{this.saveBand}}
8/>

We managed to extract the component while the app works just as before:

Using the form component to edit bands

Now that we're armed with a BandForm component, we should also use it to allow editing an existing band which is the reason we extracted the component in the first place.

The main difference between using the component for creating and editing band objects is that we need to use the form with an existing band object in the latter case. So at the outset, we'll need to fetch it and pass it to the BandForm component to work with.

Let's generate a route (which also creates the corresponding template) for the "Edit band" page – we manually added a route entry for this page in the previous post:

1$  ember g route bands/edit-band

In the route, we fetch the band so that we can pass it into the component:

1// app/routes/bands/edit-band.js
2export default class BandsEditBandRoute extends Route {
3  @service catalog;
4
5  model(params) {
6    return this.catalog.find('band', (band) => band.id === params.id);
7  }
8}
 1{{!-- app/templates/bands/edit-band.hbs --}}
 2{{page-title "Edit " @model.name "| Rock & Roll with Octane" replace=true}}
 3
 4<h3 class="text-lg leading-6 font-medium text-gray-100">
 5  Edit band
 6</h3>
 7
 8<BandForm
 9  @band={{@model}}
10  @onSave={{fn this.updateBand @model}}
11/>

We assumed that an updateBand action exists in the context and that BandForm can handle a @band being passed in. Neither of these holds at the moment, so let's make them so.

updateBand needs to exist on the controller, which we'll first need to create:

1$  ember g controller bands/edit-band

The updateBand action should be very similar to the saveBand one in app/controllers/bands/new.js. Instead of creating a band, we should update the existing one:

 1// app/controllers/band/edit-band.js
 2import Controller from '@ember/controller';
 3import { action } from '@ember/object';
 4import { inject as service } from '@ember/service';
 5
 6export default class BandsEditBandController extends Controller {
 7  @service catalog;
 8  @service router;
 9
10  @action
11  async updateBand(band, attributes) {
12    await this.catalog.update('band', band, attributes);
13    this.router.transitionTo('bands.band.details', band.id);
14  }
15}

What's missing is that even though we pass the @band to the BandForm component on the Edit band page, we don't initialize the form taking that object into account. That means the form looks as if we were creating a band from scratch:

Band form is not initialized from object

If we take another look at our form, it uses individual properties for each field (this.name, this.imagePreviewSrc) instead of using a form object. So to show the existing band properties in the fields, we need to initialize the form properties from them:

 1// app/components/band-form.js
 2export default class BandFormComponent extends Component {
 3  // (...)
 4  constructor() {
 5    super(...arguments);
 6    if (!this.args.band) {
 7      return;
 8    }
 9    let { name, imageUrl } = this.args.band;
10    this.name = name;
11    this.imagePreviewSrc = imageUrl;
12  }
13}

Perhaps somewhat surprisingly, that's the only change we need to apply to make this work:

A boring fix

When I said the above code change was the only change in the component's code to make the edit scenario work, it wasn't entirely true.

I needed to change some code in the catalog service for the update to work correctly. For the readers who have coded the Rock & Roll app and now follow along with this series also writing the code (if you're one of them, you're great!), they'll need to make the following code change:

 1// app/services/catalog.js
 2export default class CatalogService extends Service {
 3  // (...)
 4  async update(type, record, attributes) {
 5    // (...)
 6    let response = await fetch(url, {
 7      method: 'PATCH',
 8      headers: {
 9        'Content-Type': 'application/vnd.api+json',
10      },
11      body: JSON.stringify(payload),
12    });
13    let json = await response.json();
14    return this.load(json);
15  }
16
17  add(type, record) {
18    let collection = type === 'band' ? this.storage.bands : this.storage.songs;
19    let existingIndex = collection.findIndex((r) => record.id === r.id);
20    if (existingIndex === -1) {
21      collection.push(record);
22    } else {
23      collection.splice(existingIndex, 1, record);
24    }
25  }
26}

There are still fascinating further improvements to make, some of which I'm planning to write about. Removing or replacing the band image, post-processing the uploaded image, and testing the image upload come to mind at first, but I'm sure we could add several other features.

Until next time!

Share on Twitter