diff --git a/src/servers/Overview.scss b/src/servers/Overview.scss new file mode 100644 index 00000000..5e65cb71 --- /dev/null +++ b/src/servers/Overview.scss @@ -0,0 +1,8 @@ +.overview__card.overview__card { + text-align: center; +} + +.overview__card-title { + text-transform: uppercase; + color: #6c757d; +} diff --git a/src/servers/Overview.tsx b/src/servers/Overview.tsx index 813b070a..fa85b2dc 100644 --- a/src/servers/Overview.tsx +++ b/src/servers/Overview.tsx @@ -1,50 +1,77 @@ -import { useEffect } from 'react'; -import { Card, CardText, CardTitle } from 'reactstrap'; +import { FC, useEffect } from 'react'; +import { Card, CardBody, CardHeader, CardText, CardTitle } from 'reactstrap'; +import { Link } from 'react-router-dom'; import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams'; import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList'; import { prettify } from '../utils/helpers/numbers'; import { TagsList } from '../tags/reducers/tagsList'; +import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable'; +import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; +import { isServerWithId, SelectedServer } from './data'; +import './Overview.scss'; interface OverviewConnectProps { shortUrlsList: ShortUrlsListState; listShortUrls: (params: ShortUrlsListParams) => void; listTags: Function; tagsList: TagsList; + selectedServer: SelectedServer; } -export const Overview = ({ shortUrlsList, listShortUrls, listTags, tagsList }: OverviewConnectProps) => { +export const Overview = (ShortUrlsTable: FC) => boundToMercureHub(( + { shortUrlsList, listShortUrls, listTags, tagsList, selectedServer }: OverviewConnectProps, +) => { const { loading, error, shortUrls } = shortUrlsList; const { loading: loadingTags } = tagsList; + const serverId = !isServerWithId(selectedServer) ? '' : selectedServer.id; useEffect(() => { - listShortUrls({ itemsPerPage: 5 }); + listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } }); listTags(); }, []); return ( -
-
- - Visits - ? - + <> +
+
+ + Visits + ? + +
+
+ + Short URLs + + {loading && !error && 'Loading...'} + {error && !loading && 'Failed :('} + {!error && !loading && prettify(shortUrls?.pagination.totalItems ?? 0)} + + +
+
+ + Tags + {loadingTags ? 'Loading... ' : prettify(tagsList.tags.length)} + +
-
- - Short URLs - - {loading && !error && 'Loading...'} - {error && !loading && 'Failed :('} - {!error && !loading && prettify(shortUrls?.pagination.totalItems ?? 0)} - - -
-
- - Tags - {loadingTags ? 'Loading... ' : prettify(tagsList.tags.length)} - -
-
+ + + Create short URL + More options » + + Create + + + + Recently created URLs + See all » + + + + + + ); -}; +}, () => 'https://shlink.io/new-visit'); diff --git a/src/servers/services/provideServices.ts b/src/servers/services/provideServices.ts index 9b79e5e5..7101a102 100644 --- a/src/servers/services/provideServices.ts +++ b/src/servers/services/provideServices.ts @@ -44,10 +44,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton'); bottle.decorator('ServerError', connect([ 'servers', 'selectedServer' ])); - bottle.serviceFactory('Overview', () => Overview); + bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable'); bottle.decorator('Overview', connect( - [ 'shortUrlsList', 'tagsList' ], - [ 'listShortUrls', 'listTags' ], + [ 'shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo' ], + [ 'listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo' ], )); // Services diff --git a/src/short-urls/ShortUrls.tsx b/src/short-urls/ShortUrls.tsx index 1164f0f4..0b86f35d 100644 --- a/src/short-urls/ShortUrls.tsx +++ b/src/short-urls/ShortUrls.tsx @@ -1,16 +1,11 @@ import { FC, useEffect, useState } from 'react'; -import { ShlinkShortUrlsResponse } from '../utils/services/types'; import Paginator from './Paginator'; -import { ShortUrlsListProps, WithList } from './ShortUrlsList'; +import { ShortUrlsListProps } from './ShortUrlsList'; -interface ShortUrlsProps extends ShortUrlsListProps { - shortUrlsList?: ShlinkShortUrlsResponse; -} - -const ShortUrls = (SearchBar: FC, ShortUrlsList: FC) => (props: ShortUrlsProps) => { +const ShortUrls = (SearchBar: FC, ShortUrlsList: FC) => (props: ShortUrlsListProps) => { const { match, shortUrlsList } = props; const { page = '1', serverId = '' } = match?.params ?? {}; - const { data = [], pagination } = shortUrlsList ?? {}; + const { pagination } = shortUrlsList?.shortUrls ?? {}; const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`); // Using a key on a component makes react to create a new instance every time the key changes @@ -23,7 +18,7 @@ const ShortUrls = (SearchBar: FC, ShortUrlsList: FC
- +
diff --git a/src/short-urls/ShortUrlsList.scss b/src/short-urls/ShortUrlsList.scss index 171705de..f2a5edb2 100644 --- a/src/short-urls/ShortUrlsList.scss +++ b/src/short-urls/ShortUrlsList.scss @@ -1,19 +1,3 @@ -@import '../utils/base'; - -.short-urls-list__header { - @media (max-width: $smMax) { - display: none; - } -} - -.short-urls-list__header--with-action { - cursor: pointer; -} - .short-urls-list__header-icon { margin-right: 5px; } - -.short-urls-list__header-cell--with-action { - cursor: pointer; -} diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index 3627bbfb..69287f81 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -1,6 +1,6 @@ import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { head, isEmpty, keys, values } from 'ramda'; +import { head, keys, values } from 'ramda'; import { FC, useEffect, useState } from 'react'; import qs from 'qs'; import { RouteComponentProps } from 'react-router'; @@ -9,9 +9,8 @@ import { determineOrderDir, OrderDir } from '../utils/utils'; import { SelectedServer } from '../servers/data'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; -import { ShortUrlsRowProps } from './helpers/ShortUrlsRow'; -import { ShortUrl } from './data'; import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams'; +import { ShortUrlsTableProps } from './ShortUrlsTable'; import './ShortUrlsList.scss'; interface RouteParams { @@ -19,28 +18,23 @@ interface RouteParams { serverId: string; } -export interface WithList { - shortUrlsList: ShortUrl[]; -} - -export interface ShortUrlsListProps extends ShortUrlsListState, RouteComponentProps { +export interface ShortUrlsListProps extends RouteComponentProps { selectedServer: SelectedServer; + shortUrlsList: ShortUrlsListState; listShortUrls: (params: ShortUrlsListParams) => void; shortUrlsListParams: ShortUrlsListParams; resetShortUrlParams: () => void; } -const ShortUrlsList = (ShortUrlsRow: FC) => boundToMercureHub(({ +const ShortUrlsList = (ShortUrlsTable: FC) => boundToMercureHub(({ listShortUrls, resetShortUrlParams, shortUrlsListParams, match, location, - loading, - error, shortUrlsList, selectedServer, -}: ShortUrlsListProps & WithList) => { +}: ShortUrlsListProps) => { const { orderBy } = shortUrlsListParams; const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({ orderField: orderBy && (head(keys(orderBy)) as OrderableFields), @@ -69,39 +63,12 @@ const ShortUrlsList = (ShortUrlsRow: FC) => boundToMercureHub /> ); }; - const renderShortUrls = () => { - if (error) { - return ( - - Something went wrong while loading short URLs :( - - ); - } - - if (loading) { - return Loading...; - } - - if (!loading && isEmpty(shortUrlsList)) { - return No results found; - } - - return shortUrlsList.map((shortUrl) => ( - - )); - }; useEffect(() => { const query = qs.parse(location.search, { ignoreQueryPrefix: true }); const tags = query.tag ? [ query.tag as string ] : shortUrlsListParams.tags; - refreshList({ page: match.params.page, tags }); + refreshList({ page: match.params.page, tags, itemsPerPage: undefined }); return resetShortUrlParams; }, []); @@ -116,44 +83,14 @@ const ShortUrlsList = (ShortUrlsRow: FC) => boundToMercureHub onChange={handleOrderBy} />
- - - - - - - - - - - - - {renderShortUrls()} - -
- {renderOrderIcon('dateCreated')} - Created at - - {renderOrderIcon('shortCode')} - Short URL - - {renderOrderIcon('longUrl')} - Long URL - Tags - {renderOrderIcon('visits')} Visits -  
+ ); }, () => 'https://shlink.io/new-visit'); diff --git a/src/short-urls/ShortUrlsTable.scss b/src/short-urls/ShortUrlsTable.scss new file mode 100644 index 00000000..8a0b38c6 --- /dev/null +++ b/src/short-urls/ShortUrlsTable.scss @@ -0,0 +1,11 @@ +@import '../utils/base'; + +.short-urls-table__header { + @media (max-width: $smMax) { + display: none; + } +} + +.short-urls-table__header-cell--with-action { + cursor: pointer; +} diff --git a/src/short-urls/ShortUrlsTable.tsx b/src/short-urls/ShortUrlsTable.tsx new file mode 100644 index 00000000..2c799020 --- /dev/null +++ b/src/short-urls/ShortUrlsTable.tsx @@ -0,0 +1,91 @@ +import { FC, ReactNode } from 'react'; +import { isEmpty } from 'ramda'; +import classNames from 'classnames'; +import { SelectedServer } from '../servers/data'; +import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; +import { ShortUrlsRowProps } from './helpers/ShortUrlsRow'; +import { OrderableFields, ShortUrlsListParams } from './reducers/shortUrlsListParams'; +import './ShortUrlsTable.scss'; + +export interface ShortUrlsTableProps { + orderByColumn?: (column: OrderableFields) => () => void; + renderOrderIcon?: (column: OrderableFields) => ReactNode; + shortUrlsList: ShortUrlsListState; + selectedServer: SelectedServer; + refreshList?: Function; + shortUrlsListParams?: ShortUrlsListParams; + className?: string; +} + +export const ShortUrlsTable = (ShortUrlsRow: FC) => ({ + orderByColumn, + renderOrderIcon, + shortUrlsList, + refreshList, + shortUrlsListParams, + selectedServer, + className, +}: ShortUrlsTableProps) => { + const { error, loading, shortUrls } = shortUrlsList; + const orderableColumnsClasses = classNames('short-urls-table__header-cell', { + 'short-urls-table__header-cell--with-action': !!orderByColumn, + }); + const tableClasses = classNames('table table-striped table-hover', className); + + const renderShortUrls = () => { + if (error) { + return ( + + Something went wrong while loading short URLs :( + + ); + } + + if (loading) { + return Loading...; + } + + if (!loading && isEmpty(shortUrlsList)) { + return No results found; + } + + return shortUrls?.data.map((shortUrl) => ( + + )); + }; + + return ( + + + + + + + + + + + + + {renderShortUrls()} + +
+ {renderOrderIcon?.('dateCreated')} + Created at + + {renderOrderIcon?.('shortCode')} + Short URL + + {renderOrderIcon?.('longUrl')} + Long URL + Tags + {renderOrderIcon?.('visits')} Visits +  
+ ); +}; diff --git a/src/short-urls/helpers/ShortUrlsRow.tsx b/src/short-urls/helpers/ShortUrlsRow.tsx index ca8a2853..7d7c6154 100644 --- a/src/short-urls/helpers/ShortUrlsRow.tsx +++ b/src/short-urls/helpers/ShortUrlsRow.tsx @@ -16,8 +16,8 @@ import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu'; import './ShortUrlsRow.scss'; export interface ShortUrlsRowProps { - refreshList: Function; - shortUrlsListParams: ShortUrlsListParams; + refreshList?: Function; + shortUrlsListParams?: ShortUrlsListParams; selectedServer: SelectedServer; shortUrl: ShortUrl; } @@ -36,14 +36,14 @@ const ShortUrlsRow = ( return No tags; } - const selectedTags = shortUrlsListParams.tags ?? []; + const selectedTags = shortUrlsListParams?.tags ?? []; return tags.map((tag) => ( refreshList({ tags: [ ...selectedTags, tag ] })} + onClick={() => refreshList?.({ tags: [ ...selectedTags, tag ] })} /> )); }; diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 0735af20..9c030e26 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -1,5 +1,3 @@ -import { connect as reduxConnect } from 'react-redux'; -import { assoc } from 'ramda'; import Bottle from 'bottlejs'; import ShortUrls from '../ShortUrls'; import SearchBar from '../SearchBar'; @@ -19,27 +17,26 @@ import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags'; import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta'; import { resetShortUrlParams } from '../reducers/shortUrlsListParams'; import { editShortUrl } from '../reducers/shortUrlEdition'; -import { ConnectDecorator, ShlinkState } from '../../container/types'; +import { ConnectDecorator } from '../../container/types'; +import { ShortUrlsTable } from '../ShortUrlsTable'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components bottle.serviceFactory('ShortUrls', ShortUrls, 'SearchBar', 'ShortUrlsList'); - bottle.decorator('ShortUrls', reduxConnect( - (state: ShlinkState) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList), - )); + bottle.decorator('ShortUrls', connect([ 'shortUrlsList' ])); // Services bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator', 'ForServerVersion'); bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ])); - bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow'); + bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable'); bottle.decorator('ShortUrlsList', connect( [ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ], [ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ], )); + bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow'); bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout'); - bottle.serviceFactory( 'ShortUrlsRowMenu', ShortUrlsRowMenu, diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index 55e4ea91..072b39be 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -1,19 +1,30 @@ import { shallow, ShallowWrapper } from 'enzyme'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons'; import { Mock } from 'ts-mockery'; import shortUrlsListCreator, { ShortUrlsListProps } from '../../src/short-urls/ShortUrlsList'; import { ShortUrl } from '../../src/short-urls/data'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; -import { SORTABLE_FIELDS } from '../../src/short-urls/reducers/shortUrlsListParams'; +import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList'; +import SortingDropdown from '../../src/utils/SortingDropdown'; describe('', () => { let wrapper: ShallowWrapper; - const ShortUrlsRow = () => null; + const ShortUrlsTable = () => null; const listShortUrlsMock = jest.fn(); const resetShortUrlParamsMock = jest.fn(); + const shortUrlsList = Mock.of({ + shortUrls: { + data: [ + Mock.of({ + shortCode: 'testShortCode', + shortUrl: 'https://www.example.com/testShortUrl', + longUrl: 'https://www.example.com/testLongUrl', + tags: [ 'test tag' ], + }), + ], + }, + }); - const ShortUrlsList = shortUrlsListCreator(ShortUrlsRow); + const ShortUrlsList = shortUrlsListCreator(ShortUrlsTable); beforeEach(() => { wrapper = shallow( @@ -29,18 +40,7 @@ describe('', () => { }} match={{ params: {} } as any} location={{} as any} - loading={false} - error={false} - shortUrlsList={ - [ - Mock.of({ - shortCode: 'testShortCode', - shortUrl: 'https://www.example.com/testShortUrl', - longUrl: 'https://www.example.com/testLongUrl', - tags: [ 'test tag' ], - }), - ] - } + shortUrlsList={shortUrlsList} />, ).dive(); // Dive is needed as this component is wrapped in a HOC }); @@ -48,50 +48,11 @@ describe('', () => { afterEach(jest.resetAllMocks); afterEach(() => wrapper?.unmount()); - it('wraps a ShortUrlsList with 1 ShortUrlsRow', () => { - expect(wrapper.find(ShortUrlsRow)).toHaveLength(1); + it('wraps a ShortUrlsTable', () => { + expect(wrapper.find(ShortUrlsTable)).toHaveLength(1); }); - it('should render inner table by default', () => { - expect(wrapper.find('table')).toHaveLength(1); - }); - - it('should render table header by default', () => { - expect(wrapper.find('table').find('thead')).toHaveLength(1); - }); - - it('should render 6 table header cells by default', () => { - expect(wrapper.find('table').find('thead').find('tr').find('th')).toHaveLength(6); - }); - - it('should render 6 table header cells without order by icon by default', () => { - const thElements = wrapper.find('table').find('thead').find('tr').find('th'); - - thElements.forEach((thElement) => { - expect(thElement.find(FontAwesomeIcon)).toHaveLength(0); - }); - }); - - it('should render 6 table header cells with conditional order by icon', () => { - const getThElementForSortableField = (sortableField: string) => wrapper.find('table') - .find('thead') - .find('tr') - .find('th') - .filterWhere((e) => e.text().includes(SORTABLE_FIELDS[sortableField as keyof typeof SORTABLE_FIELDS])); - - Object.keys(SORTABLE_FIELDS).forEach((sortableField) => { - expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(0); - - getThElementForSortableField(sortableField).simulate('click'); - expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1); - expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual(caretUpIcon); - - getThElementForSortableField(sortableField).simulate('click'); - expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1); - expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual(caretDownIcon); - - getThElementForSortableField(sortableField).simulate('click'); - expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(0); - }); + it('wraps a SortingDropdown', () => { + expect(wrapper.find(SortingDropdown)).toHaveLength(1); }); }); diff --git a/test/short-urls/ShortUrlsTable.test.tsx b/test/short-urls/ShortUrlsTable.test.tsx new file mode 100644 index 00000000..d486e4f0 --- /dev/null +++ b/test/short-urls/ShortUrlsTable.test.tsx @@ -0,0 +1,59 @@ +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 { ShortUrlsList } from '../../src/short-urls/reducers/shortUrlsList'; + +describe('', () => { + let wrapper: ShallowWrapper; + const shortUrlsList = Mock.all(); + const orderByColumn = jest.fn(); + const ShortUrlsRow = () => null; + + const ShortUrlsTable = shortUrlsTableCreator(ShortUrlsRow); + + beforeEach(() => { + wrapper = shallow( + orderByColumn} />, + ); + }); + + afterEach(jest.resetAllMocks); + afterEach(() => wrapper?.unmount()); + + it('should render inner table by default', () => { + expect(wrapper.find('table')).toHaveLength(1); + }); + + it('should render table header by default', () => { + expect(wrapper.find('table').find('thead')).toHaveLength(1); + }); + + it('should render 6 table header cells by default', () => { + expect(wrapper.find('table').find('thead').find('tr').find('th')).toHaveLength(6); + }); + + it('should render 6 table header cells without order by icon by default', () => { + const thElements = wrapper.find('table').find('thead').find('tr').find('th'); + + thElements.forEach((thElement) => { + expect(thElement.find(FontAwesomeIcon)).toHaveLength(0); + }); + }); + + it('should render 6 table header cells with conditional order by icon', () => { + const getThElementForSortableField = (sortableField: 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); + + expect.assertions(sortableFields.length); + sortableFields.forEach((sortableField) => { + getThElementForSortableField(sortableField).simulate('click'); + expect(orderByColumn).toHaveBeenCalled(); + }); + }); +});