Compare commits

...

32 Commits

Author SHA1 Message Date
Alejandro Celaya
6e09d1372f Merge pull request #436 from acelaya-forks/feature/recover-pwa
Feature/recover pwa
2021-06-06 19:11:37 +02:00
Alejandro Celaya
ce02d29ca3 Ensure review environment does not contain a service worker 2021-06-06 19:06:24 +02:00
Alejandro Celaya
e193c700d6 Fixed TS error in App test 2021-06-06 18:58:05 +02:00
Alejandro Celaya
bfeb282aa9 Added appUpdates reducer test 2021-06-06 18:49:38 +02:00
Alejandro Celaya
5caa648112 Added banner to be displayed when the service worker has updated the app in the background 2021-06-06 18:41:10 +02:00
Alejandro Celaya
4546b74b6f Added missing webpack config that generates service worker 2021-06-06 12:54:32 +02:00
Alejandro Celaya
2fb5507803 Added service worker back to the project to recover PWA capabilities 2021-06-06 12:27:02 +02:00
Alejandro Celaya
d5530b4614 Merge pull request #429 from acelaya-forks/feature/stryker5
Updated to stryker 5
2021-05-15 12:08:26 +02:00
Alejandro Celaya
7c327099bb Updated changelog 2021-05-15 12:04:25 +02:00
Alejandro Celaya
577d7e79da Updated to stryker 5 2021-05-15 12:02:43 +02:00
Alejandro Celaya
31736fad1e Ensured proper ref is checked out on preview env 2021-05-09 21:09:24 +02:00
Alejandro Celaya
6319a81ddb Fixed event name 2021-05-09 21:02:32 +02:00
Alejandro Celaya
0ca6ff6906 Ensured checkout is done from remote remo 2021-05-09 20:52:15 +02:00
Alejandro Celaya
eb69165781 Changed event for preview deployments to use pull_request_target 2021-05-09 14:39:13 +02:00
Alejandro Celaya
4e3d311bef Changed token used for preview deployment 2021-05-09 14:25:10 +02:00
Alejandro Celaya
54b7aeed20 Added github token to preview env deployment 2021-05-09 14:14:56 +02:00
Alejandro Celaya
2ba8db1fd3 Ensured preview envs are generated on PRs only 2021-05-09 14:03:59 +02:00
Alejandro Celaya
f74270a767 Ensured branch slug is generated before building project on preview env deployment 2021-05-09 13:53:36 +02:00
Alejandro Celaya
9a245fbf13 Created new workflow to generate preview envs 2021-05-09 13:34:39 +02:00
Alejandro Celaya
f16e9565e2 Added v3.1.1 to changelog 2021-05-08 11:04:21 +02:00
Alejandro Celaya
e65f9a7b89 Merge pull request #419 from acelaya-forks/feature/edit-feedback
Feature/edit feedback
2021-05-08 11:02:17 +02:00
Alejandro Celaya
0141a1e0ed Updated changelog 2021-05-08 10:57:12 +02:00
Alejandro Celaya
937876ce67 Improved feedback when editing a short URL 2021-05-08 10:56:20 +02:00
Alejandro Celaya
b52120e0d3 Updated changelog 2021-05-07 20:37:41 +02:00
Alejandro Celaya
62b65334b5 Merge pull request #418 from antwonw/patch-1
Update QrCodeModal.tsx
2021-05-07 20:36:25 +02:00
antwonw
76dae535d9 Update QrCodeModal.tsx
Remove indivisible class to fix hyperlink extending outside modal.
2021-05-06 16:56:53 -07:00
Alejandro Celaya
23ba140ff4 Merge pull request #416 from acelaya-forks/feature/prepend-visits
Feature/prepend visits
2021-05-01 16:44:21 +02:00
Alejandro Celaya
76ff7d81b9 Updated changelog 2021-05-01 16:40:22 +02:00
Alejandro Celaya
66deba29f5 Ensured new visits are prepended and not appended, ensuring they keep the proper order 2021-05-01 16:39:13 +02:00
Alejandro Celaya
e44527e9c9 Merge pull request #415 from acelaya-forks/feature/update-url-on-list
Feature/update url on list
2021-04-24 18:06:02 +02:00
Alejandro Celaya
aec629b95c Updated changelog 2021-04-24 18:01:41 +02:00
Alejandro Celaya
fa4664e583 Ensured edited short URLs are reflected in redux state when needed 2021-04-24 17:58:37 +02:00
33 changed files with 1707 additions and 286 deletions

View File

@@ -13,5 +13,6 @@
"globals": {
"process": true,
"setImmediate": true
}
},
"ignorePatterns": ["src/service*.ts"]
}

41
.github/workflows/deploy-preview.yml vendored Normal file
View 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 }}/

View File

@@ -4,6 +4,43 @@ 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).
## [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
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#413](https://github.com/shlinkio/shlink-web-client/issues/413) Fixed edit short URL form reflecting outdated info after navigating back from other section.
* [#412](https://github.com/shlinkio/shlink-web-client/issues/412) Ensured new visits coming from mercure hub are prepended and not appended, to keep proper sorting.
* [#417](https://github.com/shlinkio/shlink-web-client/issues/417) Fixed link spanning out of QR code modal.
* [#411](https://github.com/shlinkio/shlink-web-client/issues/411) Added missing feedback when editing a short URL to know if everything went right.
## [3.1.0] - 2021-03-29
### Added
* [#379](https://github.com/shlinkio/shlink-web-client/issues/379) and [#384](https://github.com/shlinkio/shlink-web-client/issues/384) Improved QR code modal, including controls to customize size, format and margin, as well as a button to copy the link to the clipboard.

View File

@@ -83,6 +83,7 @@ module.exports = {
appNodeModules: resolveApp('node_modules'),
publicUrl: getPublicUrl(resolveApp('package.json')),
servedPath: getServedPath(resolveApp('package.json')),
swSrc: resolveModule(resolveApp, 'src/service-worker'),
};
module.exports.moduleFileExtensions = moduleFileExtensions;

View File

@@ -13,6 +13,7 @@ const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const safePostCssParser = require('postcss-safe-parser');
const ManifestPlugin = require('webpack-manifest-plugin');
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
@@ -32,6 +33,9 @@ const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
// Check if TypeScript is setup
const useTypeScript = fs.existsSync(paths.appTsConfig);
// Get the path to the uncompiled service worker (if it exists).
const swSrc = paths.swSrc;
// style files regexes
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
@@ -610,6 +614,18 @@ module.exports = (webpackEnv) => {
// You can remove this if you don't use Moment.js:
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
useTypeScript &&
new ForkTsCheckerWebpackPlugin({

1380
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -57,16 +57,21 @@
"redux": "^4.0.5",
"redux-localstorage-simple": "^2.4.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": {
"@babel/core": "^7.13.8",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
"@stryker-mutator/core": "^4.4.1",
"@stryker-mutator/jest-runner": "^4.4.1",
"@stryker-mutator/typescript-checker": "^4.4.1",
"@stryker-mutator/core": "^5.0.0",
"@stryker-mutator/jest-runner": "^5.0.0",
"@stryker-mutator/typescript-checker": "^5.0.0",
"@svgr/webpack": "^5.5.0",
"@types/chart.js": "^2.9.31",
"@types/classnames": "^2.2.11",
@@ -146,7 +151,8 @@
"webpack": "^4.44.2",
"webpack-dev-server": "^3.11.0",
"webpack-manifest-plugin": "^2.2.0",
"whatwg-fetch": "^3.5.0"
"whatwg-fetch": "^3.5.0",
"workbox-webpack-plugin": "^6.1.5"
},
"babel": {
"presets": [

13
scripts/set-homepage.js Normal file
View 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));

View File

@@ -1,4 +1,5 @@
@import './utils/base';
@import './utils/mixins/horizontal-align';
.app-container {
height: 100%;
@@ -24,3 +25,18 @@
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);
}

View File

@@ -1,15 +1,19 @@
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 './App.scss';
interface AppProps {
fetchServers: Function;
fetchServers: () => void;
servers: ServersMap;
settings: Settings;
resetAppUpdate: () => void;
appUpdated: boolean;
}
const App = (
@@ -20,7 +24,7 @@ const App = (
EditServer: FC,
Settings: FC,
ShlinkVersionsContainer: FC,
) => ({ fetchServers, servers, settings }: AppProps) => {
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
useEffect(() => {
// On first load, try to fetch the remote servers if the list is empty
if (Object.keys(servers).length === 0) {
@@ -50,6 +54,17 @@ const App = (
<ShlinkVersionsContainer />
</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>
);
};

View 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);

View 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;

View File

@@ -3,9 +3,9 @@ import { Link } from 'react-router-dom';
import { Card, Row } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import ServersListGroup from '../servers/ServersListGroup';
import './Home.scss';
import { ServersMap } from '../servers/data';
import { ShlinkLogo } from './img/ShlinkLogo';
import './Home.scss';
export interface HomeProps {
servers: ServersMap;

View File

@@ -2,7 +2,6 @@ import Bottle, { IContainer } from 'bottlejs';
import { withRouter } from 'react-router-dom';
import { connect as reduxConnect } from 'react-redux';
import { pick } from 'ramda';
import App from '../App';
import provideApiServices from '../api/services/provideServices';
import provideCommonServices from '../common/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 provideSettingsServices from '../settings/services/provideServices';
import provideDomainsServices from '../domains/services/provideServices';
import provideAppServices from '../app/services/provideServices';
import { ConnectDecorator } from './types';
type LazyActionMap = Record<string, Function>;
@@ -33,19 +33,7 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
actionServiceNames.reduce(mapActionService, {}),
);
bottle.serviceFactory(
'App',
App,
'MainHeader',
'Home',
'MenuLayout',
'CreateServer',
'EditServer',
'Settings',
'ShlinkVersionsContainer',
);
bottle.decorator('App', connect([ 'servers', 'settings' ], [ 'fetchServers' ]));
provideAppServices(bottle, connect);
provideCommonServices(bottle, connect, withRouter);
provideApiServices(bottle);
provideShortUrlsServices(bottle, connect);

View File

@@ -35,6 +35,7 @@ export interface ShlinkState {
settings: Settings;
domainsList: DomainsList;
visitsOverview: VisitsOverview;
appUpdated: boolean;
}
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;

View File

@@ -5,6 +5,7 @@ import { homepage } from '../package.json';
import container from './container';
import store from './container/store';
import { fixLeafletIcons } from './utils/helpers/leaflet';
import { register as registerServiceWorker } from './serviceWorkerRegistration';
import 'react-datepicker/dist/react-datepicker.css';
import 'leaflet/dist/leaflet.css';
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
fixLeafletIcons();
const { App, ScrollToTop, ErrorHandler } = container;
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
render(
<Provider store={store}>
@@ -26,3 +27,12 @@ render(
</Provider>,
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
},
});

View File

@@ -17,6 +17,7 @@ import mercureInfoReducer from '../mercure/reducers/mercureInfo';
import settingsReducer from '../settings/reducers/settings';
import domainsListReducer from '../domains/reducers/domainsList';
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
import appUpdatesReducer from '../app/reducers/appUpdates';
import { ShlinkState } from '../container/types';
export default combineReducers<ShlinkState>({
@@ -38,4 +39,5 @@ export default combineReducers<ShlinkState>({
settings: settingsReducer,
domainsList: domainsListReducer,
visitsOverview: visitsOverviewReducer,
appUpdated: appUpdatesReducer,
});

80
src/service-worker.ts Normal file
View 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.

View 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);
});
}
}

View File

@@ -49,7 +49,11 @@ const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResul
saving={shortUrlCreationResult.saving}
selectedServer={selectedServer}
mode={basicMode ? 'create-basic' : 'create'}
onSave={createShortUrl}
onSave={async (data: ShortUrlData) => {
resetCreateShortUrl();
return createShortUrl(data);
}}
/>
<CreateShortUrlResult
{...shortUrlCreationResult}

View File

@@ -11,6 +11,7 @@ import { parseQuery } from '../utils/helpers/query';
import Message from '../utils/Message';
import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
import { useToggle } from '../utils/helpers/hooks';
import { ShortUrlFormProps } from './ShortUrlForm';
import { ShortUrlDetail } from './reducers/shortUrlDetail';
import { EditShortUrlData, ShortUrl, ShortUrlData } from './data';
@@ -62,6 +63,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
() => getInitialState(shortUrl, shortUrlCreationSettings),
[ shortUrl, shortUrlCreationSettings ],
);
const [ savingSucceeded,, isSuccessful, isNotSuccessful ] = useToggle();
useEffect(() => {
getShortUrlDetail(params.shortCode, domain);
@@ -79,8 +81,6 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
);
}
const title = <small>Edit <ExternalLink href={shortUrl?.shortUrl ?? ''} /></small>;
return (
<>
<header className="mb-3">
@@ -89,7 +89,9 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
<Button color="link" size="lg" className="p-0 mr-3" onClick={goBack}>
<FontAwesomeIcon icon={faArrowLeft} />
</Button>
<span className="text-center">{title}</span>
<span className="text-center">
<small>Edit <ExternalLink href={shortUrl?.shortUrl ?? ''} /></small>
</span>
<span />
</h2>
</Card>
@@ -99,13 +101,23 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
saving={saving}
selectedServer={selectedServer}
mode="edit"
onSave={async (shortUrlData) => shortUrl && editShortUrl(shortUrl.shortCode, shortUrl.domain, shortUrlData)}
onSave={async (shortUrlData) => {
if (!shortUrl) {
return;
}
isNotSuccessful();
editShortUrl(shortUrl.shortCode, shortUrl.domain, shortUrlData)
.then(isSuccessful)
.catch(isNotSuccessful);
}}
/>
{savingError && (
<Result type="error" className="mt-3">
<ShlinkApiError errorData={savingErrorData} fallbackMessage="An error occurred while updating short URL :(" />
</Result>
)}
{savingSucceeded && <Result type="success" className="mt-3">Short URL properly edited.</Result>}
</>
);
};

View File

@@ -91,7 +91,7 @@ const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }:
<div className="text-center">
<div className="mb-3">
<div>QR code URL:</div>
<ExternalLink className="indivisible" href={qrCodeUrl} />
<ExternalLink href={qrCodeUrl} />
<CopyToClipboardIcon text={qrCodeUrl} />
</div>
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />

View File

@@ -9,6 +9,7 @@ import { ShlinkShortUrlsResponse } from '../../api/types';
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
import { ShortUrlsListParams } from './shortUrlsListParams';
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
/* eslint-disable padding-line-between-statements */
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
@@ -32,6 +33,7 @@ export type ListShortUrlsCombinedAction = (
& CreateVisitsAction
& CreateShortUrlAction
& DeleteShortUrlAction
& ShortUrlEditedAction
);
const initialState: ShortUrlsList = {
@@ -87,6 +89,15 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
state,
),
),
[SHORT_URL_EDITED]: (state, { shortUrl: editedShortUrl }) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'data' ],
state.shortUrls.data.map((shortUrl) => {
const { shortCode, domain } = editedShortUrl;
return shortUrlMatches(shortUrl, shortCode, domain) ? editedShortUrl : shortUrl;
}),
state,
),
}, initialState);
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (

View File

@@ -44,7 +44,7 @@ export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({
const { visits } = state;
const newVisits = createdVisits.map(({ visit }) => visit);
return { ...state, visits: [ ...visits, ...newVisits ] };
return { ...state, visits: [ ...newVisits, ...visits ] };
},
}, initialState);

View File

@@ -58,7 +58,7 @@ export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
.filter(({ shortUrl }) => shortUrl && shortUrlMatches(shortUrl, shortCode, domain))
.map(({ visit }) => visit);
return { ...state, visits: [ ...visits, ...newVisits ] };
return { ...state, visits: [ ...newVisits, ...visits ] };
},
}, initialState);

View File

@@ -52,7 +52,7 @@ export default buildReducer<TagVisits, TagsVisitsCombinedAction>({
.filter(({ shortUrl }) => shortUrl?.tags.includes(tag))
.map(({ visit }) => visit);
return { ...state, visits: [ ...visits, ...newVisits ] };
return { ...state, visits: [ ...newVisits, ...visits ] };
},
}, initialState);

View File

@@ -6,7 +6,16 @@ module.exports = {
tsconfigFile: 'tsconfig.json',
testRunner: 'jest',
reporters: [ 'progress', 'clear-text' ],
coverageAnalysis: 'perTest',
ignorePatterns: [
'coverage',
'reports',
'build',
'dist',
'home',
'scripts',
'docker-compose.*',
'public/servers.json*',
],
jest: {
projectType: 'custom',
config: jestConfig,

View File

@@ -1,23 +1,36 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { Route } from 'react-router-dom';
import { identity } from 'ramda';
import { Mock } from 'ts-mockery';
import { Alert } from 'reactstrap';
import { Settings } from '../src/settings/reducers/settings';
import appFactory from '../src/App';
describe('<App />', () => {
let wrapper: ShallowWrapper;
const MainHeader = () => null;
const ShlinkVersions = () => null;
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());
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', () => {
const routes = wrapper.find(Route);
const expectedPaths = [

View 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 });
});
});
});

View File

@@ -11,6 +11,7 @@ import { ShortUrl } from '../../../src/short-urls/data';
import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
import { ShlinkPaginator, ShlinkShortUrlsResponse } from '../../../src/api/types';
import { CREATE_SHORT_URL } from '../../../src/short-urls/reducers/shortUrlCreation';
import { SHORT_URL_EDITED } from '../../../src/short-urls/reducers/shortUrlEdition';
describe('shortUrlsListReducer', () => {
describe('reducer', () => {
@@ -124,6 +125,40 @@ describe('shortUrlsListReducer', () => {
error: false,
});
});
it.each([
((): [ShortUrl, ShortUrl[], ShortUrl[]] => {
const editedShortUrl = Mock.of<ShortUrl>({ shortCode: 'notMatching' });
const list = [ Mock.of<ShortUrl>({ shortCode: 'foo' }), Mock.of<ShortUrl>({ shortCode: 'bar' }) ];
return [ editedShortUrl, list, list ];
})(),
((): [ShortUrl, ShortUrl[], ShortUrl[]] => {
const editedShortUrl = Mock.of<ShortUrl>({ shortCode: 'matching', longUrl: 'new_one' });
const list = [
Mock.of<ShortUrl>({ shortCode: 'matching', longUrl: 'old_one' }),
Mock.of<ShortUrl>({ shortCode: 'bar' }),
];
const expectedList = [ editedShortUrl, list[1] ];
return [ editedShortUrl, list, expectedList ];
})(),
])('updates matching short URL on SHORT_URL_EDITED', (editedShortUrl, initialList, expectedList) => {
const state = {
shortUrls: Mock.of<ShlinkShortUrlsResponse>({
data: initialList,
pagination: Mock.of<ShlinkPaginator>({
totalItems: 15,
}),
}),
loading: false,
error: false,
};
const result = reducer(state, { type: SHORT_URL_EDITED, shortUrl: editedShortUrl } as any);
expect(result.shortUrls?.data).toEqual(expectedList);
});
});
describe('listShortUrls', () => {

View File

@@ -64,7 +64,7 @@ describe('orphanVisitsReducer', () => {
expect(visits).toEqual(actionVisits);
});
it('appends a new visits on CREATE_VISIT', () => {
it('prepends new visits on CREATE_VISIT', () => {
const prevState = buildState({ visits: visitsMocks });
const { visits } = reducer(
@@ -72,7 +72,7 @@ describe('orphanVisitsReducer', () => {
{ type: CREATE_VISITS, createdVisits: [{ visit: {} }, { visit: {} }] } as any,
);
expect(visits).toEqual([ ...visitsMocks, {}, {}]);
expect(visits).toEqual([{}, {}, ...visitsMocks ]);
});
it('returns new progress on GET_ORPHAN_VISITS_PROGRESS_CHANGED', () => {

View File

@@ -66,9 +66,9 @@ describe('shortUrlVisitsReducer', () => {
});
it.each([
[{ shortCode: 'abc123' }, [ ...visitsMocks, {}]],
[{ shortCode: 'abc123' }, [{}, ...visitsMocks ]],
[{ shortCode: 'def456' }, visitsMocks ],
])('appends a new visit on CREATE_VISIT', (state, expectedVisits) => {
])('prepends new visits on CREATE_VISIT', (state, expectedVisits) => {
const shortUrl = {
shortCode: 'abc123',
};

View File

@@ -66,9 +66,9 @@ describe('tagVisitsReducer', () => {
});
it.each([
[{ tag: 'foo' }, [ ...visitsMocks, {}]],
[{ tag: 'foo' }, [{}, ...visitsMocks ]],
[{ tag: 'bar' }, visitsMocks ],
])('appends a new visit on CREATE_VISIT', (state, expectedVisits) => {
])('prepends new visits on CREATE_VISIT', (state, expectedVisits) => {
const shortUrl = {
tags: [ 'foo', 'baz' ],
};