Merge pull request #5290 from strapi/front-media-lib-search

Front Media lib search & sort by
This commit is contained in:
cyril lopez 2020-02-26 17:30:26 +01:00 committed by GitHub
commit 54a36e6b36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 519 additions and 216 deletions

View File

@ -15,9 +15,16 @@ const colors = {
'gray-lighter': '#eceeef',
'gray-lightest': '#f7f7f9',
brightGrey: '#f0f3f8',
darkGrey: '#e3e9f3',
lightGrey: '#fafafa',
lightestGrey: '#fbfbfb',
mediumGrey: '#F2F3F4',
grey: '#9ea7b8',
greyDark: '#292b2c',
greyAlpha: 'rgba(227, 233, 243, 0.5)',
lightBlue: '#E6F0FB',
mediumBlue: '#007EFF',
darkBlue: '#AED4FB',
content: {
background: '#fafafb',

View File

@ -1,4 +1,5 @@
const sizes = {
borderRadius: '2px',
header: {
height: '6rem',
},
@ -14,7 +15,6 @@ const sizes = {
// TODO
md: '30px',
},
borderRadius: '2px',
};
export default sizes;

View File

@ -6,7 +6,14 @@ import SearchInfo from '../SearchInfo';
import Clear from './Clear';
import Wrapper from './Wrapper';
const HeaderSearch = ({ label, onChange, onClear, placeholder, value }) => {
const HeaderSearch = ({
label,
name,
onChange,
onClear,
placeholder,
value,
}) => {
return (
<Wrapper>
<div>
@ -14,6 +21,7 @@ const HeaderSearch = ({ label, onChange, onClear, placeholder, value }) => {
</div>
<div>
<input
name={name}
onChange={onChange}
placeholder={placeholder}
type="text"
@ -32,6 +40,7 @@ const HeaderSearch = ({ label, onChange, onClear, placeholder, value }) => {
HeaderSearch.defaultProps = {
label: '',
name: '',
onChange: () => {},
onClear: () => {},
placeholder: 'Search for an entry',
@ -40,6 +49,7 @@ HeaderSearch.defaultProps = {
HeaderSearch.propTypes = {
label: PropTypes.string,
name: PropTypes.string,
onChange: PropTypes.func,
onClear: PropTypes.func,
placeholder: PropTypes.string,

View File

@ -87,6 +87,9 @@ export {
useGlobalContext,
} from './contexts/GlobalContext';
// Hooks
export { default as useQuery } from './hooks/useQuery';
// Utils
export { default as auth } from './utils/auth';
export { default as cleanData } from './utils/cleanData';
@ -101,6 +104,9 @@ export { default as request } from './utils/request';
export { default as storeData } from './utils/storeData';
export { default as templateObject } from './utils/templateObject';
export { default as getYupInnerErrors } from './utils/getYupInnerErrors';
export { default as generateFiltersFromSearch } from './utils/generateFiltersFromSearch';
export { default as generateSearchFromFilters } from './utils/generateSearchFromFilters';
export { default as generateSearchFromObject } from './utils/generateSearchFromObject';
// SVGS
export { default as LayoutIcon } from './svgs/Layout';

View File

@ -1,4 +1,3 @@
import { isEmpty, toString } from 'lodash';
/**
* Generate filters object from string
* @param {String} search
@ -51,28 +50,4 @@ const generateFiltersFromSearch = search => {
}, []);
};
const generateSearchFromFilters = filters => {
return Object.keys(filters)
.filter(key => !isEmpty(toString(filters[key])))
.map(key => {
let ret = `${key}=${filters[key]}`;
if (key === 'filters') {
const formattedFilters = filters[key]
.reduce((acc, curr) => {
const key =
curr.filter === '=' ? curr.name : `${curr.name}${curr.filter}`;
acc.push(`${key}=${curr.value}`);
return acc;
}, [])
.join('&');
ret = formattedFilters;
}
return ret;
})
.join('&');
};
export { generateFiltersFromSearch, generateSearchFromFilters };
export default generateFiltersFromSearch;

View File

@ -0,0 +1,27 @@
import { isEmpty, toString } from 'lodash';
const generateSearchFromFilters = filters => {
return Object.keys(filters)
.filter(key => !isEmpty(toString(filters[key])))
.map(key => {
let ret = `${key}=${filters[key]}`;
if (key === 'filters') {
const formattedFilters = filters[key]
.reduce((acc, curr) => {
const key =
curr.filter === '=' ? curr.name : `${curr.name}${curr.filter}`;
acc.push(`${key}=${curr.value}`);
return acc;
}, [])
.join('&');
ret = formattedFilters;
}
return ret;
})
.join('&');
};
export default generateSearchFromFilters;

View File

@ -0,0 +1,47 @@
import generateFiltersFromSearch from '../generateFiltersFromSearch';
describe('HELPER PLUGIN | utils | generateFiltersFromSearch', () => {
it('should generate an array of filters', () => {
const search =
'?_sort=id:ASC&bool=true&big_number_ne=1&created_at_lt=2019-08-01T00:00:00Z&date_lte=2019-08-02T00:00:00Z&decimal_number_gt=2&enum_ne=noon&float_number_gte=3';
const expected = [
{
name: 'bool',
filter: '=',
value: 'true',
},
{
name: 'big_number',
filter: '_ne',
value: '1',
},
{
name: 'created_at',
filter: '_lt',
value: '2019-08-01T00:00:00Z',
},
{
name: 'date',
filter: '_lte',
value: '2019-08-02T00:00:00Z',
},
{
name: 'decimal_number',
filter: '_gt',
value: '2',
},
{
name: 'enum',
filter: '_ne',
value: 'noon',
},
{
name: 'float_number',
filter: '_gte',
value: '3',
},
];
expect(generateFiltersFromSearch(search)).toEqual(expected);
});
});

View File

@ -0,0 +1,52 @@
import generateSearchFromFilters from '../generateSearchFromFilters';
describe('HELPER PLUGIN | utils | generateSearchFromFilters', () => {
it('should return a string with all the applied filters', () => {
const data = {
_limit: 10,
_sort: 'id:ASC',
_page: 2,
filters: [
{
name: 'bool',
filter: '=',
value: 'true',
},
{
name: 'big_number',
filter: '_ne',
value: '1',
},
{
name: 'created_at',
filter: '_lt',
value: '2019-08-01T00:00:00Z',
},
{
name: 'date',
filter: '_lte',
value: '2019-08-02T00:00:00Z',
},
{
name: 'decimal_number',
filter: '_gt',
value: '2',
},
{
name: 'enum',
filter: '_ne',
value: 'noon',
},
{
name: 'float_number',
filter: '_gte',
value: '3',
},
],
};
const expected =
'_limit=10&_sort=id:ASC&_page=2&bool=true&big_number_ne=1&created_at_lt=2019-08-01T00:00:00Z&date_lte=2019-08-02T00:00:00Z&decimal_number_gt=2&enum_ne=noon&float_number_gte=3';
expect(generateSearchFromFilters(data)).toEqual(expected);
});
});

View File

@ -0,0 +1,44 @@
import generateSearchFromObject from '../generateSearchFromObject';
describe('HELPER PLUGIN | utils | generateSearchFromObject', () => {
it('should return a string containing the _limit, _start and order', () => {
const search = { _page: 1, _limit: 10, _sort: 'city:ASC' };
const expected = '_limit=10&_sort=city:ASC&_start=0';
expect(generateSearchFromObject(search)).toEqual(expected);
});
it('should remove the _q param from the search if it is empty', () => {
const search = { _page: 1, _limit: 10, _sort: 'city:ASC', _q: '' };
const expected = '_limit=10&_sort=city:ASC&_start=0';
expect(generateSearchFromObject(search)).toEqual(expected);
});
it('should not add the filters if it is empty', () => {
const search = {
_page: 1,
_limit: 10,
_sort: 'city:ASC',
_q: '',
filters: [],
};
const expected = '_limit=10&_sort=city:ASC&_start=0';
expect(generateSearchFromObject(search)).toEqual(expected);
});
it('should handle the filters correctly', () => {
const search = {
_limit: 10,
_page: 1,
_q: '',
_sort: 'city:ASC',
filters: [{ name: 'city', filter: '=', value: 'test' }],
};
const expected = '_limit=10&_sort=city:ASC&city=test&_start=0';
expect(generateSearchFromObject(search)).toEqual(expected);
});
});

View File

@ -7,20 +7,21 @@ import { FormattedMessage } from 'react-intl';
import { Header } from '@buffetjs/custom';
import {
PopUpWarning,
generateFiltersFromSearch,
generateSearchFromFilters,
generateSearchFromObject,
getQueryParameters,
useGlobalContext,
request,
} from 'strapi-helper-plugin';
import pluginId from '../../pluginId';
import DisplayedFieldsDropdown from '../../components/DisplayedFieldsDropdown';
import Container from '../../components/Container';
import CustomTable from '../../components/CustomTable';
import FilterPicker from '../../components/FilterPicker';
import Search from '../../components/Search';
import {
generateFiltersFromSearch,
generateSearchFromFilters,
} from '../../utils/search';
import ListViewProvider from '../ListViewProvider';
import { onChangeListLabels, resetListLabels } from '../Main/actions';
import { AddFilterCta, FilterIcon, Wrapper } from './components';
@ -39,7 +40,6 @@ import {
import reducer from './reducer';
import makeSelectListView from './selectors';
import getRequestUrl from '../../utils/getRequestUrl';
import generateSearchFromObject from './utils/generateSearchFromObject';
/* eslint-disable react/no-array-index-key */

View File

@ -1,46 +0,0 @@
import generateSearchFromObject from '../generateSearchFromObject';
describe('CONTENT MANAGER | containers | ListView | utils', () => {
describe('generateSearchFromObject', () => {
it('should return a string containing the _limit, _start and order', () => {
const search = { _page: 1, _limit: 10, _sort: 'city:ASC' };
const expected = '_limit=10&_sort=city:ASC&_start=0';
expect(generateSearchFromObject(search)).toEqual(expected);
});
it('should remove the _q param from the search if it is empty', () => {
const search = { _page: 1, _limit: 10, _sort: 'city:ASC', _q: '' };
const expected = '_limit=10&_sort=city:ASC&_start=0';
expect(generateSearchFromObject(search)).toEqual(expected);
});
it('should not add the filters if it is empty', () => {
const search = {
_page: 1,
_limit: 10,
_sort: 'city:ASC',
_q: '',
filters: [],
};
const expected = '_limit=10&_sort=city:ASC&_start=0';
expect(generateSearchFromObject(search)).toEqual(expected);
});
it('should handle the filters correctly', () => {
const search = {
_limit: 10,
_page: 1,
_q: '',
_sort: 'city:ASC',
filters: [{ name: 'city', filter: '=', value: 'test' }],
};
const expected = '_limit=10&_sort=city:ASC&city=test&_start=0';
expect(generateSearchFromObject(search)).toEqual(expected);
});
});
});

View File

@ -1,102 +0,0 @@
import {
generateFiltersFromSearch,
generateSearchFromFilters,
} from '../search';
describe('Content Manager | utils | search', () => {
describe('generateFiltersFromSearch', () => {
it('should generate an array of filters', () => {
const search =
'?_sort=id:ASC&bool=true&big_number_ne=1&created_at_lt=2019-08-01T00:00:00Z&date_lte=2019-08-02T00:00:00Z&decimal_number_gt=2&enum_ne=noon&float_number_gte=3';
const expected = [
{
name: 'bool',
filter: '=',
value: 'true',
},
{
name: 'big_number',
filter: '_ne',
value: '1',
},
{
name: 'created_at',
filter: '_lt',
value: '2019-08-01T00:00:00Z',
},
{
name: 'date',
filter: '_lte',
value: '2019-08-02T00:00:00Z',
},
{
name: 'decimal_number',
filter: '_gt',
value: '2',
},
{
name: 'enum',
filter: '_ne',
value: 'noon',
},
{
name: 'float_number',
filter: '_gte',
value: '3',
},
];
expect(generateFiltersFromSearch(search)).toEqual(expected);
});
});
describe('generateSearchFromFilters', () => {
it('should return a string with all the applied filters', () => {
const data = {
_limit: 10,
_sort: 'id:ASC',
_page: 2,
filters: [
{
name: 'bool',
filter: '=',
value: 'true',
},
{
name: 'big_number',
filter: '_ne',
value: '1',
},
{
name: 'created_at',
filter: '_lt',
value: '2019-08-01T00:00:00Z',
},
{
name: 'date',
filter: '_lte',
value: '2019-08-02T00:00:00Z',
},
{
name: 'decimal_number',
filter: '_gt',
value: '2',
},
{
name: 'enum',
filter: '_ne',
value: 'noon',
},
{
name: 'float_number',
filter: '_gte',
value: '3',
},
],
};
const expected =
'_limit=10&_sort=id:ASC&_page=2&bool=true&big_number_ne=1&created_at_lt=2019-08-01T00:00:00Z&date_lte=2019-08-02T00:00:00Z&decimal_number_gt=2&enum_ne=noon&float_number_gte=3';
expect(generateSearchFromFilters(data)).toEqual(expected);
});
});
});

View File

@ -14,10 +14,9 @@ import { AttributeIcon } from '@buffetjs/core';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { useGlobalContext } from 'strapi-helper-plugin';
import { useGlobalContext, useQuery } from 'strapi-helper-plugin';
import getTrad from '../../utils/getTrad';
import makeSearch from '../../utils/makeSearch';
import useQuery from '../../hooks/useQuery';
import Button from './Button';
import Card from './Card';

View File

@ -2,8 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import { components } from 'react-select';
import { upperFirst } from 'lodash';
import { useQuery } from 'strapi-helper-plugin';
import useDataManager from '../../hooks/useDataManager';
import useQuery from '../../hooks/useQuery';
import Category from './Category';
import Ul from './Ul';

View File

@ -3,10 +3,10 @@ import PropTypes from 'prop-types';
import { components } from 'react-select';
import { FormattedMessage } from 'react-intl';
import { get } from 'lodash';
import { useQuery } from 'strapi-helper-plugin';
import { Checkbox, CheckboxWrapper, Label } from '@buffetjs/styles';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import useDataManager from '../../hooks/useDataManager';
import useQuery from '../../hooks/useQuery';
import getTrad from '../../utils/getTrad';
import UpperFirst from '../UpperFirst';
import SubUl from './SubUl';

View File

@ -8,6 +8,7 @@ import {
ModalForm,
getYupInnerErrors,
useGlobalContext,
useQuery,
InputsIndex,
} from 'strapi-helper-plugin';
import { Button } from '@buffetjs/core';
@ -16,7 +17,6 @@ import { useHistory, useLocation } from 'react-router-dom';
import { FormattedMessage } from 'react-intl';
import { get, has, isEmpty, set, toLower, toString, upperFirst } from 'lodash';
import pluginId from '../../pluginId';
import useQuery from '../../hooks/useQuery';
import useDataManager from '../../hooks/useDataManager';
import AttributeOption from '../../components/AttributeOption';
import BooleanBox from '../../components/BooleanBox';

View File

@ -0,0 +1,31 @@
import styled from 'styled-components';
import PropTypes from 'prop-types';
import { themePropTypes } from 'strapi-helper-plugin';
const Wrapper = styled.ul`
display: none;
position: absolute;
top: 38px;
left: 0;
margin-bottom: 0;
padding: 0;
min-width: 230px;
z-index: 1;
list-style-type: none;
font-size: ${({ theme }) => theme.main.fontSizes.md};
background-color: ${({ theme }) => theme.main.colors.white};
border: 1px solid ${({ theme }) => theme.main.colors.darkGrey};
box-shadow: 0 2px 4px ${({ theme }) => theme.main.colors.greyAlpha};
${({ isShown }) => isShown && 'display: block;'}
`;
Wrapper.defaultProps = {
isShown: false,
};
Wrapper.propTypes = {
isShown: PropTypes.bool,
...themePropTypes,
};
export default Wrapper;

View File

@ -0,0 +1,39 @@
import React from 'react';
import PropTypes from 'prop-types';
import Wrapper from './Wrapper';
import SortListItem from '../SortListItem';
const SortList = ({ isShown, list, onClick, selectedItem }) => {
return (
<Wrapper isShown={isShown}>
{Object.keys(list).map(item => {
return (
<SortListItem
key={item}
label={item}
value={list[item]}
onClick={onClick}
selectedItem={selectedItem}
/>
);
})}
</Wrapper>
);
};
SortList.defaultProps = {
list: {},
isShown: false,
onClick: () => {},
selectedItem: null,
};
SortList.propTypes = {
list: PropTypes.object,
isShown: PropTypes.bool,
onClick: PropTypes.func,
selectedItem: PropTypes.string,
};
export default SortList;

View File

@ -0,0 +1,29 @@
import styled from 'styled-components';
import PropTypes from 'prop-types';
import { themePropTypes } from 'strapi-helper-plugin';
const SortListItem = styled.li`
padding: 0 14px;
height: 27px;
line-height: 27px;
&:hover {
cursor: pointer;
background-color: ${({ theme }) => theme.main.colors.mediumGrey};
}
${({ isActive, theme }) =>
isActive &&
`
background-color: ${theme.main.colors.mediumGrey};
`}
`;
SortListItem.defaultProps = {
isActive: false,
};
SortListItem.propTypes = {
isActive: PropTypes.bool,
...themePropTypes,
};
export default SortListItem;

View File

@ -0,0 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import getTrad from '../../utils/getTrad';
import Wrapper from './Wrapper';
const SortListItem = ({ onClick, selectedItem, label, value }) => {
const handleClick = () => {
onClick(value);
};
return (
<Wrapper isActive={selectedItem === value} onClick={handleClick}>
<FormattedMessage id={getTrad(`sort.${label}`)} />
</Wrapper>
);
};
SortListItem.defaultProps = {
selectedItem: null,
label: '',
onClick: () => {},
value: null,
};
SortListItem.propTypes = {
selectedItem: PropTypes.string,
label: PropTypes.string,
onClick: PropTypes.func,
value: PropTypes.string,
};
export default SortListItem;

View File

@ -0,0 +1,49 @@
import React from 'react';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import { themePropTypes } from 'strapi-helper-plugin';
import Text from '../Text';
const SortButton = styled(props => (
<Text
as="button"
fontWeight="semiBold"
color={props.isActive ? 'mediumBlue' : 'greyDark'}
{...props}
/>
))`
height: 32px;
padding: 0 10px;
line-height: 30px;
background-color: ${({ theme }) => theme.main.colors.white};
border: 1px solid ${({ theme }) => theme.main.colors.darkGrey};
border-radius: ${({ theme }) => theme.main.sizes.borderRadius};
&:active,
&:focus {
outline: 0;
}
${({ isActive, theme }) =>
isActive
? `
background-color: ${theme.main.colors.lightBlue};
border: 1px solid ${theme.main.colors.darkBlue};
`
: `
&:hover {
background-color: ${theme.main.colors.lightestGrey};
}
`}
`;
SortButton.defaultProps = {
isActive: false,
};
SortButton.propTypes = {
isActive: PropTypes.bool,
...themePropTypes,
};
export default SortButton;

View File

@ -0,0 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div`
position: relative;
`;
export default Wrapper;

View File

@ -1,14 +1,58 @@
import styled from 'styled-components';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
const SortPicker = styled.div`
height: 32px;
padding: 0 10px;
background: #ffffff;
line-height: 30px;
border: 1px solid #e3e9f3;
border-radius: 2px;
font-size: 13px;
font-weight: 500;
`;
import getTrad from '../../utils/getTrad';
import Wrapper from './Wrapper';
import SortButton from './SortButton';
import SortList from '../SortList';
const SortPicker = ({ onChange, value }) => {
const [isOpen, setIsOpen] = useState(false);
const orders = {
created_at_asc: 'created_at:ASC',
created_at_desc: 'created_at:DESC',
name_asc: 'name:ASC',
name_desc: 'name:DESC',
updated_at_asc: 'updated_at:ASC',
updated_at_desc: 'updated_at:DESC',
};
const handleChange = value => {
onChange({ target: { name: '_sort', value } });
hangleToggle();
};
const hangleToggle = () => {
setIsOpen(v => !v);
};
return (
<Wrapper>
<SortButton onClick={hangleToggle} isActive={isOpen}>
<FormattedMessage id={getTrad('sort.label')} />
</SortButton>
<SortList
isShown={isOpen}
list={orders}
selectedItem={value}
onClick={handleChange}
/>
</Wrapper>
);
};
SortPicker.defaultProps = {
onChange: () => {},
value: null,
};
SortPicker.propTypes = {
onChange: PropTypes.func,
value: PropTypes.string,
};
export default SortPicker;

View File

@ -1,6 +1,12 @@
import React, { useReducer, useState } from 'react';
import React, { useReducer, useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { Header } from '@buffetjs/custom';
import { HeaderSearch, useGlobalContext } from 'strapi-helper-plugin';
import {
HeaderSearch,
useGlobalContext,
useQuery,
generateSearchFromFilters,
} from 'strapi-helper-plugin';
import getTrad from '../../utils/getTrad';
import Container from '../../components/Container';
import ControlsWrapper from '../../components/ControlsWrapper';
@ -17,18 +23,56 @@ import AddFilterCTA from '../../components/AddFilterCTA';
const HomePage = () => {
const { formatMessage } = useGlobalContext();
const [reducerState, dispatch] = useReducer(reducer, initialState, init);
const [isOpen, setIsOpen] = useState(true);
const { data, dataToDelete, _q } = reducerState.toJS();
const [isOpen, setIsOpen] = useState(false);
const { push } = useHistory();
const query = useQuery();
const { data, dataToDelete } = reducerState.toJS();
const pluginName = formatMessage({ id: getTrad('plugin.name') });
useEffect(() => {
// TODO - Retrieve data
dispatch({
type: 'GET_DATA_SUCCEEDED',
data: [],
});
}, []);
const getSearchParams = () => {
const params = {};
query.forEach((value, key) => {
params[key] = value;
});
return params;
};
const getUpdatedSearchParams = updatedParams => {
return {
...getSearchParams(),
...updatedParams,
};
};
const getQueryValue = key => {
const queryParams = getSearchParams();
return queryParams[key];
};
const handleChangeParams = ({ target: { name, value } }) => {
const updatedSearch = getUpdatedSearchParams({ [name]: value });
const newSearch = generateSearchFromFilters(updatedSearch);
push({ search: encodeURI(newSearch) });
};
const handleClearSearch = () => {
handleChangeParams({ target: { name: '_q', value: '' } });
};
const handleClickToggleModal = () => {
setIsOpen(prev => !prev);
};
const handleClearSearch = () => {
dispatch({
type: 'ON_CLEAR_SEARCH',
});
};
const headerProps = {
title: {
@ -65,18 +109,19 @@ const HomePage = () => {
<Header {...headerProps} />
<HeaderSearch
label={pluginName}
// TODO: search
onChange={() => {}}
onChange={handleChangeParams}
onClear={handleClearSearch}
placeholder={formatMessage({ id: getTrad('search.placeholder') })}
value={_q}
name="_q"
value={getQueryValue('_q') || ''}
/>
<ControlsWrapper>
<SelectAll />
<SortPicker>
<span> Sort By</span>
</SortPicker>
<SortPicker
onChange={handleChangeParams}
value={getQueryValue('_sort') || null}
/>
<AddFilterCTA />
</ControlsWrapper>
<ListEmpty onClick={handleClickToggleModal} />

View File

@ -3,14 +3,12 @@ import { fromJS } from 'immutable';
const initialState = fromJS({
data: [],
dataToDelete: [],
// TODO: set to empty string
_q: 'super asset',
});
const reducer = (state, action) => {
switch (action.type) {
case 'ON_CLEAR_SEARCH':
return state.update('_q', () => '');
case 'GET_DATA_SUCCEEDED':
return state.update('data', () => action.data);
default:
return state;
}

View File

@ -27,5 +27,12 @@
"plugin.description.long": "Media file management.",
"plugin.description.short": "Media file management.",
"search.placeholder": "Search for an asset...",
"sort.label": "Sort by",
"sort.created_at_asc": "Most recent uploads",
"sort.created_at_desc": "Oldest uploads",
"sort.name_asc": "Alphabetical order (A to Z)",
"sort.name_desc": "Reverse alphabetical order (Z to A)",
"sort.updated_at_asc": "Most recent updates",
"sort.updated_at_desc": "Oldest updates",
"window.confirm.close-modal": "Are you sure? You have some files that have not been uploaded yet."
}