Merge branch 'front-media-lib-search' of github.com:strapi/strapi into front/media-lib-filters

Signed-off-by: Virginie Ky <virginie.ky@gmail.com>
This commit is contained in:
Virginie Ky 2020-02-25 22:09:01 +01:00
commit 45c8deac4d
59 changed files with 1864 additions and 571 deletions

View File

@ -9,29 +9,3 @@ To help us merge your PR, make sure to follow the instructions below:
-->
#### Description of what you did:
<!--
Replace [ ] by [x] to check these checkboxes!
-->
#### My PR is a:
- [ ] 💥 Breaking change
- [ ] 🐛 Bug fix
- [ ] 💅 Enhancement
- [ ] 🚀 New feature
#### Main update on the:
- [ ] Admin
- [ ] Documentation
- [ ] Framework
- [ ] Plugin
#### Manual testing done on the following databases:
- [ ] Not applicable
- [ ] MongoDB
- [ ] MySQL
- [ ] Postgres
- [ ] SQLite

View File

@ -205,6 +205,7 @@ module.exports = {
'/3.0.0-beta.x/guides/custom-data-response',
'/3.0.0-beta.x/guides/custom-admin',
'/3.0.0-beta.x/guides/client',
'/3.0.0-beta.x/guides/is-owner',
'/3.0.0-beta.x/guides/draft',
'/3.0.0-beta.x/guides/scheduled-publication',
'/3.0.0-beta.x/guides/slug',

View File

@ -0,0 +1,132 @@
# Create is owner policy
This guide will explain how to restrict content edition to content authors only.
## Introduction
It is often required that the author of an entry is the only user allowed to edit or delete the entry.
This is a feature that is requested a lot and in this guide we will see how to implement it.
## Example
For this example, we will need an Article Content Type.
Add a `text` field and a `relation` field for this Content Type.
The `relation` field is a **many-to-one** relation with User.<br>
One User can have many Articles and one Article can have only one User.<br>
Name the field `author` for the Article Content Type and `articles` on the User side.
Now we are ready to start customization.
## Apply the author by default
When we are creating a new Article via `POST /articles` we will need to set the authenticated user as the author of the article.
To do so we will customize the `create` controller function of the Article API.
**Concepts we will use:**
Here is the code of [core controllers](../concepts/controllers.html#core-controllers).
We will also use this [documentation](../plugins/users-permissions.html#user-object-in-strapi-context) to access the current authenticated user information.
**Path —** `./api/article/controllers/Article.js`
```js
const { parseMultipartData, sanitizeEntity } = require('strapi-utils');
module.exports = {
/**
* Create a record.
*
* @return {Object}
*/
async create(ctx) {
let entity;
if (ctx.is('multipart')) {
const { data, files } = parseMultipartData(ctx);
data.author = ctx.state.user.id;
entity = await strapi.services.article.create(data, { files });
} else {
ctx.request.body.author = ctx.state.user.id;
entity = await strapi.services.article.create(ctx.request.body);
}
return sanitizeEntity(entity, { model: strapi.models.article });
},
};
```
Now, when an article is created, the authenticated user is automaticaly set as author of the article.
## Limit the update
Now we will restrict the update of articles only for the author.
We will use the same concepts as previously.
**Path —** `./api/article/controllers/Article.js`
```js
const { parseMultipartData, sanitizeEntity } = require('strapi-utils');
module.exports = {
/**
* Create a record.
*
* @return {Object}
*/
async create(ctx) {
let entity;
if (ctx.is('multipart')) {
const { data, files } = parseMultipartData(ctx);
data.author = ctx.state.user.id;
entity = await strapi.services.article.create(data, { files });
} else {
ctx.request.body.author = ctx.state.user.id;
entity = await strapi.services.article.create(ctx.request.body);
}
return sanitizeEntity(entity, { model: strapi.models.article });
},
/**
* Update a record.
*
* @return {Object}
*/
async update(ctx) {
let entity;
const [article] = await strapi.services.article.find({
id: ctx.params.id,
'author.id': ctx.state.user.id,
});
if (!article) {
return ctx.unauthorized(`You can't update this entry`);
}
if (ctx.is('multipart')) {
const { data, files } = parseMultipartData(ctx);
entity = await strapi.services.article.update(ctx.params, data, {
files,
});
} else {
entity = await strapi.services.article.update(
ctx.params,
ctx.request.body
);
}
return sanitizeEntity(entity, { model: strapi.models.article });
},
};
```
And tada!
::: tip
For the delete action, it will be the exact same check than the update action.
:::

View File

@ -191,3 +191,85 @@ export default strapi => {
return strapi.registerPlugin(plugin);
};
```
## Adding a setting into the global section
In order to add a link into the global section of the settings view you need to create a global array containing the links you want to add;
**Path —** `plugins/my-plugin/admin/src/index.js`.
```
import pluginPkg from '../../package.json';
// Import the component
import Settings from './containers/Settings';
import SettingLink from './components/SettingLink';
import pluginId from './pluginId';
export default strapi => {
const pluginDescription =
pluginPkg.strapi.description || pluginPkg.description;
// Declare the links that will be injected into the settings menu
const menuSection = {
id: pluginId,
title: {
id: `${pluginId}.foo`,
defaultMessage: 'Super cool setting',
},
links: [
{
title: 'Setting page 1',
to: `${strapi.settingsBaseURL}/${pluginId}/setting1`,
name: 'setting1',
},
{
title: {
id: `${pluginId}.bar`,
defaultMessage: 'Setting page 2',
},
to: `${strapi.settingsBaseURL}/${pluginId}/setting2`,
name: 'setting2',
},
],
};
const plugin = {
blockerComponent: null,
blockerComponentProps: {},
description: pluginDescription,
icon: pluginPkg.strapi.icon,
id: pluginId,
initializer: () => null,
injectedComponents: [],
isReady: true,
leftMenuLinks: [],
leftMenuSections: [],
mainComponent: null,
name: pluginPkg.strapi.name,
preventComponentRendering: false,
settings: {
// Add a link into the global section of the settings view
global: [
{
title: 'Setting link 1',
to: `${strapi.settingsBaseURL}/setting-link-1`,
name: 'settingLink1',
Component: SettingLink,
// Bool : https://reacttraining.com/react-router/web/api/Route/exact-bool
exact: false,
},
],
mainComponent: Settings,
menuSection,
},
trads: {},
};
return strapi.registerPlugin(plugin);
};
```
::: danger
It is currently not possible to add a link into another plugin's setting section
:::

View File

@ -5,7 +5,7 @@ import Logo from '../../assets/images/logo-strapi.png';
const Wrapper = styled.div`
background-color: #007eff;
height: ${props => props.theme.main.sizes.header.height};
height: ${props => props.theme.main.sizes.leftMenu.height};
.leftMenuHeaderLink {
&:hover {
@ -18,7 +18,7 @@ const Wrapper = styled.div`
height: 100%;
width: 100%;
text-align: center;
height: ${props => props.theme.main.sizes.header.height};
height: ${props => props.theme.main.sizes.leftMenu.height};
vertical-align: middle;
font-size: 2rem;
letter-spacing: 0.2rem;

View File

@ -4,12 +4,14 @@ import PropTypes from 'prop-types';
const Wrapper = styled.div`
padding-top: 0.7rem;
position: absolute;
top: 6rem;
top: ${props => props.theme.main.sizes.leftMenu.height};
right: 0;
bottom: 0;
left: 0;
overflow-y: auto;
height: calc(100vh - (6rem + 10.2rem));
height: calc(
100vh - (${props => props.theme.main.sizes.leftMenu.height} + 10.2rem)
);
box-sizing: border-box;
.title {

View File

@ -6,29 +6,30 @@
import React, { memo } from 'react';
import { useGlobalContext, LeftMenu, LeftMenuList } from 'strapi-helper-plugin';
import { get } from 'lodash';
import { Switch, Redirect, Route, useParams } from 'react-router-dom';
import EditView from '../Webhooks/EditView';
import ListView from '../Webhooks/ListView';
import SettingDispatcher from './SettingDispatcher';
import Wrapper from './Wrapper';
import retrieveGlobalLinks from './utils/retrieveGlobalLinks';
import retrievePluginsMenu from './utils/retrievePluginsMenu';
function SettingsPage() {
const { settingId } = useParams();
const { formatMessage, plugins, settingsBaseURL } = useGlobalContext();
// Retrieve the links that will be injected into the global section
const globalLinks = retrieveGlobalLinks(plugins);
// Create the plugins settings section
// Note it is currently not possible to add a link into a plugin section
const pluginsMenu = retrievePluginsMenu(plugins);
const pluginsMenu = Object.keys(plugins).reduce((acc, current) => {
const pluginMenu = get(plugins, [current, 'settings', 'menuSection'], null);
if (!pluginMenu) {
return acc;
}
acc.push(pluginMenu);
return acc;
}, []);
const createdRoutes = globalLinks
.map(({ to, Component, exact }) => (
<Route path={to} key={to} component={Component} exact={exact || false} />
))
.filter((route, index, refArray) => {
return refArray.findIndex(obj => obj.key === route.key) === index;
});
const menuItems = [
{
@ -40,6 +41,7 @@ function SettingsPage() {
to: `${settingsBaseURL}/webhooks`,
name: 'webhooks',
},
...globalLinks,
],
},
...pluginsMenu,
@ -74,6 +76,7 @@ function SettingsPage() {
path={`${settingsBaseURL}/webhooks/:id`}
component={EditView}
/>
{createdRoutes}
<Route
path={`${settingsBaseURL}/:pluginId`}
component={SettingDispatcher}

View File

@ -0,0 +1,17 @@
import { get } from 'lodash';
const retrieveGlobalLinks = pluginsObj => {
return Object.values(pluginsObj).reduce((acc, current) => {
const links = get(current, ['settings', 'global'], null);
if (links) {
for (let i = 0; i < links.length; i++) {
acc.push(links[i]);
}
}
return acc;
}, []);
};
export default retrieveGlobalLinks;

View File

@ -0,0 +1,17 @@
import { get } from 'lodash';
const retrievePluginsMenu = pluginsObj => {
return Object.values(pluginsObj).reduce((acc, current) => {
const pluginMenu = get(current, ['settings', 'menuSection'], null);
if (!pluginMenu) {
return acc;
}
acc.push(pluginMenu);
return acc;
}, []);
};
export default retrievePluginsMenu;

View File

@ -0,0 +1,33 @@
import retrieveGlobalLinks from '../retrieveGlobalLinks';
describe('ADMIN | containers | SettingsPage | utils', () => {
describe('retrieveGlobalLinks', () => {
it('should return an empty array if there is no plugins', () => {
expect(retrieveGlobalLinks({})).toHaveLength(0);
});
it('should return an array of links', () => {
const plugins = {
test: {
settings: {
global: [],
},
},
noSettings: {},
foo: {
settings: {
global: ['test'],
},
},
bar: {
settings: {
global: ['test2'],
},
},
};
const expected = ['test', 'test2'];
expect(retrieveGlobalLinks(plugins)).toEqual(expected);
});
});
});

View File

@ -0,0 +1,33 @@
import retrievePluginsMenu from '../retrievePluginsMenu';
describe('ADMIN | containers | SettingsPage | utils', () => {
describe('retrievePluginsMenu', () => {
it('should return an empty array if there is no plugins', () => {
expect(retrievePluginsMenu({})).toHaveLength(0);
});
it('should return an array of menu sections', () => {
const plugins = {
test: {
settings: {
menuSection: null,
},
},
noSettings: {},
foo: {
settings: {
menuSection: { label: 'test' },
},
},
bar: {
settings: {
menuSection: { label: 'test2' },
},
},
};
const expected = [{ label: 'test' }, { label: 'test2' }];
expect(retrievePluginsMenu(plugins)).toEqual(expected);
});
});
});

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',
@ -35,27 +42,6 @@ const colors = {
'blue-dark': '#151c2e',
blue: '#0097f7',
},
filters: {
border: '#e3e9f3',
background: '#ffffff',
color: '#292b2c',
shadow: 'rgba(227, 233, 243, 0.5)',
button: {
hover: {
background: '#F7F8F8',
},
active: {
background: '#E6F0FB',
border: '#AED4FB',
color: '#007EFF',
},
},
list: {
hover: {
background: '#f6f6f6',
},
},
},
};
export default colors;

View File

@ -22,6 +22,7 @@
"app.components.BlockLink.documentation.content": "Ознакомьтесь с концепциями, справочниками и обучающими материалами.",
"app.components.Button.cancel": "Отменить",
"app.components.Button.save": "Сохранить",
"app.components.Button.reset": "Сброс",
"app.components.ComingSoonPage.comingSoon": "Скоро",
"app.components.ComingSoonPage.featuresNotAvailable": "Этот функционал все еще находится в активной разработке.",
"app.components.DownloadInfo.download": "Выполняется загрузка...",
@ -75,8 +76,10 @@
"app.components.LeftMenuLinkContainer.general": "Общие",
"app.components.LeftMenuLinkContainer.installNewPlugin": "Магазин",
"app.components.LeftMenuLinkContainer.listPlugins": "Плагины",
"app.components.LeftMenuLinkContainer.contentTypes": "Типы контента",
"app.components.LeftMenuLinkContainer.noPluginsInstalled": "Нет установленных плагинов",
"app.components.LeftMenuLinkContainer.plugins": "Плагины",
"app.components.LeftMenuLinkContainer.settings": "Настройки",
"app.components.ListPluginsPage.description": "Список установленных плагинов в проекте.",
"app.components.ListPluginsPage.helmet.title": "Список плагинов",
"app.components.ListPluginsPage.title": "Плагины",
@ -105,6 +108,7 @@
"app.components.listPlugins.title.plural": "{number} плагинов установлено",
"app.components.listPlugins.title.singular": "{number} плагин установлен",
"app.components.listPluginsPage.deletePlugin.error": "Произошла ошибка при удалении плагина",
"app.links.configure-view": "Настройка представления",
"app.utils.SelectOption.defaultMessage": " ",
"app.utils.defaultMessage": " ",
"app.utils.placeholder.defaultMessage": " ",
@ -125,7 +129,9 @@
"components.Input.error.validation.minSupMax": "Не может быть выше",
"components.Input.error.validation.regex": "Значение не соответствует регулярному выражению.",
"components.Input.error.validation.required": "Обязательное значение.",
"components.Input.error.validation.unique": "Это значение уже используется.",
"components.InputSelect.option.placeholder": "Выберите здесь",
"component.Input.error.validation.integer": "Значение должно быть целочисленным",
"components.ListRow.empty": "Нет данных для отображения.",
"components.OverlayBlocker.description": "Вы воспользовались функционалом, который требует перезапуска сервера. Пожалуйста, подождите, пока сервер не будет запущен.",
"components.OverlayBlocker.description.serverError": "Сервер должен был перезагрузиться, пожалуйста, проверьте ваши логи в терминале.",
@ -163,7 +169,6 @@
"HomePage.community": "Присоединяйтесь к сообществу",
"HomePage.roadmap": "Смотрите нашу дорожную карту",
"HomePage.greetings": "Привет {name}!",
"Auth.advanced.allow_register": "",
"Auth.privacy-policy-agreement.terms": "условия",
"Auth.privacy-policy-agreement.policy": "политика конфиденциальности",
@ -212,8 +217,43 @@
"Auth.header.register.description": "Для завершения установки и обеспечения безопасности приложения, создайте вашего первого пользователя (root admin), заполнив форму ниже.",
"Auth.link.forgot-password": "Забыли пароль?",
"Auth.link.ready": "Готовы войти?",
"Settings.global": "Глобальные настройки",
"Settings.error": "Ошибка",
"Settings.webhooks.title": "Webhooks",
"Settings.webhooks.singular": "webhook",
"Settings.webhooks.list.description": "Уведомления с помощью POST событий.",
"Settings.webhooks.list.button.add": "Добавить новый webhook",
"Settings.webhooks.list.button.delete": "Удалить",
"Settings.webhooks.list.empty.title": "Пока еще нет webhooks",
"Settings.webhooks.list.empty.description": "Добавить первый в этот список.",
"Settings.webhooks.list.empty.link": "Просмотреть документацию",
"Settings.webhooks.enabled": "Включен",
"Settings.webhooks.disabled": "Выключен",
"Settings.webhooks.create": "Создание webhook",
"Settings.webhooks.create.header": "Создание нового заголовка",
"Settings.webhooks.form.name": "Название",
"Settings.webhooks.form.url": "Url",
"Settings.webhooks.form.headers": "Заголовки",
"Settings.webhooks.form.events": "События",
"Settings.webhooks.key": "Ключ",
"Settings.webhooks.value": "Значение",
"Settings.webhooks.trigger": "Триггер",
"Settings.webhooks.trigger.title": "Сохранить перед триггером",
"Settings.webhooks.trigger.cancel": "Отмена триггера",
"Settings.webhooks.trigger.pending": "Ожидание…",
"Settings.webhooks.trigger.success": "Успех!",
"Settings.webhooks.trigger.success.label": "Триггер выполнен",
"Settings.webhooks.trigger.save": "Пожалуйста сохраните триггер",
"Settings.webhooks.trigger.test": "Тест триггер",
"Settings.webhooks.events.create": "Создание",
"Settings.webhooks.events.edit": "Редактирование",
"Settings.webhooks.events.delete": "Удаление",
"Settings.webhooks.created": "Webhook создан",
"app.containers.App.notification.error.init": "Произошла ошибка при запросе к API",
"components.Input.error.password.noMatch": "Пароль не совпадает",
"form.button.done": "Выполнено",
"notification.form.error.fields": "Форма содержит некоторые ошибки"
"form.button.finish": "Финиш",
"notification.form.error.fields": "Форма содержит некоторые ошибки",
"notification.form.success.fields": "Изменения сохранены",
"global.prompt.unsaved": "Вы действительно хотите покинуть эту страницу? Все Ваши изменения будут потеряны"
}

View File

@ -249,6 +249,7 @@ async function watchFiles(dir, ignoreFiles = []) {
const cacheDir = path.join(dir, '.cache');
const pkgJSON = require(path.join(dir, 'package.json'));
const admin = path.join(dir, 'admin');
const extensionsPath = path.join(dir, 'extensions');
const appPlugins = Object.keys(pkgJSON.dependencies).filter(
dep =>
@ -256,12 +257,7 @@ async function watchFiles(dir, ignoreFiles = []) {
fs.existsSync(path.resolve(getPkgPath(dep), 'admin', 'src', 'index.js'))
);
const pluginsToWatch = appPlugins.map(plugin =>
path.join(
dir,
'extensions',
plugin.replace(/^strapi-plugin-/i, ''),
'admin'
)
path.join(extensionsPath, plugin.replace(/^strapi-plugin-/i, ''), 'admin')
);
const filesToWatch = [admin, ...pluginsToWatch];
@ -272,20 +268,20 @@ async function watchFiles(dir, ignoreFiles = []) {
});
watcher.on('all', async (event, filePath) => {
const re = /\/extensions\/([^\/]*)\/.*$/gm;
const matched = re.exec(filePath);
const isExtension = matched !== null;
const pluginName = isExtension ? matched[1] : '';
const isExtension = filePath.includes(extensionsPath);
const pluginName = isExtension
? filePath.replace(extensionsPath, '').split(path.sep)[1]
: '';
const packageName = isExtension
? `strapi-plugin-${pluginName}`
: 'strapi-admin';
const targetPath = isExtension
? filePath
.split(`${path.sep}extensions${path.sep}`)[1]
.replace(pluginName, '')
: filePath.split(`${path.sep}admin`)[1];
? path.normalize(
filePath.split(extensionsPath)[1].replace(pluginName, '')
)
: path.normalize(filePath.split(admin)[1]);
const destFolder = isExtension
? path.join(cacheDir, 'plugins', packageName)

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';

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,29 @@
import styled from 'styled-components';
import PropTypes from 'prop-types';
import { themePropTypes } from 'strapi-helper-plugin';
const Wrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
margin-right: 5px;
background-color: ${({ theme }) => theme.main.colors.white};
border: 1px solid #e3e9f3;
border-radius: ${({ theme }) => theme.main.sizes.borderRadius};
cursor: pointer;
font-size: 11px;
color: ${({ color }) => color};
`;
Wrapper.defaultProps = {
color: '#b3b5b9',
};
Wrapper.propTypes = {
color: PropTypes.string,
...themePropTypes,
};
export default Wrapper;

View File

@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Pencil } from '@buffetjs/icons';
import { ClearIcon } from 'strapi-helper-plugin';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import Wrapper from './Wrapper';
const CardControl = ({ color, onClick, type }) => {
return (
<Wrapper onClick={onClick} color={color}>
{type === 'pencil' && <Pencil fill={color} />}
{type === 'clear' && <ClearIcon fill={color} />}
{!['pencil', 'clear'].includes(type) && <FontAwesomeIcon icon={type} />}
</Wrapper>
);
};
CardControl.defaultProps = {
color: '#b3b5b9',
onClick: () => {},
type: 'pencil',
};
CardControl.propTypes = {
color: PropTypes.string,
onClick: PropTypes.func,
type: PropTypes.string,
};
export default CardControl;

View File

@ -0,0 +1,10 @@
import styled from 'styled-components';
const CardControlsWrapper = styled.div`
position: absolute;
top: 10px;
right: 5px;
display: flex;
`;
export default CardControlsWrapper;

View File

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
const CardImgWrapper = styled.div`
position: relative;
height: ${({ isSmall }) => (isSmall ? '127px' : '156px')};
min-width: ${({ isSmall }) => (isSmall ? '200px' : '245px')};
min-width: ${({ isSmall }) => (isSmall ? '100%' : '245px')};
border-radius: 2px;
background: ${({ withOverlay }) => (withOverlay ? '#F6F6F6' : '#333740')};
@ -18,6 +18,17 @@ const CardImgWrapper = styled.div`
return '';
}}
.card-control-wrapper {
display: none;
}
&:hover {
.card-control-wrapper {
display: flex;
z-index: 1050;
}
}
`;
CardImgWrapper.defaultProps = {

View File

@ -8,9 +8,9 @@ const DropdownButton = styled.button`
height: 32px;
padding: 0 10px;
line-height: 30px;
background-color: ${({ theme }) => theme.main.colors.filters.background};
border: 1px solid ${({ theme }) => theme.main.colors.filters.border};
color: ${({ theme }) => theme.main.colors.filters.color};
background-color: ${({ theme }) => theme.main.colors.white};
border: 1px solid ${({ theme }) => theme.main.colors.darkGrey};
color: ${({ theme }) => theme.main.colors.greyDark};
font-weight: ${({ theme }) => theme.main.fontWeights.semiBold};
font-size: ${({ theme }) => theme.main.fontSizes.md};
border-radius: ${({ theme }) => theme.main.sizes.borderRadius};
@ -24,24 +24,24 @@ const DropdownButton = styled.button`
margin-left: 10px;
}
> svg g {
stroke: ${({ theme }) => theme.main.colors.filters.color};
stroke: ${({ theme }) => theme.main.colors.greyDark};
}
${({ isActive, theme }) =>
isActive
? `
background-color: ${theme.main.colors.filters.button.active.background};
border: 1px solid ${theme.main.colors.filters.button.active.border};
color: ${theme.main.colors.filters.button.active.color};
> svg g {
stroke: ${theme.main.colors.filters.button.active.color};
}
`
background-color: ${theme.main.colors.lightBlue};
border: 1px solid ${theme.main.colors.darkBlue};
color: ${theme.main.colors.mediumBlue};
> svg g {
stroke: ${theme.main.colors.mediumBlue};
}
`
: `
&:hover {
background-color: ${theme.main.colors.filters.button.hover.background};
}
`}
&:hover {
background-color: ${theme.main.colors.lightestGrey};
}
`}
`;
DropdownButton.defaultProps = {

View File

@ -8,9 +8,9 @@ const DropdownSection = styled.div`
top: 38px;
left: 0;
z-index: 1;
background-color: ${({ theme }) => theme.main.colors.filters.background};
border: 1px solid ${({ theme }) => theme.main.colors.filters.border};
box-shadow: 0 2px 4px ${({ theme }) => theme.main.colors.filters.shadow};
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};
${({ isOpen }) => isOpen && 'display: block;'}
`;

View File

@ -0,0 +1,77 @@
import React from 'react';
import PropTypes from 'prop-types';
import { get } from 'lodash';
import Flex from '../Flex';
import Text from '../Text';
import FileDetailsBoxWrapper from './FileDetailsBoxWrapper';
import formatBytes from './utils/formatBytes';
const FileDetailsBox = ({ file }) => {
const sections = [
{
key: 0,
rows: [
{ label: 'size', value: formatBytes(get(file, 'size', 0), 0) },
{ label: 'date', value: '-' },
],
},
{
key: 1,
type: 'spacer',
},
{
key: 2,
rows: [
{ label: 'dimensions', value: '-' },
{ label: 'extension', value: '-' },
],
},
];
return (
<FileDetailsBoxWrapper>
{sections.map(({ key, rows, type }) => {
if (type === 'spacer') {
return (
<Text as="section" key={key}>
<Text>&nbsp;</Text>
</Text>
);
}
return (
<Flex justifyContent="space-between" key={key}>
{rows.map(rowItem => {
return (
<Text as="div" key={rowItem.label} style={{ width: '50%' }}>
<Text
color="grey"
fontWeight="bold"
textTransform="capitalize"
>
{rowItem.label}
</Text>
<Text color="grey">{rowItem.value}</Text>
</Text>
);
})}
</Flex>
);
})}
</FileDetailsBoxWrapper>
);
};
FileDetailsBox.defaultProps = {
file: {
size: 0,
},
};
FileDetailsBox.propTypes = {
file: PropTypes.shape({
size: PropTypes.number,
}),
};
export default FileDetailsBox;

View File

@ -0,0 +1,14 @@
import styled from 'styled-components';
import { themePropTypes } from 'strapi-helper-plugin';
const FileDetailsBoxWrapper = styled.div`
width: 100%;
height: 119px;
padding: 16px;
background-color: ${({ theme }) => theme.main.colors.lightGrey};
border-radius: ${({ theme }) => theme.main.sizes.borderRadius};
`;
FileDetailsBoxWrapper.propTypes = themePropTypes;
export default FileDetailsBoxWrapper;

View File

@ -0,0 +1,31 @@
import styled from 'styled-components';
import CardImgWrapper from '../CardImgWrapper';
const FileWrapper = styled(CardImgWrapper)`
height: 401px;
width: 100%;
display: flex;
> img {
width: 100%;
object-fit: contain;
margin: auto;
}
.cropper-view-box {
outline-color: ${({ theme }) => theme.main.colors.white};
}
.cropper-point {
background-color: ${({ theme }) => theme.main.colors.white};
border-radius: 50%;
}
.point-se {
&:before {
display: none;
}
height: 5px;
width: 5px;
}
`;
export default FileWrapper;

View File

@ -0,0 +1,7 @@
import styled from 'styled-components';
const FormWrapper = styled.div`
margin-top: 22px;
`;
export default FormWrapper;

View File

@ -0,0 +1,8 @@
import styled from 'styled-components';
import { Row as Base } from 'reactstrap';
const Row = styled(Base)`
margin-bottom: 4px;
`;
export default Row;

View File

@ -0,0 +1,8 @@
import styled from 'styled-components';
import ContainerFluid from '../ContainerFluid';
const Wrapper = styled(ContainerFluid)`
padding-top: 18px;
`;
export default Wrapper;

View File

@ -0,0 +1,259 @@
import React, { useState, useEffect, useRef } from 'react';
import { get } from 'lodash';
import PropTypes from 'prop-types';
import { Inputs } from '@buffetjs/custom';
import { useGlobalContext } from 'strapi-helper-plugin';
import Cropper from 'cropperjs';
import 'cropperjs/dist/cropper.css';
import CardControl from '../CardControl';
import CardControlsWrapper from '../CardControlsWrapper';
import ModalSection from '../ModalSection';
import Text from '../Text';
import FileDetailsBox from './FileDetailsBox';
import FileWrapper from './FileWrapper';
import FormWrapper from './FormWrapper';
import Row from './Row';
import Wrapper from './Wrapper';
import form from './utils/form';
import isImageType from './utils/isImageType';
const EditForm = ({
fileToEdit,
onChange,
onClickDeleteFileToUpload,
onSubmitEditNewFile,
setCropResult,
}) => {
const { formatMessage } = useGlobalContext();
const [isCropping, setIsCropping] = useState(false);
const [infosCoordinates, setInfosCoordinates] = useState({ top: 0, left: 0 });
const [infos, setInfos] = useState({ width: 0, height: 0 });
const [src, setSrc] = useState(null);
const mimeType = get(fileToEdit, ['file', 'type'], '');
const isImg = isImageType(mimeType);
// TODO
const canCrop = isImg && !mimeType.includes('svg');
const imgRef = useRef();
let cropper = useRef();
useEffect(() => {
if (isImg) {
// TODO: update when editing existing file
const reader = new FileReader();
reader.onloadend = () => {
setSrc(reader.result);
};
reader.readAsDataURL(fileToEdit.file);
}
}, [isImg, fileToEdit]);
useEffect(() => {
if (isCropping) {
cropper.current = new Cropper(imgRef.current, {
modal: false,
initialAspectRatio: 16 / 9,
movable: true,
zoomable: false,
cropBoxResizable: true,
background: false,
ready: handleResize,
cropmove: handleResize,
});
} else if (cropper.current) {
cropper.current.destroy();
}
return () => {
if (cropper.current) {
cropper.current.destroy();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cropper, isCropping]);
const handleResize = () => {
const cropBox = cropper.current.getCropBoxData();
const { width, height } = cropBox;
const roundedWidth = Math.round(width);
const roundedHeight = Math.round(height);
const xSignWidth = 3;
const margin = 15;
const pixelWidth = 13;
const left =
cropBox.left +
width -
margin -
((roundedHeight.toString().length +
roundedWidth.toString().length +
xSignWidth) *
pixelWidth) /
2;
const top = cropBox.top + height - pixelWidth - margin;
setInfosCoordinates({ top, left });
setInfos({ width: roundedWidth, height: roundedHeight });
};
const handleToggleCropMode = () => {
setIsCropping(prev => !prev);
};
const handleClick = () => {
if (cropper) {
const canvas = cropper.current.getCroppedCanvas();
canvas.toBlob(blob => {
const {
file: { lastModifiedDate, lastModified, name },
} = fileToEdit;
setCropResult(
new File([blob], name, {
type: mimeType,
lastModified,
lastModifiedDate,
})
);
});
}
setIsCropping(false);
};
const handleClickDelete = () => {
onClickDeleteFileToUpload(fileToEdit.originalIndex);
};
const handleSubmit = e => {
e.preventDefault();
onSubmitEditNewFile(e);
};
return (
<form onSubmit={handleSubmit}>
<ModalSection>
<Wrapper>
<div className="row">
<div className="col-6">
<FileWrapper>
<CardControlsWrapper className="card-control-wrapper">
{!isCropping ? (
<>
<CardControl
color="#9EA7B8"
type="trash-alt"
onClick={handleClickDelete}
/>
{canCrop && (
<CardControl
type="crop"
color="#9EA7B8"
onClick={handleToggleCropMode}
/>
)}
</>
) : (
<>
<CardControl
type="clear"
color="#F64D0A"
onClick={handleToggleCropMode}
/>
<CardControl
type="check"
color="#6DBB1A"
onClick={handleClick}
/>
</>
)}
</CardControlsWrapper>
{isImg ? (
<>
<img
src={src}
alt={get(fileToEdit, ['file', 'name'], '')}
ref={isCropping ? imgRef : null}
/>
{isCropping && (
<Text
fontSize="md"
color="white"
as="div"
style={{
position: 'absolute',
top: infosCoordinates.top,
left: infosCoordinates.left,
background: '#333740',
borderRadius: 2,
}}
>
&nbsp;
{infos.width} x {infos.height}
&nbsp;
</Text>
)}
</>
) : null}
</FileWrapper>
</div>
<div className="col-6">
<FileDetailsBox file={fileToEdit.file} />
<FormWrapper>
{form.map(({ key, inputs }) => {
return (
<Row key={key}>
{inputs.map(input => {
return (
<div className="col-12" key={input.name}>
<Inputs
type="text"
onChange={onChange}
{...input}
label={formatMessage(input.label)}
description={
input.description
? formatMessage(input.description)
: null
}
/>
</div>
);
})}
</Row>
);
})}
</FormWrapper>
</div>
</div>
</Wrapper>
<button type="submit" style={{ display: 'none' }}>
hidden button to make to get the native form event
</button>
</ModalSection>
</form>
);
};
EditForm.defaultProps = {
fileToEdit: null,
onChange: () => {},
onClickDeleteFileToUpload: () => {},
onSubmitEditNewFile: e => e.preventDefault(),
setCropResult: () => {},
};
EditForm.propTypes = {
fileToEdit: PropTypes.object,
onChange: PropTypes.func,
onClickDeleteFileToUpload: PropTypes.func,
onSubmitEditNewFile: PropTypes.func,
setCropResult: PropTypes.func,
};
export default EditForm;

View File

@ -0,0 +1,37 @@
import getTrad from '../../../utils/getTrad';
const form = [
{
key: 1,
inputs: [
{
label: { id: getTrad('form.input.label.file-name') },
name: 'name',
value: '',
},
],
},
{
key: 2,
inputs: [
{
description: { id: getTrad('form.input.decription.file-alt') },
label: { id: getTrad('form.input.label.file-alt') },
name: 'alt',
value: '',
},
],
},
{
key: 3,
inputs: [
{
label: { id: getTrad('form.input.label.file-caption') },
name: 'caption',
value: '',
},
],
},
];
export default form;

View File

@ -0,0 +1,15 @@
// Source: https://stackoverflow.com/questions/15900485/correct-way-to-convert-size-in-bytes-to-kb-mb-gb-in-javascript
function formatBytes(bytes, decimals) {
if (bytes === 0) {
return '0 Bytes';
}
const k = 1024;
const dm = decimals <= 0 ? 0 : decimals || 2;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
}
export default formatBytes;

View File

@ -0,0 +1,3 @@
const isImageType = mimeType => mimeType.includes('image');
export default isImageType;

View File

@ -0,0 +1,29 @@
import formatBytes from '../formatBytes';
describe('UPLOAD | components | EditForm | utils', () => {
describe('formatBytes', () => {
it('should return 0 Bytes', () => {
expect(formatBytes(0)).toEqual('0 Bytes');
});
it('should return 1KB if 1024 Bytes is passed', () => {
expect(formatBytes(1024)).toEqual('1 KB');
});
it("should return 1KB if '1024' Bytes is passed", () => {
expect(formatBytes('1024')).toEqual('1 KB');
});
it('should return 1.21KB if 1034 Bytes is passed', () => {
expect(formatBytes(1234)).toEqual('1.21 KB');
});
it('should return 1.21KB if 1034 Bytes is passed with 3 decimals', () => {
expect(formatBytes(1234, 3)).toEqual('1.205 KB');
});
it('should return 1 MB if 1.1e+6 Bytes is passed', () => {
expect(formatBytes(1100000, 0)).toEqual('1 MB');
});
});
});

View File

@ -1,7 +1,17 @@
import styled from 'styled-components';
import PropTypes from 'prop-types';
const Flex = styled.div`
display: flex;
justify-content: ${({ justifyContent }) => justifyContent};
`;
Flex.defaultProps = {
justifyContent: 'normal',
};
Flex.propTypes = {
justifyContent: PropTypes.string,
};
export default Flex;

View File

@ -0,0 +1,29 @@
/*
*
*
* BackButton
*
*/
import styled from 'styled-components';
const BackButton = styled.button`
height: 6rem;
width: 6.5rem;
margin-right: 20px;
margin-left: -30px;
line-height: 6rem;
text-align: center;
color: #81848a;
border-right: 1px solid #f3f4f4;
&:before {
content: '\f053';
font-family: 'FontAwesome';
font-size: ${({ theme }) => theme.main.fontSizes.lg};
font-weight: ${({ theme }) => theme.main.fontWeights.bold};
}
&:hover {
background-color: #f3f4f4;
}
`;
export default BackButton;

View File

@ -8,17 +8,35 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { HeaderModalTitle } from 'strapi-helper-plugin';
import ModalSection from '../ModalSection';
import Text from '../Text';
import BackButton from './BackButton';
import Wrapper from './Wrapper';
const ModalHeader = ({ headers }) => {
const ModalHeader = ({ goBack, headers, withBackButton }) => {
return (
<Wrapper>
<ModalSection>
<HeaderModalTitle>
{headers.map(({ key, element }) => {
return <Fragment key={key}>{element}</Fragment>;
{withBackButton && <BackButton onClick={goBack} type="button" />}
{headers.map(({ key, element }, index) => {
const shouldDisplayChevron = index < headers.length - 1;
return (
<Fragment key={key}>
{element}
{shouldDisplayChevron && (
<Text as="span" fontSize="xs" color="#919bae">
<FontAwesomeIcon
icon="chevron-right"
style={{ margin: '0 10px' }}
/>
</Text>
)}
</Fragment>
);
})}
</HeaderModalTitle>
</ModalSection>
@ -27,11 +45,15 @@ const ModalHeader = ({ headers }) => {
};
ModalHeader.defaultProps = {
goBack: () => {},
headers: [],
withBackButton: false,
};
ModalHeader.propTypes = {
goBack: PropTypes.func,
headers: PropTypes.array,
withBackButton: PropTypes.bool,
};
export default ModalHeader;

View File

@ -7,6 +7,10 @@ const SortList = styled.ul`
min-width: 230px;
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};
${({ isOpen }) => isOpen && 'display: block;'}
`;
SortList.propTypes = {

View File

@ -8,13 +8,12 @@ const SortListItem = styled.li`
line-height: 27px;
&:hover {
cursor: pointer;
background-color: ${({ theme }) =>
theme.main.colors.filters.list.hover.background};
background-color: ${({ theme }) => theme.main.colors.mediumGrey};
}
${({ isActive, theme }) =>
isActive &&
`
background-color: ${theme.main.colors.filters.list.hover.background};
background-color: ${theme.main.colors.mediumGrey};
`}
`;

View File

@ -25,7 +25,7 @@ const SortPicker = ({ onChange, value }) => {
};
const handleChange = value => {
onChange({ target: { value } });
onChange({ target: { name: '_sort', value } });
hangleToggle();
};

View File

@ -3,15 +3,17 @@ import styled from 'styled-components';
const Text = styled.p`
margin: 0;
line-height: 18px;
color: ${({ theme, color }) => theme.main.colors[color]};
color: ${({ theme, color }) => theme.main.colors[color] || color};
font-size: ${({ theme, fontSize }) => theme.main.fontSizes[fontSize]};
font-weight: ${({ theme, fontWeight }) => theme.main.fontWeights[fontWeight]};
text-transform: ${({ textTransform }) => textTransform};
`;
Text.defaultProps = {
color: 'greyDark',
fontSize: 'md',
fontWeight: 'regular',
textTransform: 'none',
};
export default Text;

View File

@ -0,0 +1,12 @@
import styled from 'styled-components';
import ContainerFluid from '../ContainerFluid';
const Container = styled(ContainerFluid)`
margin-bottom: 4px;
padding-top: 14px;
max-height: 350px;
overflow: auto;
overflow-x: hidden;
`;
export default Container;

View File

@ -1,5 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import CardControl from '../CardControl';
import CardControlsWrapper from '../CardControlsWrapper';
import CardImgWrapper from '../CardImgWrapper';
import InfiniteLoadingIndicator from '../InfiniteLoadingIndicator';
@ -9,17 +11,27 @@ const RowItem = ({
errorMessage,
isUploading,
onClick,
onClickEdit,
originalIndex,
}) => {
const handleClick = () => {
onClick(originalIndex);
};
const handleClickEdit = () => {
onClickEdit(originalIndex);
};
return (
<div className="col-3" key={originalIndex}>
<div>
<CardImgWrapper isSmall hasError={hasError}>
{isUploading && <InfiniteLoadingIndicator onClick={handleClick} />}
{!isUploading && (
<CardControlsWrapper className="card-control-wrapper">
<CardControl onClick={handleClickEdit} />
</CardControlsWrapper>
)}
</CardImgWrapper>
<p style={{ marginBottom: 14 }}>{errorMessage || file.name}</p>
</div>
@ -37,6 +49,7 @@ RowItem.propTypes = {
errorMessage: PropTypes.string,
isUploading: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
onClickEdit: PropTypes.func.isRequired,
originalIndex: PropTypes.number.isRequired,
};

View File

@ -4,17 +4,17 @@ import { FormattedMessage } from 'react-intl';
import { Button } from '@buffetjs/core';
import createMatrix from '../../utils/createMatrix';
import getTrad from '../../utils/getTrad';
import ContainerFluid from '../ContainerFluid';
import ModalSection from '../ModalSection';
import Text from '../Text';
import Container from './Container';
import ButtonWrapper from './ButtonWrapper';
import TextWrapper from './TextWrapper';
import RowItem from './RowItem';
import ListWrapper from './ListWrapper';
const UploadList = ({
filesToUpload,
onClickCancelUpload,
onClickEditNewFile,
onGoToAddBrowseFiles,
}) => {
const matrix = createMatrix(filesToUpload);
@ -56,23 +56,22 @@ const UploadList = ({
</ModalSection>
<ModalSection>
<ContainerFluid>
<ListWrapper>
{matrix.map(({ key, rowContent }) => {
return (
<div className="row" key={key}>
{rowContent.map(data => (
<RowItem
{...data}
onClick={onClickCancelUpload}
key={data.originalIndex}
/>
))}
</div>
);
})}
</ListWrapper>
</ContainerFluid>
<Container>
{matrix.map(({ key, rowContent }) => {
return (
<div className="row" key={key}>
{rowContent.map(data => (
<RowItem
{...data}
onClick={onClickCancelUpload}
onClickEdit={onClickEditNewFile}
key={data.originalIndex}
/>
))}
</div>
);
})}
</Container>
</ModalSection>
</>
);
@ -81,12 +80,14 @@ const UploadList = ({
UploadList.defaultProps = {
filesToUpload: [],
onClickCancelUpload: () => {},
onClickEditNewFile: () => {},
onGoToAddBrowseFiles: () => {},
};
UploadList.propTypes = {
filesToUpload: PropTypes.array,
onClickCancelUpload: PropTypes.func,
onClickEditNewFile: PropTypes.func,
onGoToAddBrowseFiles: PropTypes.func,
};

View File

@ -1,13 +1,13 @@
import React, { useEffect, useReducer, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import React, { useReducer, useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { Header } from '@buffetjs/custom';
import {
HeaderSearch,
PageFooter,
useGlobalContext,
useQuery,
generateSearchFromFilters,
} from 'strapi-helper-plugin';
import useQuery from '../../hooks/useQuery';
import getTrad from '../../utils/getTrad';
import Container from '../../components/Container';
import ControlsWrapper from '../../components/ControlsWrapper';
@ -27,23 +27,17 @@ const HomePage = () => {
const [reducerState, dispatch] = useReducer(reducer, initialState, init);
const [isOpen, setIsOpen] = useState(false);
const { push } = useHistory();
const { search } = useLocation();
const query = useQuery();
const { data, dataToDelete, _limit, _page, _sort, _q } = reducerState.toJS();
const { data, dataToDelete } = reducerState.toJS();
const pluginName = formatMessage({ id: getTrad('plugin.name') });
useEffect(() => {
const searchParams = getSearchParams();
Object.keys(searchParams).map(key => {
return dispatch({
type: 'ON_QUERY_CHANGE',
key,
value: searchParams[key],
});
// TODO - Retrieve data
dispatch({
type: 'GET_DATA_SUCCEEDED',
data: [],
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search]);
}, []);
const getSearchParams = () => {
const params = {};
@ -64,38 +58,23 @@ const HomePage = () => {
const handleChangeListParams = ({ target: { name, value } }) => {
const key = name.split('.').pop();
handleChangeQuery({ key, value });
handleChangeParams({ target: { name: key, value } });
};
const handleChangeParams = ({ key, value }) => {
const updatedSearch = getUpdatedSearchParams({ [key]: value });
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: newSearch });
};
const handleChangeSearch = ({ target: { value } }) => {
handleChangeQuery({ key: '_q', value });
};
const handleChangeSort = ({ target: { value } }) => {
handleChangeQuery({ key: '_sort', value });
};
const handleChangeQuery = ({ key, value }) => {
dispatch({
type: 'ON_QUERY_CHANGE',
key,
value,
});
handleChangeParams({
key,
value,
});
};
const handleClearSearch = () => {
handleChangeSearch({ target: { value: '' } });
handleChangeParams({ target: { name: '_q', value: '' } });
};
const handleClickToggleModal = () => {
@ -133,8 +112,8 @@ const HomePage = () => {
};
const params = {
_limit: parseInt(_limit, 10),
_page: parseInt(_page, 10),
_limit: parseInt(getQueryValue('_limit'), 10) || 10,
_page: parseInt(getQueryValue('_page'), 10) || 1,
};
return (
@ -142,15 +121,19 @@ const HomePage = () => {
<Header {...headerProps} />
<HeaderSearch
label={pluginName}
onChange={handleChangeSearch}
onChange={handleChangeParams}
onClear={handleClearSearch}
placeholder={formatMessage({ id: getTrad('search.placeholder') })}
value={_q}
name="_q"
value={getQueryValue('_q') || ''}
/>
<ControlsWrapper>
<SelectAll />
<SortPicker onChange={handleChangeSort} value={_sort} />
<SortPicker
onChange={handleChangeParams}
value={getQueryValue('_sort') || null}
/>
<FiltersPicker />
<FiltersList />
</ControlsWrapper>

View File

@ -3,16 +3,12 @@ import { fromJS } from 'immutable';
const initialState = fromJS({
data: [],
dataToDelete: [],
_q: '',
_page: 1,
_limit: 10,
_sort: '',
});
const reducer = (state, action) => {
switch (action.type) {
case 'ON_QUERY_CHANGE':
return state.update(action.key, () => action.value);
case 'GET_DATA_SUCCEEDED':
return state.update('data', () => action.data);
default:
return state;
}

View File

@ -19,8 +19,10 @@ import getTrad from '../../utils/getTrad';
const ModalStepper = ({ isOpen, onToggle }) => {
const { formatMessage } = useGlobalContext();
const [reducerState, dispatch] = useReducer(reducer, initialState, init);
const { currentStep, filesToUpload } = reducerState.toJS();
const { Component, headerTradId, next, prev } = stepper[currentStep];
const { currentStep, fileToEdit, filesToUpload } = reducerState.toJS();
const { Component, headers, next, prev, withBackButton } = stepper[
currentStep
];
const filesToUploadLength = filesToUpload.length;
const toggleRef = useRef();
toggleRef.current = onToggle;
@ -53,12 +55,36 @@ const ModalStepper = ({ isOpen, onToggle }) => {
});
};
const handleClickDeleteFileToUpload = fileIndex => {
dispatch({
type: 'REMOVE_FILE_TO_UPLOAD',
fileIndex,
});
if (currentStep === 'edit-new') {
dispatch({
type: 'RESET_FILE_TO_EDIT',
});
goNext();
}
};
const handleClosed = () => {
dispatch({
type: 'RESET_PROPS',
});
};
const handleGoToEditNewFile = fileIndex => {
dispatch({
type: 'SET_FILE_TO_EDIT',
fileIndex,
});
goTo('edit-new');
};
const handleGoToAddBrowseFiles = () => {
dispatch({
type: 'CLEAN_FILES_ERROR',
@ -67,6 +93,38 @@ const ModalStepper = ({ isOpen, onToggle }) => {
goBack();
};
const handleSetCropResult = blob => {
dispatch({
type: 'SET_CROP_RESULT',
blob,
});
};
const handleSubmitEditNewFile = e => {
e.preventDefault();
dispatch({
type: 'ON_SUBMIT_EDIT_NEW_FILE',
});
goNext();
};
const handleToggle = () => {
if (filesToUploadLength > 0) {
// eslint-disable-next-line no-alert
const confirm = window.confirm(
formatMessage({ id: getTrad('window.confirm.close-modal') })
);
if (!confirm) {
return;
}
}
onToggle();
};
const handleUploadFiles = async () => {
dispatch({
type: 'SET_FILES_UPLOADING_STATE',
@ -138,29 +196,36 @@ const ModalStepper = ({ isOpen, onToggle }) => {
};
return (
<Modal isOpen={isOpen} onToggle={onToggle} onClosed={handleClosed}>
<Modal isOpen={isOpen} onToggle={handleToggle} onClosed={handleClosed}>
{/* header title */}
<ModalHeader
headers={[
{
key: headerTradId,
element: <FormattedMessage id={headerTradId} />,
},
]}
goBack={goBack}
headers={headers.map(headerTrad => ({
key: headerTrad,
element: <FormattedMessage id={headerTrad} />,
}))}
withBackButton={withBackButton}
/>
{/* body of the modal */}
{Component && (
<Component
addFilesToUpload={addFilesToUpload}
fileToEdit={fileToEdit}
filesToUpload={filesToUpload}
onClickCancelUpload={handleCancelFileToUpload}
onClickDeleteFileToUpload={handleClickDeleteFileToUpload}
onClickEditNewFile={handleGoToEditNewFile}
onGoToAddBrowseFiles={handleGoToAddBrowseFiles}
onSubmitEditNewFile={handleSubmitEditNewFile}
onToggle={handleToggle}
setCropResult={handleSetCropResult}
withBackButton={withBackButton}
/>
)}
<ModalFooter>
<section>
<Button type="button" color="cancel" onClick={onToggle}>
<Button type="button" color="cancel" onClick={handleToggle}>
{formatMessage({ id: 'app.components.Button.cancel' })}
</Button>
{currentStep === 'upload' && (
@ -177,6 +242,15 @@ const ModalStepper = ({ isOpen, onToggle }) => {
)}
</Button>
)}
{currentStep === 'edit-new' && (
<Button
color="success"
type="button"
onClick={handleSubmitEditNewFile}
>
{formatMessage({ id: 'form.button.finish' })}
</Button>
)}
</section>
</ModalFooter>
</Modal>

View File

@ -4,6 +4,7 @@ import createNewFilesToUploadArray from './utils/createNewFilesToUploadArray';
const initialState = fromJS({
currentStep: 'browse',
filesToUpload: [],
fileToEdit: null,
});
const reducer = (state, action) => {
@ -24,14 +25,28 @@ const reducer = (state, action) => {
);
case 'GO_TO':
return state.update('currentStep', () => action.to);
case 'ON_SUBMIT_EDIT_NEW_FILE': {
const originalIndex = state.getIn(['fileToEdit', 'originalIndex']);
return state
.updateIn(['filesToUpload', originalIndex], () =>
state.get('fileToEdit')
)
.update('fileToEdit', () => null);
}
case 'REMOVE_FILE_TO_UPLOAD':
return state.update('filesToUpload', list => {
return list.filter(
data => data.get('originalIndex') !== action.fileIndex
);
});
case 'RESET_FILE_TO_EDIT':
return state.update('fileToEdit', () => null);
case 'RESET_PROPS':
return initialState;
case 'SET_CROP_RESULT': {
return state.updateIn(['fileToEdit', 'file'], () => fromJS(action.blob));
}
case 'SET_FILE_ERROR':
return state.update('filesToUpload', list => {
return list.map(data => {
@ -45,6 +60,10 @@ const reducer = (state, action) => {
return data;
});
});
case 'SET_FILE_TO_EDIT':
return state.update('fileToEdit', () =>
state.getIn(['filesToUpload', action.fileIndex])
);
case 'SET_FILES_UPLOADING_STATE':
return state.update('filesToUpload', list =>
list.map(data =>

View File

@ -244,6 +244,71 @@ describe('UPLOAD | containers | ModalStepper | reducer', () => {
});
});
describe('ON_SUBMIT_EDIT_NEW_FILE', () => {
it('should update the filesToUploadList with the fileToEdit data', () => {
const action = {
type: 'ON_SUBMIT_EDIT_NEW_FILE',
};
const state = fromJS({
currentStep: 'edit-new',
filesToUpload: [
{
originalIndex: 0,
file: {
name: 'test',
},
},
{
originalIndex: 1,
file: {
test: false,
},
},
{
originalIndex: 2,
file: {
name: 'test2',
},
},
],
fileToEdit: {
originalIndex: 1,
file: {
test: true,
otherTest: true,
},
},
});
const expected = fromJS({
currentStep: 'edit-new',
filesToUpload: [
{
originalIndex: 0,
file: {
name: 'test',
},
},
{
originalIndex: 1,
file: {
test: true,
otherTest: true,
},
},
{
originalIndex: 2,
file: {
name: 'test2',
},
},
],
fileToEdit: null,
});
expect(reducer(state, action)).toEqual(expected);
});
});
describe('REMOVE_FILE_TO_UPLOAD', () => {
it('should remove the file from the filesToUpload array', () => {
const action = {
@ -306,6 +371,22 @@ describe('UPLOAD | containers | ModalStepper | reducer', () => {
});
});
describe('RESET_FILE_TO_UPLOAD', () => {
it('should set the fileToEdit key to null', () => {
const action = {
type: 'RESET_FILE_TO_EDIT',
};
const state = fromJS({
fileToEdit: 'test',
});
const expected = fromJS({
fileToEdit: null,
});
expect(reducer(state, action)).toEqual(expected);
});
});
describe('RESET_PROPS', () => {
it('should return the initialState', () => {
const action = { type: 'RESET_PROPS' };
@ -313,6 +394,34 @@ describe('UPLOAD | containers | ModalStepper | reducer', () => {
const expected = fromJS({
currentStep: 'browse',
filesToUpload: [],
fileToEdit: null,
});
expect(reducer(state, action)).toEqual(expected);
});
});
describe('SET_CROP_RESULT', () => {
it('should update the fileToEditEntry with the passed data', () => {
const action = {
type: 'SET_CROP_RESULT',
blob: {
test: true,
},
};
const state = fromJS({
fileToEdit: {
originalIndex: 1,
file: null,
},
});
const expected = fromJS({
fileToEdit: {
originalIndex: 1,
file: {
test: true,
},
},
});
expect(reducer(state, action)).toEqual(expected);
@ -390,6 +499,68 @@ describe('UPLOAD | containers | ModalStepper | reducer', () => {
});
});
describe('SET_FILE_TO_EDIT', () => {
it('should set the fileToEdit key with the file at the passed index from the filesToUpload list', () => {
const action = {
type: 'SET_FILE_TO_EDIT',
fileIndex: 1,
};
const state = fromJS({
fileToEdit: null,
filesToUpload: [
{
originalIndex: 0,
file: {
name: 'test0',
},
},
{
originalIndex: 1,
file: {
name: 'test1',
},
},
{
originalIndex: 2,
file: {
name: 'test2',
},
},
],
});
const expected = fromJS({
fileToEdit: {
originalIndex: 1,
file: {
name: 'test1',
},
},
filesToUpload: [
{
originalIndex: 0,
file: {
name: 'test0',
},
},
{
originalIndex: 1,
file: {
name: 'test1',
},
},
{
originalIndex: 2,
file: {
name: 'test2',
},
},
],
});
expect(reducer(state, action)).toEqual(expected);
});
});
describe('SET_FILES_UPLOADING_STATE', () => {
it('should change all the isUploading keys of the filesToUpload to true', () => {
const action = {

View File

@ -1,3 +1,4 @@
import EditForm from '../../../components/EditForm';
import UploadForm from '../../../components/UploadForm';
import UploadList from '../../../components/UploadList';
import getTrad from '../../../utils/getTrad';
@ -5,21 +6,28 @@ import getTrad from '../../../utils/getTrad';
const stepper = {
browse: {
Component: UploadForm,
headerTradId: getTrad('modal.header.browse'),
headers: [getTrad('modal.header.browse')],
prev: null,
next: 'upload',
},
upload: {
Component: UploadList,
headerTradId: getTrad('modal.header.select-files'),
headers: [getTrad('modal.header.select-files')],
next: null,
prev: 'browse',
},
'edit-new': {
Component: null,
headerTradId: 'coming soon',
next: null,
Component: EditForm,
// TODO: I'll leave it there for the moment
// because I am not sure about the design since it seems inconsistent
// headers: [
// getTrad('modal.header.select-files'),
// getTrad('modal.header.file-detail'),
// ],
headers: [getTrad('modal.header.file-detail')],
next: 'upload',
prev: 'upload',
withBackButton: true,
},
};

View File

@ -1,7 +0,0 @@
import { useLocation } from 'react-router-dom';
const useQuery = () => {
return new URLSearchParams(useLocation().search);
};
export default useQuery;

View File

@ -1,8 +1,10 @@
import pluginPkg from '../../package.json';
import pluginLogo from './assets/images/logo.svg';
import App from './containers/App';
import trads from './translations';
import pluginId from './pluginId';
import getTrad from './utils/getTrad';
export default strapi => {
const pluginDescription =
@ -24,6 +26,20 @@ export default strapi => {
name: pluginPkg.strapi.name,
pluginLogo,
preventComponentRendering: false,
settings: {
global: [
{
title: {
id: getTrad('settings.link.label'),
defaultMessage: 'Media Library',
},
name: 'media-library',
to: `${strapi.settingsBaseURL}/media-library`,
// TODO
Component: () => 'COMING SOON',
},
],
},
trads,
};

View File

@ -1,4 +1,8 @@
{
"form.input.label.file-alt": "Alternative text",
"form.input.decription.file-alt": "This text will be displayed if the asset cant be shown.",
"form.input.label.file-caption": "Caption",
"form.input.label.file-name": "File name",
"header.actions.upload-assets": "Upload assets",
"header.content.assets-empty": "No asset",
"header.content.assets-multiple": "{number} assets",
@ -10,6 +14,7 @@
"list.assets-empty.subtitle": "Add a first one to the list.",
"modal.header.browse": "Upload assets",
"modal.header.select-files": "Selected files",
"modal.header.file-detail": "Details",
"modal.nav.computer": "from computer",
"modal.nav.url": "from url",
"modal.upload-list.sub-header-title.plural": "{number} assets selected",
@ -28,5 +33,6 @@
"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"
}
"sort.updated_at_desc": "Oldest updates",
"window.confirm.close-modal": "Are you sure? You have some files that have not been uploaded yet."
}

View File

@ -11,6 +11,7 @@
"test": "echo \"no tests yet\""
},
"dependencies": {
"cropperjs": "^1.5.6",
"immutable": "^3.8.2",
"invariant": "^2.2.1",
"lodash": "^4.17.11",

766
yarn.lock

File diff suppressed because it is too large Load Diff