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 = ( - -

This app has just been updated!

-

Restart it to enjoy the new features.

-
+ ); }; 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; + forceUpdate: Function; +} + +export const AppUpdateBanner: FC = ({ isOpen, toggle, forceUpdate }) => { + const [ isUpdating,, setUpdating ] = useToggle(); + const update = () => { + setUpdating(); + forceUpdate(); + }; + + return ( + +

This app has just been updated!

+

+ Restart it to enjoy the new features. + +

+
+ ); +}; 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' }); + } +}; 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('', () => { let wrapper: ShallowWrapper; @@ -29,7 +29,7 @@ describe('', () => { 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('', () => { + const toggle = jest.fn(); + const forceUpdate = jest.fn(); + let wrapper: ShallowWrapper; + + beforeEach(() => { + wrapper = shallow(); + }); + + 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(); + }); +});