RelationInputWrapper: Fix prop-types, add mock data

This commit is contained in:
Gustav Hansen 2022-08-31 20:43:55 +02:00
parent 06e58a6a21
commit e4572e97bf
8 changed files with 395 additions and 255 deletions

View File

@ -0,0 +1,221 @@
import PropTypes from 'prop-types';
import React from 'react';
import styled from 'styled-components';
import { ReactSelect } from '@strapi/helper-plugin';
import { Badge } from '@strapi/design-system/Badge';
import { Box } from '@strapi/design-system/Box';
import { BaseLink } from '@strapi/design-system/BaseLink';
import { Icon } from '@strapi/design-system/Icon';
import { FieldLabel, FieldError, FieldHint, Field } from '@strapi/design-system/Field';
import { TextButton } from '@strapi/design-system/TextButton';
import { Typography } from '@strapi/design-system/Typography';
import { Loader } from '@strapi/design-system/Loader';
import Cross from '@strapi/icons/Cross';
import Refresh from '@strapi/icons/Refresh';
import { Relation } from './components/Relation';
import { RelationItem } from './components/RelationItem';
import { RelationList } from './components/RelationList';
import { Option } from './components/Option';
const RelationItemCenterChildren = styled(RelationItem)`
div {
justify-content: center;
}
`;
const RelationInput = ({
description,
disabled,
error,
id,
name,
label,
labelLoadMore,
listHeight,
loadingMessage,
relations,
onRelationAdd,
onRelationLoadMore,
onSearchClose,
onSearchOpen,
onRelationRemove,
onSearchNextPage,
onSearch,
placeholder,
publicationStateTranslations,
searchResults,
}) => {
return (
<Field error={error} name={name} hint={description} id={id}>
<Relation
search={
<>
<FieldLabel>{label}</FieldLabel>
<ReactSelect
components={{ Option }}
options={searchResults?.data?.pages?.flat().map((result) => ({
...result,
value: result.id,
label: result.mainField,
}))}
isDisabled={disabled}
isLoading={searchResults.isLoading}
error={error}
inputId={id}
isSearchable
isClear
loadingMessage={() => loadingMessage}
onChange={onRelationAdd}
onInputChange={onSearch}
onMenuClose={onSearchClose}
onMenuOpen={onSearchOpen}
onMenuScrollToBottom={onSearchNextPage}
placeholder={placeholder}
/>
</>
}
loadMore={
!disabled &&
labelLoadMore && (
<TextButton onClick={() => onRelationLoadMore()} startIcon={<Refresh />}>
{labelLoadMore}
</TextButton>
)
}
>
<RelationList height={listHeight}>
{relations.isSuccess &&
relations.data.pages.flat().map((relation) => {
const { publicationState, href, mainField, id } = relation;
const badgeColor = publicationState === 'draft' ? 'secondary' : 'success';
return (
<RelationItem
disabled={disabled}
key={`relation-${name}-${id}`}
endAction={
<button
disabled={disabled}
type="button"
onClick={() => onRelationRemove(relation)}
>
<Icon width="12px" as={Cross} />
</button>
}
>
<Box minWidth={0} paddingTop={1} paddingBottom={1} paddingRight={4}>
{href ? (
<BaseLink disabled={disabled} href={href}>
<Typography textColor={disabled ? 'neutral600' : 'primary600'} ellipsis>
{mainField}
</Typography>
</BaseLink>
) : (
<Typography textColor={disabled ? 'neutral600' : 'primary600'} ellipsis>
{mainField}
</Typography>
)}
</Box>
{publicationState && (
<Badge
borderSize={1}
borderColor={`${badgeColor}200`}
backgroundColor={`${badgeColor}100`}
textColor={`${badgeColor}700`}
shrink={0}
>
{publicationStateTranslations[publicationState]}
</Badge>
)}
</RelationItem>
);
})}
{relations.isLoading && (
<RelationItemCenterChildren>
<Loader small>{loadingMessage}</Loader>
</RelationItemCenterChildren>
)}
</RelationList>
<Box paddingTop={2}>
<FieldHint />
<FieldError />
</Box>
</Relation>
</Field>
);
};
const ReactQueryRelationResult = PropTypes.shape({
data: PropTypes.shape({
pages: PropTypes.arrayOf(
PropTypes.arrayOf(
PropTypes.shape({
href: PropTypes.string,
id: PropTypes.number.isRequired,
publicationState: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
mainField: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})
)
),
}),
isLoading: PropTypes.bool.isRequired,
isSuccess: PropTypes.bool.isRequired,
});
const ReactQuerySearchResult = PropTypes.shape({
data: PropTypes.shape({
pages: PropTypes.arrayOf(
PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
mainField: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
publicationState: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
})
)
),
}),
isLoading: PropTypes.bool.isRequired,
isSuccess: PropTypes.bool.isRequired,
});
RelationInput.defaultProps = {
description: undefined,
disabled: false,
error: undefined,
labelLoadMore: null,
listHeight: undefined,
relations: [],
searchResults: [],
};
RelationInput.propTypes = {
error: PropTypes.string,
description: PropTypes.string,
disabled: PropTypes.bool,
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
labelLoadMore: PropTypes.string,
listHeight: PropTypes.string,
loadingMessage: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
onRelationAdd: PropTypes.func.isRequired,
onRelationRemove: PropTypes.func.isRequired,
onRelationLoadMore: PropTypes.func.isRequired,
onSearch: PropTypes.func.isRequired,
onSearchNextPage: PropTypes.func.isRequired,
onSearchClose: PropTypes.func.isRequired,
onSearchOpen: PropTypes.func.isRequired,
placeholder: PropTypes.string.isRequired,
publicationStateTranslations: PropTypes.shape({
draft: PropTypes.string.isRequired,
published: PropTypes.string.isRequired,
}).isRequired,
searchResults: ReactQuerySearchResult,
relations: ReactQueryRelationResult,
};
export default RelationInput;

View File

@ -1,215 +1 @@
import PropTypes from 'prop-types';
import React from 'react';
import styled from 'styled-components';
import { ReactSelect } from '@strapi/helper-plugin';
import { Badge } from '@strapi/design-system/Badge';
import { Box } from '@strapi/design-system/Box';
import { BaseLink } from '@strapi/design-system/BaseLink';
import { Icon } from '@strapi/design-system/Icon';
import { FieldLabel, FieldError, FieldHint, Field } from '@strapi/design-system/Field';
import { TextButton } from '@strapi/design-system/TextButton';
import { Typography } from '@strapi/design-system/Typography';
import { Loader } from '@strapi/design-system/Loader';
import Cross from '@strapi/icons/Cross';
import Refresh from '@strapi/icons/Refresh';
import { Relation } from './components/Relation';
import { RelationItem } from './components/RelationItem';
import { RelationList } from './components/RelationList';
import { Option } from './components/Option';
const RelationItemCenterChildren = styled(RelationItem)`
div {
justify-content: center;
}
`;
const RelationInput = ({
description,
disabled,
error,
id,
name,
label,
labelLoadMore,
listHeight,
loadingMessage,
relations,
onRelationClose,
onRelationAdd,
onRelationLoadMore,
onRelationOpen,
onRelationRemove,
onSearchNextPage,
onSearch,
placeholder,
publicationStateTranslations,
searchResults,
}) => {
return (
<Field error={error} name={name} hint={description} id={id}>
<Relation
search={
<>
<FieldLabel>{label}</FieldLabel>
<ReactSelect
components={{ Option }}
options={searchResults.data.pages.flat()}
isDisabled={disabled}
isLoading={searchResults.isLoading}
error={error}
inputId={id}
isSearchable
isClear
loadingMessage={() => loadingMessage}
onChange={onRelationAdd}
onInputChange={onSearch}
onMenuClose={onRelationOpen}
onMenuOpen={onRelationClose}
onMenuScrollToBottom={onSearchNextPage}
placeholder={placeholder}
/>
</>
}
loadMore={
!disabled && (
<TextButton onClick={() => onRelationLoadMore()} startIcon={<Refresh />}>
{labelLoadMore}
</TextButton>
)
}
>
<RelationList height={listHeight}>
{relations.isSuccess &&
relations.data.pages.flat().map((relation) => {
const { publicationState, href, mainField, id } = relation;
const badgeColor = publicationState === 'draft' ? 'secondary' : 'success';
return (
<RelationItem
disabled={disabled}
key={`relation-${name}-${id}`}
endAction={
<button
disabled={disabled}
type="button"
onClick={() => onRelationRemove(relation)}
>
<Icon width="12px" as={Cross} />
</button>
}
>
<Box minWidth={0} paddingTop={1} paddingBottom={1} paddingRight={4}>
{href ? (
<BaseLink disabled={disabled} href={href}>
<Typography textColor={disabled ? 'neutral600' : 'primary600'} ellipsis>
{mainField}
</Typography>
</BaseLink>
) : (
<Typography textColor={disabled ? 'neutral600' : 'primary600'} ellipsis>
{mainField}
</Typography>
)}
</Box>
{publicationState && (
<Badge
borderSize={1}
borderColor={`${badgeColor}200`}
backgroundColor={`${badgeColor}100`}
textColor={`${badgeColor}700`}
shrink={0}
>
{publicationStateTranslations[publicationState]}
</Badge>
)}
</RelationItem>
);
})}
{relations.isLoading && (
<RelationItemCenterChildren>
<Loader small>{loadingMessage}</Loader>
</RelationItemCenterChildren>
)}
</RelationList>
<Box paddingTop={2}>
<FieldHint />
<FieldError />
</Box>
</Relation>
</Field>
);
};
const ReactQueryRelationResult = PropTypes.shape({
data: PropTypes.shape({
pages: PropTypes.arrayOf(
PropTypes.arrayOf(
PropTypes.shape({
href: PropTypes.string,
id: PropTypes.number.isRequired,
publicationState: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
mainField: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})
)
),
}),
isLoading: PropTypes.bool.isRequired,
isSuccess: PropTypes.bool.isRequired,
});
const ReactQuerySearchResult = PropTypes.shape({
data: PropTypes.shape({
pages: PropTypes.arrayOf(
PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
mainField: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
publicationState: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
})
)
),
}),
isLoading: PropTypes.bool.isRequired,
isSuccess: PropTypes.bool.isRequired,
});
RelationInput.defaultProps = {
description: undefined,
disabled: false,
error: undefined,
listHeight: undefined,
relations: [],
searchResults: [],
};
RelationInput.propTypes = {
error: PropTypes.string,
description: PropTypes.string,
disabled: PropTypes.bool,
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
labelLoadMore: PropTypes.string.isRequired,
listHeight: PropTypes.string,
loadingMessage: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
onRelationAdd: PropTypes.func.isRequired,
onRelationOpen: PropTypes.func.isRequired,
onRelationClose: PropTypes.func.isRequired,
onRelationRemove: PropTypes.func.isRequired,
onRelationLoadMore: PropTypes.func.isRequired,
onSearch: PropTypes.func.isRequired,
onSearchNextPage: PropTypes.func.isRequired,
placeholder: PropTypes.string.isRequired,
publicationStateTranslations: PropTypes.shape({
draft: PropTypes.string.isRequired,
published: PropTypes.string.isRequired,
}).isRequired,
searchResults: ReactQuerySearchResult,
relations: ReactQueryRelationResult,
};
export default RelationInput;
export { default as RelationInput } from './RelationInput';

View File

@ -2,8 +2,9 @@ import PropTypes from 'prop-types';
import React, { memo, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { RelationInput, useCMEditViewDataManager, NotAllowedInput } from '@strapi/helper-plugin';
import { useCMEditViewDataManager, NotAllowedInput } from '@strapi/helper-plugin';
import { RelationInput } from '../RelationInput';
import { useRelation } from '../../hooks/useRelation';
import { connect, select, normalizeRelations } from './utils';
import { PUBLICATION_STATES } from './constants';
@ -23,9 +24,10 @@ export const RelationInputWrapper = ({
targetModel,
}) => {
const { formatMessage } = useIntl();
const { addRelation, removeRelation, loadRelation, modifiedData } = useCMEditViewDataManager();
const { addRelation, removeRelation, loadRelation, modifiedData, slug, initialData } =
useCMEditViewDataManager();
const { relations, search, searchFor } = useRelation(name, {
const { relations, search, searchFor } = useRelation(`${slug}-${name}-${initialData?.id ?? ''}`, {
relation: {
endpoint: endpoints.relation,
onload(data) {
@ -105,35 +107,56 @@ export const RelationInputWrapper = ({
disabled={isDisabled}
id={name}
label={formatMessage(intlLabel)}
labelLoadMore={formatMessage({
labelLoadMore={
// TODO: only display if there are more; derive from count
!isCreatingEntry &&
formatMessage({
// TODO
id: 'tbd',
defaultMessage: 'Load More',
})
}
loadingMessage={formatMessage({
// TODO
id: 'tbd',
defaultMessage: 'Load More',
defaultMessage: 'Relations are loading',
})}
name={name}
onRelationAdd={() => handleRelationAdd()}
onRelationRemove={() => handleRelationRemove()}
onRelationAdd={(relation) => handleRelationAdd(relation)}
onRelationRemove={(relation) => handleRelationRemove(relation)}
onRelationLoadMore={() => handleRelationLoadMore()}
onSearch={() => handleSearch()}
onSearch={(term) => handleSearch(term)}
onSearchNextPage={() => handleSearchMore()}
onSearchClose={() => {}}
onSearchOpen={() => {}}
placeholder={formatMessage({
// TODO
id: 'tbd',
defaultMessage: 'Add relation',
})}
publicationStateTranslations={{
[PUBLICATION_STATES.DRAFT]: formatMessage({
// TODO
id: 'tbd',
defaultMessage: 'Draft',
}),
[PUBLICATION_STATES.PUBLISHED]: formatMessage({
// TODO
id: 'tbd',
defaultMessage: 'Published',
}),
}}
relations={normalizeRelations(relations, {
deletions: modifiedData?.[name],
mainFieldName: mainField.name,
modifiedData: modifiedData?.[name],
// TODO: Remove mock title
mainFieldName: 'title' || mainField.name,
shouldAddLink: shouldDisplayRelationLink,
targetModel,
})}
searchResults={normalizeRelations(search, {
mainFieldName: mainField.name,
// TODO: Remove mock title
mainFieldName: 'title' || mainField.name,
})}
/>
);

View File

@ -1,5 +1,5 @@
import { getRequestUrl } from '../../../utils';
export function getRelationLink(targetModel, id) {
return getRequestUrl(`collectionType/${targetModel}/${id ?? ''}`);
return `/admin${getRequestUrl(`collectionType/${targetModel}/${id ?? ''}`)}`;
}

View File

@ -4,17 +4,23 @@ import { PUBLICATION_STATES } from '../constants';
export const normalizeRelations = (
relations,
{ deletions = [], shouldAddLink = false, mainFieldName, targetModel }
{ modifiedData = {}, shouldAddLink = false, mainFieldName, targetModel }
) => {
// TODO
if (!relations?.data?.pages) {
return relations;
}
return {
...relations,
data: {
pages: relations.data.pages
.map((page) =>
page
.map((page) => [
...page.values
.map((relation) => {
const nextRelation = { ...relation };
if (deletions.find((deletion) => deletion.id === nextRelation.id)) {
if (modifiedData?.remove?.find((deletion) => deletion.id === nextRelation.id)) {
return null;
}
@ -34,8 +40,9 @@ export const normalizeRelations = (
return nextRelation;
})
.filter(Boolean)
)
.filter(Boolean),
...(modifiedData?.add ?? []),
])
.filter((page) => page.length > 0),
},
};

View File

@ -52,7 +52,7 @@ function useSelect({ isUserAllowedToEditField, isUserAllowedToReadField, name, q
...queryInfos,
endpoints: {
...queryInfos.endpoints,
fetch: relationFetchEndpoint,
relation: relationFetchEndpoint,
},
},
isCreatingEntry,

View File

@ -2,10 +2,12 @@ import { getRelationLink } from '../getRelationLink';
describe('getRelationLink', () => {
test('returns an URL containing the targetModel and id', () => {
expect(getRelationLink('model', 2)).toBe('/content-manager/collectionType/model/2');
expect(getRelationLink('model', 2)).toBe('/admin/content-manager/collectionType/model/2');
});
test('returns an URL containing the targetModel', () => {
expect(getRelationLink('model', undefined)).toBe('/content-manager/collectionType/model/');
expect(getRelationLink('model', undefined)).toBe(
'/admin/content-manager/collectionType/model/'
);
});
});

View File

@ -1,34 +1,135 @@
import { useState } from 'react';
import { useInfiniteQuery } from 'react-query';
import { axiosInstance } from '../../../core/utils';
// import { axiosInstance } from '../../../core/utils';
export const useRelation = (name, { relation, search }) => {
const FIXTURE_RELATIONS = {
values: [
{
id: 1,
title: 'Relation 1',
publishedAt: '2022',
},
{
id: 2,
title: 'Relation 2',
publishedAt: '',
},
{
id: 3,
title: 'Relation 3',
},
{
id: 4,
title: 'Relation with a very long title',
},
{
id: 5,
title: 'Another important entity',
},
{
id: 6,
title: 'Are we going to play this game really?',
},
{
id: 7,
title: 'Indeed ...',
},
],
pagination: {
page: 1,
total: 3,
},
};
const FIXTURE_SEARCH = {
values: [
{
id: 1,
title: 'Relation 1',
publishedAt: '2022',
},
{
id: 2,
title: 'Relation 2',
publishedAt: '',
},
{
id: 3,
title: 'Relation 3',
},
{
id: 4,
title: 'Relation with a very long title',
},
{
id: 5,
title: 'Another important entity',
},
{
id: 6,
title: 'Are we going to play this game really?',
},
{
id: 7,
title: 'Indeed ...',
},
],
pagination: {
page: 1,
total: 1,
},
};
export const useRelation = (cacheKey, { relation /* search */ }) => {
const [searchTerm, setSearchTerm] = useState(null);
const fetchRelations = async ({ pageParam = 1 }) => {
const { data } = await axiosInstance.get(relation?.endpoint, {
...(relation.pageParams ?? {}),
page: pageParam,
});
const fetchRelations = async (/* { pageParam = 1 } */) => {
try {
// const { data } = await axiosInstance.get(relation?.endpoint, {
// ...(relation.pageParams ?? {}),
// page: pageParam,
// });
if (relation?.onLoad) {
relation.onLoad(data);
if (relation?.onLoad) {
relation.onLoad(FIXTURE_RELATIONS);
// relation.onLoad(data);
}
// TODO: remove
return FIXTURE_RELATIONS;
// return data;
} catch (err) {
// TODO: remove
return FIXTURE_RELATIONS;
}
return data;
};
const fetchSearch = async ({ pageParam = 1 }) => {
const { data } = await axiosInstance.get(search.endpoint, {
...(search.pageParams ?? {}),
page: pageParam,
});
const fetchSearch = async (/* { pageParam = 1 } */) => {
// const { data } = await axiosInstance.get(search.endpoint, {
// ...(search.pageParams ?? {}),
// page: pageParam,
// });
return data;
return FIXTURE_SEARCH;
// return data;
};
const relationsRes = useInfiniteQuery(['relation', name], fetchRelations, {
const relationsRes = useInfiniteQuery(['relation', cacheKey], fetchRelations, {
enabled: !!relation?.endpoint,
getNextPageParam(lastPage) {
if (lastPage.pagination.page + 1 === lastPage.pagination.total) {
@ -40,7 +141,7 @@ export const useRelation = (name, { relation, search }) => {
},
});
const searchRes = useInfiniteQuery(['relation', name, 'search', searchTerm], fetchSearch, {
const searchRes = useInfiniteQuery(['relation', cacheKey, 'search', searchTerm], fetchSearch, {
enabled: !!searchTerm,
getNextPageParam(lastPage) {
if (lastPage.pagination.page + 1 === lastPage.pagination.total) {