CurateBot Devlog 6: Form validation for uploading Tweets in JSON format
Tags: devjournal, javascript, vue, twitter
Published: 2021-01-06 19:26:23

Onto the meat of our app. The whole point of the app is to allow me to load a big list of tweets in bulk, and allow me to, at my leisure, curate each one (either delete it, or queue it ready to be automatically posted). I generate the tweets programatically, so I expect to be able to load a JSON structure (Array of Strings) containing the tweets.

Therefore, I need a textbox entry that I can paste in, and have it validate the JSON structure to make sure it's all good, before uploading it to my account.  The code for this post matches [this commit here](https://github.com/meseta/curatebot/tree/ac131e6748bcfb885bf5cbf3ad40b50902a4667b)

The bulk of the code is in the new [`Load.vue` file](https://github.com/meseta/curatebot/blob/ac131e6748bcfb885bf5cbf3ad40b50902a4667b/web/src/views/Load.vue)

# The Textarea
The <template> for this `Load.vue` file is:

```vue
<template>
  <v-container>
    <Section>
      <template v-slot:title>Load Data</template>

      <v-textarea
        v-model="input"
        outlined
        label="Paste JSON of tweets to load (Array of Strings)"
        :placeholder="placeholder"
        counter
        :counter-value="getCount"
        :rules="[validate]"
        :disabled="uploading"
        :loading="uploading"
      >
        <template v-slot:progress>
          <v-progress-linear absolute :value="progress"></v-progress-linear>
        </template>
      </v-textarea>

      <template v-slot:actions>
        <v-spacer></v-spacer>
        <v-btn
          :disabled="!decoded.length"
          color="primary"
          @click="upload"
        >
          Load {{ decoded.length }} Tweets
        </v-btn>
      </template>

    </Section>
  </v-container>
</template>
```

As you can see, it's inside my [`<Section>` component](https://github.com/meseta/curatebot/blob/ac131e6748bcfb885bf5cbf3ad40b50902a4667b/web/src/components/Section.vue), which I've now updated to include an extra `actions` slot for the buttons.

The textarea itself is a Vuetify `<v-textarea>` component, and is set up with some validation rules, and a loading bar that can be turned on and off, and a counter value with a custom count.

[Tweet loader](https://dev-to-uploads.s3.amazonaws.com/i/cykccw7lghhp3cvsvxee.png)

# The counter
The counter in the bottom right is driven by a custom function that decodes the JSON, and counts how many list items it has, simply:

```ts
  getCount(input: string) {
    try {
      return JSON.parse(input).length || 0;
    } catch(err) {
      return 0;
    }
  }
```

# Validation
the validation does the same JSON decode step, but also checks that the root element is an Array as opposed to an object or anything else. Vuetify's validation rules expect a function to return either `true` if everything's ok, or a string explaining what the error is. So this function does that:

```ts
  validate(input: string) {
    if (!input) {
      return true
    }

    let tryDecode;
    try {
      tryDecode = JSON.parse(input)
    } catch(err) {
      this.decoded = [];
      return err.message
    }
          
    if (!Array.isArray(tryDecode)) {
      this.decoded = [];
      return "JSON parent was not Array";
    }
    
    this.decoded = tryDecode.map(String);
    return true
  }
```

When a validation rule returns an error string, it puts the whole component in "error mode" which gives it this angry red outline. You can also see in this example that I'm passing through the JSON decode error automatically:

![Rule validation](https://dev-to-uploads.s3.amazonaws.com/i/8h3vhlgllzuleorsibnj.png)
 
# Uploading
When everything's valid, I upload these to Firestore, using a SHA-1 hash of the tweet's text as the ID. Doing it this way means I can de-duplicate entries, as new ones with the same text will simply overwrite old ones.  I also insert a few other pieces of metadata - the date that we added it, and a `queued` boolean that lets the system know whether this is a tweet yet to be curated, or a tweet ready to be sent.

```ts
  hash(input: string) {
    const msgUint8 = new TextEncoder().encode(input);
    return crypto.subtle.digest('SHA-1', msgUint8)
    .then(hashBuffer => {
      const hashArray = Array.from(new Uint8Array(hashBuffer));
      const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
      return hashHex;
    });
  }

  upload() {
    if (this.validate(this.input) !== true) {
      return
    }

    if (!this.decoded.length) {
      return
    }

    this.uploading = true;
    const len = this.decoded.length;
    this.progress = 100/len;

    const promises = this.decoded.map((tweet) => {
      return this.hash(tweet)
      .then(hashHex => {
        firestore.collection("users").doc(this.uid).collection("tweets").doc(hashHex).set({
          tweet,
          added: firebase.firestore.FieldValue.serverTimestamp(),
          seen: false,
          queued: false
        }, {merge: true})
      })
      .then(() => {
        this.progress += 100/len;
      })
    })

    Promise.all(promises)
    .then(() => {
      this.showSuccess("Completed uploading tweets")
      this.input = "";
      this.$router.push("/")
    })
    .catch(err => {
      console.log(err)
      this.showError("Could not load all tweets")
    })

  }
```

Much of this code relates to dealing with uploading multiple tweets, the `this.decoded.map()` allows us to apply the firestore set method to every member of the decoded tweets, to upload all of them. Here's a couple of tweets viewed through Firestore's web UI:

![Tweets in the system](https://dev-to-uploads.s3.amazonaws.com/i/0ptv203vpdzqgv0lke44.png)

# Firestore rules
We also need to allow writes to this Firestore path, so the rules are updated accordingly:

```
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function isSignedIn() {
      return request.auth != null && request.auth.uid != null && request.auth.uid != "";
    }
    
    function isUser(uid) {
      return isSignedIn() && request.auth.uid == uid;
    }
    
    match /users/{uid} {
      allow read, write: if isUser(uid);
      
      match /tweets/{tweetId} {
      	allow read, write: if isUser(uid);
      }
    }

    match /{document=**} {
      allow read, write: if false;
    }
  }
}
```

With this, I am able to bulk-upload tweets to be curated!