@tunghtml/strapi-plugin-filtered-relation 1.0.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 +113 -0
- package/admin/src/components/FilteredRelationInput.jsx +319 -0
- package/admin/src/index.js +1 -0
- package/package.json +53 -0
- package/server/controllers/content-types.js +26 -0
- package/server/controllers/index.js +5 -0
- package/server/register.js +186 -0
- package/server/routes/index.js +11 -0
- package/server/services/index.js +1 -0
- package/strapi-admin.js +160 -0
- package/strapi-server.js +11 -0
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Strapi Plugin Filtered Relation
|
|
2
|
+
|
|
3
|
+
A Strapi v5 custom field plugin that displays filtered relations with dynamic placeholder support.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🎯 **Dynamic Filtering**: Filter relations based on multiple criteria
|
|
8
|
+
- 🔄 **Placeholder Support**: Use `{{documentId}}` to reference current entity
|
|
9
|
+
- 🎨 **Clean UI**: Displays filtered results in a clean, clickable list
|
|
10
|
+
- âš¡ **Auto-save**: Automatically saves filtered results to the field
|
|
11
|
+
- 🔗 **Nested Relations**: Support for filtering through nested relation fields (e.g., `meeting.documentId`)
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @tunghtml/strapi-plugin-filtered-relation
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
or
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
yarn add @tunghtml/strapi-plugin-filtered-relation
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### 1. Enable the plugin
|
|
28
|
+
|
|
29
|
+
Add the plugin to your `config/plugins.js`:
|
|
30
|
+
|
|
31
|
+
```javascript
|
|
32
|
+
module.exports = {
|
|
33
|
+
"filtered-relation": {
|
|
34
|
+
enabled: true,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 2. Add to your schema
|
|
40
|
+
|
|
41
|
+
In your content type schema (e.g., `api/meeting/content-types/meeting/schema.json`):
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"attributes": {
|
|
46
|
+
"rejectedInvestors": {
|
|
47
|
+
"type": "customField",
|
|
48
|
+
"customField": "plugin::filtered-relation.filtered-relation",
|
|
49
|
+
"options": {
|
|
50
|
+
"targetModel": "Meeting Participation Status",
|
|
51
|
+
"displayField": "investors",
|
|
52
|
+
"filterField1": "participantStatus",
|
|
53
|
+
"filterValue1": "Rejected",
|
|
54
|
+
"filterField2": "meeting.documentId",
|
|
55
|
+
"filterValue2": "{{documentId}}"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Configuration Options
|
|
63
|
+
|
|
64
|
+
| Option | Type | Description | Example |
|
|
65
|
+
| -------------- | ------ | ------------------------------------------------------ | -------------------------------- |
|
|
66
|
+
| `targetModel` | string | The target collection to query | `"Meeting Participation Status"` |
|
|
67
|
+
| `displayField` | string | The field to display from results | `"investors"` |
|
|
68
|
+
| `filterField1` | string | First filter field (supports nested with dot notation) | `"participantStatus"` |
|
|
69
|
+
| `filterValue1` | string | Value for first filter | `"Rejected"` |
|
|
70
|
+
| `filterField2` | string | Second filter field (optional) | `"meeting.documentId"` |
|
|
71
|
+
| `filterValue2` | string | Value for second filter (supports `{{documentId}}`) | `"{{documentId}}"` |
|
|
72
|
+
|
|
73
|
+
## Dynamic Placeholders
|
|
74
|
+
|
|
75
|
+
Use `{{documentId}}` in filter values to reference the current entity's document ID:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"filterField2": "meeting.documentId",
|
|
80
|
+
"filterValue2": "{{documentId}}"
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
This will automatically replace `{{documentId}}` with the actual document ID when editing an entity.
|
|
85
|
+
|
|
86
|
+
## Example Use Case
|
|
87
|
+
|
|
88
|
+
Display rejected investors for a specific meeting:
|
|
89
|
+
|
|
90
|
+
1. **Target Model**: Meeting Participation Status
|
|
91
|
+
2. **Display Field**: investors (shows investor names)
|
|
92
|
+
3. **Filter 1**: `participantStatus = "Rejected"`
|
|
93
|
+
4. **Filter 2**: `meeting.documentId = {{documentId}}`
|
|
94
|
+
|
|
95
|
+
Result: Shows only investors who rejected the current meeting.
|
|
96
|
+
|
|
97
|
+
## Requirements
|
|
98
|
+
|
|
99
|
+
- Strapi v5.x
|
|
100
|
+
- Node.js >= 18.0.0
|
|
101
|
+
- React >= 18.0.0
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
|
106
|
+
|
|
107
|
+
## Author
|
|
108
|
+
|
|
109
|
+
Tung HTML
|
|
110
|
+
|
|
111
|
+
## Repository
|
|
112
|
+
|
|
113
|
+
[https://github.com/finnwasabi/strapi-plugin-filtered-relation](https://github.com/finnwasabi/strapi-plugin-filtered-relation)
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useIntl } from "react-intl";
|
|
3
|
+
import { Field, Flex, Box, Typography } from "@strapi/design-system";
|
|
4
|
+
import { useFetchClient } from "@strapi/strapi/admin";
|
|
5
|
+
import { useLocation, useNavigate } from "react-router-dom";
|
|
6
|
+
|
|
7
|
+
const FilteredRelationInput = (props) => {
|
|
8
|
+
const { formatMessage } = useIntl();
|
|
9
|
+
const { get, put } = useFetchClient();
|
|
10
|
+
const location = useLocation();
|
|
11
|
+
const navigate = useNavigate();
|
|
12
|
+
const [items, setItems] = React.useState([]);
|
|
13
|
+
const [loading, setLoading] = React.useState(false);
|
|
14
|
+
|
|
15
|
+
if (!props) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { attribute, name, error, value } = props;
|
|
20
|
+
|
|
21
|
+
// Extract documentId from URL path
|
|
22
|
+
// URL format: /admin/content-manager/collection-types/api::meeting.meeting/bn0n8em49y7vljg5lrfh2aij
|
|
23
|
+
const getDocumentIdFromUrl = () => {
|
|
24
|
+
const pathParts = location.pathname.split("/");
|
|
25
|
+
// Get the last part of the URL which should be the documentId
|
|
26
|
+
const lastPart = pathParts[pathParts.length - 1];
|
|
27
|
+
// Check if it's not 'create' (for new documents)
|
|
28
|
+
return lastPart !== "create" ? lastPart : null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const documentId = props.documentId || props.id || getDocumentIdFromUrl();
|
|
32
|
+
|
|
33
|
+
const {
|
|
34
|
+
targetModel: targetModelInput,
|
|
35
|
+
filterField1,
|
|
36
|
+
filterValue1,
|
|
37
|
+
filterField2,
|
|
38
|
+
filterValue2,
|
|
39
|
+
displayField = "id",
|
|
40
|
+
} = attribute?.options || {};
|
|
41
|
+
|
|
42
|
+
// Parse collection name to API ID
|
|
43
|
+
// "Meeting Participation Status" -> "api::meeting-participation-status.meeting-participation-status"
|
|
44
|
+
const parseCollectionName = (name) => {
|
|
45
|
+
if (!name) return null;
|
|
46
|
+
// If already API ID format, return as is
|
|
47
|
+
if (name.startsWith("api::")) return name;
|
|
48
|
+
|
|
49
|
+
// Convert display name to API ID
|
|
50
|
+
const slug = name
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/\s+/g, "-")
|
|
53
|
+
.replace(/[^a-z0-9-]/g, "");
|
|
54
|
+
return `api::${slug}.${slug}`;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const targetModel = parseCollectionName(targetModelInput);
|
|
58
|
+
|
|
59
|
+
// Parse displayField to get relation model
|
|
60
|
+
// displayField is like "investors" -> "api::investor.investor"
|
|
61
|
+
const getRelationModel = (fieldName) => {
|
|
62
|
+
if (!fieldName) return null;
|
|
63
|
+
// Remove 's' at the end for plural -> singular
|
|
64
|
+
const singular = fieldName.endsWith("s")
|
|
65
|
+
? fieldName.slice(0, -1)
|
|
66
|
+
: fieldName;
|
|
67
|
+
const slug = singular.toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
68
|
+
return `api::${slug}.${slug}`;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const relationModel = getRelationModel(displayField);
|
|
72
|
+
|
|
73
|
+
// Build filter params with dynamic values
|
|
74
|
+
const filterParams = {};
|
|
75
|
+
if (filterField1 && filterValue1) {
|
|
76
|
+
// Support {{documentId}} placeholder for current entity
|
|
77
|
+
const value1 =
|
|
78
|
+
filterValue1 === "{{documentId}}" ? documentId : filterValue1;
|
|
79
|
+
filterParams[filterField1] = value1;
|
|
80
|
+
}
|
|
81
|
+
if (filterField2 && filterValue2) {
|
|
82
|
+
// Support {{documentId}} placeholder for current entity
|
|
83
|
+
const value2 =
|
|
84
|
+
filterValue2 === "{{documentId}}" ? documentId : filterValue2;
|
|
85
|
+
filterParams[filterField2] = value2;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Get the content type from props
|
|
89
|
+
const contentType = props.contentType || props.model;
|
|
90
|
+
|
|
91
|
+
const saveFieldValue = async (newValue) => {
|
|
92
|
+
if (!documentId || !contentType) return;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await put(
|
|
96
|
+
`/content-manager/collection-types/${contentType}/${documentId}`,
|
|
97
|
+
{
|
|
98
|
+
data: {
|
|
99
|
+
[name]: newValue,
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.error("Error auto-saving filtered relation:", err);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
const fetchFilteredData = async () => {
|
|
110
|
+
if (!targetModel || Object.keys(filterParams).length === 0) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setLoading(true);
|
|
115
|
+
try {
|
|
116
|
+
const modelParts = targetModel.split("::");
|
|
117
|
+
if (modelParts.length !== 2) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Build filter query string using $and pattern
|
|
122
|
+
// Example: filters[$and][0][participantStatus][$eq]=Rejected&filters[$and][1][meeting][documentId][$eq]=bn0n8em49y7vljg5lrfh2aij
|
|
123
|
+
const filterQuery = Object.entries(filterParams)
|
|
124
|
+
.map(([key, value], index) => {
|
|
125
|
+
// Check if the key contains a dot (relation field)
|
|
126
|
+
if (key.includes(".")) {
|
|
127
|
+
const [relationField, nestedField] = key.split(".");
|
|
128
|
+
return `filters[$and][${index}][${relationField}][${nestedField}][$eq]=${encodeURIComponent(value)}`;
|
|
129
|
+
}
|
|
130
|
+
// Regular field
|
|
131
|
+
return `filters[$and][${index}][${key}][$eq]=${encodeURIComponent(value)}`;
|
|
132
|
+
})
|
|
133
|
+
.join("&");
|
|
134
|
+
|
|
135
|
+
const apiUrl = `/content-manager/collection-types/${targetModel}?${filterQuery}&populate[${displayField}][populate]=*`;
|
|
136
|
+
const response = await get(apiUrl);
|
|
137
|
+
const items =
|
|
138
|
+
response?.data?.results || response?.results || response?.data || [];
|
|
139
|
+
|
|
140
|
+
if (items.length > 0) {
|
|
141
|
+
const formattedItems = [];
|
|
142
|
+
|
|
143
|
+
items.forEach((item) => {
|
|
144
|
+
if (displayField && item[displayField]) {
|
|
145
|
+
// If displayField is an array, create one item per array element
|
|
146
|
+
if (Array.isArray(item[displayField])) {
|
|
147
|
+
item[displayField].forEach((rel) => {
|
|
148
|
+
const displayValue =
|
|
149
|
+
rel.fullName ||
|
|
150
|
+
rel.name ||
|
|
151
|
+
rel.displayName ||
|
|
152
|
+
rel.firstName ||
|
|
153
|
+
`ID: ${rel.documentId || rel.id}`;
|
|
154
|
+
|
|
155
|
+
formattedItems.push({
|
|
156
|
+
id: `${item.documentId || item.id}-${rel.documentId || rel.id}`,
|
|
157
|
+
label: displayValue,
|
|
158
|
+
originalId: item.documentId || item.id,
|
|
159
|
+
relatedDocumentId: rel.documentId || rel.id,
|
|
160
|
+
relationModel: relationModel,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
// Handle single relation object
|
|
165
|
+
else if (
|
|
166
|
+
typeof item[displayField] === "object" &&
|
|
167
|
+
item[displayField] !== null
|
|
168
|
+
) {
|
|
169
|
+
if (item[displayField].count !== undefined) {
|
|
170
|
+
formattedItems.push({
|
|
171
|
+
id: item.id,
|
|
172
|
+
label: `${item[displayField].count} items`,
|
|
173
|
+
});
|
|
174
|
+
} else {
|
|
175
|
+
const displayValue =
|
|
176
|
+
item[displayField].fullName ||
|
|
177
|
+
item[displayField].name ||
|
|
178
|
+
item[displayField].displayName ||
|
|
179
|
+
item[displayField].firstName ||
|
|
180
|
+
`ID: ${item[displayField].id}`;
|
|
181
|
+
|
|
182
|
+
formattedItems.push({
|
|
183
|
+
id: item.id,
|
|
184
|
+
label: displayValue,
|
|
185
|
+
originalId: item.documentId || item.id,
|
|
186
|
+
relatedDocumentId:
|
|
187
|
+
item[displayField].documentId || item[displayField].id,
|
|
188
|
+
relationModel: relationModel,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
// Fallback if no displayField
|
|
194
|
+
formattedItems.push({
|
|
195
|
+
id: item.id,
|
|
196
|
+
label: `ID: ${item.id}`,
|
|
197
|
+
originalId: item.documentId || item.id,
|
|
198
|
+
relatedDocumentId: item.documentId || item.id,
|
|
199
|
+
relationModel: targetModel,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
setItems(formattedItems);
|
|
205
|
+
|
|
206
|
+
// Auto-save to database without requiring user to click Save
|
|
207
|
+
const documentIds = formattedItems.map(
|
|
208
|
+
(item) => item.relatedDocumentId || item.id,
|
|
209
|
+
);
|
|
210
|
+
const newValue = JSON.stringify(documentIds);
|
|
211
|
+
|
|
212
|
+
// Only save if value changed and we have a document ID
|
|
213
|
+
if (documentId && value !== newValue) {
|
|
214
|
+
await saveFieldValue(newValue);
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
// Clear the field if no items found
|
|
218
|
+
setItems([]);
|
|
219
|
+
if (documentId && value) {
|
|
220
|
+
await saveFieldValue(null);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.error("Error fetching filtered relations:", err);
|
|
225
|
+
} finally {
|
|
226
|
+
setLoading(false);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
fetchFilteredData();
|
|
231
|
+
}, [
|
|
232
|
+
targetModel,
|
|
233
|
+
filterField1,
|
|
234
|
+
filterValue1,
|
|
235
|
+
filterField2,
|
|
236
|
+
filterValue2,
|
|
237
|
+
displayField,
|
|
238
|
+
documentId,
|
|
239
|
+
value,
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
const fieldLabel = props.label || name;
|
|
243
|
+
const itemCount = items.length;
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<Field.Root name={name} error={error}>
|
|
247
|
+
<Flex direction="column" alignItems="stretch" gap={1}>
|
|
248
|
+
<Field.Label>
|
|
249
|
+
{fieldLabel} {itemCount > 0 && `(${itemCount})`}
|
|
250
|
+
</Field.Label>
|
|
251
|
+
{loading ? (
|
|
252
|
+
<Typography variant="omega" textColor="neutral600">
|
|
253
|
+
{formatMessage({
|
|
254
|
+
id: "filtered-relation.loading",
|
|
255
|
+
defaultMessage: "Loading...",
|
|
256
|
+
})}
|
|
257
|
+
</Typography>
|
|
258
|
+
) : items.length > 0 ? (
|
|
259
|
+
<Flex direction="column" alignItems="stretch" gap={1}>
|
|
260
|
+
{items.map((item) => {
|
|
261
|
+
// Use relation model (e.g., api::investor.investor) with relatedDocumentId
|
|
262
|
+
const editUrl = `/content-manager/collection-types/${item.relationModel}/${item.relatedDocumentId}`;
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<Box
|
|
266
|
+
key={item.id}
|
|
267
|
+
background="neutral0"
|
|
268
|
+
borderColor="neutral200"
|
|
269
|
+
borderStyle="solid"
|
|
270
|
+
borderWidth="1px"
|
|
271
|
+
hasRadius
|
|
272
|
+
paddingTop={2}
|
|
273
|
+
paddingBottom={2}
|
|
274
|
+
paddingLeft={4}
|
|
275
|
+
paddingRight={4}
|
|
276
|
+
style={{
|
|
277
|
+
cursor: "pointer",
|
|
278
|
+
height: "50px",
|
|
279
|
+
display: "flex",
|
|
280
|
+
alignItems: "center",
|
|
281
|
+
}}
|
|
282
|
+
onClick={() => navigate(editUrl)}
|
|
283
|
+
>
|
|
284
|
+
<Typography variant="omega" textColor="primary600">
|
|
285
|
+
{item.label}
|
|
286
|
+
</Typography>
|
|
287
|
+
</Box>
|
|
288
|
+
);
|
|
289
|
+
})}
|
|
290
|
+
</Flex>
|
|
291
|
+
) : (
|
|
292
|
+
<Typography variant="omega" textColor="neutral600">
|
|
293
|
+
{formatMessage({
|
|
294
|
+
id: "filtered-relation.no-results",
|
|
295
|
+
defaultMessage: "No matching records found",
|
|
296
|
+
})}
|
|
297
|
+
</Typography>
|
|
298
|
+
)}
|
|
299
|
+
<Field.Hint>
|
|
300
|
+
<Typography variant="pi" textColor="neutral600">
|
|
301
|
+
{Object.keys(filterParams).length > 0
|
|
302
|
+
? `Filters: ${Object.entries(filterParams)
|
|
303
|
+
.map(([k, v]) => {
|
|
304
|
+
// Show placeholder name if it's a dynamic value
|
|
305
|
+
const displayValue =
|
|
306
|
+
v === documentId ? "{{current documentId}}" : v;
|
|
307
|
+
return `${k} = ${displayValue}`;
|
|
308
|
+
})
|
|
309
|
+
.join(", ")}`
|
|
310
|
+
: "No filters configured"}
|
|
311
|
+
</Typography>
|
|
312
|
+
</Field.Hint>
|
|
313
|
+
</Flex>
|
|
314
|
+
<Field.Error />
|
|
315
|
+
</Field.Root>
|
|
316
|
+
);
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
export default FilteredRelationInput;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "../../strapi-admin";
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tunghtml/strapi-plugin-filtered-relation",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A Strapi custom field plugin for displaying filtered relations with dynamic placeholder support",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"strapi",
|
|
7
|
+
"strapi-plugin",
|
|
8
|
+
"custom-field",
|
|
9
|
+
"relation",
|
|
10
|
+
"filter",
|
|
11
|
+
"strapi5"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/finnwasabi/strapi-plugin-filtered-relation#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/finnwasabi/strapi-plugin-filtered-relation/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/finnwasabi/strapi-plugin-filtered-relation.git"
|
|
20
|
+
},
|
|
21
|
+
"strapi": {
|
|
22
|
+
"name": "filtered-relation",
|
|
23
|
+
"displayName": "Filtered Relation",
|
|
24
|
+
"description": "Relation field with filtering capability and dynamic placeholders",
|
|
25
|
+
"kind": "plugin"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@strapi/design-system": "^2.0.0-rc.14",
|
|
29
|
+
"@strapi/icons": "^2.0.0-rc.14"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@strapi/strapi": "^5.0.0",
|
|
33
|
+
"react": "^18.0.0",
|
|
34
|
+
"react-dom": "^18.0.0",
|
|
35
|
+
"react-router-dom": "^6.0.0",
|
|
36
|
+
"styled-components": "^6.0.0"
|
|
37
|
+
},
|
|
38
|
+
"author": {
|
|
39
|
+
"name": "Tung HTML",
|
|
40
|
+
"email": "tunghtml@example.com"
|
|
41
|
+
},
|
|
42
|
+
"maintainers": [
|
|
43
|
+
{
|
|
44
|
+
"name": "Tung HTML",
|
|
45
|
+
"email": "tunghtml@example.com"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18.0.0 <=22.x.x",
|
|
50
|
+
"npm": ">=6.0.0"
|
|
51
|
+
},
|
|
52
|
+
"license": "MIT"
|
|
53
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
async find(ctx) {
|
|
3
|
+
try {
|
|
4
|
+
const contentTypes = strapi.contentTypes;
|
|
5
|
+
const apiTypes = [];
|
|
6
|
+
|
|
7
|
+
for (const [uid, contentType] of Object.entries(contentTypes)) {
|
|
8
|
+
if (uid.startsWith("api::")) {
|
|
9
|
+
apiTypes.push({
|
|
10
|
+
uid,
|
|
11
|
+
displayName:
|
|
12
|
+
contentType.info?.displayName ||
|
|
13
|
+
contentType.schema?.displayName ||
|
|
14
|
+
uid.split("::")[1],
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
apiTypes.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
20
|
+
|
|
21
|
+
ctx.body = { data: apiTypes };
|
|
22
|
+
} catch (error) {
|
|
23
|
+
ctx.throw(500, error);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
module.exports = ({ strapi }) => {
|
|
2
|
+
strapi.customFields.register({
|
|
3
|
+
name: "filtered-relation",
|
|
4
|
+
plugin: "filtered-relation",
|
|
5
|
+
type: "json",
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// Register global lifecycle hooks for all content types
|
|
9
|
+
strapi.db.lifecycles.subscribe({
|
|
10
|
+
async afterCreate(event) {
|
|
11
|
+
await updateRelatedFilteredFields(event, strapi);
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
async afterUpdate(event) {
|
|
15
|
+
await updateRelatedFilteredFields(event, strapi);
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
async afterDelete(event) {
|
|
19
|
+
await updateRelatedFilteredFields(event, strapi);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
async function updateRelatedFilteredFields(event, strapi) {
|
|
25
|
+
const { model } = event;
|
|
26
|
+
if (!model) return;
|
|
27
|
+
|
|
28
|
+
const currentModelUid = model.uid;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
// Get all content types to find which ones have filtered-relation fields
|
|
32
|
+
const contentTypes = strapi.contentTypes;
|
|
33
|
+
|
|
34
|
+
for (const [uid, contentType] of Object.entries(contentTypes)) {
|
|
35
|
+
// Skip non-API content types
|
|
36
|
+
if (!uid.startsWith("api::")) continue;
|
|
37
|
+
|
|
38
|
+
const schema = strapi.getModel(uid);
|
|
39
|
+
if (!schema?.attributes) continue;
|
|
40
|
+
|
|
41
|
+
// Find filtered-relation fields that target the current model
|
|
42
|
+
for (const [fieldName, fieldConfig] of Object.entries(
|
|
43
|
+
schema.attributes,
|
|
44
|
+
)) {
|
|
45
|
+
if (
|
|
46
|
+
fieldConfig.customField ===
|
|
47
|
+
"plugin::filtered-relation.filtered-relation"
|
|
48
|
+
) {
|
|
49
|
+
const {
|
|
50
|
+
targetModel: targetModelInput,
|
|
51
|
+
filterField1,
|
|
52
|
+
filterValue1,
|
|
53
|
+
filterField2,
|
|
54
|
+
filterValue2,
|
|
55
|
+
displayField,
|
|
56
|
+
} = fieldConfig.options || {};
|
|
57
|
+
|
|
58
|
+
// Parse collection name to API ID
|
|
59
|
+
const parseCollectionName = (name) => {
|
|
60
|
+
if (!name) return null;
|
|
61
|
+
if (name.startsWith("api::")) return name;
|
|
62
|
+
const slug = name
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/\s+/g, "-")
|
|
65
|
+
.replace(/[^a-z0-9-]/g, "");
|
|
66
|
+
return `api::${slug}.${slug}`;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const targetModel = parseCollectionName(targetModelInput);
|
|
70
|
+
|
|
71
|
+
// Build filter params
|
|
72
|
+
// Note: {{documentId}} placeholder cannot be resolved in lifecycle hooks
|
|
73
|
+
// as we don't have context of which entity is being viewed
|
|
74
|
+
const filterParams = {};
|
|
75
|
+
if (
|
|
76
|
+
filterField1 &&
|
|
77
|
+
filterValue1 &&
|
|
78
|
+
filterValue1 !== "{{documentId}}"
|
|
79
|
+
) {
|
|
80
|
+
filterParams[filterField1] = filterValue1;
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
filterField2 &&
|
|
84
|
+
filterValue2 &&
|
|
85
|
+
filterValue2 !== "{{documentId}}"
|
|
86
|
+
) {
|
|
87
|
+
filterParams[filterField2] = filterValue2;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check if this field targets the model that was just modified
|
|
91
|
+
if (targetModel === currentModelUid) {
|
|
92
|
+
// Find all related entities that need updating
|
|
93
|
+
const relatedEntities = await findRelatedEntities(
|
|
94
|
+
event,
|
|
95
|
+
schema,
|
|
96
|
+
uid,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Update each related entity
|
|
100
|
+
for (const entityId of relatedEntities) {
|
|
101
|
+
await updateFilteredField(
|
|
102
|
+
strapi,
|
|
103
|
+
uid,
|
|
104
|
+
entityId,
|
|
105
|
+
fieldName,
|
|
106
|
+
targetModel,
|
|
107
|
+
filterParams,
|
|
108
|
+
displayField,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
strapi.log.error("Error updating related filtered fields:", error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function findRelatedEntities(event, schema, contentTypeUid) {
|
|
121
|
+
const { result, params } = event;
|
|
122
|
+
const entityIds = new Set();
|
|
123
|
+
|
|
124
|
+
// Look for relation fields in the schema that point to the content type
|
|
125
|
+
for (const [fieldName, fieldConfig] of Object.entries(schema.attributes)) {
|
|
126
|
+
if (
|
|
127
|
+
fieldConfig.type === "relation" &&
|
|
128
|
+
fieldConfig.target === contentTypeUid
|
|
129
|
+
) {
|
|
130
|
+
// Get the related entity ID from result or params
|
|
131
|
+
const relatedId = result?.[fieldName]?.id || params?.data?.[fieldName];
|
|
132
|
+
if (relatedId) {
|
|
133
|
+
entityIds.add(relatedId);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return Array.from(entityIds);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function updateFilteredField(
|
|
142
|
+
strapi,
|
|
143
|
+
contentTypeUid,
|
|
144
|
+
entityId,
|
|
145
|
+
fieldName,
|
|
146
|
+
targetModel,
|
|
147
|
+
filterParams,
|
|
148
|
+
displayField,
|
|
149
|
+
) {
|
|
150
|
+
try {
|
|
151
|
+
// Fetch filtered data
|
|
152
|
+
const filteredItems = await strapi.entityService.findMany(targetModel, {
|
|
153
|
+
filters: filterParams,
|
|
154
|
+
populate: [displayField],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Extract documentIds from the display field
|
|
158
|
+
const documentIds = [];
|
|
159
|
+
filteredItems.forEach((item) => {
|
|
160
|
+
if (displayField && item[displayField]) {
|
|
161
|
+
if (Array.isArray(item[displayField])) {
|
|
162
|
+
item[displayField].forEach((rel) => {
|
|
163
|
+
const docId = rel?.documentId || rel?.id;
|
|
164
|
+
if (docId) documentIds.push(docId);
|
|
165
|
+
});
|
|
166
|
+
} else if (item[displayField]?.documentId || item[displayField]?.id) {
|
|
167
|
+
documentIds.push(
|
|
168
|
+
item[displayField].documentId || item[displayField].id,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Update the entity
|
|
175
|
+
await strapi.entityService.update(contentTypeUid, entityId, {
|
|
176
|
+
data: {
|
|
177
|
+
[fieldName]: JSON.stringify(documentIds),
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
} catch (error) {
|
|
181
|
+
strapi.log.error(
|
|
182
|
+
`Error updating filtered field ${fieldName} for ${contentTypeUid}:${entityId}`,
|
|
183
|
+
error,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = {};
|
package/strapi-admin.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Filter } from "@strapi/icons";
|
|
3
|
+
|
|
4
|
+
const FilteredRelationIcon = () =>
|
|
5
|
+
React.createElement(
|
|
6
|
+
"div",
|
|
7
|
+
{
|
|
8
|
+
style: {
|
|
9
|
+
display: "flex",
|
|
10
|
+
justifyContent: "center",
|
|
11
|
+
alignItems: "center",
|
|
12
|
+
width: "32px",
|
|
13
|
+
height: "24px",
|
|
14
|
+
borderRadius: "4px",
|
|
15
|
+
border: "1px solid #ffb3d9",
|
|
16
|
+
background: "#ffe6f5",
|
|
17
|
+
fontSize: "14px",
|
|
18
|
+
fontWeight: "600",
|
|
19
|
+
color: "#d6006e",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
React.createElement(Filter, { width: "12px", height: "12px" }),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
export default {
|
|
26
|
+
register(app) {
|
|
27
|
+
// Register custom field
|
|
28
|
+
app.customFields.register({
|
|
29
|
+
name: "filtered-relation",
|
|
30
|
+
pluginId: "filtered-relation",
|
|
31
|
+
type: "json",
|
|
32
|
+
icon: FilteredRelationIcon,
|
|
33
|
+
intlLabel: {
|
|
34
|
+
id: "filtered-relation.label",
|
|
35
|
+
defaultMessage: "Filtered Relation",
|
|
36
|
+
},
|
|
37
|
+
intlDescription: {
|
|
38
|
+
id: "filtered-relation.description",
|
|
39
|
+
defaultMessage: "Relation field with filtering by field value",
|
|
40
|
+
},
|
|
41
|
+
components: {
|
|
42
|
+
Input: async () => {
|
|
43
|
+
const component = await import(
|
|
44
|
+
"./admin/src/components/FilteredRelationInput"
|
|
45
|
+
);
|
|
46
|
+
return component;
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
options: {
|
|
50
|
+
base: [
|
|
51
|
+
{
|
|
52
|
+
intlLabel: {
|
|
53
|
+
id: "filtered-relation.displayField.label",
|
|
54
|
+
defaultMessage: "Display Field",
|
|
55
|
+
},
|
|
56
|
+
name: "options.displayField",
|
|
57
|
+
type: "text",
|
|
58
|
+
description: {
|
|
59
|
+
id: "filtered-relation.displayField.description",
|
|
60
|
+
defaultMessage:
|
|
61
|
+
"Relation field name to display (e.g., 'investors')",
|
|
62
|
+
},
|
|
63
|
+
placeholder: {
|
|
64
|
+
id: "filtered-relation.displayField.placeholder",
|
|
65
|
+
defaultMessage: "investors",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
advanced: [
|
|
70
|
+
{
|
|
71
|
+
sectionTitle: {
|
|
72
|
+
id: "filtered-relation.section.target",
|
|
73
|
+
defaultMessage: "Target Collection",
|
|
74
|
+
},
|
|
75
|
+
items: [
|
|
76
|
+
{
|
|
77
|
+
name: "options.targetModel",
|
|
78
|
+
type: "text",
|
|
79
|
+
intlLabel: {
|
|
80
|
+
id: "filtered-relation.targetModel.label",
|
|
81
|
+
defaultMessage: "Collection Name",
|
|
82
|
+
},
|
|
83
|
+
description: {
|
|
84
|
+
id: "filtered-relation.targetModel.description",
|
|
85
|
+
defaultMessage:
|
|
86
|
+
"Collection display name (e.g., 'Meeting Participation Status'). Will auto-convert to API ID.",
|
|
87
|
+
},
|
|
88
|
+
placeholder: {
|
|
89
|
+
id: "filtered-relation.targetModel.placeholder",
|
|
90
|
+
defaultMessage: "Meeting Participation Status",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
sectionTitle: {
|
|
97
|
+
id: "filtered-relation.section.filters",
|
|
98
|
+
defaultMessage: "Filter Conditions",
|
|
99
|
+
},
|
|
100
|
+
items: [
|
|
101
|
+
{
|
|
102
|
+
name: "options.filterField1",
|
|
103
|
+
type: "text",
|
|
104
|
+
intlLabel: {
|
|
105
|
+
id: "filtered-relation.filterField1.label",
|
|
106
|
+
defaultMessage: "Filter Field 1",
|
|
107
|
+
},
|
|
108
|
+
placeholder: {
|
|
109
|
+
id: "filtered-relation.filterField1.placeholder",
|
|
110
|
+
defaultMessage: "participantStatus",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: "options.filterValue1",
|
|
115
|
+
type: "text",
|
|
116
|
+
intlLabel: {
|
|
117
|
+
id: "filtered-relation.filterValue1.label",
|
|
118
|
+
defaultMessage: "Filter Value 1",
|
|
119
|
+
},
|
|
120
|
+
placeholder: {
|
|
121
|
+
id: "filtered-relation.filterValue1.placeholder",
|
|
122
|
+
defaultMessage: "Rejected",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "options.filterField2",
|
|
127
|
+
type: "text",
|
|
128
|
+
intlLabel: {
|
|
129
|
+
id: "filtered-relation.filterField2.label",
|
|
130
|
+
defaultMessage: "Filter Field 2 (Optional)",
|
|
131
|
+
},
|
|
132
|
+
placeholder: {
|
|
133
|
+
id: "filtered-relation.filterField2.placeholder",
|
|
134
|
+
defaultMessage: "event",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "options.filterValue2",
|
|
139
|
+
type: "text",
|
|
140
|
+
intlLabel: {
|
|
141
|
+
id: "filtered-relation.filterValue2.label",
|
|
142
|
+
defaultMessage: "Filter Value 2 (Optional)",
|
|
143
|
+
},
|
|
144
|
+
description: {
|
|
145
|
+
id: "filtered-relation.filterValue2.description",
|
|
146
|
+
defaultMessage:
|
|
147
|
+
"Use {{documentId}} to filter by current entity",
|
|
148
|
+
},
|
|
149
|
+
placeholder: {
|
|
150
|
+
id: "filtered-relation.filterValue2.placeholder",
|
|
151
|
+
defaultMessage: "{{documentId}}",
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
};
|
package/strapi-server.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const register = require("./server/register");
|
|
2
|
+
const controllers = require("./server/controllers");
|
|
3
|
+
const routes = require("./server/routes");
|
|
4
|
+
const services = require("./server/services");
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
register,
|
|
8
|
+
controllers,
|
|
9
|
+
routes,
|
|
10
|
+
services,
|
|
11
|
+
};
|