DEV Community

Cover image for CurateBot Devlog 6: Form validation for uploading Tweets in JSON format
Yuan Gao
Yuan Gao

Posted on

CurateBot Devlog 6: Form validation for uploading Tweets in JSON format

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

The bulk of the code is in the new Load.vue file

The Textarea

The for this Load.vue file is:

<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>
Enter fullscreen mode Exit fullscreen mode

As you can see, it's inside my <Section> component, 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

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:

  getCount(input: string) {
    try {
      return JSON.parse(input).length || 0;
    } catch(err) {
      return 0;
    }
  }
Enter fullscreen mode Exit fullscreen mode

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:

  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
  }
Enter fullscreen mode Exit fullscreen mode

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

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.

  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")
    })

  }
Enter fullscreen mode Exit fullscreen mode

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

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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

Discussion (0)