Repository: shlinkio/shlink-web-client (ts)

Issue:
Make PWA update pop-up allow to refresh to get new version
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
-->

Currently, the PWA detects when a new version is downloaded, and displays a pop-up telling the user to reopen all tabs in order to get it.

Improve this behavior so that the pop-up itself has a CTA to refresh and get the new version.

Pull request description:
Feature/auto pwa restart
Closes #449 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8bce85c1..2c111486 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 * [#431](https://github.com/shlinkio/shlink-web-client/pull/431) Added support to filter out visits from potential bots in visits sections, when consuming Shlink >=2.7.
 * [#430](https://github.com/shlinkio/shlink-web-client/pull/430) Added support to set new and existing short URLs as crawlable, when consuming Shlink >=2.7.
 * [#450](https://github.com/shlinkio/shlink-web-client/pull/450) Improved landing page design.
+* [#449](https://github.com/shlinkio/shlink-web-client/pull/449) Improved PWA update banner, allowing to restart the app directly from it without having to close the tab.
 
 ### Changed
 * [#442](https://github.com/shlinkio/shlink-web-client/pull/442) Visits filtering now goes through the corresponding reducer.
diff --git a/package-lock.json b/package-lock.json
index 2d73c4b3..e3daeb0d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7609,9 +7609,9 @@
       "dev": true
     },
     "arch": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/arch/-/arch-2.1.2.tgz",
-      "integrity": "sha512-NTBIIbAfkJeIletyABbVtdPgeKfDafR+1mZV/AyyfC1UkVkp9iUjV+wwmqtUgphHYajbI86jejBJp5e+jkGTiQ==",
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz",
+      "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==",
       "dev": true
     },
     "arg": {
@@ -10792,40 +10792,48 @@
       "dev": true
     },
     "clipboardy": {
-      "version": "1.2.3",
-      "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-1.2.3.tgz",
-      "integrity": "sha512-2WNImOvCRe6r63Gk9pShfkwXsVtKCroMAevIbiae021mS850UkWPbevxsBz3tnvjZIEGvlwaqCPsw+4ulzNgJA==",
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-2.3.0.tgz",
+      "integrity": "sha512-mKhiIL2DrQIsuXMgBgnfEHOZOryC7kY7YO//TN6c63wlEm3NG5tz+YgY5rVi29KCmq/QQjKYvM7a19+MDOTHOQ==",
       "dev": true,
       "requires": {
-        "arch": "^2.1.0",
-        "execa": "^0.8.0"
+        "arch": "^2.1.1",
+        "execa": "^1.0.0",
+        "is-wsl": "^2.1.1"
       },
       "dependencies": {
-        "cross-spawn": {
-          "version": "5.1.0",
-          "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
-          "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=",
-          "dev": true,
-          "requires": {
-            "lru-cache": "^4.0.1",
-            "shebang-command": "^1.2.0",
-            "which": "^1.2.9"
-          }
-        },
         "execa": {
-          "version": "0.8.0",
-          "resolved": "https://registry.npmjs.org/execa/-/execa-0.8.0.tgz",
-          "integrity": "sha1-2NdrvBtVIX7RkP1t1J08d07PyNo=",
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
+          "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==",
           "dev": true,
           "requires": {
-            "cross-spawn": "^5.0.1",
-            "get-stream": "^3.0.0",
+            "cross-spawn": "^6.0.0",
+            "get-stream": "^4.0.0",
             "is-stream": "^1.1.0",
             "npm-run-path": "^2.0.0",
             "p-finally": "^1.0.0",
             "signal-exit": "^3.0.0",
             "strip-eof": "^1.0.0"
           }
+        },
+        "get-stream": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
+          "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
+          "dev": true,
+          "requires": {
+            "pump": "^3.0.0"
+          }
+        },
+        "is-wsl": {
+          "version": "2.2.0",
+          "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+          "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+          "dev": true,
+          "requires": {
+            "is-docker": "^2.0.0"
+          }
         }
       }
     },
@@ -14252,12 +14260,6 @@
       "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=",
       "dev": true
     },
-    "fast-deep-equal": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
-      "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=",
-      "dev": true
-    },
     "fast-glob": {
       "version": "3.2.4",
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.4.tgz",
@@ -26161,34 +26163,22 @@
       }
     },
     "serve": {
-      "version": "11.3.2",
-      "resolved": "https://registry.npmjs.org/serve/-/serve-11.3.2.tgz",
-      "integrity": "sha512-yKWQfI3xbj/f7X1lTBg91fXBP0FqjJ4TEi+ilES5yzH0iKJpN5LjNb1YzIfQg9Rqn4ECUS2SOf2+Kmepogoa5w==",
+      "version": "12.0.0",
+      "resolved": "https://registry.npmjs.org/serve/-/serve-12.0.0.tgz",
+      "integrity": "sha512-BkTsETQYynAZ7rXX414kg4X6EvuZQS3UVs1NY0VQYdRHSTYWPYcH38nnDh48D0x6ONuislgjag8uKlU2gTBImA==",
       "dev": true,
       "requires": {
         "@zeit/schemas": "2.6.0",
-        "ajv": "6.5.3",
+        "ajv": "6.12.6",
         "arg": "2.0.0",
         "boxen": "1.3.0",
         "chalk": "2.4.1",
-        "clipboardy": "1.2.3",
+        "clipboardy": "2.3.0",
         "compression": "1.7.3",
         "serve-handler": "6.1.3",
         "update-check": "1.5.2"
       },
       "dependencies": {
-        "ajv": {
-          "version": "6.5.3",
-          "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.5.3.tgz",
-          "integrity": "sha512-LqZ9wY+fx3UMiiPd741yB2pj3hhil+hQc8taf4o2QGRFpWgZ2V5C8HA165DY9sS3fJwsk7uT7ZlFEyC3Ig3lLg==",
-          "dev": true,
-          "requires": {
-            "fast-deep-equal": "^2.0.1",
-            "fast-json-stable-stringify": "^2.0.0",
-            "json-schema-traverse": "^0.4.1",
-            "uri-js": "^4.2.2"
-          }
-        },
         "chalk": {
           "version": "2.4.1",
           "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz",
diff --git a/package.json b/package.json
index a6c00e59..efe43b57 100644
--- a/package.json
+++ b/package.json
@@ -130,7 +130,7 @@
     "resolve": "^1.19.0",
     "sass": "^1.29.0",
     "sass-loader": "^10.1.0",
-    "serve": "^11.3.2",
+    "serve": "^12.0.0",
     "stryker-cli": "^1.0.0",
     "style-loader": "^2.0.0",
     "stylelint": "^13.7.2",
diff --git a/src/App.scss b/src/App.scss
index 0096d6e4..a6566e27 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -1,5 +1,4 @@
 @import './utils/base';
-@import './utils/mixins/horizontal-align';
 
 .app-container {
   height: 100%;
@@ -25,18 +24,3 @@
     padding: 0 15px;
   }
 }
-
-.app__update-banner.app__update-banner {
-  @include horizontal-align();
-
-  position: fixed;
-  top: $headerHeight - 25px;
-  padding: 0 4rem 0 0;
-  z-index: 1040;
-  margin: 0;
-  color: var(--text-color);
-  text-align: center;
-  width: 700px;
-  max-width: calc(100% - 30px);
-  box-shadow: 0 0 1rem var(--brand-color);
-}
diff --git a/src/App.tsx b/src/App.tsx
index f7343b53..d8ac9b2a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,11 +1,11 @@
 import { useEffect, FC } from 'react';
 import { Route, Switch } from 'react-router-dom';
-import { Alert } from 'reactstrap';
 import NotFound from './common/NotFound';
 import { ServersMap } from './servers/data';
 import { Settings } from './settings/reducers/settings';
 import { changeThemeInMarkup } from './utils/theme';
-import { SimpleCard } from './utils/SimpleCard';
+import { AppUpdateBanner } from './common/AppUpdateBanner';
+import { forceUpdate } from './utils/helpers/sw';
 import './App.scss';
 
 interface AppProps {
@@ -55,16 +55,7 @@ const App = (
         </div>
       </div>
 
-      <Alert
-        className="app__update-banner"
-        tag={SimpleCard}
-        color="secondary"
-        isOpen={appUpdated}
-        toggle={resetAppUpdate}
-      >
-        <h4 className="mb-4">This app has just been updated!</h4>
-        <p className="mb-0">Restart it to enjoy the new features.</p>
-      </Alert>
+      <AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} />
     </div>
   );
 };
diff --git a/src/common/AppUpdateBanner.scss b/src/common/AppUpdateBanner.scss
new file mode 100644
index 00000000..7f6f833a
--- /dev/null
+++ b/src/common/AppUpdateBanner.scss
@@ -0,0 +1,17 @@
+@import '../utils/base';
+@import '../utils/mixins/horizontal-align';
+
+.app-update-banner.app-update-banner {
+  @include horizontal-align();
+
+  position: fixed;
+  top: $headerHeight - 25px;
+  padding: 0 4rem 0 0;
+  z-index: 1040;
+  margin: 0;
+  color: var(--text-color);
+  text-align: center;
+  width: 700px;
+  max-width: calc(100% - 30px);
+  box-shadow: 0 0 1rem var(--brand-color);
+}
diff --git a/src/common/AppUpdateBanner.tsx b/src/common/AppUpdateBanner.tsx
new file mode 100644
index 00000000..c114dd3d
--- /dev/null
+++ b/src/common/AppUpdateBanner.tsx
@@ -0,0 +1,34 @@
+import { FC, MouseEventHandler } from 'react';
+import { Alert, Button } from 'reactstrap';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
+import { SimpleCard } from '../utils/SimpleCard';
+import { useToggle } from '../utils/helpers/hooks';
+import './AppUpdateBanner.scss';
+
+interface AppUpdateBannerProps {
+  isOpen: boolean;
+  toggle: MouseEventHandler<any>;
+  forceUpdate: Function;
+}
+
+export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forceUpdate }) => {
+  const [ isUpdating,, setUpdating ] = useToggle();
+  const update = () => {
+    setUpdating();
+    forceUpdate();
+  };
+
+  return (
+    <Alert className="app-update-banner" isOpen={isOpen} toggle={toggle} tag={SimpleCard} color="secondary">
+      <h4 className="mb-4">This app has just been updated!</h4>
+      <p className="mb-0">
+        Restart it to enjoy the new features.
+        <Button disabled={isUpdating} className="ml-2" color="secondary" size="sm" onClick={update}>
+          {!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ml-1" /></>}
+          {isUpdating && <>Restarting...</>}
+        </Button>
+      </p>
+    </Alert>
+  );
+};
diff --git a/src/utils/helpers/sw.ts b/src/utils/helpers/sw.ts
new file mode 100644
index 00000000..a318047b
--- /dev/null
+++ b/src/utils/helpers/sw.ts
@@ -0,0 +1,16 @@
+export const forceUpdate = async () => {
+  const registrations = await navigator.serviceWorker?.getRegistrations() ?? [];
+
+  for (const registration of registrations) {
+    const { waiting } = registration;
+
+    waiting?.addEventListener('statechange', (event) => {
+      if ((event.target as any)?.state === 'activated') {
+        window.location.reload();
+      }
+    });
+
+    // The logic that makes skipWaiting to be called when this message is posted is in service-worker.ts
+    waiting?.postMessage({ type: 'SKIP_WAITING' });
+  }
+};


Test patch:
diff --git a/test/App.test.tsx b/test/App.test.tsx
index 1607a7b4..2ce9c408 100644
--- a/test/App.test.tsx
+++ b/test/App.test.tsx
@@ -1,9 +1,9 @@
 import { shallow, ShallowWrapper } from 'enzyme';
 import { Route } from 'react-router-dom';
 import { Mock } from 'ts-mockery';
-import { Alert } from 'reactstrap';
 import { Settings } from '../src/settings/reducers/settings';
 import appFactory from '../src/App';
+import { AppUpdateBanner } from '../src/common/AppUpdateBanner';
 
 describe('<App />', () => {
   let wrapper: ShallowWrapper;
@@ -29,7 +29,7 @@ describe('<App />', () => {
 
   it('renders versions', () => expect(wrapper.find(ShlinkVersions)).toHaveLength(1));
 
-  it('renders an Alert', () => expect(wrapper.find(Alert)).toHaveLength(1));
+  it('renders an update banner', () => expect(wrapper.find(AppUpdateBanner)).toHaveLength(1));
 
   it('renders app main routes', () => {
     const routes = wrapper.find(Route);
diff --git a/test/common/AppUpdateBanner.test.tsx b/test/common/AppUpdateBanner.test.tsx
new file mode 100644
index 00000000..b03458b8
--- /dev/null
+++ b/test/common/AppUpdateBanner.test.tsx
@@ -0,0 +1,43 @@
+import { shallow, ShallowWrapper } from 'enzyme';
+import { Button } from 'reactstrap';
+import { AppUpdateBanner } from '../../src/common/AppUpdateBanner';
+import { SimpleCard } from '../../src/utils/SimpleCard';
+
+describe('<AppUpdateBanner />', () => {
+  const toggle = jest.fn();
+  const forceUpdate = jest.fn();
+  let wrapper: ShallowWrapper;
+
+  beforeEach(() => {
+    wrapper = shallow(<AppUpdateBanner isOpen={true} toggle={toggle} forceUpdate={forceUpdate} />);
+  });
+
+  afterEach(jest.clearAllMocks);
+  afterEach(() => wrapper?.unmount());
+
+  it('renders an alert with expected props', () => {
+    expect(wrapper.prop('className')).toEqual('app-update-banner');
+    expect(wrapper.prop('isOpen')).toEqual(true);
+    expect(wrapper.prop('toggle')).toEqual(toggle);
+    expect(wrapper.prop('tag')).toEqual(SimpleCard);
+    expect(wrapper.prop('color')).toEqual('secondary');
+  });
+
+  it('invokes toggle when alert is toggled', () => {
+    (wrapper.prop('toggle') as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
+
+    expect(toggle).toHaveBeenCalled();
+  });
+
+  it('triggers the update when clicking the button', () => {
+    expect(wrapper.find(Button).html()).toContain('Restart now');
+    expect(wrapper.find(Button).prop('disabled')).toEqual(false);
+    expect(forceUpdate).not.toHaveBeenCalled();
+
+    wrapper.find(Button).simulate('click');
+
+    expect(wrapper.find(Button).html()).toContain('Restarting...');
+    expect(wrapper.find(Button).prop('disabled')).toEqual(true);
+    expect(forceUpdate).toHaveBeenCalled();
+  });
+});