├── .all-contributorsrc
├── .github
└── issue_template.md
├── .gitignore
├── .nvmrc
├── .prettierrc
├── .travis.yml
├── CODE-OF-CONDUCT.md
├── LICENSE
├── README.md
├── build
└── icon.png
├── index.js
├── jest.config.js
├── media
├── buildingTemplate.png
├── buildingTemplate@2x.png
├── buildingTemplate@4x.png
├── errorTemplate.png
├── errorTemplate@2x.png
├── errorTemplate@4x.png
├── loadingTemplate.png
├── loadingTemplate@2x.png
├── loadingTemplate@4x.png
├── offlineTemplate.png
├── offlineTemplate@2x.png
├── offlineTemplate@4x.png
├── readyTemplate.png
├── readyTemplate@2x.png
└── readyTemplate@4x.png
├── package-lock.json
├── package.json
├── screenshot.jpg
├── src
├── __tests__
│ ├── __snapshots__
│ │ └── menus.test.ts.snap
│ ├── incidentFeed.test.ts
│ ├── menus.test.ts
│ ├── netlify.test.ts
│ ├── notify.test.ts
│ └── scheduler.test.ts
├── config.ts
├── connection.ts
├── icons.ts
├── incidentFeed.ts
├── index.ts
├── menubar.ts
├── menus.ts
├── netlify.ts
├── notify.ts
├── scheduler.ts
├── util.test.ts
└── util.ts
├── tsconfig.json
└── tslint.json
/.all-contributorsrc:
--------------------------------------------------------------------------------
1 | {
2 | "projectName": "netlify-menubar",
3 | "projectOwner": "stefanjudis",
4 | "repoType": "github",
5 | "repoHost": "https://github.com",
6 | "files": [
7 | "README.md"
8 | ],
9 | "imageSize": 100,
10 | "commit": true,
11 | "contributors": [
12 | {
13 | "login": "johnnyxbell",
14 | "name": "Johnny Bell",
15 | "avatar_url": "https://avatars2.githubusercontent.com/u/4260265?v=4",
16 | "profile": "http://johnnybell.io",
17 | "contributions": [
18 | "code"
19 | ]
20 | },
21 | {
22 | "login": "viviangb",
23 | "name": "Vivian Guillen",
24 | "avatar_url": "https://avatars3.githubusercontent.com/u/7389358?v=4",
25 | "profile": "http://codequeen.io",
26 | "contributions": [
27 | "code",
28 | "design"
29 | ]
30 | },
31 | {
32 | "login": "ukmadlz",
33 | "name": "Mike Elsmore",
34 | "avatar_url": "https://avatars2.githubusercontent.com/u/804683?v=4",
35 | "profile": "http://elsmore.me",
36 | "contributions": [
37 | "code"
38 | ]
39 | },
40 | {
41 | "login": "adamwatters",
42 | "name": "Adam Watters",
43 | "avatar_url": "https://avatars2.githubusercontent.com/u/7673611?v=4",
44 | "profile": "http://adamwatters.nyc",
45 | "contributions": [
46 | "code"
47 | ]
48 | }
49 | ]
50 | }
51 |
--------------------------------------------------------------------------------
/.github/issue_template.md:
--------------------------------------------------------------------------------
1 | # Found a problem or want to give feedback
2 |
3 | I'd love to hear from you if Netlify Menubar is useful for you and if you run into any troubles. 😊
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # TypeScript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | # dotenv environment variables file
58 | .env
59 |
60 | # next.js build output
61 | .next
62 |
63 | # typescript output
64 | dist
65 |
66 | # packed app
67 | release
68 |
69 | # local certs
70 | .certificates.p12
71 |
72 | # codecov
73 | coverage
74 |
75 | # random stuff
76 | .DS_STORE
77 | .vscode
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v12.4.0
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true
3 | }
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - '12'
4 | os: osx
5 | script: npm run test:ci && npm run release
6 |
--------------------------------------------------------------------------------
/CODE-OF-CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, gender identity and expression, level of experience,
9 | nationality, personal appearance, race, religion, or sexual identity and
10 | orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at stefanjudis@gmail.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at [https://contributor-covenant.org/version/1/4][version]
72 |
73 | [homepage]: https://contributor-covenant.org
74 | [version]: https://contributor-covenant.org/version/1/4/
75 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Stefan Judis
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Netlify Menubar
2 |
3 | [](https://travis-ci.org/stefanjudis/netlify-menubar) [](https://github.com/stefanjudis/netlify-menubar/releases) [](#contributors) [](https://codecov.io/gh/stefanjudis/netlify-menubar)
4 |
5 | > See, monitor and controls builds from within your top menubar
6 |
7 | 
8 |
9 | **🙈 It's my first project in TypeScript – don't judge. 🙈**
10 |
11 | ## Features
12 |
13 | - monitor deploys of a certain site
14 | - get notified when a new deploy is ready
15 | - trigger deploys
16 | - automatic app updates (since Netlify Menubar `1.2.1`)
17 |
18 | ## Installation
19 |
20 | Head over to the [releases section](https://github.com/stefanjudis/netlify-menubar/releases) and download the latest `dmg`. The app supports auto-updates so you only have to install it once and then it's rolling.
21 |
22 | _Install via `brew cask` is in progress._ :)
23 |
24 | ## Contribute
25 |
26 | You can run Netlify menubar right from your machine. There should be no additional setup required.
27 |
28 | ```
29 | $ git clone git@github.com:stefanjudis/netlify-menubar.git
30 | $ npm run dev
31 | ```
32 |
33 | ## Contributors
34 |
35 | Thanks goes to these wonderful people ([emoji key](https://github.com/all-contributors/all-contributors#emoji-key)):
36 |
37 |
38 |
39 | | [
Johnny Bell](http://johnnybell.io)
[💻](https://github.com/stefanjudis/netlify-menubar/commits?author=johnnyxbell "Code") | [
Vivian Guillen](http://codequeen.io)
[💻](https://github.com/stefanjudis/netlify-menubar/commits?author=viviangb "Code") [🎨](#design-viviangb "Design") | [
Mike Elsmore](http://elsmore.me)
[💻](https://github.com/stefanjudis/netlify-menubar/commits?author=ukmadlz "Code") | [
Adam Watters](http://adamwatters.nyc)
[💻](https://github.com/stefanjudis/netlify-menubar/commits?author=adamwatters "Code") |
40 | | :---: | :---: | :---: | :---: |
41 |
42 |
43 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
44 |
--------------------------------------------------------------------------------
/build/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/build/icon.png
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const app = require('./dist/index');
2 |
3 | // entry function for electron-builder to go around
4 | // file-path and configuration hassle
5 | app.start();
6 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | testEnvironment: 'node',
4 | roots: ['/src'],
5 | collectCoverage: true,
6 | collectCoverageFrom: ['src/*.ts', '!**/node_modules/**'],
7 | coverageDirectory: './coverage/'
8 | };
9 |
--------------------------------------------------------------------------------
/media/buildingTemplate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/buildingTemplate.png
--------------------------------------------------------------------------------
/media/buildingTemplate@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/buildingTemplate@2x.png
--------------------------------------------------------------------------------
/media/buildingTemplate@4x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/buildingTemplate@4x.png
--------------------------------------------------------------------------------
/media/errorTemplate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/errorTemplate.png
--------------------------------------------------------------------------------
/media/errorTemplate@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/errorTemplate@2x.png
--------------------------------------------------------------------------------
/media/errorTemplate@4x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/errorTemplate@4x.png
--------------------------------------------------------------------------------
/media/loadingTemplate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/loadingTemplate.png
--------------------------------------------------------------------------------
/media/loadingTemplate@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/loadingTemplate@2x.png
--------------------------------------------------------------------------------
/media/loadingTemplate@4x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/loadingTemplate@4x.png
--------------------------------------------------------------------------------
/media/offlineTemplate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/offlineTemplate.png
--------------------------------------------------------------------------------
/media/offlineTemplate@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/offlineTemplate@2x.png
--------------------------------------------------------------------------------
/media/offlineTemplate@4x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/offlineTemplate@4x.png
--------------------------------------------------------------------------------
/media/readyTemplate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/readyTemplate.png
--------------------------------------------------------------------------------
/media/readyTemplate@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/readyTemplate@2x.png
--------------------------------------------------------------------------------
/media/readyTemplate@4x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/readyTemplate@4x.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "netlify-menubar",
3 | "version": "1.9.3",
4 | "description": "A menubar app to keep track of Netlify builds",
5 | "author": "stefan judis ",
6 | "main": "index.js",
7 | "scripts": {
8 | "dev": "tsc && electron index.js",
9 | "postinstall": "electron-builder install-app-deps",
10 | "test": "tslint src/* && jest",
11 | "test:ci": "npm run test && codecov",
12 | "pack": "tsc && electron-builder --dir -m",
13 | "dist": "tsc && electron-builder -m",
14 | "release": "tsc && electron-builder",
15 | "contributors:add": "all-contributors add",
16 | "contributors:generate": "all-contributors generate"
17 | },
18 | "husky": {
19 | "hooks": {
20 | "pre-commit": "npm test"
21 | }
22 | },
23 | "repository": {
24 | "type": "git",
25 | "url": "git+https://github.com/stefanjudis/netlify-menubar-status.git"
26 | },
27 | "build": {
28 | "appId": "netlify.menubar.electron.app",
29 | "productName": "Netlify Menubar",
30 | "copyright": "Copyright © 2019 Stefan Judis",
31 | "directories": {
32 | "output": "release"
33 | }
34 | },
35 | "keywords": [],
36 | "license": "MIT",
37 | "bugs": {
38 | "url": "https://github.com/stefanjudis/netlify-menubar-status/issues"
39 | },
40 | "homepage": "https://github.com/stefanjudis/netlify-menubar-status#readme",
41 | "dependencies": {
42 | "auto-launch": "^5.0.5",
43 | "date-fns": "^1.30.1",
44 | "electron-is-dev": "^1.1.0",
45 | "electron-settings": "^3.2.0",
46 | "electron-updater": "^4.2.5",
47 | "node-fetch": "^2.6.0",
48 | "rss-parser": "^3.7.1"
49 | },
50 | "devDependencies": {
51 | "@types/auto-launch": "^5.0.1",
52 | "@types/electron-settings": "^3.1.1",
53 | "@types/jest": "^24.0.14",
54 | "@types/node": "^12.12.35",
55 | "@types/node-fetch": "^2.3.6",
56 | "all-contributors-cli": "^6.7.0",
57 | "codecov": "^3.5.0",
58 | "electron": "^8.2.1",
59 | "electron-builder": "^22.4.1",
60 | "husky": "^2.4.1",
61 | "jest": "^24.8.0",
62 | "ts-jest": "^24.0.2",
63 | "tslint": "^5.17.0",
64 | "tslint-config-prettier": "^1.18.0",
65 | "typescript": "^3.5.1"
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/screenshot.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/screenshot.jpg
--------------------------------------------------------------------------------
/src/__tests__/__snapshots__/menus.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`menu helper functions getCheckboxMenu should render a correct checkbox settings menu 1`] = `
4 | Array [
5 | Object {
6 | "checked": true,
7 | "click": [Function],
8 | "label": "Launch at start label",
9 | "type": "checkbox",
10 | },
11 | Object {
12 | "checked": false,
13 | "click": [Function],
14 | "label": "Show notifications label",
15 | "type": "checkbox",
16 | },
17 | ]
18 | `;
19 |
20 | exports[`menu helper functions getDeploysMenu should render a correct deploy menu 1`] = `
21 | Array [
22 | Object {
23 | "click": [Function],
24 | "label": "Overview",
25 | },
26 | Object {
27 | "type": "separator",
28 | },
29 | Object {
30 | "click": [Function],
31 | "label": "production: building → master (less than a minute ago in 123s)",
32 | },
33 | Object {
34 | "enabled": false,
35 | "label": "—",
36 | },
37 | Object {
38 | "click": [Function],
39 | "label": "production: ready → master (1 day ago in 126s)",
40 | },
41 | ]
42 | `;
43 |
44 | exports[`menu helper functions getSitesMenu should render a correct sites menu 1`] = `
45 | Array [
46 | Object {
47 | "checked": false,
48 | "click": [Function],
49 | "label": "foo.com",
50 | "type": "radio",
51 | },
52 | Object {
53 | "checked": true,
54 | "click": [Function],
55 | "label": "current.com",
56 | "type": "radio",
57 | },
58 | Object {
59 | "checked": false,
60 | "click": [Function],
61 | "label": "bar.com",
62 | "type": "radio",
63 | },
64 | ]
65 | `;
66 |
67 | exports[`menu helper functions getSitesMenu should render a correct sites menu 2`] = `
68 | Array [
69 | Object {
70 | "click": [Function],
71 | "label": "History",
72 | },
73 | Object {
74 | "type": "separator",
75 | },
76 | Object {
77 | "enabled": false,
78 | "label": "no recent incidents",
79 | },
80 | ]
81 | `;
82 |
--------------------------------------------------------------------------------
/src/__tests__/incidentFeed.test.ts:
--------------------------------------------------------------------------------
1 | const firstResponse = {
2 | items: [
3 | { link: 'id=1', content: 'not updated' },
4 | { link: 'id=2', content: 'not updated' }
5 | ]
6 | };
7 | const secondResponse = {
8 | items: [
9 | { link: 'id=1', content: 'not updated' },
10 | { link: 'id=2', content: 'not updated' },
11 | { link: 'id=3', content: 'not updated' }
12 | ]
13 | };
14 | const thirdResponse = {
15 | items: [
16 | { link: 'id=1', content: 'updated' },
17 | { link: 'id=2', content: 'not updated' },
18 | { link: 'id=3', content: 'not updated' }
19 | ]
20 | };
21 | const fourthResponse = {
22 | items: [
23 | { link: 'id=1', content: 'updated' },
24 | { link: 'id=2', content: 'not updated' },
25 | { link: 'id=3', content: 'not updated' }
26 | ]
27 | };
28 |
29 | // TODO place this in global config somehwere
30 | // tslint:disable-next-line
31 | console.log = () => {};
32 |
33 | jest.doMock(
34 | 'rss-parser',
35 | () =>
36 | // has to use function keyword to be called with new keyword (ie act as a constructor)
37 | /* tslint:disable-line only-arrow-functions */ function() {
38 | return {
39 | parseURL: jest
40 | .fn()
41 | .mockReturnValueOnce(firstResponse)
42 | .mockReturnValueOnce(secondResponse)
43 | .mockReturnValueOnce(thirdResponse)
44 | .mockReturnValueOnce(fourthResponse)
45 | };
46 | }
47 | );
48 |
49 | import IncidentFeed from '../incidentFeed';
50 | const incidentFeed = new IncidentFeed();
51 |
52 | describe('IncidentFeed', () => {
53 | test('before :update is called, :getFeed returns an empty array', () => {
54 | expect(incidentFeed.getFeed()).toMatchObject([]);
55 | expect(incidentFeed.getFeed()).not.toMatchObject(['some value']);
56 | });
57 | test('first update', async () => {
58 | await incidentFeed.update();
59 | expect(incidentFeed.getFeed()).toBe(firstResponse.items);
60 | expect(incidentFeed.newIncidents()).toMatchObject([]);
61 | expect(incidentFeed.updatedIncidents()).toMatchObject([]);
62 | });
63 | test('second update', async () => {
64 | await incidentFeed.update();
65 | expect(incidentFeed.getFeed()).toBe(secondResponse.items);
66 | expect(incidentFeed.newIncidents()).toMatchObject([
67 | { link: 'id=3', content: 'not updated' }
68 | ]);
69 | expect(incidentFeed.updatedIncidents()).toMatchObject([]);
70 | });
71 | test('third update', async () => {
72 | await incidentFeed.update();
73 | expect(incidentFeed.getFeed()).toBe(thirdResponse.items);
74 | expect(incidentFeed.newIncidents()).toMatchObject([]);
75 | expect(incidentFeed.updatedIncidents()).toMatchObject([
76 | { link: 'id=1', content: 'updated' }
77 | ]);
78 | });
79 | test('fourth update', async () => {
80 | await incidentFeed.update();
81 | expect(incidentFeed.getFeed()).toBe(fourthResponse.items);
82 | expect(incidentFeed.newIncidents()).toMatchObject([]);
83 | expect(incidentFeed.updatedIncidents()).toMatchObject([]);
84 | });
85 | });
86 |
--------------------------------------------------------------------------------
/src/__tests__/menus.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getCheckboxMenu,
3 | getDeploysMenu,
4 | getIncidentsMenu,
5 | getSitesMenu
6 | } from '../menus';
7 | import IncidentFeed from '../incidentFeed';
8 |
9 | describe('menu helper functions', () => {
10 | describe('getCheckboxMenu', () => {
11 | test('should render a correct checkbox settings menu', () => {
12 | const result = getCheckboxMenu({
13 | items: [
14 | { label: 'Launch at start label', key: 'launchAtStart' },
15 | { label: 'Show notifications label', key: 'showNotifications' }
16 | ],
17 | settings: {
18 | updateAutomatically: true,
19 | currentSiteId: 'jooooooo',
20 | launchAtStart: true,
21 | pollInterval: 10000,
22 | showNotifications: false
23 | },
24 |
25 | // tslint:disable-next-line
26 | onItemClick: id => {}
27 | });
28 |
29 | expect(result).toMatchSnapshot();
30 | });
31 | });
32 |
33 | describe('getDeploysMenu', () => {
34 | test('should render a correct deploy menu', () => {
35 | const getToday = () => new Date();
36 | const getYday = () => {
37 | const d = new Date();
38 | d.setDate(d.getDate() - 1);
39 | return d;
40 | };
41 |
42 | const result = getDeploysMenu({
43 | currentSite: {
44 | admin_url: 'https://foo-admin.com',
45 | id: 'foo',
46 | name: 'Foo',
47 | url: 'https://foo.com'
48 | },
49 | deploys: {
50 | pending: [
51 | {
52 | branch: 'master',
53 | context: 'production',
54 | created_at: getToday().toISOString(),
55 | deploy_time: '123',
56 | error_message: '',
57 | id: '2',
58 | state: 'building'
59 | }
60 | ],
61 | ready: [
62 | {
63 | branch: 'master',
64 | context: 'production',
65 | created_at: getYday().toISOString(),
66 | deploy_time: '126',
67 | error_message: '',
68 | id: '1',
69 | state: 'ready'
70 | }
71 | ]
72 | },
73 | // tslint:disable-next-line
74 | onItemClick: id => {}
75 | });
76 |
77 | expect(result).toMatchSnapshot();
78 | });
79 | });
80 |
81 | describe('getSitesMenu', () => {
82 | test('should render a correct sites menu', () => {
83 | const result = getSitesMenu({
84 | currentSite: {
85 | admin_url: 'https://current-admin.com',
86 | id: 'current-id',
87 | name: 'current-name',
88 | url: 'https://current.com'
89 | },
90 | sites: [
91 | {
92 | admin_url: 'https://foo-admin.com',
93 | id: 'foo-id',
94 | name: 'foo-name',
95 | url: 'https://foo.com'
96 | },
97 | {
98 | admin_url: 'https://current-admin.com',
99 | id: 'current-id',
100 | name: 'current-name',
101 | url: 'https://current.com'
102 | },
103 | {
104 | admin_url: 'https://bar-admin.com',
105 | id: 'bar-id',
106 | name: 'bar-name',
107 | url: 'https://bar.com'
108 | }
109 | ],
110 | // tslint:disable-next-line
111 | onItemClick: id => {}
112 | });
113 |
114 | expect(result).toMatchSnapshot();
115 | });
116 | });
117 |
118 | describe('getSitesMenu', () => {
119 | test('should render a correct sites menu', () => {
120 | const result = getIncidentsMenu(({
121 | getFeed: () => [
122 | {
123 | link: 'https://example.com',
124 | title: 'incident 1',
125 | pubDate: '2019-11-02',
126 | content: ''
127 | }
128 | ]
129 | // // tslint:disable-next-line
130 | // onItemClick: id => {}
131 | } as unknown) as IncidentFeed);
132 |
133 | expect(result).toMatchSnapshot();
134 | });
135 | });
136 | });
137 |
--------------------------------------------------------------------------------
/src/__tests__/netlify.test.ts:
--------------------------------------------------------------------------------
1 | import fetch from 'node-fetch';
2 | import Netlify, { API_URL } from '../netlify';
3 |
4 | // TODO place this in global config somehwere
5 | // tslint:disable-next-line
6 | console.log = () => {};
7 |
8 | jest.mock('node-fetch');
9 | jest.mock('electron', () => ({
10 | dialog: {
11 | showMessageBox: jest.fn(options => Promise.resolve({ response: 0 }))
12 | },
13 | shell: {
14 | openExternal: jest.fn()
15 | }
16 | }));
17 |
18 | interface IFetchResponse {
19 | response?: number;
20 | json: () => {};
21 | }
22 |
23 | const getFetchPromise = async (
24 | json: {} = {},
25 | response: {} = {}
26 | ): Promise => {
27 | const result = await {
28 | ...(response && response),
29 | json: () => new Promise(res => res(json))
30 | };
31 | return result;
32 | };
33 |
34 | describe('netlify api client', () => {
35 | const apiToken = 'awesomeToken';
36 | let apiClient: Netlify;
37 | const mFetch = (fetch as unknown) as jest.Mock>;
38 |
39 | beforeEach(() => {
40 | apiClient = new Netlify(apiToken);
41 | mFetch.mockReset();
42 | });
43 |
44 | describe(':fetch', () => {
45 | test('stores accessToken in api client', async () => {
46 | expect(apiClient.accessToken).toBe(apiToken);
47 | });
48 |
49 | test('sets authorization header properly', async () => {
50 | mFetch.mockResolvedValue(getFetchPromise());
51 | await apiClient.fetch('/foo');
52 | expect(mFetch.mock.calls[0][0].endsWith('/foo')).toBeTruthy();
53 | expect(mFetch.mock.calls[0][1].headers.authorization).toBe(
54 | `Bearer ${apiToken}`
55 | );
56 | });
57 |
58 | test("rejects 'NOT_AUTHORIZED' error in case of 401", async () => {
59 | mFetch.mockResolvedValue(
60 | getFetchPromise(
61 | {},
62 | {
63 | status: 401
64 | }
65 | )
66 | );
67 | await expect(apiClient.fetch('/foo')).rejects.toThrow('NOT_AUTHORIZED');
68 | });
69 |
70 | test('returns the correct response', async () => {
71 | const response = { foo: 'bar' };
72 | mFetch.mockResolvedValue(getFetchPromise(response));
73 |
74 | const result = await apiClient.fetch('/foo');
75 | expect(result).toBe(response);
76 | });
77 | });
78 |
79 | describe(':getCurrentUser', () => {
80 | test('calls the correct URL', async () => {
81 | mFetch.mockResolvedValue(getFetchPromise({}));
82 | await apiClient.getCurrentUser();
83 | expect(mFetch.mock.calls[0][0]).toBe(`${API_URL}/user`);
84 | });
85 | });
86 |
87 | describe(':getSites', () => {
88 | test('calls the correct URL', async () => {
89 | mFetch.mockResolvedValue(getFetchPromise({}));
90 | await apiClient.getSites();
91 | expect(mFetch.mock.calls[0][0]).toBe(`${API_URL}/sites`);
92 | });
93 | });
94 |
95 | describe(':getSiteDeploys', () => {
96 | test('calls the correct URL', async () => {
97 | const siteId = '123456789';
98 | mFetch.mockResolvedValue(getFetchPromise({}));
99 | await apiClient.getSiteDeploys(siteId);
100 | expect(mFetch.mock.calls[0][0]).toBe(
101 | `${API_URL}/sites/${siteId}/deploys?page=1&per_page=15`
102 | );
103 | });
104 | });
105 |
106 | describe(':createSiteBuild', () => {
107 | test('calls the correct URL with the correct HTTP method', async () => {
108 | const siteId = '123456789';
109 | mFetch.mockResolvedValue(getFetchPromise({}));
110 | await apiClient.createSiteBuild(siteId);
111 | expect(mFetch.mock.calls[0][0]).toBe(`${API_URL}/sites/${siteId}/builds`);
112 | expect(mFetch.mock.calls[0][1].method).toBe('POST');
113 | });
114 | });
115 |
116 | describe(':authorize', () => {
117 | test('calls the API check validity of the token and return client', async () => {
118 | mFetch.mockResolvedValue(getFetchPromise({}));
119 | const client = await apiClient.authorize('clientId');
120 | expect(mFetch.mock.calls.length).toBe(1);
121 | expect(client).toBe(apiClient);
122 | });
123 |
124 | test('invalid token', async () => {
125 | const newToken = 'yeah-awesome-token';
126 |
127 | mFetch.mockResolvedValueOnce(getFetchPromise({}, { status: 401 }));
128 | mFetch.mockResolvedValueOnce(getFetchPromise({ id: 'ticketId' }));
129 | mFetch.mockResolvedValueOnce(getFetchPromise({ authorized: false }));
130 | mFetch.mockResolvedValueOnce(getFetchPromise({ authorized: true }));
131 | mFetch.mockResolvedValueOnce(getFetchPromise({ access_token: newToken }));
132 |
133 | const client = await apiClient.authorize('clientId2');
134 |
135 | expect(client.accessToken).toBe(newToken);
136 | expect(mFetch.mock.calls.length).toBe(5);
137 | });
138 | });
139 | });
140 |
--------------------------------------------------------------------------------
/src/__tests__/notify.test.ts:
--------------------------------------------------------------------------------
1 | const mockGetSettings = jest.fn();
2 | const mockOn = jest.fn();
3 | const mockShow = jest.fn();
4 | const inMockConstructor = jest.fn();
5 | class Notification {
6 | public on = mockOn;
7 | public show = mockShow;
8 | constructor() {
9 | inMockConstructor();
10 | }
11 | }
12 | /* doMock instead of mock to prevent hoisting above class and const declarations*/
13 | jest.doMock('electron', () => ({
14 | Notification
15 | }));
16 | jest.doMock('electron-settings', () => ({
17 | get: mockGetSettings
18 | }));
19 |
20 | // this import must come after jest.doMock(...
21 | import notify from '../notify';
22 |
23 | describe('notify function', () => {
24 | test('if showNotifications setting is false, does not create a Notification ', () => {
25 | mockGetSettings.mockImplementation(() => false); // getting any setting will return false
26 | notify({ title: 'test title', body: 'test body', onClick: jest.fn() });
27 | expect(mockGetSettings).toHaveBeenCalledWith('showNotifications');
28 | expect(mockGetSettings.mock.results[0].value).toEqual(false);
29 | expect(inMockConstructor).not.toHaveBeenCalled();
30 | });
31 | test('if showNotifications setting is true, it creates a Notification, registers callbacks, calls notifation.show()', () => {
32 | mockGetSettings.mockImplementation(() => true);
33 | notify({ title: 'test title', body: 'test body', onClick: jest.fn() });
34 | expect(inMockConstructor).toHaveBeenCalled();
35 | expect(mockOn).toHaveBeenCalledTimes(2);
36 | expect(mockShow).toHaveBeenCalled();
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/src/__tests__/scheduler.test.ts:
--------------------------------------------------------------------------------
1 | import scheduler from '../scheduler';
2 |
3 | function flushPromises() {
4 | return new Promise(resolve => setImmediate(resolve));
5 | }
6 |
7 | describe('scheduler', () => {
8 | describe(':repeat', () => {
9 | test('repeats function call in set interval and accepts stop/resume', async () => {
10 | jest.useFakeTimers();
11 | const mFn = jest.fn();
12 | mFn.mockImplementation(() => Promise.resolve());
13 |
14 | scheduler.repeat([
15 | {
16 | fn: mFn,
17 | interval: 1000
18 | }
19 | ]);
20 |
21 | expect(mFn).toHaveBeenLastCalledWith({ isFirstRun: true });
22 | expect(mFn.mock.calls.length).toBe(1);
23 |
24 | await flushPromises();
25 | jest.advanceTimersByTime(1000);
26 | expect(mFn).toHaveBeenLastCalledWith({ isFirstRun: false });
27 | expect(mFn.mock.calls.length).toBe(2);
28 |
29 | await flushPromises();
30 | scheduler.stop();
31 | jest.advanceTimersByTime(10000);
32 | expect(mFn.mock.calls.length).toBe(2);
33 |
34 | scheduler.resume();
35 | expect(mFn.mock.calls.length).toBe(3);
36 |
37 | await flushPromises();
38 | jest.advanceTimersByTime(1000);
39 | expect(mFn.mock.calls.length).toBe(4);
40 | });
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | export const POLL_DURATIONS = [
2 | { value: 10000, label: '10sec' },
3 | { value: 30000, label: '30sec' },
4 | { value: 60000, label: '1min' },
5 | { value: 180000, label: '3min' },
6 | { value: 300000, label: '5min' }
7 | ];
8 |
--------------------------------------------------------------------------------
/src/connection.ts:
--------------------------------------------------------------------------------
1 | import { BrowserWindow, ipcMain } from 'electron'; // tslint:disable-line no-implicit-dependencies
2 | import { EventEmitter } from 'events';
3 |
4 | export default class Connection extends EventEmitter {
5 | private status: string;
6 | private statusWindow: BrowserWindow;
7 |
8 | public constructor() {
9 | super();
10 |
11 | this.status = 'PENDING';
12 | this.statusWindow = new BrowserWindow({
13 | height: 0,
14 | show: false,
15 | webPreferences: {
16 | nodeIntegration: true
17 | },
18 | width: 0
19 | });
20 | this.statusWindow.loadURL(`data:text/html;charset=utf-8,
21 |
22 |
23 |
30 |
31 | `);
32 |
33 | ipcMain.on('status-changed', (event, status) => {
34 | this.status = status ? 'ONLINE' : 'OFFLINE';
35 | this.emit('status-changed', this);
36 | });
37 | }
38 |
39 | public get isOnline(): boolean {
40 | return this.status === 'ONLINE';
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/icons.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 |
3 | const getIconPath = (fileName: string): string =>
4 | path.join(__dirname, '..', 'media', fileName);
5 |
6 | export default {
7 | building: getIconPath('buildingTemplate.png'),
8 | enqueued: getIconPath('buildingTemplate.png'),
9 | error: getIconPath('errorTemplate.png'),
10 | loading: getIconPath('loadingTemplate.png'),
11 | new: getIconPath('buildingTemplate.png'),
12 | offline: getIconPath('offlineTemplate.png'),
13 | processing: getIconPath('buildingTemplate.png'),
14 | ready: getIconPath('readyTemplate.png'),
15 | uploaded: getIconPath('buildingTemplate.png'),
16 | uploading: getIconPath('buildingTemplate.png')
17 | };
18 |
--------------------------------------------------------------------------------
/src/incidentFeed.ts:
--------------------------------------------------------------------------------
1 | import Parser from 'rss-parser';
2 |
3 | const FEED_URL = 'https://www.netlifystatus.com/history.rss';
4 |
5 | export interface IFeedItem {
6 | title: string;
7 | pubDate: string;
8 | content: string;
9 | link: string;
10 | }
11 |
12 | export default class IncidentFeed {
13 | private parser: { parseURL(feedUrl: string) };
14 | private currentFeed: IFeedItem[];
15 | private previousFeed: IFeedItem[];
16 |
17 | constructor() {
18 | this.parser = new Parser();
19 | this.currentFeed = [];
20 | this.previousFeed = [];
21 | }
22 |
23 | public async update(): Promise {
24 | console.log('FEED CALL'); // tslint:disable-line no-console
25 | const fetchedFeed: IFeedItem[] = await this.fetchAndParseFeed();
26 | console.log('FEED CALL DONE'); // tslint:disable-line no-console
27 | this.previousFeed = this.currentFeed;
28 | this.currentFeed = fetchedFeed;
29 | }
30 |
31 | public newIncidents(): ReadonlyArray {
32 | if (this.previousFeed.length === 0) {
33 | return [];
34 | }
35 | return this.currentFeed.filter(currentItem => {
36 | return !this.previousFeed.some(previousItem => {
37 | return previousItem.link === currentItem.link;
38 | });
39 | });
40 | }
41 |
42 | public updatedIncidents(): ReadonlyArray {
43 | return this.currentFeed.filter(currentItem => {
44 | return this.previousFeed.find(previousItem => {
45 | return (
46 | previousItem.link === currentItem.link &&
47 | previousItem.content !== currentItem.content
48 | );
49 | });
50 | });
51 | }
52 |
53 | public getFeed(): ReadonlyArray {
54 | return this.currentFeed as ReadonlyArray;
55 | }
56 |
57 | private async fetchAndParseFeed(): Promise {
58 | const response = await this.parser.parseURL(FEED_URL);
59 | return response.items;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import AutoLaunch from 'auto-launch';
2 | import { app, powerMonitor } from 'electron'; // tslint:disable-line no-implicit-dependencies
3 | import isDev from 'electron-is-dev';
4 | import settings from 'electron-settings';
5 | import { autoUpdater } from 'electron-updater';
6 | import Connection from './connection';
7 | import IncidentFeed from './incidentFeed';
8 | import MenuUI from './menubar';
9 | import Netlify from './netlify';
10 |
11 | const OAUTH_CLIENT_ID =
12 | '38a923fc70a44fce96c3ad5abd41c732a3c33d3cdb134c0d32c577181671e077';
13 |
14 | const getNetlifyClient = async (accessToken: string): Promise => {
15 | const apiClient = new Netlify(accessToken);
16 | return apiClient.authorize(OAUTH_CLIENT_ID);
17 | };
18 |
19 | const getOnlineConnection = (): Promise => {
20 | return new Promise((resolve) => {
21 | const connection = new Connection();
22 |
23 | connection.on('status-changed', (conn) => {
24 | if (conn.isOnline) {
25 | resolve(connection);
26 | }
27 | });
28 | });
29 | };
30 |
31 | const configureAutoLauncher = (
32 | autoLauncher: AutoLaunch,
33 | { shouldAutoLaunch }
34 | ): void => {
35 | if (shouldAutoLaunch) {
36 | autoLauncher.enable();
37 | } else {
38 | autoLauncher.disable();
39 | }
40 | };
41 |
42 | /**
43 | *
44 | *
45 | * @returns {Promise}
46 | */
47 | const onAppReady = async (): Promise => {
48 | const connection = await getOnlineConnection();
49 | const incidentFeed = new IncidentFeed();
50 | const apiClient = await getNetlifyClient(
51 | settings.get('accessToken') as string
52 | );
53 |
54 | settings.set('accessToken', apiClient.accessToken);
55 |
56 | if (!isDev) {
57 | const autoLauncher = new AutoLaunch({
58 | name: 'Netlify Menubar',
59 | path: '/Applications/Netlify Menubar.app',
60 | });
61 |
62 | configureAutoLauncher(autoLauncher, {
63 | shouldAutoLaunch: settings.get('launchAtStart'),
64 | });
65 |
66 | settings.watch('launchAtStart', (launchAtStart) => {
67 | configureAutoLauncher(autoLauncher, { shouldAutoLaunch: launchAtStart });
68 | });
69 | }
70 |
71 | const ui = new MenuUI({
72 | apiClient,
73 | connection,
74 | incidentFeed,
75 | });
76 |
77 | // only hide dock icon when everything's running
78 | // otherwise the auth prompt disappears in MacOS
79 | app.dock.hide();
80 |
81 | if (
82 | settings.get('updateAutomatically') ||
83 | // it defaults to true but is not stored initially
84 | settings.get('updateAutomatically') === undefined
85 | ) {
86 | autoUpdater.checkForUpdatesAndNotify();
87 | autoUpdater.on('update-downloaded', () => {
88 | ui.on('ready-to-update', () => autoUpdater.quitAndInstall());
89 | ui.setState({ updateAvailable: true });
90 | });
91 | powerMonitor.on('unlock-screen', () =>
92 | autoUpdater.checkForUpdatesAndNotify()
93 | );
94 | }
95 | };
96 |
97 | export const start = () => {
98 | app.on('ready', onAppReady);
99 | };
100 |
--------------------------------------------------------------------------------
/src/menubar.ts:
--------------------------------------------------------------------------------
1 | import { isToday, isYesterday } from 'date-fns';
2 | import { app, Menu, MenuItemConstructorOptions, shell, Tray } from 'electron'; // tslint:disable-line no-implicit-dependencies
3 | import settings from 'electron-settings';
4 | import { EventEmitter } from 'events';
5 | import { POLL_DURATIONS } from './config';
6 | import Connection from './connection';
7 | import ICONS from './icons';
8 | import IncidentFeed from './incidentFeed';
9 | import {
10 | getCheckboxMenu,
11 | getDeploysMenu,
12 | getIncidentsMenu,
13 | getSitesMenu
14 | } from './menus';
15 | import Netlify, { INetlifyDeploy, INetlifySite, INetlifyUser } from './netlify';
16 | import notify from './notify';
17 | import scheduler from './scheduler';
18 | import {
19 | getFormattedDeploys,
20 | getNotificationOptions,
21 | getSuspendedDeployCount
22 | } from './util';
23 |
24 | interface IJsonObject {
25 | [x: string]: JsonValue;
26 | }
27 |
28 | interface IJsonArray extends Array {} // tslint:disable-line no-empty-interface
29 | type JsonValue = string | number | boolean | null | IJsonArray | IJsonObject;
30 |
31 | export interface IAppSettings {
32 | updateAutomatically: boolean;
33 | launchAtStart: boolean;
34 | pollInterval: number;
35 | showNotifications: boolean;
36 | currentSiteId: string | null;
37 | }
38 |
39 | interface IAppState {
40 | currentSite?: INetlifySite;
41 | menuIsOpen: boolean;
42 | previousDeploy: INetlifyDeploy | null;
43 | updateAvailable: boolean;
44 | }
45 |
46 | export interface IAppDeploys {
47 | pending: INetlifyDeploy[];
48 | ready: INetlifyDeploy[];
49 | }
50 |
51 | interface IAppNetlifyData {
52 | deploys: IAppDeploys;
53 | sites: INetlifySite[];
54 | user?: INetlifyUser;
55 | }
56 |
57 | const DEFAULT_SETTINGS: IAppSettings = {
58 | currentSiteId: null,
59 | launchAtStart: false,
60 | pollInterval: 10000,
61 | showNotifications: false,
62 | updateAutomatically: true
63 | };
64 |
65 | export default class UI extends EventEmitter {
66 | private apiClient: Netlify;
67 | private incidentFeed: IncidentFeed;
68 | private connection: Connection;
69 | private state: IAppState;
70 | private tray: Tray;
71 | private settings: IAppSettings;
72 | private netlifyData: IAppNetlifyData;
73 |
74 | public constructor({
75 | apiClient,
76 | connection,
77 | incidentFeed
78 | }: {
79 | apiClient: Netlify;
80 | connection: Connection;
81 | incidentFeed: IncidentFeed;
82 | }) {
83 | super();
84 |
85 | this.incidentFeed = incidentFeed;
86 | this.tray = new Tray(ICONS.loading);
87 | this.apiClient = apiClient;
88 | this.connection = connection;
89 |
90 | this.settings = {
91 | ...DEFAULT_SETTINGS,
92 | ...(settings.getAll() as {})
93 | };
94 |
95 | this.netlifyData = {
96 | deploys: { pending: [], ready: [] },
97 | sites: []
98 | };
99 |
100 | this.state = {
101 | menuIsOpen: false,
102 | previousDeploy: null,
103 | updateAvailable: false
104 | };
105 |
106 | this.setup().then(() => this.setupScheduler());
107 | }
108 |
109 | public setState(state: Partial) {
110 | this.state = { ...this.state, ...state };
111 | this.render();
112 | }
113 |
114 | private async setup(): Promise {
115 | await this.fetchData(async () => {
116 | if (!this.settings.currentSiteId) {
117 | this.settings.currentSiteId = await this.getFallbackSiteId();
118 | }
119 |
120 | const [currentUser, sites, deploys] = await Promise.all([
121 | this.apiClient.getCurrentUser(),
122 | this.apiClient.getSites(),
123 | this.apiClient.getSiteDeploys(this.settings.currentSiteId)
124 | ]);
125 |
126 | this.netlifyData = {
127 | deploys: getFormattedDeploys(deploys),
128 | sites,
129 | user: {
130 | email: currentUser.email
131 | }
132 | };
133 |
134 | this.state.currentSite = this.getSite(this.settings.currentSiteId);
135 | });
136 | }
137 |
138 | private async setupScheduler(): Promise {
139 | scheduler.repeat([
140 | {
141 | fn: async () => {
142 | await this.updateDeploys();
143 | },
144 | interval: this.settings.pollInterval
145 | },
146 | {
147 | fn: async ({ isFirstRun }) => {
148 | await this.updateFeed();
149 | if (isFirstRun) {
150 | this.notifyForIncidentsPastTwoDays();
151 | } else {
152 | this.notifyForNewAndUpdatedIncidents();
153 | }
154 | },
155 | // going with a minute for now
156 | interval: 60000
157 | }
158 | ]);
159 |
160 | this.connection.on('status-changed', connection => {
161 | if (connection.isOnline) {
162 | scheduler.resume();
163 | } else {
164 | scheduler.stop();
165 | console.error('Currently offline, unable to get updates...'); // tslint:disable-line no-console
166 | this.tray.setImage(ICONS.offline);
167 | }
168 | });
169 | }
170 |
171 | private getSite(siteId: string): INetlifySite {
172 | return (
173 | this.netlifyData.sites.find(({ id }) => id === siteId) ||
174 | this.netlifyData.sites[0]
175 | );
176 | }
177 |
178 | private async getFallbackSiteId(): Promise {
179 | const sites = await this.apiClient.getSites();
180 | return sites[0].id;
181 | }
182 |
183 | private async fetchData(fn: () => void): Promise {
184 | if (this.connection.isOnline) {
185 | this.tray.setImage(ICONS.loading);
186 | // catch possible network hickups
187 | try {
188 | await fn();
189 | this.evaluateDeployState();
190 | if (this.state.previousDeploy) {
191 | this.tray.setImage(ICONS[this.state.previousDeploy.state]);
192 | }
193 | } catch (e) {
194 | console.error(e); // tslint:disable-line no-console
195 | this.tray.setImage(ICONS.offline);
196 | }
197 | }
198 |
199 | this.render();
200 | }
201 |
202 | private updateFeed(): Promise {
203 | return this.fetchData(async () => {
204 | await this.incidentFeed.update();
205 | });
206 | }
207 |
208 | private updateDeploys(): Promise {
209 | return this.fetchData(async () => {
210 | if (this.settings.currentSiteId) {
211 | const deploys = await this.apiClient.getSiteDeploys(
212 | this.settings.currentSiteId
213 | );
214 |
215 | this.netlifyData.deploys = getFormattedDeploys(deploys);
216 | }
217 | });
218 | }
219 |
220 | private notifyForIncidentsPastTwoDays(): void {
221 | const recentIncidents = this.incidentFeed.getFeed().filter(item => {
222 | const publicationDate = new Date(item.pubDate);
223 | return isToday(publicationDate) || isYesterday(publicationDate);
224 | });
225 | if (recentIncidents.length) {
226 | this.notifyIncident(recentIncidents[0], 'Recently reported incident');
227 | }
228 | }
229 |
230 | private notifyForNewAndUpdatedIncidents(): void {
231 | const newIncidents = this.incidentFeed.newIncidents();
232 | const updatedIncidents = this.incidentFeed.updatedIncidents();
233 | if (newIncidents.length) {
234 | this.notifyIncident(newIncidents[0], 'New incident reported');
235 | }
236 | if (updatedIncidents.length) {
237 | this.notifyIncident(updatedIncidents[0], 'Incident report updated');
238 | }
239 | }
240 |
241 | private notifyIncident(
242 | incident: { title: string; link: string },
243 | title: string
244 | ): void {
245 | notify({
246 | body: incident.title,
247 | onClick: () => {
248 | shell.openExternal(incident.link);
249 | },
250 | title
251 | });
252 | }
253 |
254 | private evaluateDeployState(): void {
255 | const { deploys } = this.netlifyData;
256 | const { previousDeploy, currentSite } = this.state;
257 |
258 | let currentDeploy: INetlifyDeploy | null = null;
259 |
260 | if (deploys.pending.length) {
261 | currentDeploy = deploys.pending[deploys.pending.length - 1];
262 | } else if (deploys.ready.length) {
263 | currentDeploy = deploys.ready[0];
264 | }
265 |
266 | // cover edge case for new users
267 | // who don't have any deploys yet
268 | if (currentDeploy === null) {
269 | return;
270 | }
271 |
272 | if (previousDeploy) {
273 | const notificationOptions = getNotificationOptions(
274 | previousDeploy,
275 | currentDeploy
276 | );
277 |
278 | if (notificationOptions) {
279 | notify({
280 | ...notificationOptions,
281 | onClick: () => {
282 | if (currentSite && currentDeploy) {
283 | shell.openExternal(
284 | `https://app.netlify.com/sites/${currentSite.name}/deploys/${
285 | currentDeploy.id
286 | }`
287 | );
288 | }
289 | }
290 | });
291 | }
292 | }
293 |
294 | this.state.previousDeploy = currentDeploy;
295 | }
296 |
297 | private saveSetting(key: string, value: JsonValue): void {
298 | settings.set(key, value);
299 | this.settings[key] = value;
300 | this.render();
301 | }
302 |
303 | private async render(): Promise {
304 | if (!this.state.currentSite) {
305 | console.error('No current site found'); // tslint:disable-line no-console
306 | return;
307 | }
308 |
309 | this.tray.setTitle(
310 | getSuspendedDeployCount(this.netlifyData.deploys.pending.length)
311 | );
312 |
313 | this.renderMenu(this.state.currentSite);
314 | }
315 |
316 | private async renderMenu(currentSite: INetlifySite): Promise {
317 | if (!this.connection.isOnline) {
318 | return this.tray.setContextMenu(
319 | Menu.buildFromTemplate([
320 | {
321 | enabled: false,
322 | label: "Looks like you're offline..."
323 | }
324 | ])
325 | );
326 | }
327 |
328 | const { sites, deploys, user } = this.netlifyData;
329 | const { pollInterval } = this.settings;
330 |
331 | const menu = Menu.buildFromTemplate([
332 | {
333 | enabled: false,
334 | label: `Netlify Menubar ${app.getVersion()}`
335 | },
336 | { type: 'separator' },
337 | {
338 | label: 'Reported Incidents',
339 | submenu: getIncidentsMenu(this.incidentFeed)
340 | },
341 | { type: 'separator' },
342 | {
343 | enabled: false,
344 | label: user && user.email
345 | },
346 | { type: 'separator' },
347 | {
348 | label: 'Choose site:',
349 | submenu: getSitesMenu({
350 | currentSite,
351 | onItemClick: siteId => {
352 | this.saveSetting('currentSiteId', siteId);
353 | this.state.previousDeploy = null;
354 | this.state.currentSite = this.getSite(siteId);
355 | this.updateDeploys();
356 | },
357 | sites
358 | })
359 | },
360 | { type: 'separator' },
361 | {
362 | enabled: false,
363 | label: `${currentSite.url.replace(/https?:\/\//, '')}`
364 | },
365 | {
366 | click: () => shell.openExternal(currentSite.url),
367 | label: 'Go to Site'
368 | },
369 | {
370 | click: () => shell.openExternal(currentSite.admin_url),
371 | label: 'Go to Admin'
372 | },
373 | {
374 | enabled: false,
375 | label: '—'
376 | },
377 | {
378 | label: 'Deploys',
379 | submenu: getDeploysMenu({
380 | currentSite,
381 | deploys,
382 | onItemClick: deployId =>
383 | shell.openExternal(
384 | `https://app.netlify.com/sites/${
385 | currentSite.name
386 | }/deploys/${deployId}`
387 | )
388 | })
389 | },
390 | {
391 | click: async () => {
392 | this.fetchData(async () => {
393 | await this.apiClient.createSiteBuild(currentSite.id);
394 | this.updateDeploys();
395 | });
396 | },
397 | label: 'Trigger new deploy'
398 | },
399 | { type: 'separator' },
400 | {
401 | label: 'Settings',
402 | submenu: [
403 | ...getCheckboxMenu({
404 | items: [
405 | {
406 | key: 'updateAutomatically',
407 | label: 'Receive automatic updates'
408 | },
409 | { key: 'launchAtStart', label: 'Launch at Start' },
410 | { key: 'showNotifications', label: 'Show notifications' }
411 | ],
412 | onItemClick: (key, value) => this.saveSetting(key, !value),
413 | settings: this.settings
414 | }),
415 | {
416 | label: 'Poll interval',
417 | submenu: POLL_DURATIONS.map(
418 | ({ label, value }): MenuItemConstructorOptions => ({
419 | checked: pollInterval === value,
420 | click: () => this.saveSetting('pollInterval', value),
421 | label,
422 | type: 'radio'
423 | })
424 | )
425 | }
426 | ]
427 | },
428 | { type: 'separator' },
429 | {
430 | click: () =>
431 | shell.openExternal(
432 | `https://github.com/stefanjudis/netlify-menubar/releases/tag/v${app.getVersion()}`
433 | ),
434 | label: 'Changelog'
435 | },
436 | {
437 | click: () =>
438 | shell.openExternal(
439 | 'https://github.com/stefanjudis/netlify-menubar/issues/new'
440 | ),
441 | label: 'Give feedback'
442 | },
443 | { type: 'separator' },
444 | {
445 | click: () => {
446 | settings.deleteAll();
447 | app.exit();
448 | },
449 | label: 'Logout'
450 | },
451 | { type: 'separator' },
452 | ...(this.state.updateAvailable
453 | ? [
454 | {
455 | click: () => this.emit('ready-to-update'),
456 | label: 'Restart and update...'
457 | }
458 | ]
459 | : []),
460 | { label: 'Quit Netlify Menubar', role: 'quit' }
461 | ]);
462 |
463 | menu.on('menu-will-show', () => (this.state.menuIsOpen = true));
464 | menu.on('menu-will-close', () => {
465 | this.state.menuIsOpen = false;
466 | // queue it behind other event handlers because otherwise
467 | // the menu-rerender will cancel ongoing click handlers
468 | setImmediate(() => this.render());
469 | });
470 |
471 | // avoid the menu to close in case the user has it open
472 | if (!this.state.menuIsOpen) {
473 | // tslint:disable-next-line
474 | console.log('UI: rerending menu');
475 | this.tray.setContextMenu(menu);
476 | }
477 | }
478 | }
479 |
--------------------------------------------------------------------------------
/src/menus.ts:
--------------------------------------------------------------------------------
1 | import { distanceInWords, isWithinRange, subMonths } from 'date-fns';
2 | import { MenuItemConstructorOptions, shell } from 'electron'; // tslint:disable-line no-implicit-dependencies
3 | import IncidentFeed, { IFeedItem } from './incidentFeed';
4 | import { IAppDeploys, IAppSettings } from './menubar';
5 | import { INetlifySite } from './netlify';
6 | import { shortenString } from './util';
7 |
8 | interface IDeployMenuOptions {
9 | currentSite: INetlifySite;
10 | deploys: IAppDeploys;
11 | onItemClick: (deployId: string) => void;
12 | }
13 |
14 | const isOlderThanAMonth = (incident: IFeedItem): boolean => {
15 | const pubDate = new Date(incident.pubDate);
16 | const today = new Date();
17 | const aMonthAgo = subMonths(today, 1);
18 | return isWithinRange(pubDate, aMonthAgo, today);
19 | };
20 |
21 | const SEPARATOR: MenuItemConstructorOptions = { type: 'separator' };
22 |
23 | export const getIncidentsMenu = (
24 | incidentFeed: IncidentFeed
25 | ): MenuItemConstructorOptions[] => {
26 | const recentIncidents = incidentFeed
27 | .getFeed()
28 | .filter(isOlderThanAMonth)
29 | // create menu option objects from incidents
30 | .map(incident => {
31 | return {
32 | click: () => shell.openExternal(incident.link),
33 | label: `${shortenString(incident.title, 60)} (${distanceInWords(
34 | new Date(incident.pubDate),
35 | new Date()
36 | )} ago)`
37 | };
38 | });
39 | // if there are no recent incidents, replace with message
40 | const renderedItems = recentIncidents.length
41 | ? recentIncidents
42 | : [
43 | {
44 | enabled: false,
45 | label: 'no recent incidents'
46 | }
47 | ];
48 | return [
49 | {
50 | click: () => shell.openExternal('https://www.netlifystatus.com/history'),
51 | label: 'History'
52 | },
53 | SEPARATOR,
54 | ...renderedItems
55 | ];
56 | };
57 |
58 | export const getDeploysMenu = ({
59 | currentSite,
60 | deploys,
61 | onItemClick
62 | }: IDeployMenuOptions): MenuItemConstructorOptions[] => {
63 | const { pending: pendingDeploys, ready: doneDeploys } = deploys;
64 | const mapDeployToMenuItem = ({
65 | context,
66 | created_at,
67 | state,
68 | branch,
69 | deploy_time,
70 | id
71 | }) => {
72 | return {
73 | click: () => onItemClick(id),
74 | label: `${context}: ${state} → ${branch} (${distanceInWords(
75 | new Date(created_at),
76 | new Date()
77 | )} ago ${deploy_time ? `in ${deploy_time}s` : ''})`
78 | };
79 | };
80 |
81 | return [
82 | {
83 | click: () => shell.openExternal(`${currentSite.admin_url}/deploys`),
84 | label: 'Overview'
85 | },
86 | SEPARATOR,
87 | ...pendingDeploys.map(mapDeployToMenuItem),
88 | ...(pendingDeploys.length ? [{ label: '—', enabled: false }] : []),
89 | ...doneDeploys.map(mapDeployToMenuItem)
90 | ].slice(0, 20);
91 | };
92 |
93 | interface ISiteMenuOptions {
94 | sites: INetlifySite[];
95 | currentSite: INetlifySite;
96 | onItemClick: (siteId: string) => void;
97 | }
98 |
99 | export const getSitesMenu = ({
100 | sites,
101 | currentSite,
102 | onItemClick
103 | }: ISiteMenuOptions): MenuItemConstructorOptions[] => {
104 | return sites.map(
105 | ({ id, url }): MenuItemConstructorOptions => ({
106 | checked: currentSite.id === id,
107 | click: () => onItemClick(id),
108 | label: `${url.replace(/https?:\/\//, '')}`,
109 | type: 'radio'
110 | })
111 | );
112 | };
113 |
114 | interface ICheckboxMenuOptions {
115 | items: Array<{ key: string; label: string }>;
116 | settings: IAppSettings;
117 | onItemClick: (key: string, value: boolean) => void;
118 | }
119 |
120 | export const getCheckboxMenu = ({
121 | items,
122 | settings,
123 | onItemClick
124 | }: ICheckboxMenuOptions): MenuItemConstructorOptions[] => {
125 | return items.map(
126 | ({ label, key }): MenuItemConstructorOptions => ({
127 | checked: settings[key],
128 | click: () => onItemClick(key, settings[key]),
129 | label,
130 | type: 'checkbox'
131 | })
132 | );
133 | };
134 |
--------------------------------------------------------------------------------
/src/netlify.ts:
--------------------------------------------------------------------------------
1 | import { dialog, MessageBoxReturnValue, shell } from 'electron'; // tslint:disable-line no-implicit-dependencies
2 | import fetch from 'node-fetch';
3 |
4 | const showMessageBox = (options: any): Promise =>
5 | dialog.showMessageBox(options);
6 |
7 | interface INetlifyTicket {
8 | id: string;
9 | client_id: string;
10 | authorized: boolean;
11 | }
12 |
13 | interface INetlifyBuild {
14 | id: string;
15 | deploy_id: string;
16 | sha: string;
17 | done: boolean;
18 | error: string;
19 | }
20 |
21 | export interface INetlifyDeploy {
22 | context: string;
23 | created_at: string;
24 | state: string;
25 | branch: string;
26 | deploy_time: string;
27 | error_message: string;
28 | id: string;
29 | }
30 |
31 | export interface INetlifySite {
32 | id: string;
33 | name: string;
34 | url: string;
35 | admin_url: string;
36 | }
37 |
38 | export interface INetlifyUser {
39 | email: string;
40 | }
41 |
42 | interface INetlifyAccessToken {
43 | id: string;
44 | access_token: string;
45 | user_id: string;
46 | user_email: string;
47 | }
48 |
49 | export const API_URL = 'https://api.netlify.com/api/v1';
50 | export const UI_URL = 'https://app.netlify.com';
51 |
52 | class Netlify {
53 | public accessToken: string | null;
54 | private API_URL: string;
55 | private UI_URL: string;
56 |
57 | constructor(accessToken: string) {
58 | this.accessToken = accessToken;
59 | this.API_URL = API_URL;
60 | this.UI_URL = UI_URL;
61 | }
62 |
63 | public async authorize(clientId: string): Promise {
64 | if (this.accessToken) {
65 | try {
66 | await this.getCurrentUser();
67 | return this;
68 | } catch (e) {
69 | // fetching the current user failed
70 | // meaning that the access token is not valid
71 | // -> clear access token and issue a new one
72 | this.accessToken = null;
73 | return this.authorize(clientId);
74 | }
75 | }
76 |
77 | const messageBoxReturn = await showMessageBox({
78 | buttons: ['Open Netlify', 'Cancel'],
79 | cancelId: 1,
80 | defaultId: 0,
81 | message:
82 | "In order to access your build information Netlify Menubar has to be authorized in the Netlify UI.\n\n This is a mandatory step and without it the App won't work...",
83 | title: 'Authorize Netlify Menubar',
84 | type: 'question'
85 | });
86 |
87 | if (messageBoxReturn.response !== 0) {
88 | return process.exit();
89 | }
90 |
91 | const ticket = await this.fetch(
92 | `/oauth/tickets?client_id=${clientId}`,
93 | 'POST'
94 | );
95 |
96 | shell.openExternal(
97 | `${this.UI_URL}/authorize?response_type=ticket&ticket=${ticket.id}`
98 | );
99 |
100 | this.accessToken = await this.getAccessToken(ticket.id);
101 |
102 | return this;
103 | }
104 |
105 | /**
106 | * @param {string} siteId
107 | * @returns {Promise}
108 | * @memberof Netlify
109 | * @tested
110 | */
111 | public createSiteBuild(siteId: string): Promise {
112 | return this.fetch(`/sites/${siteId}/builds`, 'POST');
113 | }
114 |
115 | /**
116 | * @returns {Promise}
117 | * @memberof Netlify
118 | * @tested
119 | */
120 | public getCurrentUser(): Promise {
121 | return this.fetch('/user');
122 | }
123 |
124 | /**
125 | * @returns {Promise}
126 | * @memberof Netlify
127 | * @tested
128 | */
129 | public getSites(): Promise {
130 | return this.fetch('/sites');
131 | }
132 |
133 | /**
134 | * @param {string} siteId
135 | * @returns {Promise}
136 | * @memberof Netlify
137 | * @tested
138 | */
139 | public getSiteDeploys(siteId: string): Promise {
140 | // paginate the deploys to not generate too much load on netlify's side
141 | // https://github.com/stefanjudis/netlify-menubar/issues/20
142 | return this.fetch(
143 | `/sites/${siteId}/deploys?page=1&per_page=15`
144 | );
145 | }
146 |
147 | /**
148 | * @template T
149 | * @param {string} path
150 | * @param {string} [method='GET']
151 | * @returns {Promise}
152 | * @memberof Netlify
153 | * @tested
154 | */
155 | public async fetch(path: string, method: string = 'GET'): Promise {
156 | // tslint:disable-next-line
157 | console.log('NETLIFY CALL:', path, method);
158 | const response = await fetch(`${this.API_URL}${path}`, {
159 | headers: {
160 | authorization: `Bearer ${this.accessToken}`
161 | },
162 | method
163 | });
164 |
165 | if (response.status === 401) {
166 | throw new Error('NOT_AUTHORIZED');
167 | }
168 |
169 | // tslint:disable-next-line
170 | console.log('NETLIFY CALL DONE:', path, method);
171 | return response.json();
172 | }
173 |
174 | private async getAccessToken(ticketId: string): Promise {
175 | const waitFor = (delay: number): Promise =>
176 | new Promise(resolve => setTimeout(resolve, delay));
177 |
178 | const checkTicket = async (): Promise => {
179 | return this.fetch(`/oauth/tickets/${ticketId}`);
180 | };
181 |
182 | let authorizedTicket: INetlifyTicket | null = null;
183 |
184 | while (!authorizedTicket) {
185 | const ticket = await checkTicket();
186 | if (ticket.authorized) {
187 | authorizedTicket = ticket;
188 | }
189 | await waitFor(1000);
190 | }
191 |
192 | const response = await this.fetch(
193 | `/oauth/tickets/${ticketId}/exchange`,
194 | 'POST'
195 | );
196 | return response.access_token;
197 | }
198 | }
199 |
200 | export default Netlify;
201 |
--------------------------------------------------------------------------------
/src/notify.ts:
--------------------------------------------------------------------------------
1 | import { Notification, NotificationConstructorOptions } from 'electron'; // tslint:disable-line no-implicit-dependencies
2 | import settings from 'electron-settings';
3 |
4 | interface INotificationOptions extends NotificationConstructorOptions {
5 | onClick: () => void;
6 | }
7 |
8 | const notify = (options: INotificationOptions): void => {
9 | if (settings.get('showNotifications')) {
10 | const notification = new Notification(options);
11 | notification.on('click', options.onClick);
12 | // notifications with an attached click handler
13 | // won't disappear by itself
14 | // -> close it after certain timeframe automatically
15 | notification.on('show', () => setTimeout(() => notification.close(), 4000));
16 | notification.show();
17 | }
18 | };
19 |
20 | export default notify;
21 |
--------------------------------------------------------------------------------
/src/scheduler.ts:
--------------------------------------------------------------------------------
1 | interface IRun {
2 | fn: (options: any) => Promise;
3 | interval: number;
4 | timeout?: NodeJS.Timeout | null;
5 | }
6 |
7 | let queue: IRun[] = [];
8 | const errors: Error[] = [];
9 |
10 | const repeat = (fns: IRun[]): void => {
11 | fns.forEach(async run => {
12 | queue.push(run);
13 | const { fn, interval } = run;
14 | await fn({ isFirstRun: true });
15 |
16 | const repeatFn = () => {
17 | run.timeout = setTimeout(async () => {
18 | try {
19 | await fn({ isFirstRun: false });
20 | } catch (e) {
21 | errors.push(e);
22 | } finally {
23 | repeatFn();
24 | }
25 | }, interval);
26 | };
27 |
28 | repeatFn();
29 | });
30 | };
31 |
32 | const stop = (): void => {
33 | queue.forEach(run => {
34 | if (run.timeout) {
35 | clearTimeout(run.timeout);
36 | run.timeout = null;
37 | }
38 | });
39 | };
40 |
41 | const resume = (): void => {
42 | const tmpQueue = [...queue];
43 | queue = [];
44 | repeat(tmpQueue);
45 | };
46 |
47 | export default {
48 | repeat,
49 | resume,
50 | stop
51 | };
52 |
--------------------------------------------------------------------------------
/src/util.test.ts:
--------------------------------------------------------------------------------
1 | import { INetlifyDeploy } from './netlify';
2 | import {
3 | getFormattedDeploys,
4 | getNotificationOptions,
5 | getSuspendedDeployCount
6 | } from './util';
7 |
8 | const getDeploy = (deploy: any): INetlifyDeploy => ({
9 | ...{
10 | branch: 'master',
11 | context: '...',
12 | created_at: 'createdAt',
13 | deploy_time: 'deploytime',
14 | error_message: '',
15 | id: '123',
16 | state: 'pending'
17 | },
18 | ...deploy
19 | });
20 |
21 | describe('utils', () => {
22 | describe(':getNotificationOptions', () => {
23 | test('returns correct notification for different deploys', () => {
24 | expect.assertions(2);
25 | const previousDeploy = getDeploy({ id: '123' });
26 | const currentDeploy = getDeploy({ id: '234', state: 'skipped' });
27 |
28 | const notificationOptions = getNotificationOptions(
29 | previousDeploy,
30 | currentDeploy
31 | );
32 |
33 | if (notificationOptions) {
34 | expect(notificationOptions.title).toBe('New deploy started');
35 | expect(notificationOptions.body).toBe('New deploy state: skipped');
36 | }
37 | });
38 |
39 | test('returns correct notification for same deploys but with different state', () => {
40 | expect.assertions(2);
41 | const previousDeploy = getDeploy({ id: '123', state: 'pending' });
42 | const currentDeploy = getDeploy({ id: '1123', state: 'ready' });
43 |
44 | const notificationOptions = getNotificationOptions(
45 | previousDeploy,
46 | currentDeploy
47 | );
48 |
49 | if (notificationOptions) {
50 | expect(notificationOptions.title).toBe('New deploy started');
51 | expect(notificationOptions.body).toBe('New deploy state: ready');
52 | }
53 | });
54 |
55 | test('returns correct notification for different deploys', () => {
56 | const previousDeploy = getDeploy({ id: '123', state: 'same' });
57 | const currentDeploy = getDeploy({ id: '123', state: 'same' });
58 |
59 | const notificationOptions = getNotificationOptions(
60 | previousDeploy,
61 | currentDeploy
62 | );
63 |
64 | expect(notificationOptions).toBeNull();
65 | });
66 | });
67 | describe(':getFormattedDeploys', () => {
68 | test('groups deploys correctly', () => {
69 | const deploys = [
70 | getDeploy({ state: 'new', error_message: null, id: '1' }),
71 | getDeploy({ state: 'building', error_message: null, id: '2' }),
72 | getDeploy({ state: 'error', error_message: '', id: '3' }),
73 | getDeploy({ state: 'error', error_message: 'Skipped', id: '4' }),
74 | getDeploy({ state: 'ready', error_message: null, id: '5' }),
75 | getDeploy({ state: 'ready', error_message: null, id: '6' })
76 | ];
77 |
78 | const {
79 | pending: pendingDeploys,
80 | ready: readyDeploys
81 | } = getFormattedDeploys(deploys);
82 | expect(pendingDeploys.length).toBe(2);
83 | expect(readyDeploys.length).toBe(4);
84 | });
85 | });
86 |
87 | describe(':getSuspendedDeployCount', () => {
88 | test('returns correct string if in range', () => {
89 | const suspendedCount = getSuspendedDeployCount(5);
90 |
91 | expect(suspendedCount).toBe('₅');
92 | });
93 |
94 | test('returns correct string if out of range', () => {
95 | const suspendedCount = getSuspendedDeployCount(11);
96 |
97 | expect(suspendedCount).toBe('₉₊');
98 | });
99 |
100 | test('returns empty string if number of builds is 0', () => {
101 | const suspendedCount = getSuspendedDeployCount(0);
102 |
103 | expect(suspendedCount).toBe('');
104 | });
105 | });
106 | });
107 |
--------------------------------------------------------------------------------
/src/util.ts:
--------------------------------------------------------------------------------
1 | import { NotificationConstructorOptions } from 'electron'; // tslint:disable-line no-implicit-dependencies
2 | import { IAppDeploys } from './menubar';
3 | import { INetlifyDeploy } from './netlify';
4 |
5 | interface IDeploysReduceAcc {
6 | pending: INetlifyDeploy[];
7 | ready: INetlifyDeploy[];
8 | foundReadyDeploy: boolean;
9 | }
10 |
11 | const isReady = (deploy: INetlifyDeploy) => deploy.state === 'ready';
12 | const isError = (deploy: INetlifyDeploy) => deploy.state === 'error';
13 | const isSkipped = (deploy: INetlifyDeploy) =>
14 | deploy.state === 'error' && deploy.error_message === 'Skipped';
15 | const isDifferentDeploy = (prev: INetlifyDeploy, current: INetlifyDeploy) =>
16 | prev.id !== current.id;
17 | const isDifferentDeployState = (
18 | prev: INetlifyDeploy,
19 | current: INetlifyDeploy
20 | ) => prev.state !== current.state;
21 |
22 | /**
23 | *
24 | * @param previous {INetlifyDeploy}
25 | * @param current {INetlifyDeploy}
26 | * @returns INotification | null
27 | * @tested
28 | */
29 | export const getNotificationOptions = (
30 | previous: INetlifyDeploy,
31 | current: INetlifyDeploy
32 | ): NotificationConstructorOptions | null => {
33 | if (isDifferentDeploy(previous, current)) {
34 | return {
35 | body: `New deploy state: ${current.state}`,
36 | title: 'New deploy started'
37 | };
38 | } else if (isDifferentDeployState(previous, current)) {
39 | return {
40 | body: `Deploy state: ${current.state}`,
41 | title: 'Deploy progressed'
42 | };
43 | }
44 |
45 | return null;
46 | };
47 |
48 | /**
49 | *
50 | * @param deploys {INetlifyDeploy[]}
51 | * @returns IAppDeploys
52 | * @tested
53 | */
54 | export const getFormattedDeploys = (deploys: INetlifyDeploy[]): IAppDeploys => {
55 | const formattedDeploys = deploys.reduce(
56 | (acc: IDeploysReduceAcc, deploy: INetlifyDeploy): IDeploysReduceAcc => {
57 | if (
58 | !acc.foundReadyDeploy &&
59 | (isReady(deploy) || isError(deploy) || isSkipped(deploy))
60 | ) {
61 | acc.foundReadyDeploy = true;
62 | }
63 |
64 | if (acc.foundReadyDeploy) {
65 | acc.ready.push(deploy);
66 | } else {
67 | acc.pending.push(deploy);
68 | }
69 |
70 | if (deploy.state === 'error' && deploy.error_message === 'Skipped') {
71 | deploy.state = 'skipped';
72 | }
73 |
74 | return acc;
75 | },
76 | { pending: [], ready: [], foundReadyDeploy: false }
77 | );
78 |
79 | return {
80 | pending: formattedDeploys.pending,
81 | ready: formattedDeploys.ready
82 | };
83 | };
84 |
85 | /**
86 | *
87 | * @param deployCount
88 | * @tested
89 | */
90 | export const getSuspendedDeployCount = (deployCount: number): string => {
91 | if (deployCount > 0) {
92 | return `${
93 | deployCount > 9
94 | ? String.fromCharCode(8329) + '₊'
95 | : String.fromCharCode(8320 + deployCount)
96 | }`;
97 | } else {
98 | return '';
99 | }
100 | };
101 |
102 | // shorten strings without splitting the last word
103 | export const shortenString = (str: string, maxLen: number) => {
104 | if (str.length <= maxLen) {
105 | return str;
106 | }
107 | return str.substr(0, str.lastIndexOf(' ', maxLen));
108 | };
109 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Basic Options */
4 | "target": "ES2018" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */,
5 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
6 | // "lib": [], /* Specify library files to be included in the compilation. */
7 | // "allowJs": true, /* Allow javascript files to be compiled. */
8 | // "checkJs": true, /* Report errors in .js files. */
9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
10 | // "declaration": true, /* Generates corresponding '.d.ts' file. */
11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
12 | // "sourceMap": true, /* Generates corresponding '.map' file. */
13 | // "outFile": "./", /* Concatenate and emit output to single file. */
14 | "outDir": "./dist" /* Redirect output structure to the directory. */,
15 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
16 | // "composite": true, /* Enable project compilation */
17 | // "removeComments": true, /* Do not emit comments to output. */
18 | // "noEmit": true, /* Do not emit outputs. */
19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
22 |
23 | /* Strict Type-Checking Options */
24 | "strict": true /* Enable all strict type-checking options. */,
25 | "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */,
26 | // "strictNullChecks": true, /* Enable strict null checks. */
27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
28 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
29 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
30 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
31 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
32 |
33 | /* Additional Checks */
34 | // "noUnusedLocals": true, /* Report errors on unused locals. */
35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
37 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
38 |
39 | /* Module Resolution Options */
40 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
41 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
44 | "typeRoots": [
45 | "./node_modules/@types"
46 | ] /* List of folders to include type definitions from. */,
47 | // "types": [], /* Type declaration files to be included in compilation. */
48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
51 |
52 | /* Source Map Options */
53 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
55 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
56 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
57 |
58 | /* Experimental Options */
59 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
60 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
61 | },
62 | "exclude": ["node_modules", "typings"]
63 | }
64 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "defaultSeverity": "error",
3 | "extends": ["tslint:latest", "tslint-config-prettier"],
4 | "jsRules": {},
5 | "rules": {
6 | "interface-name": [true, "always-prefix"]
7 | },
8 | "rulesDirectory": []
9 | }
10 |
--------------------------------------------------------------------------------