multi-content-type-relation 0.1.0

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 (55) hide show
  1. package/README.md +86 -0
  2. package/TODO.md +4 -0
  3. package/admin/src/components/Input/InputContentSuggestions.tsx +162 -0
  4. package/admin/src/components/Input/MainInput.tsx +135 -0
  5. package/admin/src/components/Input/PublicationState.tsx +28 -0
  6. package/admin/src/components/Input/TableItem.tsx +109 -0
  7. package/admin/src/components/Input/index.tsx +27 -0
  8. package/admin/src/components/PluginIcon/index.tsx +12 -0
  9. package/admin/src/helpers/content.ts +60 -0
  10. package/admin/src/helpers/storage.ts +32 -0
  11. package/admin/src/hooks/useSearchedEntries.ts +41 -0
  12. package/admin/src/index.tsx +140 -0
  13. package/admin/src/interface.ts +37 -0
  14. package/admin/src/pluginId.ts +5 -0
  15. package/admin/src/translations/en.json +1 -0
  16. package/admin/src/translations/fr.json +1 -0
  17. package/admin/src/utils/getTrad.ts +5 -0
  18. package/dist/server/bootstrap.js +5 -0
  19. package/dist/server/config/index.js +27 -0
  20. package/dist/server/content-types/index.js +3 -0
  21. package/dist/server/controllers/controller.js +92 -0
  22. package/dist/server/controllers/index.js +9 -0
  23. package/dist/server/destroy.js +5 -0
  24. package/dist/server/index.js +27 -0
  25. package/dist/server/interface.js +2 -0
  26. package/dist/server/middlewares/index.js +9 -0
  27. package/dist/server/middlewares/middleware.js +163 -0
  28. package/dist/server/policies/index.js +3 -0
  29. package/dist/server/register.js +15 -0
  30. package/dist/server/routes/index.js +29 -0
  31. package/dist/server/services/index.js +9 -0
  32. package/dist/server/services/service.js +8 -0
  33. package/dist/server/utils.js +15 -0
  34. package/dist/tsconfig.server.tsbuildinfo +1 -0
  35. package/package.json +53 -0
  36. package/server/bootstrap.ts +5 -0
  37. package/server/config/index.ts +28 -0
  38. package/server/content-types/index.ts +1 -0
  39. package/server/controllers/controller.ts +107 -0
  40. package/server/controllers/index.ts +5 -0
  41. package/server/destroy.ts +5 -0
  42. package/server/index.ts +23 -0
  43. package/server/interface.ts +50 -0
  44. package/server/middlewares/index.ts +5 -0
  45. package/server/middlewares/middleware.ts +197 -0
  46. package/server/policies/index.ts +1 -0
  47. package/server/register.ts +14 -0
  48. package/server/routes/index.ts +27 -0
  49. package/server/services/index.ts +5 -0
  50. package/server/services/service.ts +11 -0
  51. package/server/utils.ts +14 -0
  52. package/strapi-admin.js +3 -0
  53. package/strapi-server.js +3 -0
  54. package/tsconfig.json +20 -0
  55. package/tsconfig.server.json +25 -0
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ <div align="center">
2
+ <h1>Strapi Multi Content Type Relation</h1>
3
+
4
+ <p style="margin-top: 0;">Create deep relations in the contribution between content types.</p>
5
+
6
+ </div>
7
+
8
+ - **Multilingual** Support i18n out of the box
9
+ - Support **Publication State**
10
+ - **Required, minimum, maximum** validators for the custom field
11
+ - Seamless UI integration with **Strapi Design System**
12
+
13
+ ## Installation
14
+
15
+ Install the plugin in your Strapi project
16
+
17
+ ```bash
18
+ npm install multi-content-type-relation
19
+ ```
20
+
21
+ After installation, enable the plugin in your config file
22
+
23
+ ```js
24
+ // config/plugins.js
25
+
26
+ module.exports = () => ({
27
+ "multi-content-type-relation": {
28
+ enabled: true,
29
+ config: {
30
+ recursive: {
31
+ enabled: true,
32
+ maxDepth: 2,
33
+ },
34
+ debug: false,
35
+ },
36
+ },
37
+ // .. other plugins
38
+ });
39
+ ```
40
+
41
+ The plugin should now appear in the **Settings** section of your Strapi app
42
+
43
+ ## Usage
44
+
45
+ This plugin allows you to create a custom field inside any content type you want. This custom field will allow you, after some configuration in the **content type builder** to select multiple content types in the contribution
46
+
47
+ Configuring a MRCT field by selecting content types you want to link
48
+ ![](https://i.imgur.com/J1cCGKM.png)
49
+
50
+ Advanced settings Tab
51
+ ![](https://i.imgur.com/ik75kGH.png)
52
+
53
+ Usage from contribution side
54
+
55
+ https://i.imgur.com/UDz7pUh.mp4
56
+
57
+ ## Configuration
58
+
59
+ Plugin configuration settings
60
+
61
+ ###### Key: `recursive`
62
+
63
+ > `required:` no | `type:` { enabled: Boolean, maxDepth: number} | default { enabled: false, maxDepth: 1}
64
+
65
+ By default, the plugin will only hydrate the direct relations of the content you fetch
66
+
67
+ If, for some reasons, you want to hydrate the relations of the relations of the content you fetch, you can through this setting.
68
+
69
+ > Note: this setting will DRAMATICALLY increase the load on Strapi. The complexity is O(n^maxDepth) and the plugin will fetch n^maxDepth items through Strapi API. **I strongly recommand to never go above maxDepth set to 2.**
70
+
71
+ ###### Key: `debug`
72
+
73
+ > `required:` no | `type:` Boolean | default false
74
+
75
+ This setting show debug log of the plugin for better understanding
76
+
77
+ ## Submit an issue
78
+
79
+ You can use github issues to raise an issue about this plugin
80
+
81
+ ## Contributing
82
+
83
+ Feel free to fork and make a pull request of this plugin !
84
+
85
+ - [NPM package](https://www.npmjs.com/package/multi-content-type-relation)
86
+ - [GitHub repository](https://github.com/kaliop/multi-content-type-relation)
package/TODO.md ADDED
@@ -0,0 +1,4 @@
1
+ # Remaining tasks
2
+
3
+ - publish it
4
+ - Create tests
@@ -0,0 +1,162 @@
1
+ import React, { useMemo } from "react"
2
+ import { Box, Table, Thead, Tr, Th, Tbody, Typography, Divider } from "@strapi/design-system"
3
+ import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core"
4
+ import { SortableContext, arrayMove, verticalListSortingStrategy } from "@dnd-kit/sortable"
5
+
6
+ import { MatchingContent, SelectedEntry } from "../../interface"
7
+ import { TableItem } from "./TableItem"
8
+
9
+ type Props = {
10
+ uniqueId: number
11
+ suggestions?: MatchingContent[]
12
+ selected?: SelectedEntry[]
13
+ onAddEntry?(entry: SelectedEntry): void
14
+ onDeleteEntry?(entry: SelectedEntry): void
15
+ onEntriesSorted?(entries: SelectedEntry[]): void
16
+ sortable?: boolean
17
+ maximum?: number
18
+ }
19
+
20
+ export function InputContentSuggestions({
21
+ uniqueId,
22
+ suggestions,
23
+ selected,
24
+ onAddEntry,
25
+ onDeleteEntry,
26
+ onEntriesSorted,
27
+ maximum,
28
+ sortable
29
+ }: Props) {
30
+ const suggestionAsSelectedEntry = useMemo(() => {
31
+ return (suggestions || [])
32
+ .flatMap((suggestion) =>
33
+ suggestion.results.map<SelectedEntry>((entrySuggestion) => ({
34
+ displayName: suggestion.displayName,
35
+ item: entrySuggestion,
36
+ searchableField: suggestion.searchableField,
37
+ uid: suggestion.uid
38
+ }))
39
+ )
40
+ .slice(0, 10)
41
+ }, [suggestions])
42
+
43
+ const buildSelectedId = (entry: SelectedEntry) => {
44
+ return `${uniqueId}-${entry.uid}-${entry.item.id}`
45
+ }
46
+
47
+ const availableSuggestions = useMemo(() => {
48
+ const selectedIdentifiers = (selected || []).map(buildSelectedId)
49
+
50
+ return suggestionAsSelectedEntry.filter((suggestion) => !selectedIdentifiers.includes(buildSelectedId(suggestion)))
51
+ }, [suggestions, selected])
52
+
53
+ const onAdd = (entry: SelectedEntry) => {
54
+ if (typeof onAddEntry === "function") {
55
+ onAddEntry(entry)
56
+ }
57
+ }
58
+
59
+ const onDelete = (entry: SelectedEntry) => {
60
+ if (typeof onDeleteEntry === "function") {
61
+ onDeleteEntry(entry)
62
+ }
63
+ }
64
+
65
+ // Sortable behavior
66
+ const onSort = (entries: SelectedEntry[]) => {
67
+ if (typeof onEntriesSorted === "function") {
68
+ onEntriesSorted(entries)
69
+ }
70
+ }
71
+
72
+ const sensors = useSensors(
73
+ useSensor(PointerSensor, {
74
+ activationConstraint: {
75
+ distance: 5
76
+ }
77
+ })
78
+ )
79
+
80
+ const handleDragEnd = (event) => {
81
+ const { active, over } = event
82
+
83
+ if (!active || !over) return
84
+
85
+ if (active.id !== over.id) {
86
+ const oldIndex = selected!.findIndex((entry) => buildSelectedId(entry) === active.id)
87
+ const newIndex = selected!.findIndex((entry) => buildSelectedId(entry) === over.id)
88
+
89
+ onSort(arrayMove(selected!, oldIndex, newIndex))
90
+ }
91
+ }
92
+
93
+ if (!availableSuggestions?.length && !selected?.length) return null
94
+
95
+ return (
96
+ <Box padding={[2, 0, 2, 0]} background="neutral100">
97
+ <Table style={{ whiteSpace: "unset" }}>
98
+ <Thead>
99
+ <Tr>
100
+ <Th></Th>
101
+ <Th>
102
+ <Typography variant="sigma">Title</Typography>
103
+ </Th>
104
+ <Th>
105
+ <Typography variant="sigma">ID</Typography>
106
+ </Th>
107
+ <Th>
108
+ <Typography variant="sigma">Content type</Typography>
109
+ </Th>
110
+ <Th>
111
+ <Typography variant="sigma">State</Typography>
112
+ </Th>
113
+ </Tr>
114
+ </Thead>
115
+
116
+ <Tbody>
117
+ {selected?.length ? (
118
+ sortable ? (
119
+ <>
120
+ {/* @ts-expect-error Server Component */}
121
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
122
+ <SortableContext
123
+ items={selected.map((entry) => buildSelectedId(entry))}
124
+ strategy={verticalListSortingStrategy}
125
+ >
126
+ {selected.map((entry) => (
127
+ <TableItem
128
+ uniqueId={uniqueId}
129
+ key={buildSelectedId(entry)}
130
+ entry={entry}
131
+ type="selected"
132
+ onDelete={onDelete}
133
+ sortable={sortable}
134
+ />
135
+ ))}
136
+ </SortableContext>
137
+ </DndContext>
138
+ </>
139
+ ) : (
140
+ (selected || []).map((entry) => (
141
+ <TableItem uniqueId={uniqueId} entry={entry} type="selected" onDelete={onDelete} />
142
+ ))
143
+ )
144
+ ) : null}
145
+
146
+ {availableSuggestions.length && selected?.length ? <Tr style={{ height: "32px" }} /> : null}
147
+
148
+ {availableSuggestions.map((entry) => (
149
+ <TableItem
150
+ uniqueId={uniqueId}
151
+ key={buildSelectedId(entry)}
152
+ entry={entry}
153
+ type="suggestion"
154
+ onAdd={onAdd}
155
+ disabled={typeof maximum === "number" ? (selected?.length ?? 0) >= maximum : false}
156
+ />
157
+ ))}
158
+ </Tbody>
159
+ </Table>
160
+ </Box>
161
+ )
162
+ }
@@ -0,0 +1,135 @@
1
+ import React, { useEffect, useMemo, useState } from "react"
2
+ import { useIntl } from "react-intl"
3
+ import { useLocation } from "react-router-dom"
4
+
5
+ import { Loader, Field, TextInput } from "@strapi/design-system"
6
+
7
+ import { FormattedStrapiEntry, PluginOption, SelectedEntry } from "../../interface"
8
+ import { useSearchedEntries } from "../../hooks/useSearchedEntries"
9
+ import { InputContentSuggestions } from "./InputContentSuggestions"
10
+ import { formatToStrapiField, validateCurrentRelations } from "../../helpers/content"
11
+
12
+ type Props = {
13
+ name: string
14
+ error: string
15
+ description: string
16
+ value: string
17
+ onChange(payload: Record<string, unknown>): void
18
+ intlLabel: Record<string, string>
19
+ attribute: PluginOption
20
+ required: boolean
21
+ }
22
+
23
+ export function MainInput({ name, error, description, onChange, value, intlLabel, attribute, required }: Props) {
24
+ const { formatMessage } = useIntl()
25
+ const location = useLocation()
26
+ const maximumItems = attribute.options.max
27
+ const minimumItems = attribute.options.min || 0
28
+ const currentLocale = new URLSearchParams(location.search).get("plugins[i18n][locale]") as string
29
+
30
+ const [keyword, setKeyword] = useState("")
31
+ const [selected, setSelected] = useState<SelectedEntry[]>([])
32
+ const [loading, setLoading] = useState(true)
33
+
34
+ useEffect(() => {
35
+ const value = selected.length > maximumItems || selected.length < minimumItems ? [] : selected
36
+
37
+ onChange({ target: { name, value: formatToStrapiField(value) } })
38
+ }, [selected])
39
+
40
+ const hint = useMemo(() => {
41
+ const minLabel = minimumItems > 0 ? `min. ${minimumItems} ${minimumItems > 1 ? "entries" : "entry"}` : ""
42
+ const maxLabel = maximumItems > 0 ? `max. ${maximumItems} ${maximumItems > 1 ? "entries" : "entry"}` : ""
43
+
44
+ return `
45
+ ${minLabel ? `${minLabel}` : ""}
46
+ ${minLabel && maxLabel ? ", " : ""}
47
+ ${maxLabel}
48
+ ${minLabel || maxLabel ? " - " : ""}
49
+ ${selected.length} selected
50
+ `
51
+ }, [selected, maximumItems, minimumItems])
52
+
53
+ const inputError = useMemo(() => {
54
+ if (!error) return ""
55
+
56
+ if (selected.length < minimumItems) return `${error} - A minimum of ${minimumItems} item(s) is required`
57
+
58
+ if (selected.length > maximumItems) return `${error} - A maximum of ${maximumItems} item(s) is required`
59
+
60
+ return error
61
+ }, [error, maximumItems, minimumItems, selected])
62
+
63
+ const { loading: searchLoading, results } = useSearchedEntries(keyword, attribute.options.contentTypes, currentLocale)
64
+
65
+ // Validate relations and remove the one that got deleted
66
+ useEffect(() => {
67
+ async function validateContent() {
68
+ if (!value) {
69
+ setLoading(false)
70
+ return
71
+ }
72
+
73
+ const entries = JSON.parse(value) as FormattedStrapiEntry[]
74
+
75
+ const result = await validateCurrentRelations(entries)
76
+
77
+ setSelected(result)
78
+ setLoading(false)
79
+ }
80
+
81
+ validateContent()
82
+ }, [])
83
+
84
+ const onAddEntry = (entry: SelectedEntry) => {
85
+ const alreadyDefined = selected.some(
86
+ (selectedEntry) => selectedEntry.uid === entry.uid && selectedEntry.item.id === entry.item.id
87
+ )
88
+
89
+ if (alreadyDefined) return
90
+
91
+ setSelected([...selected, entry])
92
+ }
93
+
94
+ const onDeleteEntry = (entry: SelectedEntry) => {
95
+ const newSelected = selected.filter(
96
+ (selectedEntry) => !(selectedEntry.uid === entry.uid && selectedEntry.item.id === entry.item.id)
97
+ )
98
+
99
+ setSelected(newSelected)
100
+ }
101
+
102
+ const onEntriesSorted = (entries) => {
103
+ setSelected(entries)
104
+ }
105
+
106
+ if (loading) return <Loader />
107
+
108
+ return (
109
+ <Field name={name} id={name} error={error} hint={description} required={required}>
110
+ <TextInput
111
+ label={formatMessage(intlLabel)}
112
+ placeholder="Type a term to search"
113
+ required={required}
114
+ hint={hint}
115
+ error={inputError}
116
+ value={keyword}
117
+ onChange={(e) => setKeyword(e.target.value)}
118
+ />
119
+ {searchLoading ? (
120
+ <Loader />
121
+ ) : (
122
+ <InputContentSuggestions
123
+ uniqueId={Date.now()}
124
+ suggestions={results}
125
+ selected={selected}
126
+ onAddEntry={onAddEntry}
127
+ onDeleteEntry={onDeleteEntry}
128
+ onEntriesSorted={onEntriesSorted}
129
+ maximum={maximumItems}
130
+ sortable
131
+ />
132
+ )}
133
+ </Field>
134
+ )
135
+ }
@@ -0,0 +1,28 @@
1
+ import React, { useMemo } from "react"
2
+ import { Status, Typography } from "@strapi/design-system"
3
+
4
+ type Props = {
5
+ isPublished: boolean
6
+ hasDraftAndPublish?: boolean
7
+ }
8
+
9
+ export const PublicationState = ({ isPublished, hasDraftAndPublish }: Props) => {
10
+ const configuration = useMemo(() => {
11
+ const conf = { variant: "alternative", text: "N/A" }
12
+
13
+ if (hasDraftAndPublish) {
14
+ conf.variant = isPublished ? "success" : "secondary"
15
+ conf.text = isPublished ? "Published" : "Draft"
16
+ }
17
+
18
+ return conf
19
+ }, [isPublished, hasDraftAndPublish])
20
+
21
+ return (
22
+ <Status showBullet={false} variant={configuration.variant} size="S" width="min-content">
23
+ <Typography fontWeight="bold" textColor={`${configuration.variant}700`}>
24
+ {configuration.text}
25
+ </Typography>
26
+ </Status>
27
+ )
28
+ }
@@ -0,0 +1,109 @@
1
+ import React, { useEffect, useMemo, useState } from "react"
2
+ import { Tr, Td, Typography, IconButton } from "@strapi/design-system"
3
+ import { Trash, Plus, Drag, Eye } from "@strapi/icons"
4
+ import { PublicationState } from "./PublicationState"
5
+ import { useSortable } from "@dnd-kit/sortable"
6
+ import { CSS } from "@dnd-kit/utilities"
7
+
8
+ import { SelectedEntry } from "../../interface"
9
+ import { getContentTypeForUid, getContentTypes } from "../../helpers/storage"
10
+ import { useLocation } from "react-router-dom"
11
+
12
+ type Props = {
13
+ entry: SelectedEntry
14
+ type: "suggestion" | "selected"
15
+ uniqueId: number
16
+ onAdd?(entry: SelectedEntry): void
17
+ onDelete?(entry: SelectedEntry): void
18
+ disabled?: boolean
19
+ sortable?: boolean
20
+ }
21
+
22
+ export const TableItem = ({ entry, type, uniqueId, disabled, onAdd, onDelete, sortable }: Props) => {
23
+ const entryIdentifier = useMemo(() => `${uniqueId}-${entry.uid}-${entry.item.id}`, [entry])
24
+ const contentType = getContentTypeForUid(entry.uid)
25
+ const location = useLocation()
26
+
27
+ const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: entryIdentifier })
28
+
29
+ const trueRef = document.querySelector(`[data-tableitem="${entryIdentifier}"]`)
30
+ useEffect(() => {
31
+ if (!trueRef) return
32
+
33
+ setNodeRef(trueRef as HTMLElement)
34
+ }, [trueRef])
35
+
36
+ const style = {
37
+ transform: CSS.Transform.toString(transform),
38
+ transition
39
+ }
40
+
41
+ const [currentLocale, setCurrentLocale] = useState("")
42
+ useEffect(() => {
43
+ const searchParams = new URLSearchParams(location.search)
44
+ const locale = searchParams.get("plugins[i18n][locale]")
45
+ if (!locale) return
46
+ setCurrentLocale(locale)
47
+ }, [location])
48
+
49
+ const goToEntry = () => {
50
+ if (!currentLocale) return
51
+
52
+ const contentTypes = window.sessionStorage.getItem("mctr::content_types")
53
+
54
+ if (contentTypes) {
55
+ try {
56
+ const parsedContentTypes = JSON.parse(contentTypes)
57
+ if (Array.isArray(parsedContentTypes)) {
58
+ const contentType = parsedContentTypes.find((ct) => ct.uid === entry.uid)
59
+ if (contentType) {
60
+ const kind = contentType.kind
61
+ let url = `/admin/content-manager/${kind}/${entry.uid}`
62
+ if (kind === "collectionType") {
63
+ url += `/${entry.item.id}`
64
+ }
65
+ url += `?plugins[i18n][locale]=${currentLocale}`
66
+
67
+ window.open(url, "_blank")
68
+ return
69
+ }
70
+ }
71
+ } catch (e) {
72
+ console.error("[MRCT] Failed to retrieve content types")
73
+ }
74
+ } else {
75
+ alert("An error occured, please try to refresh the page")
76
+ }
77
+ }
78
+
79
+ return (
80
+ <Tr style={style} {...attributes} {...listeners} data-tableitem={entryIdentifier}>
81
+ <Td>{type === "selected" ? <IconButton icon={<Drag />} noBorder /> : null}</Td>
82
+ <Td>
83
+ <Typography color="neutral800">{entry.item[entry.searchableField]}</Typography>
84
+ </Td>
85
+ <Td>
86
+ <Typography color="neutral800">{entry.item.id}</Typography>
87
+ </Td>
88
+ <Td>
89
+ <Typography color="neutral800">{entry.displayName}</Typography>
90
+ </Td>
91
+ <Td>
92
+ <PublicationState
93
+ isPublished={!!entry.item.publishedAt}
94
+ hasDraftAndPublish={contentType?.options?.draftAndPublish}
95
+ />
96
+ </Td>
97
+ <Td>
98
+ <div style={{ display: "flex" }}>
99
+ <IconButton label="Go to entry" onClick={goToEntry} icon={<Eye />} style={{ "margin-right": "5px" }} />
100
+ {type === "suggestion" ? (
101
+ <IconButton label="Add" onClick={() => onAdd!(entry)} icon={<Plus />} disabled={disabled} />
102
+ ) : type === "selected" ? (
103
+ <IconButton label="Delete" onClick={() => onDelete!(entry)} icon={<Trash />} />
104
+ ) : null}
105
+ </div>
106
+ </Td>
107
+ </Tr>
108
+ )
109
+ }
@@ -0,0 +1,27 @@
1
+ import React, { useMemo } from "react"
2
+
3
+ import { MainInput } from "./MainInput"
4
+
5
+ export default function Index(props) {
6
+ const attribute = useMemo(() => {
7
+ if (!props.attribute) return props.attribute
8
+
9
+ if (!props.attribute.options) return props.attribute
10
+
11
+ if (!props.attribute.options.contentTypes) return props.attribute
12
+
13
+ const contentTypes = Object.keys(props.attribute.options.contentTypes).filter(
14
+ (key) => props.attribute.options.contentTypes[key]
15
+ )
16
+
17
+ return {
18
+ ...props.attribute,
19
+ options: {
20
+ ...props.attribute.options,
21
+ contentTypes: contentTypes.join(",")
22
+ }
23
+ }
24
+ }, [props.attribute])
25
+
26
+ return <MainInput {...props} attribute={attribute} />
27
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ *
3
+ * PluginIcon
4
+ *
5
+ */
6
+
7
+ import React from 'react';
8
+ import { Puzzle } from '@strapi/icons';
9
+
10
+ const PluginIcon = () => <Puzzle />;
11
+
12
+ export default PluginIcon;
@@ -0,0 +1,60 @@
1
+ import { getFetchClient } from "@strapi/helper-plugin"
2
+
3
+ import pluginId from "../pluginId"
4
+ import { FormattedStrapiEntry, MatchingContent, MatchingContentResponse, SelectedEntry } from "../interface"
5
+
6
+ export const fetchMatchingContent = async (
7
+ keyword: string,
8
+ contentTypes: string,
9
+ locale: string
10
+ ): Promise<MatchingContentResponse> => {
11
+ const { post } = getFetchClient()
12
+ const response = await post(`/${pluginId}/get-content`, {
13
+ contentTypes: contentTypes.split(","),
14
+ keyword,
15
+ locale
16
+ })
17
+
18
+ const data = response.data as MatchingContent[]
19
+
20
+ if (!data) throw new Error("No data returned from API")
21
+
22
+ const total = data.reduce((accumulator, option) => {
23
+ if (!option.results) return accumulator
24
+
25
+ return accumulator + option.results.length
26
+ }, 0)
27
+
28
+ return {
29
+ data,
30
+ total
31
+ }
32
+ }
33
+
34
+ export const formatToStrapiField = (entries: SelectedEntry[]) => {
35
+ if (entries.length === 0) return ""
36
+
37
+ return JSON.stringify(entries.map((entry) => ({ uid: entry.uid, id: entry.item.id, MRCT: true })).filter(Boolean))
38
+ }
39
+
40
+ export const validateCurrentRelations = async (entries: FormattedStrapiEntry[]) => {
41
+ const { post } = getFetchClient()
42
+
43
+ const response = await post(`/${pluginId}/validate-relations`, {
44
+ entries
45
+ })
46
+
47
+ return response.data as SelectedEntry[]
48
+ }
49
+
50
+ export const listContentTypes = async () => {
51
+ try {
52
+ const { get } = getFetchClient()
53
+ const response = await get(`/${pluginId}/list-content-types`)
54
+
55
+ return response.data
56
+ } catch (error) {
57
+ console.error(error)
58
+ return []
59
+ }
60
+ }
@@ -0,0 +1,32 @@
1
+ export type LightContentType = {
2
+ apiName: string
3
+ attributes: Record<string, Record<string, unknown>>[]
4
+ collectionName: string
5
+ globalId: string
6
+ modelName: string
7
+ modelType: string
8
+ options?: { draftAndPublish?: boolean }
9
+ uid: string
10
+ }
11
+
12
+ const STORAGE_KEY = "mctr::content_types"
13
+
14
+ export function getContentTypes() {
15
+ const raw = sessionStorage.getItem(STORAGE_KEY)
16
+
17
+ return raw ? (JSON.parse(raw) as LightContentType[]) : undefined
18
+ }
19
+
20
+ export function getContentTypeForUid(uid: string) {
21
+ const contentTypes = getContentTypes()
22
+
23
+ if (!Array.isArray(contentTypes)) return
24
+
25
+ return contentTypes.find((contentType) => contentType.uid === uid)
26
+ }
27
+
28
+ export function setContentTypes(contentTypes: LightContentType[]) {
29
+ const stringified = JSON.stringify(contentTypes)
30
+
31
+ sessionStorage.setItem(STORAGE_KEY, stringified)
32
+ }