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

Issue:
Add support for title property in short URLs list when consuming 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.

With that said, please fill in the information requested next. More information might be requested once the issue is open.
-->

#### Summary

* [ ] When present, use the title in the short URLs list instead of the long URL, but still linking to the long URL.
* [ ] Think on some way to still allow ordering the short URLs list both by title or long URL.

---

Blocked by https://github.com/shlinkio/shlink/issues/941


Pull request:
Feature/title in list
Closes #383 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 583a6b02..66806cfc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,7 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 * [#385](https://github.com/shlinkio/shlink-web-client/issues/385) Added setting to determine if "validate URL" should be enabled or disabled by default.
 * [#386](https://github.com/shlinkio/shlink-web-client/issues/386) Added new card in overview section to display amount of orphan visits when using Shlink 2.6.0 or higher.
 * [#177](https://github.com/shlinkio/shlink-web-client/issues/177) Added dark theme.
-* [#387](https://github.com/shlinkio/shlink-web-client/issues/387) Added section to see orphan visits stats, when consuming Shlink >=2.6.0.
+* [#387](https://github.com/shlinkio/shlink-web-client/issues/387) Added a section to see orphan visits stats, when consuming Shlink >=2.6.0.
+* [#383](https://github.com/shlinkio/shlink-web-client/issues/383) Added title to short URLs list, displayed when consuming Shlink >=2.6.0.
 
 ### Changed
 * [#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.
diff --git a/src/index.scss b/src/index.scss
index 65ebc4f1..5bcc10ae 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -61,7 +61,8 @@ body,
 .dropdown-divider,
 .dropdown-menu,
 .list-group-item,
-.modal-content {
+.modal-content,
+hr {
   border-color: var(--border-color);
 }
 
diff --git a/src/short-urls/ShortUrlsList.scss b/src/short-urls/ShortUrlsList.scss
index f2a5edb2..5b1be099 100644
--- a/src/short-urls/ShortUrlsList.scss
+++ b/src/short-urls/ShortUrlsList.scss
@@ -1,3 +1,3 @@
 .short-urls-list__header-icon {
-  margin-right: 5px;
+  margin-left: .4rem;
 }
diff --git a/src/short-urls/ShortUrlsTable.tsx b/src/short-urls/ShortUrlsTable.tsx
index 99e971a0..9bc0c9d6 100644
--- a/src/short-urls/ShortUrlsTable.tsx
+++ b/src/short-urls/ShortUrlsTable.tsx
@@ -2,6 +2,7 @@ import { FC, ReactNode } from 'react';
 import { isEmpty } from 'ramda';
 import classNames from 'classnames';
 import { SelectedServer } from '../servers/data';
+import { titleIsSupported } from '../utils/helpers/features';
 import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
 import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
 import { OrderableFields } from './reducers/shortUrlsListParams';
@@ -25,10 +26,10 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
   className,
 }: ShortUrlsTableProps) => {
   const { error, loading, shortUrls } = shortUrlsList;
-  const orderableColumnsClasses = classNames('short-urls-table__header-cell', {
-    'short-urls-table__header-cell--with-action': !!orderByColumn,
-  });
+  const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn });
+  const orderableColumnsClasses = classNames('short-urls-table__header-cell', actionableFieldClasses);
   const tableClasses = classNames('table table-hover', className);
+  const supportsTitle = titleIsSupported(selectedServer);
 
   const renderShortUrls = () => {
     if (error) {
@@ -62,20 +63,34 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
       <thead className="short-urls-table__header">
         <tr>
           <th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
-            {renderOrderIcon?.('dateCreated')}
             Created at
+            {renderOrderIcon?.('dateCreated')}
           </th>
           <th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
-            {renderOrderIcon?.('shortCode')}
             Short URL
+            {renderOrderIcon?.('shortCode')}
           </th>
-          <th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}>
-            {renderOrderIcon?.('longUrl')}
-            Long URL
-          </th>
+          {!supportsTitle && (
+            <th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}>
+              Long URL
+              {renderOrderIcon?.('longUrl')}
+            </th>
+          ) || (
+            <th className="short-urls-table__header-cell">
+              <span className={actionableFieldClasses} onClick={orderByColumn?.('title')}>
+                Title
+                {renderOrderIcon?.('title')}
+              </span>
+              &nbsp;&nbsp;/&nbsp;&nbsp;
+              <span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}>
+                <span className="indivisible">Long URL</span>
+                {renderOrderIcon?.('longUrl')}
+              </span>
+            </th>
+          )}
           <th className="short-urls-table__header-cell">Tags</th>
           <th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
-            <span className="indivisible">{renderOrderIcon?.('visits')} Visits</span>
+            <span className="indivisible">Visits{renderOrderIcon?.('visits')}</span>
           </th>
           <th className="short-urls-table__header-cell">&nbsp;</th>
         </tr>
diff --git a/src/short-urls/data/index.ts b/src/short-urls/data/index.ts
index 237c6b6a..44c280bf 100644
--- a/src/short-urls/data/index.ts
+++ b/src/short-urls/data/index.ts
@@ -23,6 +23,7 @@ export interface ShortUrl {
   meta: Required<Nullable<ShortUrlMeta>>;
   tags: string[];
   domain: string | null;
+  title?: string | null;
 }
 
 export interface ShortUrlMeta {
diff --git a/src/short-urls/helpers/ShortUrlsRow.scss b/src/short-urls/helpers/ShortUrlsRow.scss
index 17ec51ac..a199b69b 100644
--- a/src/short-urls/helpers/ShortUrlsRow.scss
+++ b/src/short-urls/helpers/ShortUrlsRow.scss
@@ -44,10 +44,6 @@
   position: relative;
 }
 
-.short-urls-row__cell--big {
-  transform: scale(1.5);
-}
-
 .short-urls-row__copy-hint {
   @include vertical-align(translateX(10px));
 
diff --git a/src/short-urls/helpers/ShortUrlsRow.tsx b/src/short-urls/helpers/ShortUrlsRow.tsx
index 5551938c..db40e670 100644
--- a/src/short-urls/helpers/ShortUrlsRow.tsx
+++ b/src/short-urls/helpers/ShortUrlsRow.tsx
@@ -64,9 +64,14 @@ const ShortUrlsRow = (
           </span>
         </span>
       </td>
-      <td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: ">
-        <ExternalLink href={shortUrl.longUrl} />
+      <td className="short-urls-row__cell short-urls-row__cell--break" data-th={`${shortUrl.title ? 'Title' : 'Long URL'}: `}>
+        <ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink>
       </td>
+      {shortUrl.title && (
+        <td className="short-urls-row__cell d-lg-none" data-th="Long URL: ">
+          <ExternalLink href={shortUrl.longUrl} />
+        </td>
+      )}
       <td className="short-urls-row__cell" data-th="Tags: ">{renderTags(shortUrl.tags)}</td>
       <td className="short-urls-row__cell text-md-right" data-th="Visits: ">
         <ShortUrlVisitsCount
diff --git a/src/short-urls/reducers/shortUrlsListParams.ts b/src/short-urls/reducers/shortUrlsListParams.ts
index 88868df8..beb5c812 100644
--- a/src/short-urls/reducers/shortUrlsListParams.ts
+++ b/src/short-urls/reducers/shortUrlsListParams.ts
@@ -8,6 +8,7 @@ export const SORTABLE_FIELDS = {
   dateCreated: 'Created at',
   shortCode: 'Short URL',
   longUrl: 'Long URL',
+  title: 'Title',
   visits: 'Visits',
 };
 
diff --git a/src/utils/base.scss b/src/utils/base.scss
index d5c52b2c..ba6ff54c 100644
--- a/src/utils/base.scss
+++ b/src/utils/base.scss
@@ -18,7 +18,7 @@ $mediumGrey: #dee2e6;
 $textPlaceholder: #6c757d;
 
 // Misc
-$headerHeight: 57px;
+$headerHeight: 56px;
 $asideMenuWidth: 260px;
 $footer-height: 2.3rem;
 $footer-margin: .8rem;
diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts
new file mode 100644
index 00000000..7a382477
--- /dev/null
+++ b/src/utils/helpers/features.ts
@@ -0,0 +1,7 @@
+import { isReachableServer, SelectedServer } from '../../servers/data';
+import { versionMatch, Versions } from './version';
+
+const serverMatchesVersions = (versions: Versions) => (selectedServer: SelectedServer): boolean =>
+  isReachableServer(selectedServer) && versionMatch(selectedServer.version, versions);
+
+export const titleIsSupported = serverMatchesVersions({ minVersion: '2.6.0' });
diff --git a/src/visits/ShortUrlVisitsHeader.tsx b/src/visits/ShortUrlVisitsHeader.tsx
index d5e8973c..38826b8e 100644
--- a/src/visits/ShortUrlVisitsHeader.tsx
+++ b/src/visits/ShortUrlVisitsHeader.tsx
@@ -17,6 +17,7 @@ const ShortUrlVisitsHeader = ({ shortUrlDetail, shortUrlVisits, goBack }: ShortU
   const { visits } = shortUrlVisits;
   const shortLink = shortUrl?.shortUrl ?? '';
   const longLink = shortUrl?.longUrl ?? '';
+  const title = shortUrl?.title;
 
   const renderDate = () => !shortUrl ? <small>Loading...</small> : (
     <span>
@@ -39,9 +40,9 @@ const ShortUrlVisitsHeader = ({ shortUrlDetail, shortUrlVisits, goBack }: ShortU
       <hr />
       <div>Created: {renderDate()}</div>
       <div>
-        Long URL:{' '}
+        {`${title ? 'Title' : 'Long URL'}: `}
         {loading && <small>Loading...</small>}
-        {!loading && <ExternalLink href={longLink} />}
+        {!loading && <ExternalLink href={longLink}>{title ?? longLink}</ExternalLink>}
       </div>
     </VisitsHeader>
   );


Test patch:
diff --git a/test/short-urls/ShortUrlsTable.test.tsx b/test/short-urls/ShortUrlsTable.test.tsx
index d486e4f0..74a736b3 100644
--- a/test/short-urls/ShortUrlsTable.test.tsx
+++ b/test/short-urls/ShortUrlsTable.test.tsx
@@ -2,23 +2,26 @@ import { shallow, ShallowWrapper } from 'enzyme';
 import { Mock } from 'ts-mockery';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { ShortUrlsTable as shortUrlsTableCreator } from '../../src/short-urls/ShortUrlsTable';
-import { SORTABLE_FIELDS } from '../../src/short-urls/reducers/shortUrlsListParams';
+import { OrderableFields, SORTABLE_FIELDS } from '../../src/short-urls/reducers/shortUrlsListParams';
 import { ShortUrlsList } from '../../src/short-urls/reducers/shortUrlsList';
+import { ReachableServer, SelectedServer } from '../../src/servers/data';
 
 describe('<ShortUrlsTable />', () => {
   let wrapper: ShallowWrapper;
   const shortUrlsList = Mock.all<ShortUrlsList>();
   const orderByColumn = jest.fn();
   const ShortUrlsRow = () => null;
-
   const ShortUrlsTable = shortUrlsTableCreator(ShortUrlsRow);
 
-  beforeEach(() => {
+  const createWrapper = (server: SelectedServer = null) => {
     wrapper = shallow(
-      <ShortUrlsTable shortUrlsList={shortUrlsList} selectedServer={null} orderByColumn={() => orderByColumn} />,
+      <ShortUrlsTable shortUrlsList={shortUrlsList} selectedServer={server} orderByColumn={() => orderByColumn} />,
     );
-  });
 
+    return wrapper;
+  };
+
+  beforeEach(() => createWrapper());
   afterEach(jest.resetAllMocks);
   afterEach(() => wrapper?.unmount());
 
@@ -42,13 +45,13 @@ describe('<ShortUrlsTable />', () => {
     });
   });
 
-  it('should render 6 table header cells with conditional order by icon', () => {
-    const getThElementForSortableField = (sortableField: string) => wrapper.find('table')
+  it('should render table header cells with conditional order by icon', () => {
+    const getThElementForSortableField = (orderableField: string) => wrapper.find('table')
       .find('thead')
       .find('tr')
       .find('th')
-      .filterWhere((e) => e.text().includes(SORTABLE_FIELDS[sortableField as keyof typeof SORTABLE_FIELDS]));
-    const sortableFields = Object.keys(SORTABLE_FIELDS);
+      .filterWhere((e) => e.text().includes(SORTABLE_FIELDS[orderableField as OrderableFields]));
+    const sortableFields = Object.keys(SORTABLE_FIELDS).filter((sortableField) => sortableField !== 'title');
 
     expect.assertions(sortableFields.length);
     sortableFields.forEach((sortableField) => {
@@ -56,4 +59,18 @@ describe('<ShortUrlsTable />', () => {
       expect(orderByColumn).toHaveBeenCalled();
     });
   });
+
+  it.each([
+    [ '2.6.0' ],
+    [ '2.6.1' ],
+    [ '2.7.0' ],
+    [ '3.0.0' ],
+  ])('should render composed column when server supports title', (version) => {
+    const wrapper = createWrapper(Mock.of<ReachableServer>({ version }));
+    const composedColumn = wrapper.find('table').find('th').at(2);
+    const text = composedColumn.text();
+
+    expect(text).toContain('Title');
+    expect(text).toContain('Long URL');
+  });
 });
diff --git a/test/short-urls/helpers/ShortUrlsRow.test.tsx b/test/short-urls/helpers/ShortUrlsRow.test.tsx
index e5429a7c..a929467c 100644
--- a/test/short-urls/helpers/ShortUrlsRow.test.tsx
+++ b/test/short-urls/helpers/ShortUrlsRow.test.tsx
@@ -22,9 +22,7 @@ describe('<ShortUrlsRow />', () => {
     getColorForKey: jest.fn(),
     setColorForKey: jest.fn(),
   });
-  const server = Mock.of<ReachableServer>({
-    url: 'https://doma.in',
-  });
+  const server = Mock.of<ReachableServer>({ url: 'https://doma.in' });
   const shortUrl: ShortUrl = {
     shortCode: 'abc123',
     shortUrl: 'http://doma.in/abc123',
@@ -39,14 +37,29 @@ describe('<ShortUrlsRow />', () => {
       maxVisits: null,
     },
   };
+  const ShortUrlsRow = createShortUrlsRow(ShortUrlsRowMenu, colorGenerator, useStateFlagTimeout);
+  const createWrapper = (title?: string | null) => {
+    wrapper = shallow(
+      <ShortUrlsRow selectedServer={server} shortUrl={{ ...shortUrl, title }} onTagClick={mockFunction} />,
+    );
 
-  beforeEach(() => {
-    const ShortUrlsRow = createShortUrlsRow(ShortUrlsRowMenu, colorGenerator, useStateFlagTimeout);
+    return wrapper;
+  };
 
-    wrapper = shallow(<ShortUrlsRow selectedServer={server} shortUrl={shortUrl} onTagClick={mockFunction} />);
-  });
+  beforeEach(() => createWrapper());
   afterEach(() => wrapper.unmount());
 
+  it.each([
+    [ null, 6 ],
+    [ undefined, 6 ],
+    [ 'The title', 7 ],
+  ])('renders expected amount of columns', (title, expectedAmount) => {
+    const wrapper = createWrapper(title);
+    const cols = wrapper.find('td');
+
+    expect(cols).toHaveLength(expectedAmount);
+  });
+
   it('renders date in first column', () => {
     const col = wrapper.find('td').first();
     const moment = col.find(Moment);
@@ -68,6 +81,20 @@ describe('<ShortUrlsRow />', () => {
     expect(link.prop('href')).toEqual(shortUrl.longUrl);
   });
 
+  it('renders title when short URL has it', () => {
+    const wrapper = createWrapper('My super cool title');
+    const cols = wrapper.find('td');
+    const titleSharedCol = cols.at(2).find(ExternalLink);
+    const dedicatedShortUrlCol = cols.at(3).find(ExternalLink);
+
+    expect(titleSharedCol).toHaveLength(1);
+    expect(dedicatedShortUrlCol).toHaveLength(1);
+    expect(titleSharedCol.prop('href')).toEqual(shortUrl.longUrl);
+    expect(dedicatedShortUrlCol.prop('href')).toEqual(shortUrl.longUrl);
+    expect(titleSharedCol.html()).toContain('My super cool title');
+    expect(dedicatedShortUrlCol.prop('children')).not.toBeDefined();
+  });
+
   describe('renders list of tags in fourth row', () => {
     it('with tags', () => {
       const col = wrapper.find('td').at(3);
diff --git a/test/visits/ShortUrlVisitsHeader.test.tsx b/test/visits/ShortUrlVisitsHeader.test.tsx
index cd68cfd2..c2701eba 100644
--- a/test/visits/ShortUrlVisitsHeader.test.tsx
+++ b/test/visits/ShortUrlVisitsHeader.test.tsx
@@ -8,35 +8,48 @@ import { ShortUrlVisits } from '../../src/visits/reducers/shortUrlVisits';
 
 describe('<ShortUrlVisitsHeader />', () => {
   let wrapper: ShallowWrapper;
-  const shortUrlDetail = Mock.of<ShortUrlDetail>({
-    shortUrl: {
-      shortUrl: 'https://doma.in/abc123',
-      longUrl: 'https://foo.bar/bar/foo',
-      dateCreated: '2018-01-01T10:00:00+01:00',
-    },
-    loading: false,
-  });
+  const dateCreated = '2018-01-01T10:00:00+01:00';
+  const longUrl = 'https://foo.bar/bar/foo';
   const shortUrlVisits = Mock.of<ShortUrlVisits>({
     visits: [{}, {}, {}],
   });
   const goBack = jest.fn();
+  const createWrapper = (title?: string | null) => {
+    const shortUrlDetail = Mock.of<ShortUrlDetail>({
+      shortUrl: {
+        shortUrl: 'https://doma.in/abc123',
+        longUrl,
+        dateCreated,
+        title,
+      },
+      loading: false,
+    });
 
-  beforeEach(() => {
     wrapper = shallow(
       <ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />,
     );
-  });
+
+    return wrapper;
+  };
+
+  beforeEach(() => createWrapper());
   afterEach(() => wrapper.unmount());
 
   it('shows when the URL was created', () => {
     const moment = wrapper.find(Moment).first();
 
-    expect(moment.prop('children')).toEqual(shortUrlDetail.shortUrl?.dateCreated);
+    expect(moment.prop('children')).toEqual(dateCreated);
   });
 
-  it('shows the long URL', () => {
+  it.each([
+    [ null, longUrl ],
+    [ undefined, longUrl ],
+    [ 'My cool title', 'My cool title' ],
+  ])('shows the long URL and title', (title, expectedContent) => {
+    const wrapper = createWrapper(title);
     const longUrlLink = wrapper.find(ExternalLink).last();
 
-    expect(longUrlLink.prop('href')).toEqual(shortUrlDetail.shortUrl?.longUrl);
+    expect(longUrlLink.prop('href')).toEqual(longUrl);
+    expect(longUrlLink.prop('children')).toEqual(expectedContent);
   });
 });