@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 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,5 @@
1
+ const contentTypes = require("./content-types");
2
+
3
+ module.exports = {
4
+ contentTypes,
5
+ };
@@ -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,11 @@
1
+ module.exports = [
2
+ {
3
+ method: "GET",
4
+ path: "/content-types",
5
+ handler: "contentTypes.find",
6
+ config: {
7
+ policies: [],
8
+ auth: false,
9
+ },
10
+ },
11
+ ];
@@ -0,0 +1 @@
1
+ module.exports = {};
@@ -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
+ };
@@ -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
+ };