This article describes the process of implementing a Chrome Extension with React+TypeScript+Firebase to count Gitlab Spent Time.

The contents include:

  • background
  • Function is introduced
  • How to use
  • Configure multiple JS entries with Webpack
  • Use the TypeScript
  • Turn it into Chrome Extension
  • Use Firebase Auth to register login
  • Use Firestore to access data

Project address: GitHub

background

At the beginning, I was motivated to write this extension because our company switched the project management platform from Redmine to GitLab. GitLab is indeed more fashion than Redmine, but it has a function that we really need but is not perfect, that is, time statistical report. We need to record the time spent on each issue. On Redmine, PM can easily query and generate a report on the time spent by each person in a certain period, but GitLab is not. Therefore, PM is very upset, so they think of writing this plug-in to relieve their pain.

We have tried some third-party tools, such as GTT, but these tools take a long time (they all traverse projects, issues under project and time Notes under issue through GitLab API), and for PM, Too complex to use (GTT is a command-line tool with many arguments).

Of course, in the end, PMS did not use my tool, because they found a simpler way later. After checking the source code of GitLab, they found that there was actually a table named Timelogs in GitLab database, which directly stored time Notes. Unfortunately, GitLab didn’t open any APIS to access the table, so we wrote a Rails project that directly accessed GitLab’s Database to generate the report (the project is still being developed internally).

Still, I learned a lot about TypeScript, Firebase, and Webpack. I will continue to optimize it as my side project.

Function is introduced

(The asterisk hides some real information)

  1. Generate a real-time Spent Time report for each issue on each issue page

  2. Generate a real-time Spent Time dashboard for all projects and users

  3. A quick log of today’s Spent Time button to resolve time zone issues if the server is deployed in a distant time zone

How to use

Because this extension is recommended for internal use within companies, it has not been released to the Chrome Store.

If you want to try it or have a need for it, check out this document:

  • How does the extension work

Configure multiple JS entries with Webpack

We use React to implement this extension. The create-react-app extension uses the create-react-app scaffolding. However, since this extension requires two js files, One to inject into the issue page of each GitLab and one to display the Dashboard Page. However, create-react-app can only output one JS file, and webpack.config.js generated through yarn eject is too complex, so we have to manually configure webpack to output two JS files.

Here we use Webpack4. The entire configuration process can be seen on the tag WebPack4_boilerplate, referring to the previous notes:

  • Webpack3
  • Webpack4

Configuration of multiple outputs:

// webpack.config.js
module.exports = {
  entry: {
    dashboard: './src/js/dashboard.tsx',
    'issue-report': './src/js/issue-report.tsx',
  },
  output: {},
  ...
}
Copy the code

Two JS entries are configured. The output option is left blank and the default is kept, so the output is placed in the default folder dist. The output JS file name is the same as the key defined in entry. So code starting from dashboard.tsx entry will be packaged as dashboard.js, code starting from issue-report-tsx entry will be packaged as issue-report-js,

The rest of the configuration is general configuration, such as CSS with Sas-loader, postCSS-loader, CSS-loader and mini-CSs-extract-plugin. Use url-loader and file-loader to process images and font files.

Generate dashboard.html with the html-webpack-plugin plugin. Because dashboard.html needs to run dashboard.js, use the chunks option to declare that this HTML needs to load dashboard.js.

// webpack.config.js
module.exports = {
  ...
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/html/template.html',
      filename: 'dashboard.html',
      chunks: ['dashboard'],
      hash: true
    }),
    ...
}
Copy the code

JSX loader, we use babel-loader and the corresponding “env” and “React” preset to handle.jsx.

// webpack.config.js
module.exports = {
  ...
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        use: 'babel-loader',
        include: /src/,
        exclude: /node_modules/
      },
      ...
}

// .babelrc
{
  "presets": [
    "env",
    "stage-0",
    "react"
  ]
}
Copy the code

“Stage-0” is used to transform ES7 syntax (such as async/await) and is not required here.

Note that all NPM packages used here need to be installed manually by NPM Install yourself.

Use the TypeScript

We introduced TypeScript purely as a way to get a taste of how TypeScript works. We’ve heard about it but haven’t had a chance to use it. It turned out to work, and I started using TypeScript for my work projects.

React & TypeScript configuration tutorial: React & Webpack

The main things to install are typescript and awesome-typescript-loader, which is the loader that handles.tsx.

Webpack configuration:

// webpack.config.js
module.exports = {
  ...
  module: {
    rules: [
      ...
      // All files with a '.ts' or '.tsx' extension will be handled by 'awesome-typescript-loader'.
      { test: /\.tsx?$/, loader: "awesome-typescript-loader" },
}
Copy the code

TypeScript configuration files:

// tsconfig.json
{
    "compilerOptions": {
        "outDir": "./dist/",
        "sourceMap": true,
        "strict": true,
        "module": "commonjs",
        "target": "es6",
        "jsx": "react"
    },
    "include": [
        "./src/**/*"
    ]
}
Copy the code

When we actually use TypeScript, we only have.tsx and.ts files. We no longer have.jsx files, so we deal with.jsx? The $rule is no longer needed.

Turn it into Chrome Extension

After the previous step, executing the NPM run build in the chrome_ext directory produces output to the dist directory. Double-click dashboard.html to open it in your browser. Start or perform NPM run dev webpack – dev – server, and then visit http://localhost:8080/dashboard.html in the browser, dashboard page is ready to work alone.

However, issue-report.js cannot run alone and must be injected into the Gitlab Issue page to run. Let’s declare a manifest.json to turn the application into a plug-in.

Create a new public directory where you will place the manifest.json declaration file and ICONS required by the plug-in.

This is the first version of manifest.json:

{"name": "GitLab Time Report", "version": "0.1.6", "version_code": 16, "manifest_version": 2, "description": "report your gitlab spent time", "icons": { "128": "icons/circle_128.png" }, "browser_action": { "default_icon": "icons/circle_128.png" }, "author": "baurine", "options_page": "dashboard.html", "content_scripts": [ { "matches": ["<all_urls>"], "js": ["issue-report.js"], "css": ["issue-report.css"] } ], "permissions": [ "storage" ] }Copy the code

The main options are content_scripts and options_page. The former is used to declare which javascript code to inject on which pages and which CSS code to use. Since the plugin supports different domain names, the matches value is all urls. Options_page is used to declare the page to be opened after right-clicking the extension icon and selecting Options in the pop-up menu. It is used to access the Dashboard Page.

I decided that it would take two steps to get to the Dashboard Page, so I changed it to a left mouse click that opens the Dashboard Page directly, but it’s a bit more difficult to implement. Let’s look at the new manifest.json.

{"name": "GitLab Time Report", "version": "0.1.7", "version_code": 17, "manifest_version": 2, "description": "report your gitlab spent time", "author": "baurine", "icons": { "128": "icons/circle_128.png" }, "browser_action": { "default_icon": "icons/circle_128.png" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": ["issue-report.js"], "css": ["issue-report.css"] } ], "background": { "scripts": ["background.js"], "persistent": false }, "permissions": [ "storage", "tabs" ] }Copy the code

We have removed the options_page option and added the background option, which is used to declare js code running in the background. The background JS code is not injected into the Web page and does not require HTML. It can be used to listen for browser behavior and invoke Chrome’s Extension API to manipulate the browser, such as opening a new TAB. The job of background.js is to listen for the browser to click on the extension icon and then open TAB to load dashboard.html.

The code is short and looks like this:

// background.js // ref: https://adamfeuer.com/notes/2013/01/26/chrome-extension-making-browser-action-icon-open-options-page/ const OPTIONS_PAGE  = 'dashboard.html' function openOrFocusOptionsPage() { const optionsUrl = chrome.extension.getURL(OPTIONS_PAGE) chrome.tabs.query({}, function (extensionTabs) { let found = false for (let i = 0; i < extensionTabs.length; i++) { if (optionsUrl === extensionTabs[i].url) { found = true chrome.tabs.update(extensionTabs[i].id, { "selected": true }) break } } if (found === false) { chrome.tabs.create({ url: OPTIONS_PAGE }) } }) } chrome.browserAction.onClicked.addListener(openOrFocusOptionsPage)Copy the code

Because background.js calls the chrome.tabs related API, you also need to add the tabs permission declaration in the Permissions option. The storage permission is used by Firebase to store login status. If you do not add this permission, the browser plug-in will be opened in a non-login state.

Finally, there is one more thing to do. When we run the NPM run build, we need to copy all the files from the public directory to the dist directory together. We use copy-webpack-plugin in Webpack.

// webpack.config.js
const CopyWebpackPlugin = require('copy-webpack-plugin')

module.exports = {
  ...
  plugins: [
    ...
    new CopyWebpackPlugin([
      { from: './public', to: '' }
    ])
  ...
}
Copy the code

Use Firebase Auth to register login

To summarize the use of the Firebase user authentication API, see the official documentation. The sample code in chrome_ext/SRC/js/components/AuthBox TSX.

First fetch the firebaseAuth object:

// chrome_ext/src/js/firebase/index.ts
const firebase = require('firebase/app')
require('firebase/auth')

firebase.initializeApp(firebaseConfig)
const firebaseAuth = firebase.auth()
Copy the code

Register with email password:

firebaseAuth.createUserWithEmailAndPassword(email, password)
Copy the code

Log in with your email password:

firebaseAuth.signInWithEmailAndPassword(email, password)
Copy the code

Log out:

firebaseAuth.signOut()
Copy the code

If the login succeeds, the user object will be returned in the callback. Otherwise, the user object will be null after logging out.

firebaseAuth.onAuthStateChanged((user: any) => {
  this.setState({user, loading: false, message: ''})
})
Copy the code

If the user’s email address is not authenticated after login, you will need to verify the email address (depending on your own requirements) :

user.sendEmailVerification()
Copy the code

Reset password:

firebaseAuth.sendPasswordResetEmail(email)
Copy the code

Use Firestore to access data

Firestore is a new real-time database contained in the Firebase component. It is a kind of NoSQL, similar to MongoDB, and also has the concept of collection and Document. Collection is similar to tables in relational database. Document is equivalent to a record in a table. Firestore, like MongoDB, can nest child collections with Documents (but with nested hierarchies).

Databases are nothing more than add, delete, change, and query, so let’s take a look at how to CRUD Firestore. Sample code mainly in chrome_ext/SRC/js/components/IssueReport TSX and TotalReport TSX.

(Note that Firestore does not use RESTful apis.)

First fetch the firebaseDb object:

const firebase = require('firebase/app')
require('firebase/firestore')

firebase.initializeApp(firebaseConfig)
const firebaseDb = firebase.firestore()
Copy the code

Create a document

Document can only be subordinate to a Collection, but you don’t need to create a collection first. If you add the first document to a collection by a given name, the colletion will be created automatically. If all documents in a collection are deleted, the collection will be deleted automatically. So there is no API for creating and deleting collections.

So before we create the document, we specify the collection. We use FirebasedB. collection(collection_name) to get the collection reference, which is CollectionRef, Call the add() method on the collection reference object to create a document belonging to the collection. Example:

firebaseDb.collection('users')
  .add({
    name: 'spark',
    gender: 'male'
  })
  .then((docRef: DocumentRef) => console.log(docRef.id)) // docRef.gender ? undefined
  .catch(err => console.log(err))
Copy the code

The return value of the add() method is a Promise

. DocumentRef is a reference to document and does not directly contain the data associated with the document. For example, it does not have a gender attribute, it only contains an ID attribute.

Once we have the ID, we can then use Firebasedb.Collection (‘ Users ‘).doc(id) to get a reference to the corresponding document (of course, this is unnecessary, because the return value is already document ref).

Also, you might be wondering why the Add () method only returns the Document ref, rather than its entire object, as RESTful apis do. What if I want to access the name and gender attributes?

I think because the values in add() are known, all we need is the ID, so the return value only includes the ID. As you can see, when the set() and update() methods are called later, the return value is void, excluding the id, which is already known.

The Document created with the add() method, whose ID is generated by Firestore, is a long, irregular string similar to a UUID (like the ObjectID in MongoDB). What if we want to use the id that we specify. For example, here we want to create a user with the ID spark in the Users Collection.

First, we use Firebasedb.Collection (‘users’).doc(‘spark’) to get the document reference (it doesn’t matter if the document actually exists), and then, We fill in the values by calling the set() method on the Document Ref object.

firebaseDb.collection('users')
  .doc('spark')
  .set({
    name: 'spark',
    gender: 'male'
  })
  .then(() => console.log('add successful'))
  .catch(err => console.log(err))
Copy the code

As mentioned earlier, we know both the ID and the value when we call the set() method to create the document, so we don’t need to return the value, just success or failure.

Now we have two data types: CollectionRef, which is a reference to a collection, and DocumentRef, which is a reference to a document.

You may still be wondering, how on earth can I get a complete document data? Don’t worry, we’ll talk about that in the query section.

Delete a document

Deleting is easier by taking the Document reference and calling the delete() method, which returns a Promise

.

For example, to delete the spark user that was created:

firebaseDb.collection('users')
  .doc('spark')
  .delete()
  .then(() => console.log('delete successful'))
  .catch(err => console.log(err))
Copy the code

What if, on the client side, I want to delete multiple documents at the same time, or delete an entire collection at the same time? Unfortunately or oddly, Firestore doesn’t support this unless you do it on the console or through the Admin API. We can only loop through the document reference objects to be deleted and call their delete() method, which is a bit painful, probably for data security reasons, since we are directly manipulating the database on the client side.

Modify a document

Like the set() and delete() methods, we get a reference to document and then call the Update () method, which returns a Promise

.

firebaseDb.collection('users')
  .doc('spark')
  .update({
    name: 'spark001',
  })
  .then(() => console.log('update successful'))
  .catch(err => console.log(err))
Copy the code

Update () does not specify a field, and its value remains unchanged.

Modify multiple documents at the same time? Let’s not think about it.

Query the document

Inquiry is the main play.

Create/delete/modify can only operate on a document, query can not.

Query a document

First, back to the previous problem, how to retrieve the actual data in a document reference from FirebasedB. collection(colletion_name).doc(ID). The DocumentRef object has a get() method that returns a Promise

, and then calls the data() method on the DocumentSnapshot object to actually access the data, The data() method returns an object of type DocumentData. But before we do that, we need to make sure that this document actually exists, because we can refer to an empty document that doesn’t exist.

Sample code:

firebaseDb.collection('users')
  .doc('spark')
  .get()
  .then((docSnapshot: DocumentSnapshot) => {
    if (docSnapshot.exists) {
      console.log('user:': docSnapshot.data())  // {name: 'spark001', gender: 'male'}
    } else {
      console.log('no this user')
    }
  })
  .catch((err: Error) => console.log(err))
Copy the code

Query multiple documents

What if we were looking for multiple documents, such as returning all documents in a collection, or documents that meet some criteria, such as looking for users with gender male in the Users table?

For example, return all documents in the collection:

firebaseDb.collection('users')
  .get()
Copy the code

Returns the qualified document in the collection:

firebaseDb.collection('users')
  .where('gender', '==', 'male')
  .get()
Copy the code

Calling the WHERE () Query condition method on CollectionRef yields a Query object. Calling the get() method on both CollectionRef and Query results in a Promise

object.

The QuerySnapshot object is a collection of DocumentSnapshots. It has a forEach method to iterate over and retrieve other DocumentSnapshot objects in turn. Then get the DocumentData object from the DocumentSnapshot, the data we really need.

Take a look at a practical example from this project:

// TotalReport.tsx
loadUsers = (domain: string) => {
  return firebaseDb.collection(dbCollections.DOMAINS)
    .doc(domain)
    .collection(dbCollections.USERS)
    .orderBy('username')
    .get()
    .then((querySnapshot: any) => {
      let users: IProfile[] = [DEF_USER]
      querySnapshot.forEach((snapshot: any)=>users.push(snapshot.data()))
      this.setState({users})
      this.autoChooseUser(users)
    })
}
Copy the code

Real-time query

Firestore is a real-time database, which means that we can listen for changes in the database and receive notification of changes if any of the eligible data changes, thus implementing real-time queries.

Firestore uses the onSnapshot() method to listen for data changes on DocumentRef, CollectionRef, and Query objects. It takes a callback function as an argument of the same type as the data contained in the Promise returned by the get() method, DocumentSnapshot and QuerySnapshot.

After onSnapshot() is called, we need to unlisten when appropriate, otherwise we waste resources. The return value of onSnapshot() is a function that can be called to cancel listening.

Real example code from this project:

// TotalReport.tsx
componentWillUnmount() {
  this.unsubscribe && this.unsubscribe()
}

queryTimeLogs = () => {
  this.unsubscribe = query.onSnapshot((snapshot: any) => {
      let timeLogs: ITimeNote[] = []
      snapshot.forEach((s: any) => timeLogs.push(s.data()))
      this.aggregateTimeLogs(timeLogs)
    }, (err: any) => {
      this.setState({message: CommonUtil.formatFirebaseError(err), loading: false})
    })
    ...
}
Copy the code

Finally, if you think this example is too complicated to understand Firebase, look at this example: CF-Firebase-Demo, TodoList implemented with Firebase, less than 100 lines of core code.