mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-02 13:51:48 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e09d1372f | ||
|
|
ce02d29ca3 | ||
|
|
e193c700d6 | ||
|
|
bfeb282aa9 | ||
|
|
5caa648112 | ||
|
|
4546b74b6f | ||
|
|
2fb5507803 | ||
|
|
d5530b4614 | ||
|
|
7c327099bb | ||
|
|
577d7e79da | ||
|
|
31736fad1e | ||
|
|
6319a81ddb | ||
|
|
0ca6ff6906 | ||
|
|
eb69165781 | ||
|
|
4e3d311bef | ||
|
|
54b7aeed20 | ||
|
|
2ba8db1fd3 | ||
|
|
f74270a767 | ||
|
|
9a245fbf13 |
@@ -13,5 +13,6 @@
|
|||||||
"globals": {
|
"globals": {
|
||||||
"process": true,
|
"process": true,
|
||||||
"setImmediate": true
|
"setImmediate": true
|
||||||
}
|
},
|
||||||
|
"ignorePatterns": ["src/service*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
41
.github/workflows/deploy-preview.yml
vendored
Normal file
41
.github/workflows/deploy-preview.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Deploy preview
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target: null
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
ref: ${{ github.event.pull_request.head.ref }}
|
||||||
|
- name: Use node.js 14.15
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 14.15
|
||||||
|
- name: Generate slug
|
||||||
|
id: generate_slug
|
||||||
|
run: echo "##[set-output name=slug;]$(echo ${GITHUB_HEAD_REF#refs/heads/} | sed -r 's/[~\^]+//g' | sed -r 's/[^a-zA-Z0-9]+/-/g' | sed -r 's/^-+\|-+$//g' | tr A-Z a-z)"
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
npm ci && \
|
||||||
|
node ./scripts/set-homepage.js /shlink-web-client/${{ steps.generate_slug.outputs.slug }} && \
|
||||||
|
rm src/service-worker.ts && \
|
||||||
|
npm run build
|
||||||
|
- name: Deploy
|
||||||
|
uses: JamesIves/github-pages-deploy-action@4.1.1
|
||||||
|
with:
|
||||||
|
branch: preview-env
|
||||||
|
folder: build
|
||||||
|
target-folder: ${{ steps.generate_slug.outputs.slug }}
|
||||||
|
- name: Publish env
|
||||||
|
uses: marocchino/sticky-pull-request-comment@v2
|
||||||
|
with:
|
||||||
|
header: Preview environment
|
||||||
|
message: |
|
||||||
|
## Preview environment
|
||||||
|
https://shlinkio.github.io/shlink-web-client/${{ steps.generate_slug.outputs.slug }}/
|
||||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [3.1.2] - 2021-06-06
|
||||||
|
### Added
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* [#428](https://github.com/shlinkio/shlink-web-client/issues/428) Updated to StrykerJS 5.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* [#371](https://github.com/shlinkio/shlink-web-client/issues/371) Recovered PWA functionality.
|
||||||
|
|
||||||
|
|
||||||
## [3.1.1] - 2021-05-08
|
## [3.1.1] - 2021-05-08
|
||||||
### Added
|
### Added
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ module.exports = {
|
|||||||
appNodeModules: resolveApp('node_modules'),
|
appNodeModules: resolveApp('node_modules'),
|
||||||
publicUrl: getPublicUrl(resolveApp('package.json')),
|
publicUrl: getPublicUrl(resolveApp('package.json')),
|
||||||
servedPath: getServedPath(resolveApp('package.json')),
|
servedPath: getServedPath(resolveApp('package.json')),
|
||||||
|
swSrc: resolveModule(resolveApp, 'src/service-worker'),
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports.moduleFileExtensions = moduleFileExtensions;
|
module.exports.moduleFileExtensions = moduleFileExtensions;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
|
|||||||
const safePostCssParser = require('postcss-safe-parser');
|
const safePostCssParser = require('postcss-safe-parser');
|
||||||
const ManifestPlugin = require('webpack-manifest-plugin');
|
const ManifestPlugin = require('webpack-manifest-plugin');
|
||||||
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
|
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
|
||||||
|
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
|
||||||
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
|
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
|
||||||
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
|
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
|
||||||
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
|
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
|
||||||
@@ -32,6 +33,9 @@ const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
|
|||||||
// Check if TypeScript is setup
|
// Check if TypeScript is setup
|
||||||
const useTypeScript = fs.existsSync(paths.appTsConfig);
|
const useTypeScript = fs.existsSync(paths.appTsConfig);
|
||||||
|
|
||||||
|
// Get the path to the uncompiled service worker (if it exists).
|
||||||
|
const swSrc = paths.swSrc;
|
||||||
|
|
||||||
// style files regexes
|
// style files regexes
|
||||||
const cssRegex = /\.css$/;
|
const cssRegex = /\.css$/;
|
||||||
const cssModuleRegex = /\.module\.css$/;
|
const cssModuleRegex = /\.module\.css$/;
|
||||||
@@ -610,6 +614,18 @@ module.exports = (webpackEnv) => {
|
|||||||
// You can remove this if you don't use Moment.js:
|
// You can remove this if you don't use Moment.js:
|
||||||
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
|
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
|
||||||
|
|
||||||
|
// Generate a service worker script that will precache, and keep up to date,
|
||||||
|
// the HTML & assets that are part of the webpack build.
|
||||||
|
isEnvProduction && fs.existsSync(swSrc) && new WorkboxWebpackPlugin.InjectManifest({
|
||||||
|
swSrc,
|
||||||
|
dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./,
|
||||||
|
exclude: [ /\.map$/, /asset-manifest\.json$/, /LICENSE/ ],
|
||||||
|
// Bump up the default maximum size (2mb) that's precached,
|
||||||
|
// to make lazy-loading failure scenarios less likely.
|
||||||
|
// See https://github.com/cra-template/pwa/issues/13#issuecomment-722667270
|
||||||
|
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||||
|
}),
|
||||||
|
|
||||||
// TypeScript type checking
|
// TypeScript type checking
|
||||||
useTypeScript &&
|
useTypeScript &&
|
||||||
new ForkTsCheckerWebpackPlugin({
|
new ForkTsCheckerWebpackPlugin({
|
||||||
|
|||||||
1368
package-lock.json
generated
1368
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -57,16 +57,21 @@
|
|||||||
"redux": "^4.0.5",
|
"redux": "^4.0.5",
|
||||||
"redux-localstorage-simple": "^2.4.0",
|
"redux-localstorage-simple": "^2.4.0",
|
||||||
"redux-thunk": "^2.3.0",
|
"redux-thunk": "^2.3.0",
|
||||||
"uuid": "^8.3.2"
|
"uuid": "^8.3.2",
|
||||||
|
"workbox-core": "^6.1.5",
|
||||||
|
"workbox-expiration": "^6.1.5",
|
||||||
|
"workbox-precaching": "^6.1.5",
|
||||||
|
"workbox-routing": "^6.1.5",
|
||||||
|
"workbox-strategies": "^6.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.13.8",
|
"@babel/core": "^7.13.8",
|
||||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
|
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
|
||||||
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
|
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
|
||||||
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
|
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
|
||||||
"@stryker-mutator/core": "^4.4.1",
|
"@stryker-mutator/core": "^5.0.0",
|
||||||
"@stryker-mutator/jest-runner": "^4.4.1",
|
"@stryker-mutator/jest-runner": "^5.0.0",
|
||||||
"@stryker-mutator/typescript-checker": "^4.4.1",
|
"@stryker-mutator/typescript-checker": "^5.0.0",
|
||||||
"@svgr/webpack": "^5.5.0",
|
"@svgr/webpack": "^5.5.0",
|
||||||
"@types/chart.js": "^2.9.31",
|
"@types/chart.js": "^2.9.31",
|
||||||
"@types/classnames": "^2.2.11",
|
"@types/classnames": "^2.2.11",
|
||||||
@@ -146,7 +151,8 @@
|
|||||||
"webpack": "^4.44.2",
|
"webpack": "^4.44.2",
|
||||||
"webpack-dev-server": "^3.11.0",
|
"webpack-dev-server": "^3.11.0",
|
||||||
"webpack-manifest-plugin": "^2.2.0",
|
"webpack-manifest-plugin": "^2.2.0",
|
||||||
"whatwg-fetch": "^3.5.0"
|
"whatwg-fetch": "^3.5.0",
|
||||||
|
"workbox-webpack-plugin": "^6.1.5"
|
||||||
},
|
},
|
||||||
"babel": {
|
"babel": {
|
||||||
"presets": [
|
"presets": [
|
||||||
|
|||||||
13
scripts/set-homepage.js
Normal file
13
scripts/set-homepage.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const [ homepage ] = argv;
|
||||||
|
|
||||||
|
if (!homepage) {
|
||||||
|
throw new Error('Homepage has to be provided as the first arg for this script');
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJsonPath = `${__dirname}/../package.json`;
|
||||||
|
const packageJson = require(packageJsonPath);
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
packageJson.homepage = homepage;
|
||||||
|
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
||||||
16
src/App.scss
16
src/App.scss
@@ -1,4 +1,5 @@
|
|||||||
@import './utils/base';
|
@import './utils/base';
|
||||||
|
@import './utils/mixins/horizontal-align';
|
||||||
|
|
||||||
.app-container {
|
.app-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -24,3 +25,18 @@
|
|||||||
padding: 0 15px;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
19
src/App.tsx
19
src/App.tsx
@@ -1,15 +1,19 @@
|
|||||||
import { useEffect, FC } from 'react';
|
import { useEffect, FC } from 'react';
|
||||||
import { Route, Switch } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
|
import { Alert } from 'reactstrap';
|
||||||
import NotFound from './common/NotFound';
|
import NotFound from './common/NotFound';
|
||||||
import { ServersMap } from './servers/data';
|
import { ServersMap } from './servers/data';
|
||||||
import { Settings } from './settings/reducers/settings';
|
import { Settings } from './settings/reducers/settings';
|
||||||
import { changeThemeInMarkup } from './utils/theme';
|
import { changeThemeInMarkup } from './utils/theme';
|
||||||
|
import { SimpleCard } from './utils/SimpleCard';
|
||||||
import './App.scss';
|
import './App.scss';
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
fetchServers: Function;
|
fetchServers: () => void;
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
|
resetAppUpdate: () => void;
|
||||||
|
appUpdated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const App = (
|
const App = (
|
||||||
@@ -20,7 +24,7 @@ const App = (
|
|||||||
EditServer: FC,
|
EditServer: FC,
|
||||||
Settings: FC,
|
Settings: FC,
|
||||||
ShlinkVersionsContainer: FC,
|
ShlinkVersionsContainer: FC,
|
||||||
) => ({ fetchServers, servers, settings }: AppProps) => {
|
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// On first load, try to fetch the remote servers if the list is empty
|
// On first load, try to fetch the remote servers if the list is empty
|
||||||
if (Object.keys(servers).length === 0) {
|
if (Object.keys(servers).length === 0) {
|
||||||
@@ -50,6 +54,17 @@ const App = (
|
|||||||
<ShlinkVersionsContainer />
|
<ShlinkVersionsContainer />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
18
src/app/reducers/appUpdates.ts
Normal file
18
src/app/reducers/appUpdates.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Action } from 'redux';
|
||||||
|
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||||
|
|
||||||
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
export const APP_UPDATE_AVAILABLE = 'shlink/appUpdates/APP_UPDATE_AVAILABLE';
|
||||||
|
export const RESET_APP_UPDATE = 'shlink/appUpdates/RESET_APP_UPDATE';
|
||||||
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
const initialState = false;
|
||||||
|
|
||||||
|
export default buildReducer<boolean, Action<string>>({
|
||||||
|
[APP_UPDATE_AVAILABLE]: () => true,
|
||||||
|
[RESET_APP_UPDATE]: () => false,
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const appUpdateAvailable = buildActionCreator(APP_UPDATE_AVAILABLE);
|
||||||
|
|
||||||
|
export const resetAppUpdate = buildActionCreator(RESET_APP_UPDATE);
|
||||||
26
src/app/services/provideServices.ts
Normal file
26
src/app/services/provideServices.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Bottle from 'bottlejs';
|
||||||
|
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
||||||
|
import App from '../../App';
|
||||||
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
|
||||||
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory(
|
||||||
|
'App',
|
||||||
|
App,
|
||||||
|
'MainHeader',
|
||||||
|
'Home',
|
||||||
|
'MenuLayout',
|
||||||
|
'CreateServer',
|
||||||
|
'EditServer',
|
||||||
|
'Settings',
|
||||||
|
'ShlinkVersionsContainer',
|
||||||
|
);
|
||||||
|
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
|
||||||
|
bottle.serviceFactory('resetAppUpdate', () => resetAppUpdate);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
@@ -3,9 +3,9 @@ import { Link } from 'react-router-dom';
|
|||||||
import { Card, Row } from 'reactstrap';
|
import { Card, Row } from 'reactstrap';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import ServersListGroup from '../servers/ServersListGroup';
|
import ServersListGroup from '../servers/ServersListGroup';
|
||||||
import './Home.scss';
|
|
||||||
import { ServersMap } from '../servers/data';
|
import { ServersMap } from '../servers/data';
|
||||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
|
import './Home.scss';
|
||||||
|
|
||||||
export interface HomeProps {
|
export interface HomeProps {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Bottle, { IContainer } from 'bottlejs';
|
|||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
import { connect as reduxConnect } from 'react-redux';
|
import { connect as reduxConnect } from 'react-redux';
|
||||||
import { pick } from 'ramda';
|
import { pick } from 'ramda';
|
||||||
import App from '../App';
|
|
||||||
import provideApiServices from '../api/services/provideServices';
|
import provideApiServices from '../api/services/provideServices';
|
||||||
import provideCommonServices from '../common/services/provideServices';
|
import provideCommonServices from '../common/services/provideServices';
|
||||||
import provideShortUrlsServices from '../short-urls/services/provideServices';
|
import provideShortUrlsServices from '../short-urls/services/provideServices';
|
||||||
@@ -13,6 +12,7 @@ import provideUtilsServices from '../utils/services/provideServices';
|
|||||||
import provideMercureServices from '../mercure/services/provideServices';
|
import provideMercureServices from '../mercure/services/provideServices';
|
||||||
import provideSettingsServices from '../settings/services/provideServices';
|
import provideSettingsServices from '../settings/services/provideServices';
|
||||||
import provideDomainsServices from '../domains/services/provideServices';
|
import provideDomainsServices from '../domains/services/provideServices';
|
||||||
|
import provideAppServices from '../app/services/provideServices';
|
||||||
import { ConnectDecorator } from './types';
|
import { ConnectDecorator } from './types';
|
||||||
|
|
||||||
type LazyActionMap = Record<string, Function>;
|
type LazyActionMap = Record<string, Function>;
|
||||||
@@ -33,19 +33,7 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
|
|||||||
actionServiceNames.reduce(mapActionService, {}),
|
actionServiceNames.reduce(mapActionService, {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
bottle.serviceFactory(
|
provideAppServices(bottle, connect);
|
||||||
'App',
|
|
||||||
App,
|
|
||||||
'MainHeader',
|
|
||||||
'Home',
|
|
||||||
'MenuLayout',
|
|
||||||
'CreateServer',
|
|
||||||
'EditServer',
|
|
||||||
'Settings',
|
|
||||||
'ShlinkVersionsContainer',
|
|
||||||
);
|
|
||||||
bottle.decorator('App', connect([ 'servers', 'settings' ], [ 'fetchServers' ]));
|
|
||||||
|
|
||||||
provideCommonServices(bottle, connect, withRouter);
|
provideCommonServices(bottle, connect, withRouter);
|
||||||
provideApiServices(bottle);
|
provideApiServices(bottle);
|
||||||
provideShortUrlsServices(bottle, connect);
|
provideShortUrlsServices(bottle, connect);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface ShlinkState {
|
|||||||
settings: Settings;
|
settings: Settings;
|
||||||
domainsList: DomainsList;
|
domainsList: DomainsList;
|
||||||
visitsOverview: VisitsOverview;
|
visitsOverview: VisitsOverview;
|
||||||
|
appUpdated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { homepage } from '../package.json';
|
|||||||
import container from './container';
|
import container from './container';
|
||||||
import store from './container/store';
|
import store from './container/store';
|
||||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||||
|
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
@@ -12,7 +13,7 @@ import './index.scss';
|
|||||||
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
||||||
fixLeafletIcons();
|
fixLeafletIcons();
|
||||||
|
|
||||||
const { App, ScrollToTop, ErrorHandler } = container;
|
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
@@ -26,3 +27,12 @@ render(
|
|||||||
</Provider>,
|
</Provider>,
|
||||||
document.getElementById('root'),
|
document.getElementById('root'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If you want your app to work offline and load faster, you can change
|
||||||
|
// unregister() to register() below. Note this comes with some pitfalls.
|
||||||
|
// Learn more about service workers: https://cra.link/PWA
|
||||||
|
registerServiceWorker({
|
||||||
|
onUpdate() {
|
||||||
|
store.dispatch(appUpdateAvailable()); // eslint-disable-line @typescript-eslint/no-unsafe-call
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
|||||||
import settingsReducer from '../settings/reducers/settings';
|
import settingsReducer from '../settings/reducers/settings';
|
||||||
import domainsListReducer from '../domains/reducers/domainsList';
|
import domainsListReducer from '../domains/reducers/domainsList';
|
||||||
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
|
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
|
||||||
|
import appUpdatesReducer from '../app/reducers/appUpdates';
|
||||||
import { ShlinkState } from '../container/types';
|
import { ShlinkState } from '../container/types';
|
||||||
|
|
||||||
export default combineReducers<ShlinkState>({
|
export default combineReducers<ShlinkState>({
|
||||||
@@ -38,4 +39,5 @@ export default combineReducers<ShlinkState>({
|
|||||||
settings: settingsReducer,
|
settings: settingsReducer,
|
||||||
domainsList: domainsListReducer,
|
domainsList: domainsListReducer,
|
||||||
visitsOverview: visitsOverviewReducer,
|
visitsOverview: visitsOverviewReducer,
|
||||||
|
appUpdated: appUpdatesReducer,
|
||||||
});
|
});
|
||||||
|
|||||||
80
src/service-worker.ts
Normal file
80
src/service-worker.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
/* eslint-disable no-restricted-globals */
|
||||||
|
|
||||||
|
// This service worker can be customized!
|
||||||
|
// See https://developers.google.com/web/tools/workbox/modules
|
||||||
|
// for the list of available Workbox modules, or add any other
|
||||||
|
// code you'd like.
|
||||||
|
// You can also remove this file if you'd prefer not to use a
|
||||||
|
// service worker, and the Workbox build step will be skipped.
|
||||||
|
|
||||||
|
import { clientsClaim } from 'workbox-core';
|
||||||
|
import { ExpirationPlugin } from 'workbox-expiration';
|
||||||
|
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
|
||||||
|
import { registerRoute } from 'workbox-routing';
|
||||||
|
import { StaleWhileRevalidate } from 'workbox-strategies';
|
||||||
|
|
||||||
|
declare const self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
clientsClaim();
|
||||||
|
|
||||||
|
// Precache all of the assets generated by your build process.
|
||||||
|
// Their URLs are injected into the manifest variable below.
|
||||||
|
// This variable must be present somewhere in your service worker file,
|
||||||
|
// even if you decide not to use precaching. See https://cra.link/PWA
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
|
||||||
|
// Set up App Shell-style routing, so that all navigation requests
|
||||||
|
// are fulfilled with your index.html shell. Learn more at
|
||||||
|
// https://developers.google.com/web/fundamentals/architecture/app-shell
|
||||||
|
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
|
||||||
|
registerRoute(
|
||||||
|
// Return false to exempt requests from being fulfilled by index.html.
|
||||||
|
({ request, url }: { request: Request; url: URL }) => {
|
||||||
|
// If this isn't a navigation, skip.
|
||||||
|
if (request.mode !== 'navigate') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is a URL that starts with /_, skip.
|
||||||
|
if (url.pathname.startsWith('/_')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this looks like a URL for a resource, because it contains
|
||||||
|
// a file extension, skip.
|
||||||
|
if (url.pathname.match(fileExtensionRegexp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return true to signal that we want to use the handler.
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
|
||||||
|
);
|
||||||
|
|
||||||
|
// An example runtime caching route for requests that aren't handled by the
|
||||||
|
// precache, in this case same-origin .png requests like those from in public/
|
||||||
|
registerRoute(
|
||||||
|
// Add in any other file extensions or routing criteria as needed.
|
||||||
|
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
|
||||||
|
// Customize this strategy as needed, e.g., by changing to CacheFirst.
|
||||||
|
new StaleWhileRevalidate({
|
||||||
|
cacheName: 'images',
|
||||||
|
plugins: [
|
||||||
|
// Ensure that once this runtime cache reaches a maximum size the
|
||||||
|
// least-recently used images are removed.
|
||||||
|
new ExpirationPlugin({ maxEntries: 50 }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// This allows the web app to trigger skipWaiting via
|
||||||
|
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Any other custom service worker logic can go here.
|
||||||
142
src/serviceWorkerRegistration.ts
Normal file
142
src/serviceWorkerRegistration.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// This optional code is used to register a service worker.
|
||||||
|
// register() is not called by default.
|
||||||
|
|
||||||
|
// This lets the app load faster on subsequent visits in production, and gives
|
||||||
|
// it offline capabilities. However, it also means that developers (and users)
|
||||||
|
// will only see deployed updates on subsequent visits to a page, after all the
|
||||||
|
// existing tabs open on the page have been closed, since previously cached
|
||||||
|
// resources are updated in the background.
|
||||||
|
|
||||||
|
// To learn more about the benefits of this model and instructions on how to
|
||||||
|
// opt-in, read https://cra.link/PWA
|
||||||
|
|
||||||
|
const isLocalhost = Boolean(
|
||||||
|
window.location.hostname === 'localhost' ||
|
||||||
|
// [::1] is the IPv6 localhost address.
|
||||||
|
window.location.hostname === '[::1]' ||
|
||||||
|
// 127.0.0.0/8 are considered localhost for IPv4.
|
||||||
|
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
|
||||||
|
);
|
||||||
|
|
||||||
|
type Config = {
|
||||||
|
onSuccess?: (registration: ServiceWorkerRegistration) => void;
|
||||||
|
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function register(config?: Config) {
|
||||||
|
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||||
|
// The URL constructor is available in all browsers that support SW.
|
||||||
|
const publicUrl = new URL(process.env.PUBLIC_URL ?? '', window.location.href);
|
||||||
|
if (publicUrl.origin !== window.location.origin) {
|
||||||
|
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||||
|
// from what our page is served on. This might happen if a CDN is used to
|
||||||
|
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||||
|
|
||||||
|
if (isLocalhost) {
|
||||||
|
// This is running on localhost. Let's check if a service worker still exists or not.
|
||||||
|
checkValidServiceWorker(swUrl, config);
|
||||||
|
|
||||||
|
// Add some additional logging to localhost, pointing developers to the
|
||||||
|
// service worker/PWA documentation.
|
||||||
|
navigator.serviceWorker.ready.then(() => {
|
||||||
|
console.log(
|
||||||
|
'This web app is being served cache-first by a service ' +
|
||||||
|
'worker. To learn more, visit https://cra.link/PWA'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Is not localhost. Just register service worker
|
||||||
|
registerValidSW(swUrl, config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerValidSW(swUrl: string, config?: Config) {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register(swUrl)
|
||||||
|
.then((registration) => {
|
||||||
|
registration.onupdatefound = () => {
|
||||||
|
const installingWorker = registration.installing;
|
||||||
|
if (installingWorker == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
installingWorker.onstatechange = () => {
|
||||||
|
if (installingWorker.state === 'installed') {
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
// At this point, the updated precached content has been fetched,
|
||||||
|
// but the previous service worker will still serve the older
|
||||||
|
// content until all client tabs are closed.
|
||||||
|
console.log(
|
||||||
|
'New content is available and will be used when all ' +
|
||||||
|
'tabs for this page are closed. See https://cra.link/PWA.'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute callback
|
||||||
|
if (config && config.onUpdate) {
|
||||||
|
config.onUpdate(registration);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// At this point, everything has been precached.
|
||||||
|
// It's the perfect time to display a
|
||||||
|
// "Content is cached for offline use." message.
|
||||||
|
console.log('Content is cached for offline use.');
|
||||||
|
|
||||||
|
// Execute callback
|
||||||
|
if (config && config.onSuccess) {
|
||||||
|
config.onSuccess(registration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error during service worker registration:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkValidServiceWorker(swUrl: string, config?: Config) {
|
||||||
|
// Check if the service worker can be found. If it can't reload the page.
|
||||||
|
fetch(swUrl, {
|
||||||
|
headers: { 'Service-Worker': 'script' },
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
// Ensure service worker exists, and that we really are getting a JS file.
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (
|
||||||
|
response.status === 404 ||
|
||||||
|
(contentType != null && contentType.indexOf('javascript') === -1)
|
||||||
|
) {
|
||||||
|
// No service worker found. Probably a different app. Reload the page.
|
||||||
|
navigator.serviceWorker.ready.then((registration) => {
|
||||||
|
registration.unregister().then(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Service worker found. Proceed as normal.
|
||||||
|
registerValidSW(swUrl, config);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.log('No internet connection found. App is running in offline mode.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregister() {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.ready
|
||||||
|
.then((registration) => {
|
||||||
|
registration.unregister();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,16 @@ module.exports = {
|
|||||||
tsconfigFile: 'tsconfig.json',
|
tsconfigFile: 'tsconfig.json',
|
||||||
testRunner: 'jest',
|
testRunner: 'jest',
|
||||||
reporters: [ 'progress', 'clear-text' ],
|
reporters: [ 'progress', 'clear-text' ],
|
||||||
coverageAnalysis: 'perTest',
|
ignorePatterns: [
|
||||||
|
'coverage',
|
||||||
|
'reports',
|
||||||
|
'build',
|
||||||
|
'dist',
|
||||||
|
'home',
|
||||||
|
'scripts',
|
||||||
|
'docker-compose.*',
|
||||||
|
'public/servers.json*',
|
||||||
|
],
|
||||||
jest: {
|
jest: {
|
||||||
projectType: 'custom',
|
projectType: 'custom',
|
||||||
config: jestConfig,
|
config: jestConfig,
|
||||||
|
|||||||
@@ -1,23 +1,36 @@
|
|||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { Route } from 'react-router-dom';
|
import { Route } from 'react-router-dom';
|
||||||
import { identity } from 'ramda';
|
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { Alert } from 'reactstrap';
|
||||||
import { Settings } from '../src/settings/reducers/settings';
|
import { Settings } from '../src/settings/reducers/settings';
|
||||||
import appFactory from '../src/App';
|
import appFactory from '../src/App';
|
||||||
|
|
||||||
describe('<App />', () => {
|
describe('<App />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const MainHeader = () => null;
|
const MainHeader = () => null;
|
||||||
|
const ShlinkVersions = () => null;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const App = appFactory(MainHeader, () => null, () => null, () => null, () => null, () => null, () => null);
|
const App = appFactory(MainHeader, () => null, () => null, () => null, () => null, () => null, ShlinkVersions);
|
||||||
|
|
||||||
wrapper = shallow(<App fetchServers={identity} servers={{}} settings={Mock.all<Settings>()} />);
|
wrapper = shallow(
|
||||||
|
<App
|
||||||
|
fetchServers={() => {}}
|
||||||
|
servers={{}}
|
||||||
|
settings={Mock.all<Settings>()}
|
||||||
|
appUpdated={false}
|
||||||
|
resetAppUpdate={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
afterEach(() => wrapper.unmount());
|
afterEach(() => wrapper.unmount());
|
||||||
|
|
||||||
it('renders a header', () => expect(wrapper.find(MainHeader)).toHaveLength(1));
|
it('renders a header', () => expect(wrapper.find(MainHeader)).toHaveLength(1));
|
||||||
|
|
||||||
|
it('renders versions', () => expect(wrapper.find(ShlinkVersions)).toHaveLength(1));
|
||||||
|
|
||||||
|
it('renders an Alert', () => expect(wrapper.find(Alert)).toHaveLength(1));
|
||||||
|
|
||||||
it('renders app main routes', () => {
|
it('renders app main routes', () => {
|
||||||
const routes = wrapper.find(Route);
|
const routes = wrapper.find(Route);
|
||||||
const expectedPaths = [
|
const expectedPaths = [
|
||||||
|
|||||||
30
test/app/reducers/appUpdates.test.ts
Normal file
30
test/app/reducers/appUpdates.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import reducer, {
|
||||||
|
APP_UPDATE_AVAILABLE,
|
||||||
|
RESET_APP_UPDATE,
|
||||||
|
appUpdateAvailable,
|
||||||
|
resetAppUpdate,
|
||||||
|
} from '../../../src/app/reducers/appUpdates';
|
||||||
|
|
||||||
|
describe('appUpdatesReducer', () => {
|
||||||
|
describe('reducer', () => {
|
||||||
|
it('returns true on APP_UPDATE_AVAILABLE', () => {
|
||||||
|
expect(reducer(undefined, { type: APP_UPDATE_AVAILABLE })).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false on RESET_APP_UPDATE', () => {
|
||||||
|
expect(reducer(undefined, { type: RESET_APP_UPDATE })).toEqual(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('appUpdateAvailable', () => {
|
||||||
|
test('creates expected action', () => {
|
||||||
|
expect(appUpdateAvailable()).toEqual({ type: APP_UPDATE_AVAILABLE });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetAppUpdate', () => {
|
||||||
|
test('creates expected action', () => {
|
||||||
|
expect(resetAppUpdate()).toEqual({ type: RESET_APP_UPDATE });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user