@webbio/strapi-plugin-page-builder 0.12.2-platform → 0.12.4-platform

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/README.md +166 -166
  2. package/admin/src/api/collection-type.ts +111 -111
  3. package/admin/src/api/entity-relation.ts +42 -42
  4. package/admin/src/api/has-page-relation.ts +37 -37
  5. package/admin/src/api/has-platform-relation.ts +40 -40
  6. package/admin/src/api/page-type-relation.ts +41 -41
  7. package/admin/src/api/platform-page-types.ts +45 -45
  8. package/admin/src/api/platform-relation.ts +42 -42
  9. package/admin/src/api/platform.ts +35 -35
  10. package/admin/src/api/search-filtered-entity.ts +114 -114
  11. package/admin/src/api/template.ts +39 -39
  12. package/admin/src/components/Combobox/index.tsx +83 -83
  13. package/admin/src/components/Combobox/react-select-custom-styles.tsx +121 -121
  14. package/admin/src/components/Combobox/styles.ts +46 -46
  15. package/admin/src/components/ConfirmModal/index.tsx +90 -90
  16. package/admin/src/components/EditView/CollectionTypeSearch/index.tsx +127 -127
  17. package/admin/src/components/EditView/CollectionTypeSettings/CreatePageButton/index.tsx +149 -149
  18. package/admin/src/components/EditView/CollectionTypeSettings/CreatePageButton/styles.ts +19 -19
  19. package/admin/src/components/EditView/CollectionTypeSettings/index.tsx +82 -82
  20. package/admin/src/components/EditView/Details/index.tsx +48 -48
  21. package/admin/src/components/EditView/Details/styles.ts +51 -51
  22. package/admin/src/components/EditView/PageSettings/index.tsx +124 -124
  23. package/admin/src/components/EditView/Platform/platform-select.tsx +30 -30
  24. package/admin/src/components/EditView/Template/TemplateConfirmModal/index.tsx +36 -36
  25. package/admin/src/components/EditView/Template/TemplateSelect/index.tsx +70 -70
  26. package/admin/src/components/EditView/Template/TemplateSelect/use-template-modules.ts +41 -41
  27. package/admin/src/components/EditView/index.tsx +35 -35
  28. package/admin/src/components/EditView/page-type-select.tsx +32 -32
  29. package/admin/src/components/EditView/wrapper.tsx +41 -41
  30. package/admin/src/components/GlobalPlatformSelect/index.tsx +40 -40
  31. package/admin/src/components/GlobalPlatformSelect/styles.ts +27 -27
  32. package/admin/src/components/Initializer/index.tsx +24 -24
  33. package/admin/src/components/PageFilters/PageTypeFilter/index.tsx +39 -39
  34. package/admin/src/components/PageFilters/PlatformFilter/index.tsx +32 -32
  35. package/admin/src/components/PageFilters/filters.tsx +189 -189
  36. package/admin/src/components/PageFilters/index.tsx +35 -35
  37. package/admin/src/components/PageTypeEditView/TemplatePlatformSelect/index.tsx +76 -76
  38. package/admin/src/components/PageTypeEditView/index.tsx +53 -53
  39. package/admin/src/components/PlatformFilteredSelectField/InputIcon/index.tsx +23 -23
  40. package/admin/src/components/PlatformFilteredSelectField/Multi/index.tsx +210 -210
  41. package/admin/src/components/PlatformFilteredSelectField/Single/index.tsx +197 -197
  42. package/admin/src/components/PlatformFilteredSelectField/hooks/useRelationLoad.tsx +128 -128
  43. package/admin/src/components/PlatformFilteredSelectField/index.tsx +85 -85
  44. package/admin/src/components/PlatformFilteredSelectField/styles.tsx +77 -77
  45. package/admin/src/components/PlatformFilteredSelectField/utils/get-translations.ts +3 -3
  46. package/admin/src/components/PlatformFilteredSelectField/utils/relation-helper.ts +147 -147
  47. package/admin/src/components/PluginIcon/index.tsx +94 -94
  48. package/admin/src/components/StrapiCore/admin/admin/src/content-manager/components/Relations/RelationInput.tsx +689 -689
  49. package/admin/src/components/StrapiCore/admin/admin/src/content-manager/components/Relations/RelationInputDataManager.tsx +6 -6
  50. package/admin/src/components/StrapiCore/admin/admin/src/content-manager/components/Relations/useRelation.ts +170 -170
  51. package/admin/src/components/StrapiCore/admin/admin/src/content-manager/components/Relations/utils/getRelationLink.ts +5 -5
  52. package/admin/src/components/StrapiCore/admin/admin/src/content-manager/components/Relations/utils/normalizeRelations.ts +52 -52
  53. package/admin/src/components/StrapiCore/admin/admin/src/content-manager/constants/attributes.ts +3 -3
  54. package/admin/src/components/StrapiCore/admin/admin/src/content-manager/hooks/useDragAndDrop.ts +253 -253
  55. package/admin/src/components/StrapiCore/admin/admin/src/content-manager/hooks/useKeyboardDragAndDrop.ts +96 -96
  56. package/admin/src/components/StrapiCore/admin/admin/src/content-manager/hooks/usePrev.ts +11 -11
  57. package/admin/src/components/StrapiCore/admin/admin/src/content-manager/utils/dragAndDrop.ts +8 -8
  58. package/admin/src/components/StrapiCore/admin/admin/src/content-manager/utils/paths.ts +29 -29
  59. package/admin/src/components/StrapiCore/admin/admin/src/content-manager/utils/refs.ts +19 -19
  60. package/admin/src/components/StrapiCore/admin/admin/src/content-manager/utils/translations.ts +3 -3
  61. package/admin/src/components/StrapiCore/content-manager/shared/contracts/collection-types.ts +300 -300
  62. package/admin/src/components/StrapiCore/content-manager/shared/contracts/components.ts +72 -72
  63. package/admin/src/components/StrapiCore/content-manager/shared/contracts/content-types.ts +116 -116
  64. package/admin/src/components/StrapiCore/content-manager/shared/contracts/index.ts +8 -8
  65. package/admin/src/components/StrapiCore/content-manager/shared/contracts/init.ts +22 -22
  66. package/admin/src/components/StrapiCore/content-manager/shared/contracts/relations.ts +80 -80
  67. package/admin/src/components/StrapiCore/content-manager/shared/contracts/review-workflows.ts +88 -88
  68. package/admin/src/components/StrapiCore/content-manager/shared/contracts/single-types.ts +112 -112
  69. package/admin/src/components/StrapiCore/content-manager/shared/contracts/uid.ts +48 -48
  70. package/admin/src/components/StrapiCore/content-manager/shared/index.ts +1 -1
  71. package/admin/src/constants.ts +3 -3
  72. package/admin/src/index.tsx +101 -101
  73. package/admin/src/middlewares/index.tsx +37 -37
  74. package/admin/src/pages/app/index.tsx +14 -14
  75. package/admin/src/pluginId.ts +5 -5
  76. package/admin/src/translations/en.json +9 -9
  77. package/admin/src/translations/nl.json +9 -9
  78. package/admin/src/utils/findDomElement.ts +6 -6
  79. package/admin/src/utils/findElementParent.ts +20 -20
  80. package/admin/src/utils/getObjectFromFormName.ts +42 -42
  81. package/admin/src/utils/getRequestUrl.ts +11 -11
  82. package/admin/src/utils/getTrad.ts +5 -5
  83. package/admin/src/utils/hooks/useDebounce.ts +17 -17
  84. package/admin/src/utils/hooks/useDefaultPlatformFromLocalStorage.ts +61 -61
  85. package/admin/src/utils/hooks/useGetLocaleFromUrl.ts +9 -9
  86. package/admin/src/utils/hooks/useHideOverviewFilterTags.ts +34 -34
  87. package/admin/src/utils/hooks/usePlatformFormData.ts +64 -64
  88. package/admin/src/utils/hooks/usePrevious.ts +12 -12
  89. package/admin/src/utils/sanitizeModules.ts +93 -93
  90. package/custom.d.ts +6 -6
  91. package/dist/package.json +1 -1
  92. package/dist/server/bootstrap/collection-type-lifecycles.js +1 -1
  93. package/dist/server/bootstrap.js +1 -1
  94. package/dist/server/graphql/page-by-path.js +20 -17
  95. package/dist/server/graphql/page-by-slug.js +9 -9
  96. package/dist/server/graphql/pages-by-uid.js +5 -5
  97. package/dist/server/services/private-content/graphql/index.js +27 -27
  98. package/dist/server/services/private-content/graphql/types/index.js +74 -74
  99. package/dist/server/utils/graphql.js +18 -18
  100. package/dist/tsconfig.server.tsbuildinfo +1 -1
  101. package/package.json +78 -78
  102. package/server/bootstrap/collection-type-lifecycles.ts +47 -47
  103. package/server/bootstrap/permissions.ts +161 -161
  104. package/server/bootstrap.ts +261 -261
  105. package/server/config/index.ts +4 -4
  106. package/server/content-types/index.ts +7 -7
  107. package/server/content-types/user-category/schema.json +23 -23
  108. package/server/controllers/collection-types.ts +32 -32
  109. package/server/controllers/index.ts +19 -19
  110. package/server/controllers/page-type.ts +18 -18
  111. package/server/controllers/page.ts +9 -9
  112. package/server/controllers/platform.ts +21 -21
  113. package/server/controllers/private-content.ts +17 -17
  114. package/server/controllers/sitemap.ts +32 -32
  115. package/server/controllers/template.ts +16 -16
  116. package/server/controllers/user-category.ts +3 -3
  117. package/server/destroy.ts +5 -5
  118. package/server/graphql/index.ts +9 -9
  119. package/server/graphql/page-by-path.ts +135 -132
  120. package/server/graphql/page-type.ts +67 -67
  121. package/server/graphql/pages-by-uid.ts +89 -89
  122. package/server/index.ts +23 -23
  123. package/server/middlewares/index.ts +1 -1
  124. package/server/policies/index.ts +5 -5
  125. package/server/policies/isAuthorizedPage.ts +11 -11
  126. package/server/register.ts +22 -22
  127. package/server/routes/index.ts +115 -115
  128. package/server/routes/user-category.ts +3 -3
  129. package/server/schema/page-end.json +96 -96
  130. package/server/schema/page-start.json +87 -87
  131. package/server/schema/page-type-end.json +53 -53
  132. package/server/schema/page-type-start.json +38 -38
  133. package/server/schema/platform-start.json +21 -21
  134. package/server/schema/template-end.json +40 -40
  135. package/server/schema/template-start.json +35 -35
  136. package/server/services/builder.ts +232 -232
  137. package/server/services/collection-types.ts +95 -95
  138. package/server/services/email.ts +127 -127
  139. package/server/services/index.ts +23 -23
  140. package/server/services/page-type.ts +30 -30
  141. package/server/services/page.ts +24 -24
  142. package/server/services/platform.ts +30 -30
  143. package/server/services/private-content/components/admin-email.json +22 -22
  144. package/server/services/private-content/components/email.json +22 -22
  145. package/server/services/private-content/components/platform-email.json +30 -30
  146. package/server/services/private-content/constants/index.ts +13 -13
  147. package/server/services/private-content/graphql/index.ts +88 -88
  148. package/server/services/private-content/graphql/resolvers/findOnePage.ts +40 -40
  149. package/server/services/private-content/graphql/resolvers/findPage.ts +45 -45
  150. package/server/services/private-content/graphql/resolvers/forgot-password.ts +34 -34
  151. package/server/services/private-content/graphql/resolvers/login.ts +56 -56
  152. package/server/services/private-content/graphql/resolvers/register.ts +78 -78
  153. package/server/services/private-content/graphql/resolvers/reset-password.ts +44 -44
  154. package/server/services/private-content/graphql/types/index.ts +96 -96
  155. package/server/services/private-content/index.ts +95 -95
  156. package/server/services/private-content/mail-template/txtMail.email.template.text.ts +6 -6
  157. package/server/services/private-content/page.ts +20 -20
  158. package/server/services/private-content/platform.ts +19 -19
  159. package/server/services/private-content/schemas/index.ts +28 -28
  160. package/server/services/private-content/user.ts +197 -197
  161. package/server/services/sitemap.ts +83 -83
  162. package/server/services/template.ts +13 -13
  163. package/server/services/user-category.ts +3 -3
  164. package/server/utils/filter-underscore-arguments.ts +12 -12
  165. package/server/utils/reload-strapi-on-load.ts +13 -13
  166. package/server/utils/strapi.ts +50 -50
  167. package/shared/utils/constants.ts +8 -8
  168. package/shared/utils/sleep.ts +1 -1
  169. package/strapi-admin.js +3 -3
  170. package/strapi-server.js +3 -3
  171. package/tsconfig.json +20 -20
  172. package/tsconfig.server.json +25 -25
@@ -1,689 +1,689 @@
1
- import * as React from 'react';
2
-
3
- import {
4
- Status,
5
- Box,
6
- Link,
7
- Icon,
8
- Flex,
9
- TextButton,
10
- Typography,
11
- Tooltip,
12
- VisuallyHidden,
13
- Combobox,
14
- IconButton,
15
- FlexProps,
16
- ComboboxOption,
17
- ComboboxProps
18
- } from '@strapi/design-system';
19
- import { pxToRem, useFocusInputField } from '@strapi/helper-plugin';
20
- import { Cross, Drag, Refresh } from '@strapi/icons';
21
- import { getEmptyImage } from 'react-dnd-html5-backend';
22
- import { useIntl } from 'react-intl';
23
- import { FixedSizeList, FixedSizeList as List, ListChildComponentProps } from 'react-window';
24
- import styled from 'styled-components';
25
-
26
- import { UseDragAndDropOptions, useDragAndDrop, DROP_SENSITIVITY } from '../../hooks/useDragAndDrop';
27
- import { usePrev } from '../../hooks/usePrev';
28
- import { ItemTypes } from '../../utils/dragAndDrop';
29
- import { composeRefs } from '../../utils/refs';
30
- import { getTranslation } from '../../utils/translations';
31
-
32
- import type { NormalizedRelation } from './utils/normalizeRelations';
33
- import type { Contracts } from '../../../../../../content-manager/shared';
34
- import type { Entity } from '@strapi/types';
35
-
36
- const RELATION_ITEM_HEIGHT = 50;
37
- const RELATION_GUTTER = 4;
38
-
39
- /* -------------------------------------------------------------------------------------------------
40
- * RelationInput
41
- * -----------------------------------------------------------------------------------------------*/
42
-
43
- interface RelationInputProps
44
- extends Pick<
45
- // @ts-ignore
46
- ComboboxProps,
47
- 'disabled' | 'error' | 'id' | 'labelAction' | 'placeholder' | 'required'
48
- >,
49
- Pick<RelationItemProps, 'onCancel' | 'onDropItem' | 'onGrabItem' | 'iconButtonAriaLabel'> {
50
- canReorder: boolean;
51
- // @ts-ignore
52
- description: ComboboxProps['hint'];
53
- numberOfRelationsToDisplay: number;
54
- label: string;
55
- labelLoadMore?: string;
56
- labelDisconnectRelation: string;
57
- listAriaDescription: string;
58
- liveText: string;
59
- loadingMessage: string;
60
- name: string;
61
- noRelationsMessage: string;
62
- onRelationConnect: (relation: Contracts.Relations.RelationResult) => void;
63
- onRelationLoadMore: () => void;
64
- onRelationDisconnect: (relation: NormalizedRelation) => void;
65
- onRelationReorder?: (currentIndex: number, newIndex: number) => void;
66
- onSearchNextPage: () => void;
67
- onSearch: (searchTerm?: string) => void;
68
- publicationStateTranslations: {
69
- draft: string;
70
- published: string;
71
- };
72
- relations: {
73
- data: NormalizedRelation[];
74
- isLoading: boolean;
75
- isFetchingNextPage: boolean;
76
- hasNextPage?: boolean;
77
- };
78
- searchResults: {
79
- data: NormalizedRelation[];
80
- isLoading: boolean;
81
- hasNextPage?: boolean;
82
- };
83
- size: number;
84
- }
85
-
86
- const RelationInput = ({
87
- canReorder,
88
- description,
89
- disabled,
90
- error,
91
- iconButtonAriaLabel,
92
- id,
93
- name,
94
- numberOfRelationsToDisplay,
95
- label,
96
- labelAction,
97
- labelLoadMore,
98
- labelDisconnectRelation,
99
- listAriaDescription,
100
- liveText,
101
- loadingMessage,
102
- onCancel,
103
- onDropItem,
104
- onGrabItem,
105
- noRelationsMessage,
106
- onRelationConnect,
107
- onRelationLoadMore,
108
- onRelationDisconnect,
109
- onRelationReorder,
110
- onSearchNextPage,
111
- onSearch,
112
- placeholder,
113
- publicationStateTranslations,
114
- required,
115
- relations: paginatedRelations,
116
- searchResults,
117
- size
118
- }: RelationInputProps) => {
119
- const [textValue, setTextValue] = React.useState<string | undefined>('');
120
- const [overflow, setOverflow] = React.useState<'top' | 'bottom' | 'top-bottom'>();
121
-
122
- const listRef = React.useRef<FixedSizeList>(null);
123
- const outerListRef = React.useRef<HTMLUListElement>(null);
124
-
125
- const fieldRef = useFocusInputField(name);
126
-
127
- const { data } = searchResults;
128
-
129
- const relations = paginatedRelations.data;
130
- const totalNumberOfRelations = relations.length ?? 0;
131
-
132
- const dynamicListHeight = React.useMemo(
133
- () =>
134
- totalNumberOfRelations > numberOfRelationsToDisplay
135
- ? Math.min(totalNumberOfRelations, numberOfRelationsToDisplay) * (RELATION_ITEM_HEIGHT + RELATION_GUTTER) +
136
- RELATION_ITEM_HEIGHT / 2
137
- : Math.min(totalNumberOfRelations, numberOfRelationsToDisplay) * (RELATION_ITEM_HEIGHT + RELATION_GUTTER),
138
- [totalNumberOfRelations, numberOfRelationsToDisplay]
139
- );
140
-
141
- const shouldDisplayLoadMoreButton = !!labelLoadMore && paginatedRelations.hasNextPage;
142
-
143
- const options = React.useMemo(
144
- () =>
145
- data
146
- .flat()
147
- .filter(Boolean)
148
- .map((result) => ({
149
- ...result,
150
- value: result.id,
151
- label: result.mainField
152
- })),
153
- [data]
154
- );
155
-
156
- React.useEffect(() => {
157
- if (totalNumberOfRelations <= numberOfRelationsToDisplay) {
158
- return setOverflow(undefined);
159
- }
160
-
161
- const handleNativeScroll = (e: Event) => {
162
- const el = e.target as HTMLUListElement;
163
- const parentScrollContainerHeight = (el.parentNode as HTMLDivElement).scrollHeight;
164
- const maxScrollBottom = el.scrollHeight - el.scrollTop;
165
-
166
- if (el.scrollTop === 0) {
167
- return setOverflow('bottom');
168
- }
169
-
170
- if (maxScrollBottom === parentScrollContainerHeight) {
171
- return setOverflow('top');
172
- }
173
-
174
- return setOverflow('top-bottom');
175
- };
176
-
177
- const outerListRefCurrent = outerListRef?.current;
178
-
179
- if (!paginatedRelations.isLoading && relations.length > 0 && outerListRefCurrent) {
180
- outerListRef.current.addEventListener('scroll', handleNativeScroll);
181
- }
182
-
183
- return () => {
184
- if (outerListRefCurrent) {
185
- outerListRefCurrent.removeEventListener('scroll', handleNativeScroll);
186
- }
187
- };
188
- }, [paginatedRelations, relations, numberOfRelationsToDisplay, totalNumberOfRelations]);
189
-
190
- const handleMenuOpen = (isOpen?: boolean) => {
191
- if (isOpen) {
192
- onSearch(textValue);
193
- }
194
- };
195
-
196
- const handleUpdatePositionOfRelation = (newIndex: number, currentIndex: number) => {
197
- if (onRelationReorder && newIndex >= 0 && newIndex < relations.length) {
198
- onRelationReorder(currentIndex, newIndex);
199
- }
200
- };
201
-
202
- const previewRelationsLength = usePrev(relations.length);
203
- const updatedRelationsWith = React.useRef<'onChange' | 'loadMore'>();
204
-
205
- const handleLoadMore = () => {
206
- updatedRelationsWith.current = 'loadMore';
207
- onRelationLoadMore();
208
- };
209
-
210
- React.useEffect(() => {
211
- if (updatedRelationsWith.current === 'onChange') {
212
- setTextValue('');
213
- }
214
-
215
- if (updatedRelationsWith.current === 'onChange' && relations.length !== previewRelationsLength) {
216
- listRef.current?.scrollToItem(relations.length, 'end');
217
- updatedRelationsWith.current = undefined;
218
- } else if (updatedRelationsWith.current === 'loadMore' && relations.length !== previewRelationsLength) {
219
- listRef.current?.scrollToItem(0, 'start');
220
- updatedRelationsWith.current = undefined;
221
- }
222
- }, [previewRelationsLength, relations]);
223
-
224
- const ariaDescriptionId = `${name}-item-instructions`;
225
-
226
- return (
227
- <Flex direction="column" gap={3} justifyContent="space-between" alignItems="stretch" wrap="wrap">
228
- <Flex direction="row" alignItems="end" justifyContent="end" gap={2} width="100%">
229
- <ComboboxWrapper marginRight="auto" maxWidth="100%" width="100%">
230
- <Combobox
231
- ref={fieldRef}
232
- autocomplete="none"
233
- error={error}
234
- name={name}
235
- hint={description}
236
- id={id}
237
- required={required}
238
- label={label}
239
- labelAction={labelAction}
240
- disabled={disabled}
241
- placeholder={placeholder}
242
- hasMoreItems={searchResults.hasNextPage}
243
- loading={searchResults.isLoading}
244
- onOpenChange={handleMenuOpen}
245
- noOptionsMessage={() => noRelationsMessage}
246
- loadingMessage={loadingMessage}
247
- onLoadMore={() => {
248
- onSearchNextPage();
249
- }}
250
- textValue={textValue}
251
- // @ts-ignore
252
- onChange={(relationId) => {
253
- if (!relationId) {
254
- return;
255
- }
256
- onRelationConnect(data.flat().find((opt) => opt.id.toString() === relationId)!);
257
- updatedRelationsWith.current = 'onChange';
258
- }}
259
- // @ts-ignore
260
- onTextValueChange={(text) => {
261
- setTextValue(text);
262
- }}
263
- // @ts-ignore
264
- onInputChange={(event) => {
265
- onSearch(event.currentTarget.value);
266
- }}
267
- >
268
- {options.map((opt) => {
269
- return <Option key={opt.id} {...opt} />;
270
- })}
271
- </Combobox>
272
- </ComboboxWrapper>
273
-
274
- {shouldDisplayLoadMoreButton && (
275
- <TextButton
276
- disabled={paginatedRelations.isLoading || paginatedRelations.isFetchingNextPage}
277
- onClick={handleLoadMore}
278
- loading={paginatedRelations.isLoading || paginatedRelations.isFetchingNextPage}
279
- startIcon={<Refresh />}
280
- // prevent the label from line-wrapping
281
- shrink={0}
282
- >
283
- {labelLoadMore}
284
- </TextButton>
285
- )}
286
- </Flex>
287
-
288
- {relations.length > 0 && (
289
- <ShadowBox overflowDirection={overflow}>
290
- <VisuallyHidden id={ariaDescriptionId}>{listAriaDescription}</VisuallyHidden>
291
- <VisuallyHidden aria-live="assertive">{liveText}</VisuallyHidden>
292
- {/* @ts-expect-error – width is expected, but we've not needed to pass it before. */}
293
- <List
294
- height={dynamicListHeight}
295
- ref={listRef}
296
- outerRef={outerListRef}
297
- itemCount={totalNumberOfRelations}
298
- itemSize={RELATION_ITEM_HEIGHT + RELATION_GUTTER}
299
- itemData={{
300
- name,
301
- ariaDescribedBy: ariaDescriptionId,
302
- canDrag: canReorder,
303
- disabled,
304
- handleCancel: onCancel,
305
- handleDropItem: onDropItem,
306
- handleGrabItem: onGrabItem,
307
- iconButtonAriaLabel,
308
- labelDisconnectRelation,
309
- onRelationDisconnect,
310
- publicationStateTranslations,
311
- relations,
312
- updatePositionOfRelation: handleUpdatePositionOfRelation
313
- }}
314
- itemKey={(index) => `${relations[index].mainField}_${relations[index].id}`}
315
- innerElementType="ol"
316
- >
317
- {ListItem}
318
- </List>
319
- </ShadowBox>
320
- )}
321
- </Flex>
322
- );
323
- };
324
-
325
- const ComboboxWrapper = styled(Box)`
326
- align-self: flex-start;
327
- `;
328
-
329
- const ShadowBox = styled(Box)<{ overflowDirection?: 'top-bottom' | 'top' | 'bottom' }>`
330
- position: relative;
331
- overflow: hidden;
332
- flex: 1;
333
-
334
- &:before,
335
- &:after {
336
- position: absolute;
337
- width: 100%;
338
- height: 4px;
339
- z-index: 1;
340
- }
341
-
342
- &:before {
343
- /* TODO: as for DS Table component we would need this to be handled by the DS theme */
344
- content: '';
345
- background: linear-gradient(rgba(3, 3, 5, 0.2) 0%, rgba(0, 0, 0, 0) 100%);
346
- top: 0;
347
- opacity: ${({ overflowDirection }) => (overflowDirection === 'top-bottom' || overflowDirection === 'top' ? 1 : 0)};
348
- transition: opacity 0.2s ease-in-out;
349
- }
350
-
351
- &:after {
352
- /* TODO: as for DS Table component we would need this to be handled by the DS theme */
353
- content: '';
354
- background: linear-gradient(0deg, rgba(3, 3, 5, 0.2) 0%, rgba(0, 0, 0, 0) 100%);
355
- bottom: 0;
356
- opacity: ${({ overflowDirection }) =>
357
- overflowDirection === 'top-bottom' || overflowDirection === 'bottom' ? 1 : 0};
358
- transition: opacity 0.2s ease-in-out;
359
- }
360
- `;
361
-
362
- /* -------------------------------------------------------------------------------------------------
363
- * Option
364
- * -----------------------------------------------------------------------------------------------*/
365
-
366
- const Option = ({
367
- publicationState,
368
- mainField,
369
- id
370
- }: Pick<NormalizedRelation, 'id' | 'mainField' | 'publicationState'>) => {
371
- const { formatMessage } = useIntl();
372
- const stringifiedDisplayValue = (mainField ?? id).toString();
373
-
374
- if (publicationState) {
375
- const isDraft = publicationState === 'draft';
376
- const draftMessage = {
377
- id: getTranslation('components.Select.draft-info-title'),
378
- defaultMessage: 'State: Draft'
379
- };
380
- const publishedMessage = {
381
- id: getTranslation('components.Select.publish-info-title'),
382
- defaultMessage: 'State: Published'
383
- };
384
- const title = isDraft ? formatMessage(draftMessage) : formatMessage(publishedMessage);
385
-
386
- return (
387
- <ComboboxOption value={id.toString()} textValue={stringifiedDisplayValue}>
388
- <Flex>
389
- <StyledBullet title={title} isDraft={isDraft} />
390
- <Typography ellipsis>{stringifiedDisplayValue}</Typography>
391
- </Flex>
392
- </ComboboxOption>
393
- );
394
- }
395
-
396
- return (
397
- <ComboboxOption value={id.toString()} textValue={stringifiedDisplayValue}>
398
- {stringifiedDisplayValue}
399
- </ComboboxOption>
400
- );
401
- };
402
-
403
- const StyledBullet = styled.div<{ isDraft?: boolean }>`
404
- flex-shrink: 0;
405
- width: ${pxToRem(6)};
406
- height: ${pxToRem(6)};
407
- margin-right: ${({ theme }) => theme.spaces[2]};
408
- background-color: ${({ theme, isDraft }) => theme.colors[isDraft ? 'secondary600' : 'success600']};
409
- border-radius: 50%;
410
- `;
411
-
412
- /* -------------------------------------------------------------------------------------------------
413
- * ListItem
414
- * -----------------------------------------------------------------------------------------------*/
415
-
416
- /**
417
- * This is in a separate component to enforce passing all the props the component requires to react-window
418
- * to ensure drag & drop correctly works.
419
- */
420
-
421
- interface ListItemProps extends Pick<RelationItemProps, 'index' | 'style'> {
422
- data: Pick<
423
- RelationItemProps,
424
- 'ariaDescribedBy' | 'canDrag' | 'disabled' | 'iconButtonAriaLabel' | 'name' | 'updatePositionOfRelation'
425
- > & {
426
- handleCancel: RelationItemProps['onCancel'];
427
- handleDropItem: RelationItemProps['onDropItem'];
428
- handleGrabItem: RelationItemProps['onGrabItem'];
429
- labelDisconnectRelation: string;
430
- onRelationDisconnect: (relation: NormalizedRelation) => void;
431
- publicationStateTranslations: {
432
- draft: string;
433
- published: string;
434
- };
435
- relations: NormalizedRelation[];
436
- };
437
- }
438
-
439
- const ListItem = ({ data, index, style }: ListItemProps) => {
440
- const {
441
- ariaDescribedBy,
442
- canDrag,
443
- disabled,
444
- handleCancel,
445
- handleDropItem,
446
- handleGrabItem,
447
- iconButtonAriaLabel,
448
- name,
449
- labelDisconnectRelation,
450
- onRelationDisconnect,
451
- publicationStateTranslations,
452
- relations,
453
- updatePositionOfRelation
454
- } = data;
455
- const { publicationState, href, mainField, id } = relations[index];
456
- const statusColor = publicationState === 'draft' ? 'secondary' : 'success';
457
-
458
- return (
459
- <RelationItem
460
- ariaDescribedBy={ariaDescribedBy}
461
- canDrag={canDrag}
462
- disabled={disabled}
463
- displayValue={String(mainField ?? id)}
464
- iconButtonAriaLabel={iconButtonAriaLabel}
465
- id={id}
466
- index={index}
467
- name={name}
468
- endAction={
469
- <DisconnectButton
470
- data-testid={`remove-relation-${id}`}
471
- disabled={disabled}
472
- type="button"
473
- onClick={() => onRelationDisconnect(relations[index])}
474
- aria-label={labelDisconnectRelation}
475
- >
476
- <Icon width="12px" as={Cross} />
477
- </DisconnectButton>
478
- }
479
- onCancel={handleCancel}
480
- onDropItem={handleDropItem}
481
- onGrabItem={handleGrabItem}
482
- status={publicationState || undefined}
483
- style={{
484
- ...style,
485
- bottom: style.bottom ?? 0 + RELATION_GUTTER,
486
- height: style.height ?? 0 - RELATION_GUTTER
487
- }}
488
- updatePositionOfRelation={updatePositionOfRelation}
489
- >
490
- <Box minWidth={0} paddingTop={1} paddingBottom={1} paddingRight={4}>
491
- <Tooltip description={mainField ?? `${id}`}>
492
- {href ? (
493
- <LinkEllipsis to={href}>{mainField ?? id}</LinkEllipsis>
494
- ) : (
495
- <Typography textColor={disabled ? 'neutral600' : 'primary600'} ellipsis>
496
- {mainField ?? id}
497
- </Typography>
498
- )}
499
- </Tooltip>
500
- </Box>
501
-
502
- {publicationState && (
503
- <Status variant={statusColor} showBullet={false} size="S">
504
- <Typography fontWeight="bold" textColor={`${statusColor}700`}>
505
- {/* @ts-ignore */}
506
- {publicationStateTranslations[publicationState]}
507
- </Typography>
508
- </Status>
509
- )}
510
- </RelationItem>
511
- );
512
- };
513
-
514
- /* -------------------------------------------------------------------------------------------------
515
- * DisconnectButton
516
- * -----------------------------------------------------------------------------------------------*/
517
-
518
- const DisconnectButton = styled.button`
519
- svg path {
520
- fill: ${({ theme, disabled }) => (disabled ? theme.colors.neutral600 : theme.colors.neutral500)};
521
- }
522
-
523
- &:hover svg path,
524
- &:focus svg path {
525
- fill: ${({ theme, disabled }) => !disabled && theme.colors.neutral600};
526
- }
527
- `;
528
-
529
- /* -------------------------------------------------------------------------------------------------
530
- * LinkEllipsis
531
- * -----------------------------------------------------------------------------------------------*/
532
-
533
- const LinkEllipsis = styled(Link)`
534
- display: block;
535
-
536
- > span {
537
- white-space: nowrap;
538
- overflow: hidden;
539
- text-overflow: ellipsis;
540
- display: block;
541
- }
542
- `;
543
-
544
- /* -------------------------------------------------------------------------------------------------
545
- * RelationItem
546
- * -----------------------------------------------------------------------------------------------*/
547
-
548
- interface RelationItemProps
549
- extends Pick<UseDragAndDropOptions, 'onCancel' | 'onDropItem' | 'onGrabItem'>,
550
- // @ts-ignore
551
- Omit<FlexProps, 'id' | 'style'>,
552
- Pick<ListChildComponentProps, 'style' | 'index'> {
553
- ariaDescribedBy: string;
554
- canDrag: boolean;
555
- children: React.ReactNode;
556
- displayValue: string;
557
- disabled: boolean;
558
- endAction: React.ReactNode;
559
- iconButtonAriaLabel: string;
560
- id: Entity.ID;
561
- name: string;
562
- status?: NormalizedRelation['publicationState'];
563
- updatePositionOfRelation: UseDragAndDropOptions['onMoveItem'];
564
- }
565
-
566
- const RelationItem = ({
567
- ariaDescribedBy,
568
- children,
569
- displayValue,
570
- canDrag,
571
- disabled,
572
- endAction,
573
- iconButtonAriaLabel,
574
- style,
575
- id,
576
- index,
577
- name,
578
- onCancel,
579
- onDropItem,
580
- onGrabItem,
581
- status,
582
- updatePositionOfRelation,
583
- ...props
584
- }: RelationItemProps) => {
585
- const [{ handlerId, isDragging, handleKeyDown }, relationRef, dropRef, dragRef, dragPreviewRef] = useDragAndDrop(
586
- canDrag && !disabled,
587
- {
588
- type: `${ItemTypes.RELATION}_${name}`,
589
- index,
590
- item: {
591
- displayedValue: displayValue,
592
- status,
593
- id,
594
- index
595
- },
596
- onMoveItem: updatePositionOfRelation,
597
- onDropItem,
598
- onGrabItem,
599
- onCancel,
600
- dropSensitivity: DROP_SENSITIVITY.IMMEDIATE
601
- }
602
- );
603
-
604
- const composedRefs = composeRefs(relationRef, dragRef);
605
-
606
- React.useEffect(() => {
607
- dragPreviewRef(getEmptyImage());
608
- }, [dragPreviewRef]);
609
-
610
- return (
611
- <Box
612
- style={style}
613
- as="li"
614
- ref={dropRef}
615
- aria-describedby={ariaDescribedBy}
616
- cursor={canDrag ? 'all-scroll' : 'default'}
617
- >
618
- {isDragging ? (
619
- <RelationItemPlaceholder />
620
- ) : (
621
- <Flex
622
- paddingTop={2}
623
- paddingBottom={2}
624
- paddingLeft={canDrag ? 2 : 4}
625
- paddingRight={4}
626
- hasRadius
627
- borderColor="neutral200"
628
- background={disabled ? 'neutral150' : 'neutral0'}
629
- justifyContent="space-between"
630
- ref={canDrag ? composedRefs : undefined}
631
- data-handler-id={handlerId}
632
- {...props}
633
- >
634
- <FlexWrapper gap={1}>
635
- {canDrag ? (
636
- <IconButton
637
- forwardedAs="div"
638
- role="button"
639
- tabIndex={0}
640
- aria-label={iconButtonAriaLabel}
641
- borderWidth={0}
642
- onKeyDown={handleKeyDown}
643
- disabled={disabled}
644
- >
645
- <Drag />
646
- </IconButton>
647
- ) : null}
648
- <ChildrenWrapper justifyContent="space-between">{children}</ChildrenWrapper>
649
- </FlexWrapper>
650
- {endAction && <Box paddingLeft={4}>{endAction}</Box>}
651
- </Flex>
652
- )}
653
- </Box>
654
- );
655
- };
656
-
657
- const RelationItemPlaceholder = () => (
658
- <Box
659
- paddingTop={2}
660
- paddingBottom={2}
661
- paddingLeft={4}
662
- paddingRight={4}
663
- hasRadius
664
- borderStyle="dashed"
665
- borderColor="primary600"
666
- borderWidth="1px"
667
- background="primary100"
668
- height={`calc(100% - ${RELATION_GUTTER}px)`}
669
- />
670
- );
671
-
672
- const FlexWrapper = styled(Flex)`
673
- width: 100%;
674
- /* Used to prevent endAction to be pushed out of container */
675
- min-width: 0;
676
-
677
- & > div[role='button'] {
678
- cursor: all-scroll;
679
- }
680
- `;
681
-
682
- const ChildrenWrapper = styled(Flex)`
683
- width: 100%;
684
- /* Used to prevent endAction to be pushed out of container */
685
- min-width: 0;
686
- `;
687
-
688
- export { RelationInput, FlexWrapper, ChildrenWrapper, LinkEllipsis, DisconnectButton };
689
- export type { RelationInputProps };
1
+ import * as React from 'react';
2
+
3
+ import {
4
+ Status,
5
+ Box,
6
+ Link,
7
+ Icon,
8
+ Flex,
9
+ TextButton,
10
+ Typography,
11
+ Tooltip,
12
+ VisuallyHidden,
13
+ Combobox,
14
+ IconButton,
15
+ FlexProps,
16
+ ComboboxOption,
17
+ ComboboxProps
18
+ } from '@strapi/design-system';
19
+ import { pxToRem, useFocusInputField } from '@strapi/helper-plugin';
20
+ import { Cross, Drag, Refresh } from '@strapi/icons';
21
+ import { getEmptyImage } from 'react-dnd-html5-backend';
22
+ import { useIntl } from 'react-intl';
23
+ import { FixedSizeList, FixedSizeList as List, ListChildComponentProps } from 'react-window';
24
+ import styled from 'styled-components';
25
+
26
+ import { UseDragAndDropOptions, useDragAndDrop, DROP_SENSITIVITY } from '../../hooks/useDragAndDrop';
27
+ import { usePrev } from '../../hooks/usePrev';
28
+ import { ItemTypes } from '../../utils/dragAndDrop';
29
+ import { composeRefs } from '../../utils/refs';
30
+ import { getTranslation } from '../../utils/translations';
31
+
32
+ import type { NormalizedRelation } from './utils/normalizeRelations';
33
+ import type { Contracts } from '../../../../../../content-manager/shared';
34
+ import type { Entity } from '@strapi/types';
35
+
36
+ const RELATION_ITEM_HEIGHT = 50;
37
+ const RELATION_GUTTER = 4;
38
+
39
+ /* -------------------------------------------------------------------------------------------------
40
+ * RelationInput
41
+ * -----------------------------------------------------------------------------------------------*/
42
+
43
+ interface RelationInputProps
44
+ extends Pick<
45
+ // @ts-ignore
46
+ ComboboxProps,
47
+ 'disabled' | 'error' | 'id' | 'labelAction' | 'placeholder' | 'required'
48
+ >,
49
+ Pick<RelationItemProps, 'onCancel' | 'onDropItem' | 'onGrabItem' | 'iconButtonAriaLabel'> {
50
+ canReorder: boolean;
51
+ // @ts-ignore
52
+ description: ComboboxProps['hint'];
53
+ numberOfRelationsToDisplay: number;
54
+ label: string;
55
+ labelLoadMore?: string;
56
+ labelDisconnectRelation: string;
57
+ listAriaDescription: string;
58
+ liveText: string;
59
+ loadingMessage: string;
60
+ name: string;
61
+ noRelationsMessage: string;
62
+ onRelationConnect: (relation: Contracts.Relations.RelationResult) => void;
63
+ onRelationLoadMore: () => void;
64
+ onRelationDisconnect: (relation: NormalizedRelation) => void;
65
+ onRelationReorder?: (currentIndex: number, newIndex: number) => void;
66
+ onSearchNextPage: () => void;
67
+ onSearch: (searchTerm?: string) => void;
68
+ publicationStateTranslations: {
69
+ draft: string;
70
+ published: string;
71
+ };
72
+ relations: {
73
+ data: NormalizedRelation[];
74
+ isLoading: boolean;
75
+ isFetchingNextPage: boolean;
76
+ hasNextPage?: boolean;
77
+ };
78
+ searchResults: {
79
+ data: NormalizedRelation[];
80
+ isLoading: boolean;
81
+ hasNextPage?: boolean;
82
+ };
83
+ size: number;
84
+ }
85
+
86
+ const RelationInput = ({
87
+ canReorder,
88
+ description,
89
+ disabled,
90
+ error,
91
+ iconButtonAriaLabel,
92
+ id,
93
+ name,
94
+ numberOfRelationsToDisplay,
95
+ label,
96
+ labelAction,
97
+ labelLoadMore,
98
+ labelDisconnectRelation,
99
+ listAriaDescription,
100
+ liveText,
101
+ loadingMessage,
102
+ onCancel,
103
+ onDropItem,
104
+ onGrabItem,
105
+ noRelationsMessage,
106
+ onRelationConnect,
107
+ onRelationLoadMore,
108
+ onRelationDisconnect,
109
+ onRelationReorder,
110
+ onSearchNextPage,
111
+ onSearch,
112
+ placeholder,
113
+ publicationStateTranslations,
114
+ required,
115
+ relations: paginatedRelations,
116
+ searchResults,
117
+ size
118
+ }: RelationInputProps) => {
119
+ const [textValue, setTextValue] = React.useState<string | undefined>('');
120
+ const [overflow, setOverflow] = React.useState<'top' | 'bottom' | 'top-bottom'>();
121
+
122
+ const listRef = React.useRef<FixedSizeList>(null);
123
+ const outerListRef = React.useRef<HTMLUListElement>(null);
124
+
125
+ const fieldRef = useFocusInputField(name);
126
+
127
+ const { data } = searchResults;
128
+
129
+ const relations = paginatedRelations.data;
130
+ const totalNumberOfRelations = relations.length ?? 0;
131
+
132
+ const dynamicListHeight = React.useMemo(
133
+ () =>
134
+ totalNumberOfRelations > numberOfRelationsToDisplay
135
+ ? Math.min(totalNumberOfRelations, numberOfRelationsToDisplay) * (RELATION_ITEM_HEIGHT + RELATION_GUTTER) +
136
+ RELATION_ITEM_HEIGHT / 2
137
+ : Math.min(totalNumberOfRelations, numberOfRelationsToDisplay) * (RELATION_ITEM_HEIGHT + RELATION_GUTTER),
138
+ [totalNumberOfRelations, numberOfRelationsToDisplay]
139
+ );
140
+
141
+ const shouldDisplayLoadMoreButton = !!labelLoadMore && paginatedRelations.hasNextPage;
142
+
143
+ const options = React.useMemo(
144
+ () =>
145
+ data
146
+ .flat()
147
+ .filter(Boolean)
148
+ .map((result) => ({
149
+ ...result,
150
+ value: result.id,
151
+ label: result.mainField
152
+ })),
153
+ [data]
154
+ );
155
+
156
+ React.useEffect(() => {
157
+ if (totalNumberOfRelations <= numberOfRelationsToDisplay) {
158
+ return setOverflow(undefined);
159
+ }
160
+
161
+ const handleNativeScroll = (e: Event) => {
162
+ const el = e.target as HTMLUListElement;
163
+ const parentScrollContainerHeight = (el.parentNode as HTMLDivElement).scrollHeight;
164
+ const maxScrollBottom = el.scrollHeight - el.scrollTop;
165
+
166
+ if (el.scrollTop === 0) {
167
+ return setOverflow('bottom');
168
+ }
169
+
170
+ if (maxScrollBottom === parentScrollContainerHeight) {
171
+ return setOverflow('top');
172
+ }
173
+
174
+ return setOverflow('top-bottom');
175
+ };
176
+
177
+ const outerListRefCurrent = outerListRef?.current;
178
+
179
+ if (!paginatedRelations.isLoading && relations.length > 0 && outerListRefCurrent) {
180
+ outerListRef.current.addEventListener('scroll', handleNativeScroll);
181
+ }
182
+
183
+ return () => {
184
+ if (outerListRefCurrent) {
185
+ outerListRefCurrent.removeEventListener('scroll', handleNativeScroll);
186
+ }
187
+ };
188
+ }, [paginatedRelations, relations, numberOfRelationsToDisplay, totalNumberOfRelations]);
189
+
190
+ const handleMenuOpen = (isOpen?: boolean) => {
191
+ if (isOpen) {
192
+ onSearch(textValue);
193
+ }
194
+ };
195
+
196
+ const handleUpdatePositionOfRelation = (newIndex: number, currentIndex: number) => {
197
+ if (onRelationReorder && newIndex >= 0 && newIndex < relations.length) {
198
+ onRelationReorder(currentIndex, newIndex);
199
+ }
200
+ };
201
+
202
+ const previewRelationsLength = usePrev(relations.length);
203
+ const updatedRelationsWith = React.useRef<'onChange' | 'loadMore'>();
204
+
205
+ const handleLoadMore = () => {
206
+ updatedRelationsWith.current = 'loadMore';
207
+ onRelationLoadMore();
208
+ };
209
+
210
+ React.useEffect(() => {
211
+ if (updatedRelationsWith.current === 'onChange') {
212
+ setTextValue('');
213
+ }
214
+
215
+ if (updatedRelationsWith.current === 'onChange' && relations.length !== previewRelationsLength) {
216
+ listRef.current?.scrollToItem(relations.length, 'end');
217
+ updatedRelationsWith.current = undefined;
218
+ } else if (updatedRelationsWith.current === 'loadMore' && relations.length !== previewRelationsLength) {
219
+ listRef.current?.scrollToItem(0, 'start');
220
+ updatedRelationsWith.current = undefined;
221
+ }
222
+ }, [previewRelationsLength, relations]);
223
+
224
+ const ariaDescriptionId = `${name}-item-instructions`;
225
+
226
+ return (
227
+ <Flex direction="column" gap={3} justifyContent="space-between" alignItems="stretch" wrap="wrap">
228
+ <Flex direction="row" alignItems="end" justifyContent="end" gap={2} width="100%">
229
+ <ComboboxWrapper marginRight="auto" maxWidth="100%" width="100%">
230
+ <Combobox
231
+ ref={fieldRef}
232
+ autocomplete="none"
233
+ error={error}
234
+ name={name}
235
+ hint={description}
236
+ id={id}
237
+ required={required}
238
+ label={label}
239
+ labelAction={labelAction}
240
+ disabled={disabled}
241
+ placeholder={placeholder}
242
+ hasMoreItems={searchResults.hasNextPage}
243
+ loading={searchResults.isLoading}
244
+ onOpenChange={handleMenuOpen}
245
+ noOptionsMessage={() => noRelationsMessage}
246
+ loadingMessage={loadingMessage}
247
+ onLoadMore={() => {
248
+ onSearchNextPage();
249
+ }}
250
+ textValue={textValue}
251
+ // @ts-ignore
252
+ onChange={(relationId) => {
253
+ if (!relationId) {
254
+ return;
255
+ }
256
+ onRelationConnect(data.flat().find((opt) => opt.id.toString() === relationId)!);
257
+ updatedRelationsWith.current = 'onChange';
258
+ }}
259
+ // @ts-ignore
260
+ onTextValueChange={(text) => {
261
+ setTextValue(text);
262
+ }}
263
+ // @ts-ignore
264
+ onInputChange={(event) => {
265
+ onSearch(event.currentTarget.value);
266
+ }}
267
+ >
268
+ {options.map((opt) => {
269
+ return <Option key={opt.id} {...opt} />;
270
+ })}
271
+ </Combobox>
272
+ </ComboboxWrapper>
273
+
274
+ {shouldDisplayLoadMoreButton && (
275
+ <TextButton
276
+ disabled={paginatedRelations.isLoading || paginatedRelations.isFetchingNextPage}
277
+ onClick={handleLoadMore}
278
+ loading={paginatedRelations.isLoading || paginatedRelations.isFetchingNextPage}
279
+ startIcon={<Refresh />}
280
+ // prevent the label from line-wrapping
281
+ shrink={0}
282
+ >
283
+ {labelLoadMore}
284
+ </TextButton>
285
+ )}
286
+ </Flex>
287
+
288
+ {relations.length > 0 && (
289
+ <ShadowBox overflowDirection={overflow}>
290
+ <VisuallyHidden id={ariaDescriptionId}>{listAriaDescription}</VisuallyHidden>
291
+ <VisuallyHidden aria-live="assertive">{liveText}</VisuallyHidden>
292
+ {/* @ts-expect-error – width is expected, but we've not needed to pass it before. */}
293
+ <List
294
+ height={dynamicListHeight}
295
+ ref={listRef}
296
+ outerRef={outerListRef}
297
+ itemCount={totalNumberOfRelations}
298
+ itemSize={RELATION_ITEM_HEIGHT + RELATION_GUTTER}
299
+ itemData={{
300
+ name,
301
+ ariaDescribedBy: ariaDescriptionId,
302
+ canDrag: canReorder,
303
+ disabled,
304
+ handleCancel: onCancel,
305
+ handleDropItem: onDropItem,
306
+ handleGrabItem: onGrabItem,
307
+ iconButtonAriaLabel,
308
+ labelDisconnectRelation,
309
+ onRelationDisconnect,
310
+ publicationStateTranslations,
311
+ relations,
312
+ updatePositionOfRelation: handleUpdatePositionOfRelation
313
+ }}
314
+ itemKey={(index) => `${relations[index].mainField}_${relations[index].id}`}
315
+ innerElementType="ol"
316
+ >
317
+ {ListItem}
318
+ </List>
319
+ </ShadowBox>
320
+ )}
321
+ </Flex>
322
+ );
323
+ };
324
+
325
+ const ComboboxWrapper = styled(Box)`
326
+ align-self: flex-start;
327
+ `;
328
+
329
+ const ShadowBox = styled(Box)<{ overflowDirection?: 'top-bottom' | 'top' | 'bottom' }>`
330
+ position: relative;
331
+ overflow: hidden;
332
+ flex: 1;
333
+
334
+ &:before,
335
+ &:after {
336
+ position: absolute;
337
+ width: 100%;
338
+ height: 4px;
339
+ z-index: 1;
340
+ }
341
+
342
+ &:before {
343
+ /* TODO: as for DS Table component we would need this to be handled by the DS theme */
344
+ content: '';
345
+ background: linear-gradient(rgba(3, 3, 5, 0.2) 0%, rgba(0, 0, 0, 0) 100%);
346
+ top: 0;
347
+ opacity: ${({ overflowDirection }) => (overflowDirection === 'top-bottom' || overflowDirection === 'top' ? 1 : 0)};
348
+ transition: opacity 0.2s ease-in-out;
349
+ }
350
+
351
+ &:after {
352
+ /* TODO: as for DS Table component we would need this to be handled by the DS theme */
353
+ content: '';
354
+ background: linear-gradient(0deg, rgba(3, 3, 5, 0.2) 0%, rgba(0, 0, 0, 0) 100%);
355
+ bottom: 0;
356
+ opacity: ${({ overflowDirection }) =>
357
+ overflowDirection === 'top-bottom' || overflowDirection === 'bottom' ? 1 : 0};
358
+ transition: opacity 0.2s ease-in-out;
359
+ }
360
+ `;
361
+
362
+ /* -------------------------------------------------------------------------------------------------
363
+ * Option
364
+ * -----------------------------------------------------------------------------------------------*/
365
+
366
+ const Option = ({
367
+ publicationState,
368
+ mainField,
369
+ id
370
+ }: Pick<NormalizedRelation, 'id' | 'mainField' | 'publicationState'>) => {
371
+ const { formatMessage } = useIntl();
372
+ const stringifiedDisplayValue = (mainField ?? id).toString();
373
+
374
+ if (publicationState) {
375
+ const isDraft = publicationState === 'draft';
376
+ const draftMessage = {
377
+ id: getTranslation('components.Select.draft-info-title'),
378
+ defaultMessage: 'State: Draft'
379
+ };
380
+ const publishedMessage = {
381
+ id: getTranslation('components.Select.publish-info-title'),
382
+ defaultMessage: 'State: Published'
383
+ };
384
+ const title = isDraft ? formatMessage(draftMessage) : formatMessage(publishedMessage);
385
+
386
+ return (
387
+ <ComboboxOption value={id.toString()} textValue={stringifiedDisplayValue}>
388
+ <Flex>
389
+ <StyledBullet title={title} isDraft={isDraft} />
390
+ <Typography ellipsis>{stringifiedDisplayValue}</Typography>
391
+ </Flex>
392
+ </ComboboxOption>
393
+ );
394
+ }
395
+
396
+ return (
397
+ <ComboboxOption value={id.toString()} textValue={stringifiedDisplayValue}>
398
+ {stringifiedDisplayValue}
399
+ </ComboboxOption>
400
+ );
401
+ };
402
+
403
+ const StyledBullet = styled.div<{ isDraft?: boolean }>`
404
+ flex-shrink: 0;
405
+ width: ${pxToRem(6)};
406
+ height: ${pxToRem(6)};
407
+ margin-right: ${({ theme }) => theme.spaces[2]};
408
+ background-color: ${({ theme, isDraft }) => theme.colors[isDraft ? 'secondary600' : 'success600']};
409
+ border-radius: 50%;
410
+ `;
411
+
412
+ /* -------------------------------------------------------------------------------------------------
413
+ * ListItem
414
+ * -----------------------------------------------------------------------------------------------*/
415
+
416
+ /**
417
+ * This is in a separate component to enforce passing all the props the component requires to react-window
418
+ * to ensure drag & drop correctly works.
419
+ */
420
+
421
+ interface ListItemProps extends Pick<RelationItemProps, 'index' | 'style'> {
422
+ data: Pick<
423
+ RelationItemProps,
424
+ 'ariaDescribedBy' | 'canDrag' | 'disabled' | 'iconButtonAriaLabel' | 'name' | 'updatePositionOfRelation'
425
+ > & {
426
+ handleCancel: RelationItemProps['onCancel'];
427
+ handleDropItem: RelationItemProps['onDropItem'];
428
+ handleGrabItem: RelationItemProps['onGrabItem'];
429
+ labelDisconnectRelation: string;
430
+ onRelationDisconnect: (relation: NormalizedRelation) => void;
431
+ publicationStateTranslations: {
432
+ draft: string;
433
+ published: string;
434
+ };
435
+ relations: NormalizedRelation[];
436
+ };
437
+ }
438
+
439
+ const ListItem = ({ data, index, style }: ListItemProps) => {
440
+ const {
441
+ ariaDescribedBy,
442
+ canDrag,
443
+ disabled,
444
+ handleCancel,
445
+ handleDropItem,
446
+ handleGrabItem,
447
+ iconButtonAriaLabel,
448
+ name,
449
+ labelDisconnectRelation,
450
+ onRelationDisconnect,
451
+ publicationStateTranslations,
452
+ relations,
453
+ updatePositionOfRelation
454
+ } = data;
455
+ const { publicationState, href, mainField, id } = relations[index];
456
+ const statusColor = publicationState === 'draft' ? 'secondary' : 'success';
457
+
458
+ return (
459
+ <RelationItem
460
+ ariaDescribedBy={ariaDescribedBy}
461
+ canDrag={canDrag}
462
+ disabled={disabled}
463
+ displayValue={String(mainField ?? id)}
464
+ iconButtonAriaLabel={iconButtonAriaLabel}
465
+ id={id}
466
+ index={index}
467
+ name={name}
468
+ endAction={
469
+ <DisconnectButton
470
+ data-testid={`remove-relation-${id}`}
471
+ disabled={disabled}
472
+ type="button"
473
+ onClick={() => onRelationDisconnect(relations[index])}
474
+ aria-label={labelDisconnectRelation}
475
+ >
476
+ <Icon width="12px" as={Cross} />
477
+ </DisconnectButton>
478
+ }
479
+ onCancel={handleCancel}
480
+ onDropItem={handleDropItem}
481
+ onGrabItem={handleGrabItem}
482
+ status={publicationState || undefined}
483
+ style={{
484
+ ...style,
485
+ bottom: style.bottom ?? 0 + RELATION_GUTTER,
486
+ height: style.height ?? 0 - RELATION_GUTTER
487
+ }}
488
+ updatePositionOfRelation={updatePositionOfRelation}
489
+ >
490
+ <Box minWidth={0} paddingTop={1} paddingBottom={1} paddingRight={4}>
491
+ <Tooltip description={mainField ?? `${id}`}>
492
+ {href ? (
493
+ <LinkEllipsis to={href}>{mainField ?? id}</LinkEllipsis>
494
+ ) : (
495
+ <Typography textColor={disabled ? 'neutral600' : 'primary600'} ellipsis>
496
+ {mainField ?? id}
497
+ </Typography>
498
+ )}
499
+ </Tooltip>
500
+ </Box>
501
+
502
+ {publicationState && (
503
+ <Status variant={statusColor} showBullet={false} size="S">
504
+ <Typography fontWeight="bold" textColor={`${statusColor}700`}>
505
+ {/* @ts-ignore */}
506
+ {publicationStateTranslations[publicationState]}
507
+ </Typography>
508
+ </Status>
509
+ )}
510
+ </RelationItem>
511
+ );
512
+ };
513
+
514
+ /* -------------------------------------------------------------------------------------------------
515
+ * DisconnectButton
516
+ * -----------------------------------------------------------------------------------------------*/
517
+
518
+ const DisconnectButton = styled.button`
519
+ svg path {
520
+ fill: ${({ theme, disabled }) => (disabled ? theme.colors.neutral600 : theme.colors.neutral500)};
521
+ }
522
+
523
+ &:hover svg path,
524
+ &:focus svg path {
525
+ fill: ${({ theme, disabled }) => !disabled && theme.colors.neutral600};
526
+ }
527
+ `;
528
+
529
+ /* -------------------------------------------------------------------------------------------------
530
+ * LinkEllipsis
531
+ * -----------------------------------------------------------------------------------------------*/
532
+
533
+ const LinkEllipsis = styled(Link)`
534
+ display: block;
535
+
536
+ > span {
537
+ white-space: nowrap;
538
+ overflow: hidden;
539
+ text-overflow: ellipsis;
540
+ display: block;
541
+ }
542
+ `;
543
+
544
+ /* -------------------------------------------------------------------------------------------------
545
+ * RelationItem
546
+ * -----------------------------------------------------------------------------------------------*/
547
+
548
+ interface RelationItemProps
549
+ extends Pick<UseDragAndDropOptions, 'onCancel' | 'onDropItem' | 'onGrabItem'>,
550
+ // @ts-ignore
551
+ Omit<FlexProps, 'id' | 'style'>,
552
+ Pick<ListChildComponentProps, 'style' | 'index'> {
553
+ ariaDescribedBy: string;
554
+ canDrag: boolean;
555
+ children: React.ReactNode;
556
+ displayValue: string;
557
+ disabled: boolean;
558
+ endAction: React.ReactNode;
559
+ iconButtonAriaLabel: string;
560
+ id: Entity.ID;
561
+ name: string;
562
+ status?: NormalizedRelation['publicationState'];
563
+ updatePositionOfRelation: UseDragAndDropOptions['onMoveItem'];
564
+ }
565
+
566
+ const RelationItem = ({
567
+ ariaDescribedBy,
568
+ children,
569
+ displayValue,
570
+ canDrag,
571
+ disabled,
572
+ endAction,
573
+ iconButtonAriaLabel,
574
+ style,
575
+ id,
576
+ index,
577
+ name,
578
+ onCancel,
579
+ onDropItem,
580
+ onGrabItem,
581
+ status,
582
+ updatePositionOfRelation,
583
+ ...props
584
+ }: RelationItemProps) => {
585
+ const [{ handlerId, isDragging, handleKeyDown }, relationRef, dropRef, dragRef, dragPreviewRef] = useDragAndDrop(
586
+ canDrag && !disabled,
587
+ {
588
+ type: `${ItemTypes.RELATION}_${name}`,
589
+ index,
590
+ item: {
591
+ displayedValue: displayValue,
592
+ status,
593
+ id,
594
+ index
595
+ },
596
+ onMoveItem: updatePositionOfRelation,
597
+ onDropItem,
598
+ onGrabItem,
599
+ onCancel,
600
+ dropSensitivity: DROP_SENSITIVITY.IMMEDIATE
601
+ }
602
+ );
603
+
604
+ const composedRefs = composeRefs(relationRef, dragRef);
605
+
606
+ React.useEffect(() => {
607
+ dragPreviewRef(getEmptyImage());
608
+ }, [dragPreviewRef]);
609
+
610
+ return (
611
+ <Box
612
+ style={style}
613
+ as="li"
614
+ ref={dropRef}
615
+ aria-describedby={ariaDescribedBy}
616
+ cursor={canDrag ? 'all-scroll' : 'default'}
617
+ >
618
+ {isDragging ? (
619
+ <RelationItemPlaceholder />
620
+ ) : (
621
+ <Flex
622
+ paddingTop={2}
623
+ paddingBottom={2}
624
+ paddingLeft={canDrag ? 2 : 4}
625
+ paddingRight={4}
626
+ hasRadius
627
+ borderColor="neutral200"
628
+ background={disabled ? 'neutral150' : 'neutral0'}
629
+ justifyContent="space-between"
630
+ ref={canDrag ? composedRefs : undefined}
631
+ data-handler-id={handlerId}
632
+ {...props}
633
+ >
634
+ <FlexWrapper gap={1}>
635
+ {canDrag ? (
636
+ <IconButton
637
+ forwardedAs="div"
638
+ role="button"
639
+ tabIndex={0}
640
+ aria-label={iconButtonAriaLabel}
641
+ borderWidth={0}
642
+ onKeyDown={handleKeyDown}
643
+ disabled={disabled}
644
+ >
645
+ <Drag />
646
+ </IconButton>
647
+ ) : null}
648
+ <ChildrenWrapper justifyContent="space-between">{children}</ChildrenWrapper>
649
+ </FlexWrapper>
650
+ {endAction && <Box paddingLeft={4}>{endAction}</Box>}
651
+ </Flex>
652
+ )}
653
+ </Box>
654
+ );
655
+ };
656
+
657
+ const RelationItemPlaceholder = () => (
658
+ <Box
659
+ paddingTop={2}
660
+ paddingBottom={2}
661
+ paddingLeft={4}
662
+ paddingRight={4}
663
+ hasRadius
664
+ borderStyle="dashed"
665
+ borderColor="primary600"
666
+ borderWidth="1px"
667
+ background="primary100"
668
+ height={`calc(100% - ${RELATION_GUTTER}px)`}
669
+ />
670
+ );
671
+
672
+ const FlexWrapper = styled(Flex)`
673
+ width: 100%;
674
+ /* Used to prevent endAction to be pushed out of container */
675
+ min-width: 0;
676
+
677
+ & > div[role='button'] {
678
+ cursor: all-scroll;
679
+ }
680
+ `;
681
+
682
+ const ChildrenWrapper = styled(Flex)`
683
+ width: 100%;
684
+ /* Used to prevent endAction to be pushed out of container */
685
+ min-width: 0;
686
+ `;
687
+
688
+ export { RelationInput, FlexWrapper, ChildrenWrapper, LinkEllipsis, DisconnectButton };
689
+ export type { RelationInputProps };