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

Issue:
Edit tags using the [PATCH /short-urls/{shortCoe}] endpoint when using Shlink 2.6 or higher
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
-->

Shlink 2.6. deprecates the endpoint to edit tags, and instead, the one to edit the short URL should be used.

Dynamically use one or the other based on Shlink's version.

Pull request description:
Feature/patch tags
Closes #382 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c39495e..464ed70f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 * [#177](https://github.com/shlinkio/shlink-web-client/issues/177) Added dark theme.
 
 ### Changed
-* *Nothing*
+* [#382](https://github.com/shlinkio/shlink-web-client/issues/382) Ensured short URL tags are edited through the `PATCH /short-urls/{shortCode}` endpoint when using Shlink 2.6.0 or higher.
 
 ### Deprecated
 * *Nothing*
diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts
index f264d580..b7c78ba0 100644
--- a/src/api/services/ShlinkApiClient.ts
+++ b/src/api/services/ShlinkApiClient.ts
@@ -63,6 +63,7 @@ export default class ShlinkApiClient {
     this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
       .then(() => {});
 
+  /* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrlMeta instead */
   public readonly updateShortUrlTags = async (
     shortCode: string,
     domain: OptionalString,
@@ -75,9 +76,9 @@ export default class ShlinkApiClient {
     shortCode: string,
     domain: OptionalString,
     meta: ShlinkShortUrlMeta,
-  ): Promise<ShlinkShortUrlMeta> =>
-    this.performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta)
-      .then(() => meta);
+  ): Promise<ShortUrl> =>
+    this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta)
+      .then(({ data }) => data);
 
   public readonly listTags = async (): Promise<ShlinkTags> =>
     this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' })
diff --git a/src/api/types/index.ts b/src/api/types/index.ts
index 4ca5f7e3..b8383a74 100644
--- a/src/api/types/index.ts
+++ b/src/api/types/index.ts
@@ -59,6 +59,7 @@ export interface ShlinkVisitsParams {
 
 export interface ShlinkShortUrlMeta extends ShortUrlMeta {
   longUrl?: string;
+  tags?: string[];
 }
 
 export interface ShlinkDomain {
diff --git a/src/short-urls/reducers/shortUrlTags.ts b/src/short-urls/reducers/shortUrlTags.ts
index 277386b2..0efc999f 100644
--- a/src/short-urls/reducers/shortUrlTags.ts
+++ b/src/short-urls/reducers/shortUrlTags.ts
@@ -1,4 +1,5 @@
 import { Action, Dispatch } from 'redux';
+import { prop } from 'ramda';
 import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
 import { GetState } from '../../container/types';
 import { OptionalString } from '../../utils/utils';
@@ -6,6 +7,8 @@ import { ShortUrlIdentifier } from '../data';
 import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
 import { ProblemDetailsError } from '../../api/types';
 import { parseApiError } from '../../api/utils';
+import { isReachableServer } from '../../servers/data';
+import { versionMatch } from '../../utils/helpers/version';
 
 /* eslint-disable padding-line-between-statements */
 export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
@@ -50,10 +53,19 @@ export const editShortUrlTags = (buildShlinkApiClient: ShlinkApiClientBuilder) =
   tags: string[],
 ) => async (dispatch: Dispatch, getState: GetState) => {
   dispatch({ type: EDIT_SHORT_URL_TAGS_START });
-  const { updateShortUrlTags } = buildShlinkApiClient(getState);
+  const { selectedServer } = getState();
+  const supportsTagsInPatch = isReachableServer(selectedServer) && versionMatch(
+    selectedServer.version,
+    { minVersion: '2.6.0' },
+  );
+  const { updateShortUrlTags, updateShortUrlMeta } = buildShlinkApiClient(getState);
 
   try {
-    const normalizedTags = await updateShortUrlTags(shortCode, domain, tags);
+    const normalizedTags = await (
+      supportsTagsInPatch
+        ? updateShortUrlMeta(shortCode, domain, { tags }).then(prop('tags'))
+        : updateShortUrlTags(shortCode, domain, tags)
+    );
 
     dispatch<EditShortUrlTagsAction>({ tags: normalizedTags, shortCode, domain, type: SHORT_URL_TAGS_EDITED });
   } catch (e) {


Test patch:
diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts
index 93a2d7dd..2c5d0ae7 100644
--- a/test/api/services/ShlinkApiClient.test.ts
+++ b/test/api/services/ShlinkApiClient.test.ts
@@ -3,6 +3,7 @@ import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
 import { OptionalString } from '../../../src/utils/utils';
 import { Mock } from 'ts-mockery';
 import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/api/types';
+import { ShortUrl } from '../../../src/short-urls/data';
 
 describe('ShlinkApiClient', () => {
   const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance;
@@ -139,16 +140,17 @@ describe('ShlinkApiClient', () => {
 
   describe('updateShortUrlMeta', () => {
     it.each(shortCodesWithDomainCombinations)('properly updates short URL meta', async (shortCode, domain) => {
-      const expectedMeta = {
+      const meta = {
         maxVisits: 50,
         validSince: '2025-01-01T10:00:00+01:00',
       };
-      const axiosSpy = createAxiosMock();
+      const expectedResp = Mock.of<ShortUrl>()
+      const axiosSpy = createAxiosMock({ data: expectedResp });
       const { updateShortUrlMeta } = new ShlinkApiClient(axiosSpy, '', '');
 
-      const result = await updateShortUrlMeta(shortCode, domain, expectedMeta);
+      const result = await updateShortUrlMeta(shortCode, domain, meta);
 
-      expect(expectedMeta).toEqual(result);
+      expect(expectedResp).toEqual(result);
       expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
         url: `/short-urls/${shortCode}`,
         method: 'PATCH',
diff --git a/test/short-urls/helpers/EditTagsModal.test.tsx b/test/short-urls/helpers/EditTagsModal.test.tsx
index fddccad0..4c7294b3 100644
--- a/test/short-urls/helpers/EditTagsModal.test.tsx
+++ b/test/short-urls/helpers/EditTagsModal.test.tsx
@@ -1,10 +1,10 @@
 import { shallow, ShallowWrapper } from 'enzyme';
 import { Mock } from 'ts-mockery';
+import { Modal } from 'reactstrap';
 import createEditTagsModal from '../../../src/short-urls/helpers/EditTagsModal';
 import { ShortUrl } from '../../../src/short-urls/data';
 import { ShortUrlTags } from '../../../src/short-urls/reducers/shortUrlTags';
 import { OptionalString } from '../../../src/utils/utils';
-import { Modal } from 'reactstrap';
 
 describe('<EditTagsModal />', () => {
   let wrapper: ShallowWrapper;
diff --git a/test/short-urls/reducers/shortUrlTags.test.ts b/test/short-urls/reducers/shortUrlTags.test.ts
index 8542ac2d..004093bb 100644
--- a/test/short-urls/reducers/shortUrlTags.test.ts
+++ b/test/short-urls/reducers/shortUrlTags.test.ts
@@ -9,6 +9,7 @@ import reducer, {
   EditShortUrlTagsAction,
 } from '../../../src/short-urls/reducers/shortUrlTags';
 import { ShlinkState } from '../../../src/container/types';
+import { ReachableServer, SelectedServer } from '../../../src/servers/data';
 
 describe('shortUrlTagsReducer', () => {
   const tags = [ 'foo', 'bar', 'baz' ];
@@ -60,9 +61,10 @@ describe('shortUrlTagsReducer', () => {
 
   describe('editShortUrlTags', () => {
     const updateShortUrlTags = jest.fn();
-    const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlTags });
+    const updateShortUrlMeta = jest.fn();
+    const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlTags, updateShortUrlMeta });
     const dispatch = jest.fn();
-    const getState = () => Mock.all<ShlinkState>();
+    const buildGetState = (selectedServer?: SelectedServer) => () => Mock.of<ShlinkState>({ selectedServer });
 
     afterEach(jest.clearAllMocks);
 
@@ -71,11 +73,12 @@ describe('shortUrlTagsReducer', () => {
 
       updateShortUrlTags.mockResolvedValue(normalizedTags);
 
-      await editShortUrlTags(buildShlinkApiClient)(shortCode, domain, tags)(dispatch, getState);
+      await editShortUrlTags(buildShlinkApiClient)(shortCode, domain, tags)(dispatch, buildGetState());
 
       expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
       expect(updateShortUrlTags).toHaveBeenCalledTimes(1);
       expect(updateShortUrlTags).toHaveBeenCalledWith(shortCode, domain, tags);
+      expect(updateShortUrlMeta).not.toHaveBeenCalled();
       expect(dispatch).toHaveBeenCalledTimes(2);
       expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START });
       expect(dispatch).toHaveBeenNthCalledWith(
@@ -84,13 +87,35 @@ describe('shortUrlTagsReducer', () => {
       );
     });
 
+    it('calls updateShortUrlMeta when server is version 2.6.0 or above', async () => {
+      const normalizedTags = [ 'bar', 'foo' ];
+
+      updateShortUrlMeta.mockResolvedValue({ tags: normalizedTags });
+
+      await editShortUrlTags(buildShlinkApiClient)(shortCode, undefined, tags)(
+        dispatch,
+        buildGetState(Mock.of<ReachableServer>({ printableVersion: '', version: '2.6.0' })),
+      );
+
+      expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
+      expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
+      expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, undefined, { tags });
+      expect(updateShortUrlTags).not.toHaveBeenCalled();
+      expect(dispatch).toHaveBeenCalledTimes(2);
+      expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START });
+      expect(dispatch).toHaveBeenNthCalledWith(
+        2,
+        { type: SHORT_URL_TAGS_EDITED, tags: normalizedTags, shortCode },
+      );
+    });
+
     it('dispatches error on failure', async () => {
       const error = new Error();
 
       updateShortUrlTags.mockRejectedValue(error);
 
       try {
-        await editShortUrlTags(buildShlinkApiClient)(shortCode, undefined, tags)(dispatch, getState);
+        await editShortUrlTags(buildShlinkApiClient)(shortCode, undefined, tags)(dispatch, buildGetState());
       } catch (e) {
         expect(e).toBe(error);
       }
diff --git a/test/visits/helpers/MapModal.test.tsx b/test/visits/helpers/MapModal.test.tsx
index 71cdda26..8bab0a3a 100644
--- a/test/visits/helpers/MapModal.test.tsx
+++ b/test/visits/helpers/MapModal.test.tsx
@@ -1,8 +1,8 @@
 import { shallow, ShallowWrapper } from 'enzyme';
 import { Marker, Popup } from 'react-leaflet';
+import { Modal } from 'reactstrap';
 import MapModal from '../../../src/visits/helpers/MapModal';
 import { CityStats } from '../../../src/visits/types';
-import { Modal } from 'reactstrap';
 
 describe('<MapModal />', () => {
   let wrapper: ShallowWrapper;