diff --git a/src/index.scss b/src/index.scss
index b8243d0a..3d9909de 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -52,6 +52,10 @@ body,
white-space: nowrap;
}
+.pointer {
+ cursor: pointer;
+}
+
.text-ellipsis {
text-overflow: ellipsis;
overflow: hidden;
diff --git a/src/short-urls/CreateShortUrl.tsx b/src/short-urls/CreateShortUrl.tsx
index 14b434ae..65c574a7 100644
--- a/src/short-urls/CreateShortUrl.tsx
+++ b/src/short-urls/CreateShortUrl.tsx
@@ -202,7 +202,11 @@ const CreateShortUrl = (
-
+
);
};
diff --git a/src/short-urls/helpers/CreateShortUrlResult.tsx b/src/short-urls/helpers/CreateShortUrlResult.tsx
index 5c4c41ce..5dfdb971 100644
--- a/src/short-urls/helpers/CreateShortUrlResult.tsx
+++ b/src/short-urls/helpers/CreateShortUrlResult.tsx
@@ -1,4 +1,5 @@
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
+import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isNil } from 'ramda';
import { useEffect } from 'react';
@@ -10,10 +11,11 @@ import './CreateShortUrlResult.scss';
export interface CreateShortUrlResultProps extends ShortUrlCreation {
resetCreateShortUrl: () => void;
+ canBeClosed: boolean;
}
const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
- { error, result, resetCreateShortUrl }: CreateShortUrlResultProps,
+ { error, result, resetCreateShortUrl, canBeClosed }: CreateShortUrlResultProps,
) => {
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
@@ -38,6 +40,7 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
return (
+ {canBeClosed && }
Great! The short URL is {shortUrl}
diff --git a/src/short-urls/reducers/shortUrlDeletion.ts b/src/short-urls/reducers/shortUrlDeletion.ts
index 86fa00ee..a21eb5e4 100644
--- a/src/short-urls/reducers/shortUrlDeletion.ts
+++ b/src/short-urls/reducers/shortUrlDeletion.ts
@@ -18,7 +18,7 @@ export interface ShortUrlDeletion {
errorData?: ProblemDetailsError;
}
-interface DeleteShortUrlAction extends Action {
+export interface DeleteShortUrlAction extends Action {
shortCode: string;
domain?: string | null;
}
diff --git a/src/short-urls/reducers/shortUrlsList.ts b/src/short-urls/reducers/shortUrlsList.ts
index a07e50b4..dfba771c 100644
--- a/src/short-urls/reducers/shortUrlsList.ts
+++ b/src/short-urls/reducers/shortUrlsList.ts
@@ -1,4 +1,4 @@
-import { assoc, assocPath, last, reject } from 'ramda';
+import { assoc, assocPath, init, last, pipe, reject } from 'ramda';
import { Action, Dispatch } from 'redux';
import { shortUrlMatches } from '../helpers';
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
@@ -8,10 +8,11 @@ import { GetState } from '../../container/types';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { ShlinkShortUrlsResponse } from '../../utils/services/types';
import { EditShortUrlTagsAction, SHORT_URL_TAGS_EDITED } from './shortUrlTags';
-import { SHORT_URL_DELETED } from './shortUrlDeletion';
+import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
import { SHORT_URL_META_EDITED, ShortUrlMetaEditedAction } from './shortUrlMeta';
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
import { ShortUrlsListParams } from './shortUrlsListParams';
+import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
/* eslint-disable padding-line-between-statements */
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
@@ -31,7 +32,13 @@ export interface ListShortUrlsAction extends Action {
}
export type ListShortUrlsCombinedAction = (
- ListShortUrlsAction & EditShortUrlTagsAction & ShortUrlEditedAction & ShortUrlMetaEditedAction & CreateVisitsAction
+ ListShortUrlsAction
+ & EditShortUrlTagsAction
+ & ShortUrlEditedAction
+ & ShortUrlMetaEditedAction
+ & CreateVisitsAction
+ & CreateShortUrlAction
+ & DeleteShortUrlAction
);
const initialState: ShortUrlsList = {
@@ -55,10 +62,17 @@ export default buildReducer({
[LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }),
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }),
[LIST_SHORT_URLS]: (_, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
- [SHORT_URL_DELETED]: (state, { shortCode, domain }) => !state.shortUrls ? state : assocPath(
- [ 'shortUrls', 'data' ],
- reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
- state,
+ [SHORT_URL_DELETED]: pipe(
+ (state: ShortUrlsList, { shortCode, domain }: DeleteShortUrlAction) => !state.shortUrls ? state : assocPath(
+ [ 'shortUrls', 'data' ],
+ reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
+ state,
+ ),
+ (state) => !state.shortUrls ? state : assocPath(
+ [ 'shortUrls', 'pagination', 'totalItems' ],
+ state.shortUrls.pagination.totalItems - 1,
+ state,
+ ),
),
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
@@ -77,6 +91,20 @@ export default buildReducer({
),
state,
),
+ [CREATE_SHORT_URL]: pipe(
+ // The only place where the list and the creation form coexist is the overview page.
+ // There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL and remove the last one.
+ (state: ShortUrlsList, { result }: CreateShortUrlAction) => !state.shortUrls ? state : assocPath(
+ [ 'shortUrls', 'data' ],
+ [ result, ...init(state.shortUrls.data) ],
+ state,
+ ),
+ (state: ShortUrlsList) => !state.shortUrls ? state : assocPath(
+ [ 'shortUrls', 'pagination', 'totalItems' ],
+ state.shortUrls.pagination.totalItems + 1,
+ state,
+ ),
+ ),
}, initialState);
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
diff --git a/test/short-urls/reducers/shortUrlsList.test.ts b/test/short-urls/reducers/shortUrlsList.test.ts
index e677b352..b9a6ed64 100644
--- a/test/short-urls/reducers/shortUrlsList.test.ts
+++ b/test/short-urls/reducers/shortUrlsList.test.ts
@@ -11,7 +11,9 @@ import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrl
import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation';
import { ShortUrl } from '../../../src/short-urls/data';
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
-import { ShlinkShortUrlsResponse } from '../../../src/utils/services/types';
+import { ShlinkPaginator, ShlinkShortUrlsResponse } from '../../../src/utils/services/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', () => {
@@ -94,7 +96,7 @@ describe('shortUrlsListReducer', () => {
});
});
- it('removes matching URL on SHORT_URL_DELETED', () => {
+ it('removes matching URL and reduces total on SHORT_URL_DELETED', () => {
const shortCode = 'abc123';
const state = {
shortUrls: Mock.of({
@@ -103,6 +105,9 @@ describe('shortUrlsListReducer', () => {
Mock.of({ shortCode, domain: 'example.com' }),
Mock.of({ shortCode: 'foo' }),
],
+ pagination: Mock.of({
+ totalItems: 10,
+ }),
}),
loading: false,
error: false,
@@ -111,6 +116,34 @@ describe('shortUrlsListReducer', () => {
expect(reducer(state, { type: SHORT_URL_DELETED, shortCode } as any)).toEqual({
shortUrls: {
data: [{ shortCode, domain: 'example.com' }, { shortCode: 'foo' }],
+ pagination: { totalItems: 9 },
+ },
+ loading: false,
+ error: false,
+ });
+ });
+
+ it('updates edited short URL on SHORT_URL_EDITED', () => {
+ const shortCode = 'abc123';
+ const state = {
+ shortUrls: Mock.of({
+ data: [
+ Mock.of({ shortCode, longUrl: 'old' }),
+ Mock.of({ shortCode, domain: 'example.com', longUrl: 'foo' }),
+ Mock.of({ shortCode: 'foo', longUrl: 'bar' }),
+ ],
+ }),
+ loading: false,
+ error: false,
+ };
+
+ expect(reducer(state, { type: SHORT_URL_EDITED, shortCode, longUrl: 'newValue' } as any)).toEqual({
+ shortUrls: {
+ data: [
+ { shortCode, longUrl: 'newValue' },
+ { shortCode, longUrl: 'foo', domain: 'example.com' },
+ { shortCode: 'foo', longUrl: 'bar' },
+ ],
},
loading: false,
error: false,
@@ -147,6 +180,34 @@ describe('shortUrlsListReducer', () => {
error: false,
});
});
+
+ it('prepends new short URL and increases total on CREATE_SHORT_URL', () => {
+ const newShortUrl = Mock.of({ shortCode: 'newOne' });
+ const shortCode = 'abc123';
+ const state = {
+ shortUrls: Mock.of({
+ data: [
+ Mock.of({ shortCode }),
+ Mock.of({ shortCode, domain: 'example.com' }),
+ Mock.of({ shortCode: 'foo' }),
+ ],
+ pagination: Mock.of({
+ totalItems: 15,
+ }),
+ }),
+ loading: false,
+ error: false,
+ };
+
+ expect(reducer(state, { type: CREATE_SHORT_URL, result: newShortUrl } as any)).toEqual({
+ shortUrls: {
+ data: [{ shortCode: 'newOne' }, { shortCode }, { shortCode, domain: 'example.com' }],
+ pagination: { totalItems: 16 },
+ },
+ loading: false,
+ error: false,
+ });
+ });
});
describe('listShortUrls', () => {