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.
- package/README.md +86 -0
- package/TODO.md +4 -0
- package/admin/src/components/Input/InputContentSuggestions.tsx +162 -0
- package/admin/src/components/Input/MainInput.tsx +135 -0
- package/admin/src/components/Input/PublicationState.tsx +28 -0
- package/admin/src/components/Input/TableItem.tsx +109 -0
- package/admin/src/components/Input/index.tsx +27 -0
- package/admin/src/components/PluginIcon/index.tsx +12 -0
- package/admin/src/helpers/content.ts +60 -0
- package/admin/src/helpers/storage.ts +32 -0
- package/admin/src/hooks/useSearchedEntries.ts +41 -0
- package/admin/src/index.tsx +140 -0
- package/admin/src/interface.ts +37 -0
- package/admin/src/pluginId.ts +5 -0
- package/admin/src/translations/en.json +1 -0
- package/admin/src/translations/fr.json +1 -0
- package/admin/src/utils/getTrad.ts +5 -0
- package/dist/server/bootstrap.js +5 -0
- package/dist/server/config/index.js +27 -0
- package/dist/server/content-types/index.js +3 -0
- package/dist/server/controllers/controller.js +92 -0
- package/dist/server/controllers/index.js +9 -0
- package/dist/server/destroy.js +5 -0
- package/dist/server/index.js +27 -0
- package/dist/server/interface.js +2 -0
- package/dist/server/middlewares/index.js +9 -0
- package/dist/server/middlewares/middleware.js +163 -0
- package/dist/server/policies/index.js +3 -0
- package/dist/server/register.js +15 -0
- package/dist/server/routes/index.js +29 -0
- package/dist/server/services/index.js +9 -0
- package/dist/server/services/service.js +8 -0
- package/dist/server/utils.js +15 -0
- package/dist/tsconfig.server.tsbuildinfo +1 -0
- package/package.json +53 -0
- package/server/bootstrap.ts +5 -0
- package/server/config/index.ts +28 -0
- package/server/content-types/index.ts +1 -0
- package/server/controllers/controller.ts +107 -0
- package/server/controllers/index.ts +5 -0
- package/server/destroy.ts +5 -0
- package/server/index.ts +23 -0
- package/server/interface.ts +50 -0
- package/server/middlewares/index.ts +5 -0
- package/server/middlewares/middleware.ts +197 -0
- package/server/policies/index.ts +1 -0
- package/server/register.ts +14 -0
- package/server/routes/index.ts +27 -0
- package/server/services/index.ts +5 -0
- package/server/services/service.ts +11 -0
- package/server/utils.ts +14 -0
- package/strapi-admin.js +3 -0
- package/strapi-server.js +3 -0
- package/tsconfig.json +20 -0
- 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
|
+

|
|
49
|
+
|
|
50
|
+
Advanced settings Tab
|
|
51
|
+

|
|
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,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,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
|
+
}
|