Features/i18n locale in filters (#9611)

This commit is contained in:
Marvin Frachet 2021-03-08 11:02:01 +01:00 committed by GitHub
parent 068a31c3e5
commit 32a5e2becf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 312 additions and 31 deletions

View File

@ -9,7 +9,7 @@ import { useFetchContentTypeLayout } from '../../hooks';
import { formatLayoutToApi } from '../../utils';
import EditView from '../EditView';
import EditSettingsView from '../EditSettingsView';
import ListView from '../ListView';
import ListViewLayout from '../ListView/ListViewLayout';
import ListSettingsView from '../ListSettingsView';
const CollectionTypeRecursivePath = ({
@ -75,7 +75,7 @@ const CollectionTypeRecursivePath = ({
const routes = [
{ path: ':id/clone/:origin', comp: EditView },
{ path: ':id', comp: EditView },
{ path: '', comp: ListView },
{ path: '', comp: ListViewLayout },
].map(({ path, comp }) => (
<Route key={path} path={`${url}/${path}`} render={props => renderRoute(props, comp)} />
));

View File

@ -0,0 +1,48 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { setLayout } from './actions';
import ListView from '.';
import { useQueryParams } from '../../hooks';
const ListViewLayout = ({ layout, ...props }) => {
const dispatch = useDispatch();
const initialParams = useSelector(state => state.get('content-manager_listView').initialParams);
const [, setQuery] = useQueryParams(initialParams);
useEffect(() => {
dispatch(setLayout(layout.contentType));
}, [dispatch, layout]);
useEffect(() => {
if (initialParams) {
setQuery(initialParams);
}
}, [initialParams, setQuery]);
if (!initialParams) {
return null;
}
return <ListView {...props} layout={layout} />;
};
ListViewLayout.propTypes = {
layout: PropTypes.exact({
components: PropTypes.object.isRequired,
contentType: PropTypes.shape({
attributes: PropTypes.object.isRequired,
metadatas: PropTypes.object.isRequired,
info: PropTypes.shape({ label: PropTypes.string.isRequired }).isRequired,
layouts: PropTypes.shape({
list: PropTypes.array.isRequired,
editRelations: PropTypes.array,
}).isRequired,
options: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
pluginOptions: PropTypes.object,
}).isRequired,
}).isRequired,
};
export default ListViewLayout;

View File

@ -78,6 +78,23 @@ export function toggleModalDelete() {
};
}
export const setLayout = layout => ({ layout, type: SET_LIST_LAYOUT });
export const setLayout = contentType => {
const { layouts, settings } = contentType;
const defaultSort = `${settings.defaultSortBy}:${settings.defaultSortOrder}`;
return {
contentType,
displayedHeaders: layouts.list,
type: SET_LIST_LAYOUT,
// initParams needs to explicitly set in the action so that external
// plugin can override this one.
// For instance, the i18n plugin will catch this action in a middleware and enhance it with a "locale" key
initialParams: {
page: 1,
pageSize: settings.pageSize,
_sort: defaultSort,
},
};
};
export const onChangeListHeaders = target => ({ type: ON_CHANGE_LIST_HEADERS, target });

View File

@ -8,7 +8,6 @@ import { useHistory, useLocation } from 'react-router-dom';
import { Header } from '@buffetjs/custom';
import { Flex, Padded } from '@buffetjs/core';
import isEqual from 'react-fast-compare';
import { stringify } from 'qs';
import {
PopUpWarning,
request,
@ -53,7 +52,7 @@ import {
} from './actions';
import makeSelectListView from './selectors';
import { getAllAllowedHeaders, getFirstSortableHeader } from './utils';
import { getAllAllowedHeaders, getFirstSortableHeader, buildQueryString } from './utils';
/* eslint-disable react/no-array-index-key */
function ListView({
@ -80,21 +79,14 @@ function ListView({
onResetListHeaders,
pagination: { total },
resetProps,
setLayout,
slug,
initialParams,
}) {
const {
contentType: {
attributes,
metadatas,
settings: {
defaultSortBy,
defaultSortOrder,
bulkable: isBulkable,
filterable: isFilterable,
searchable: isSearchable,
pageSize: defaultPageSize,
},
settings: { bulkable: isBulkable, filterable: isFilterable, searchable: isSearchable },
},
} = layout;
@ -105,12 +97,9 @@ function ListView({
isLoading: isLoadingForPermissions,
allowedActions: { canCreate, canRead, canUpdate, canDelete },
} = useUserPermissions(viewPermissions);
const defaultSort = `${defaultSortBy}:${defaultSortOrder}`;
const initParams = useMemo(() => ({ page: 1, pageSize: defaultPageSize, _sort: defaultSort }), [
defaultPageSize,
defaultSort,
]);
const [{ query, rawQuery }, setQuery] = useQueryParams(initParams);
const [{ query }, setQuery] = useQueryParams(initialParams);
const params = buildQueryString(query);
const { pathname } = useLocation();
const { push } = useHistory();
@ -131,22 +120,17 @@ function ListView({
const label = contentType.info.label;
const params = useMemo(() => {
return rawQuery || `?${stringify(initParams, { encode: false })}`;
}, [initParams, rawQuery]);
const firstSortableHeader = useMemo(() => getFirstSortableHeader(displayedHeaders), [
displayedHeaders,
]);
useEffect(() => {
setLayout(layout);
setFilterPickerState(false);
return () => {
resetProps();
};
}, [layout, setLayout, resetProps]);
}, [resetProps]);
// Using a ref to avoid requests being fired multiple times on slug on change
// We need it because the hook as mulitple dependencies so it may run before the permissions have checked
@ -528,6 +512,7 @@ ListView.propTypes = {
toggleModalDelete: PropTypes.func.isRequired,
toggleModalDeleteAll: PropTypes.func.isRequired,
setLayout: PropTypes.func.isRequired,
initialParams: PropTypes.shape({}).isRequired,
};
const mapStateToProps = makeSelectListView();

View File

@ -35,6 +35,7 @@ export const initialState = {
pagination: {
total: 0,
},
initialParams: null,
};
const listViewReducer = (state = initialState, action) =>
@ -47,6 +48,7 @@ const listViewReducer = (state = initialState, action) =>
contentType: state.contentType,
initialDisplayedHeaders: state.initialDisplayedHeaders,
displayedHeaders: state.displayedHeaders,
initialParams: state.initialParams,
};
}
@ -166,11 +168,12 @@ const listViewReducer = (state = initialState, action) =>
break;
}
case SET_LIST_LAYOUT: {
const { contentType } = action.layout;
const { contentType, displayedHeaders, initialParams } = action;
drafState.contentType = contentType;
drafState.displayedHeaders = contentType.layouts.list;
drafState.initialDisplayedHeaders = contentType.layouts.list;
drafState.displayedHeaders = displayedHeaders;
drafState.initialDisplayedHeaders = displayedHeaders;
drafState.initialParams = initialParams;
break;
}

View File

@ -28,6 +28,7 @@ describe('CONTENT MANAGER | CONTAINERS | ListView | reducer', () => {
contentType: {},
initialDisplayedHeaders: [],
displayedHeaders: [],
initialParams: null,
pagination: {
total: 0,
},

View File

@ -0,0 +1,8 @@
/**
* Accepts an object of plugins options like: { i18n: { locale: 'en' }, other: {some: "value"} }
* and transform it into an array looking like: [{ i18n: { locale: 'en' } }, { other: {some: "value"} }]
*/
const arrayOfPluginOptions = (pluginOptions = {}) =>
Object.keys(pluginOptions || {}).map(key => ({ [key]: pluginOptions[key] }));
export default arrayOfPluginOptions;

View File

@ -0,0 +1,26 @@
import { stringify } from 'qs';
import arrayOfPluginOptions from './arrayOfPluginOptions';
/**
* Creates a valid query string from an object of queryParams
* This includes:
* - a _where clause
* - plugin options
*/
const buildQueryString = (queryParams = {}) => {
const pluginOptionArray = arrayOfPluginOptions(queryParams.pluginOptions);
const _where = queryParams._where || [];
/**
* Extracting pluginOptions from the query since we don't want them to be part
* of the url
*/
const { pluginOptions: _, ...otherQueryParams } = {
...queryParams,
_where: _where.concat(pluginOptionArray),
};
return `?${stringify(otherQueryParams, { encode: false })}`;
};
export default buildQueryString;

View File

@ -1,2 +1,4 @@
export { default as getAllAllowedHeaders } from './getAllAllowedHeaders';
export { default as getFirstSortableHeader } from './getFirstSortableHeader';
export { default as arrayOfPluginOptions } from './arrayOfPluginOptions';
export { default as buildQueryString } from './buildQueryString';

View File

@ -0,0 +1,61 @@
import buildQueryString from '../buildQueryString';
describe('buildQueryString', () => {
it('creates a valid query string with default params', () => {
const queryParams = {
page: '1',
pageSize: '10',
_sort: 'name:ASC',
};
const queryString = buildQueryString(queryParams);
expect(queryString).toBe('?page=1&pageSize=10&_sort=name:ASC');
});
it('creates a valid query string with default params & plugin options', () => {
const queryParams = {
page: '1',
pageSize: '10',
_sort: 'name:ASC',
pluginOptions: {
i18n: { locale: 'en' },
},
};
const queryString = buildQueryString(queryParams);
expect(queryString).toBe('?page=1&pageSize=10&_sort=name:ASC&_where[0][i18n][locale]=en');
});
it('creates a valid query string with a _where clause', () => {
const queryParams = {
page: '1',
pageSize: '10',
_sort: 'name:ASC',
_where: [{ name: 'hello world' }],
};
const queryString = buildQueryString(queryParams);
expect(queryString).toBe('?page=1&pageSize=10&_sort=name:ASC&_where[0][name]=hello world');
});
it('creates a valid query string with a _where and plugin options', () => {
const queryParams = {
page: '1',
pageSize: '10',
_sort: 'name:ASC',
_where: [{ name: 'hello world' }],
pluginOptions: {
i18n: { locale: 'en' },
},
};
const queryString = buildQueryString(queryParams);
expect(queryString).toBe(
'?page=1&pageSize=10&_sort=name:ASC&_where[0][name]=hello world&_where[1][i18n][locale]=en'
);
});
});

View File

@ -14,6 +14,7 @@ const dtoFields = [
'options',
'pluginOptions',
'attributes',
'pluginOptions',
];
module.exports = {

View File

@ -3,6 +3,7 @@ import { useSelector } from 'react-redux';
import { Picker, Padded, Text, Flex } from '@buffetjs/core';
import { Carret } from 'strapi-helper-plugin';
import styled from 'styled-components';
import get from 'lodash/get';
const List = styled.ul`
list-style-type: none;
@ -33,10 +34,22 @@ const EllipsisParagraph = styled(Text)`
text-align: left;
`;
const selectContentManagerListViewPluginOptions = state =>
state.get('content-manager_listView').contentType.pluginOptions;
const selectI18NLocales = state => state.get('i18n_locales').locales;
const LocalePicker = () => {
const locales = useSelector(state => state.get('i18n_locales').locales);
const pluginOptions = useSelector(selectContentManagerListViewPluginOptions);
const locales = useSelector(selectI18NLocales);
const [selected, setSelected] = useState(locales && locales[0]);
const isFieldLocalized = get(pluginOptions, 'i18n.localized', false);
if (!isFieldLocalized) {
return null;
}
if (!locales || locales.length === 0) {
return null;
}

View File

@ -1,6 +1,11 @@
import extendCTBInitialDataMiddleware from './extendCTBInitialDataMiddleware';
import extendCTBAttributeInitialDataMiddleware from './extendCTBAttributeInitialDataMiddleware';
import localeQueryParamsMiddleware from './localeQueryParamsMiddleware';
const middlewares = [extendCTBInitialDataMiddleware, extendCTBAttributeInitialDataMiddleware];
const middlewares = [
extendCTBInitialDataMiddleware,
extendCTBAttributeInitialDataMiddleware,
localeQueryParamsMiddleware,
];
export default middlewares;

View File

@ -0,0 +1,25 @@
import get from 'lodash/get';
const localeQueryParamsMiddleware = () => () => next => action => {
if (action.type !== 'ContentManager/ListView/SET_LIST_LAYOUT ') {
return next(action);
}
const isFieldLocalized = get(action, 'contentType.pluginOptions.i18n.localized', false);
if (!isFieldLocalized) {
return next(action);
}
if (action.initialParams.pluginOptions) {
action.initialParams.pluginOptions.locale = 'en';
} else {
action.initialParams.pluginOptions = {
locale: 'en',
};
}
return next(action);
};
export default localeQueryParamsMiddleware;

View File

@ -0,0 +1,86 @@
import localeQueryParamsMiddleware from '../localeQueryParamsMiddleware';
describe('localeQueryParamsMiddleware', () => {
it('does nothing on unknown actions', () => {
const middleware = localeQueryParamsMiddleware()();
const nextFn = jest.fn();
const action = { type: 'UNKNOWN' };
middleware(nextFn)(action);
expect(nextFn).toBeCalledWith(action);
expect(action).toEqual({
type: 'UNKNOWN',
});
});
it('does nothing when there s no i18n.localized key in the action', () => {
const middleware = localeQueryParamsMiddleware()();
const nextFn = jest.fn();
const action = {
type: 'ContentManager/ListView/SET_LIST_LAYOUT ',
contentType: { pluginOptions: {} },
initialParams: {},
};
middleware(nextFn)(action);
expect(nextFn).toBeCalledWith(action);
expect(action).toEqual({
contentType: { pluginOptions: {} },
initialParams: {},
type: 'ContentManager/ListView/SET_LIST_LAYOUT ',
});
});
it('creates a pluginOptions key with a locale when initialParams does not have a pluginOptions key and the field is localized', () => {
const middleware = localeQueryParamsMiddleware()();
const nextFn = jest.fn();
const action = {
type: 'ContentManager/ListView/SET_LIST_LAYOUT ',
contentType: {
pluginOptions: {
i18n: { localized: true },
},
},
initialParams: {},
};
middleware(nextFn)(action);
expect(nextFn).toBeCalledWith(action);
expect(action).toEqual({
contentType: { pluginOptions: { i18n: { localized: true } } },
initialParams: { pluginOptions: { locale: 'en' } },
type: 'ContentManager/ListView/SET_LIST_LAYOUT ',
});
});
it('adds a key to pluginOptions with a locale when initialParams has a pluginOptions key and the field is localized', () => {
const middleware = localeQueryParamsMiddleware()();
const nextFn = jest.fn();
const action = {
type: 'ContentManager/ListView/SET_LIST_LAYOUT ',
contentType: {
pluginOptions: {
i18n: { localized: true },
},
},
initialParams: {
pluginOptions: {
hello: 'world',
},
},
};
middleware(nextFn)(action);
expect(nextFn).toBeCalledWith(action);
expect(action).toEqual({
contentType: { pluginOptions: { i18n: { localized: true } } },
initialParams: { pluginOptions: { locale: 'en', hello: 'world' } },
type: 'ContentManager/ListView/SET_LIST_LAYOUT ',
});
});
});