@tunghtml/strapi-plugin-filtered-relation 1.0.0 → 1.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/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ ## [1.1.0] - 2024-12-02
4
+
5
+ ### Added
6
+
7
+ - **Status Change Dropdown**: Added dropdown at the end of each item to change status
8
+ - Dropdown shows current status and all available enum options
9
+ - Options are dynamically fetched from target model schema (tries multiple endpoints)
10
+ - Clicking a different status moves the item between records using connect/disconnect API
11
+ - Dispatches custom events to refresh all filtered relation fields without page reload
12
+ - **New Configuration Option**: `statusField`
13
+ - Optional field to enable status change feature
14
+ - When configured, dropdown appears for each item
15
+ - Must be an enum field in target model
16
+ - Example: `"statusField": "participantStatus"`
17
+
18
+ ### Changed
19
+
20
+ - Updated UI to include status dropdown alongside item label
21
+ - Improved item box layout to accommodate dropdown (space-between)
22
+ - Enhanced click handling to prevent navigation when interacting with dropdown
23
+ - Uses proper Strapi 5 connect/disconnect API format with both id and documentId
24
+ - Sends full payload matching UI admin format for reliable updates
25
+
26
+ ### Technical Details
27
+
28
+ - Fetches enum values from schema via content-type-builder or content-manager endpoints
29
+ - Uses `SingleSelect` component from Strapi Design System
30
+ - Implements loading state during status update
31
+ - Uses connect/disconnect API with format: `{ id, documentId }`
32
+ - Dispatches `filtered-relation-updated` custom event for cross-field refresh
33
+ - All fields listen to event and refresh when same documentId
34
+ - Maintains backward compatibility with version 1.0.0
35
+
36
+ ### Use Case
37
+
38
+ Perfect for scenarios like Meeting Participation Status where:
39
+
40
+ - Items are filtered by status (Pending, Accepted, Rejected, etc.)
41
+ - Users need to quickly change status without navigating to detail page
42
+ - Status change should automatically move items between lists
43
+
44
+ ## [1.0.0] - 2024-11-XX
45
+
46
+ ### Initial Release
47
+
48
+ - Display filtered relations based on field values
49
+ - Support for dynamic placeholders (`{{documentId}}`)
50
+ - Configurable filter fields (filterField1, filterField2)
51
+ - Auto-save functionality
52
+ - Click to navigate to related record
53
+ - Display count in field label
package/PUBLISH.md ADDED
@@ -0,0 +1,94 @@
1
+ # Publishing Guide
2
+
3
+ ## Prerequisites
4
+
5
+ 1. Make sure you're logged in to npm:
6
+
7
+ ```bash
8
+ npm login
9
+ ```
10
+
11
+ 2. Verify your npm account has access to publish `@tunghtml` scoped packages
12
+
13
+ ## Steps to Publish
14
+
15
+ ### 1. Test Locally First
16
+
17
+ The plugin is already configured as a local plugin in `backend/config/plugins.js`:
18
+
19
+ ```javascript
20
+ "filtered-relation": {
21
+ enabled: true,
22
+ resolve: "./src/plugins/filtered-relation",
23
+ }
24
+ ```
25
+
26
+ Test thoroughly in your local Strapi instance before publishing.
27
+
28
+ ### 2. Update Version
29
+
30
+ Update version in `package.json`:
31
+
32
+ - Patch: 1.1.0 -> 1.1.1 (bug fixes)
33
+ - Minor: 1.1.0 -> 1.2.0 (new features, backward compatible)
34
+ - Major: 1.1.0 -> 2.0.0 (breaking changes)
35
+
36
+ ### 3. Publish to NPM
37
+
38
+ From the plugin directory:
39
+
40
+ ```bash
41
+ cd backend/src/plugins/filtered-relation
42
+ npm publish --access public
43
+ ```
44
+
45
+ ### 4. Update Project to Use NPM Package
46
+
47
+ After publishing, you can switch back to using the npm package:
48
+
49
+ 1. Update `backend/package.json`:
50
+
51
+ ```json
52
+ {
53
+ "dependencies": {
54
+ "@tunghtml/strapi-plugin-filtered-relation": "^1.1.0"
55
+ }
56
+ }
57
+ ```
58
+
59
+ 2. Update `backend/config/plugins.js`:
60
+
61
+ ```javascript
62
+ "filtered-relation": {
63
+ enabled: true,
64
+ // Remove the resolve line to use npm package
65
+ }
66
+ ```
67
+
68
+ 3. Install the package:
69
+
70
+ ```bash
71
+ cd backend
72
+ yarn install
73
+ ```
74
+
75
+ ## Current Version
76
+
77
+ Version 1.1.0 includes:
78
+
79
+ - Status change dropdown feature
80
+ - Dynamic status options from schema
81
+ - Auto-refresh on status change
82
+ - Backward compatible with 1.0.0
83
+
84
+ ## Testing Checklist
85
+
86
+ Before publishing, verify:
87
+
88
+ - [ ] Plugin loads without errors
89
+ - [ ] Filtered relations display correctly
90
+ - [ ] Status dropdown shows correct options
91
+ - [ ] Status change updates the record
92
+ - [ ] Lists refresh automatically after status change
93
+ - [ ] Works with different content types (reusable)
94
+ - [ ] No console errors
package/README.md CHANGED
@@ -1,113 +1,111 @@
1
1
  # Strapi Plugin Filtered Relation
2
2
 
3
- A Strapi v5 custom field plugin that displays filtered relations with dynamic placeholder support.
3
+ A Strapi 5 custom field plugin for displaying filtered relations with dynamic placeholder support and status change capability.
4
4
 
5
5
  ## Features
6
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`)
7
+ - Display filtered relations based on field values
8
+ - Support dynamic placeholders (e.g., `{{documentId}}`)
9
+ - Change status directly from the relation list via dropdown
10
+ - Auto-refresh when status changes
11
+ - Fully configurable and reusable
12
12
 
13
13
  ## Installation
14
14
 
15
- ```bash
16
- npm install @tunghtml/strapi-plugin-filtered-relation
17
- ```
15
+ ### Local Development
18
16
 
19
- or
17
+ This plugin is currently being used as a local plugin. It's located in `backend/src/plugins/filtered-relation`.
18
+
19
+ ### NPM Package (for publishing)
20
20
 
21
21
  ```bash
22
+ npm install @tunghtml/strapi-plugin-filtered-relation
23
+ # or
22
24
  yarn add @tunghtml/strapi-plugin-filtered-relation
23
25
  ```
24
26
 
25
- ## Usage
26
-
27
- ### 1. Enable the plugin
27
+ ## Configuration
28
28
 
29
- Add the plugin to your `config/plugins.js`:
29
+ Add to `config/plugins.js`:
30
30
 
31
31
  ```javascript
32
32
  module.exports = {
33
33
  "filtered-relation": {
34
34
  enabled: true,
35
+ resolve: "./src/plugins/filtered-relation", // for local plugin
35
36
  },
36
37
  };
37
38
  ```
38
39
 
39
- ### 2. Add to your schema
40
+ ## Usage
40
41
 
41
- In your content type schema (e.g., `api/meeting/content-types/meeting/schema.json`):
42
+ ### Schema Configuration
43
+
44
+ Add the custom field to your content type schema:
42
45
 
43
46
  ```json
44
47
  {
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
- }
48
+ "pendingInvestors": {
49
+ "type": "customField",
50
+ "customField": "plugin::filtered-relation.filtered-relation",
51
+ "options": {
52
+ "displayField": "investors",
53
+ "targetModel": "Meeting Participation Status",
54
+ "filterField1": "participantStatus",
55
+ "filterValue1": "Pending",
56
+ "filterField2": "meeting.documentId",
57
+ "filterValue2": "{{documentId}}",
58
+ "statusField": "participantStatus"
57
59
  }
58
60
  }
59
61
  }
60
62
  ```
61
63
 
62
- ## Configuration Options
64
+ ### Options
63
65
 
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}}"` |
66
+ | Option | Type | Required | Description |
67
+ | -------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
68
+ | `displayField` | string | Yes | The relation field name to display from target model (e.g., "investors") |
69
+ | `targetModel` | string | Yes | Target collection display name (e.g., "Meeting Participation Status"). Will auto-convert to API ID format. |
70
+ | `filterField1` | string | Yes | First filter field name (e.g., "participantStatus") |
71
+ | `filterValue1` | string | Yes | First filter value (e.g., "Pending"). Supports `{{documentId}}` placeholder for current entity. |
72
+ | `filterField2` | string | No | Second filter field name (e.g., "meeting.documentId") for additional filtering |
73
+ | `filterValue2` | string | No | Second filter value. Supports `{{documentId}}` placeholder. |
74
+ | `statusField` | string | No | Field name for status dropdown (e.g., "participantStatus"). When configured, enables status change feature with dropdown. Must be an enum field in target model. |
72
75
 
73
- ## Dynamic Placeholders
76
+ ### Status Change Feature
74
77
 
75
- Use `{{documentId}}` in filter values to reference the current entity's document ID:
78
+ When `statusField` is configured, a dropdown appears at the end of each item allowing quick status changes.
76
79
 
77
- ```json
78
- {
79
- "filterField2": "meeting.documentId",
80
- "filterValue2": "{{documentId}}"
81
- }
82
- ```
80
+ **Requirements:**
83
81
 
84
- This will automatically replace `{{documentId}}` with the actual document ID when editing an entity.
82
+ - `statusField` must be an enum field in the target model
83
+ - Target model must have records for each status value
84
+ - All records must be linked to the same parent entity (via filterField2)
85
85
 
86
- ## Example Use Case
86
+ **How it works:**
87
+
88
+ 1. Dropdown shows current status and all available enum options (fetched from schema)
89
+ 2. User selects a new status
90
+ 3. Plugin automatically:
91
+ - Removes the item from current status record (disconnect)
92
+ - Adds the item to target status record (connect)
93
+ - Refreshes all filtered relation fields on the page
94
+ 4. No page reload needed - all lists update automatically via custom events
87
95
 
88
- Display rejected investors for a specific meeting:
96
+ ## Example Use Case
89
97
 
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}}`
98
+ For a Meeting with multiple investor status lists (Pending, Accepted, Rejected, etc.):
94
99
 
95
- Result: Shows only investors who rejected the current meeting.
100
+ 1. Each list shows investors filtered by their participation status
101
+ 2. Click the dropdown on any investor to change their status
102
+ 3. The investor automatically moves to the appropriate list
103
+ 4. All lists refresh to reflect the change
96
104
 
97
- ## Requirements
105
+ ## Version
98
106
 
99
- - Strapi v5.x
100
- - Node.js >= 18.0.0
101
- - React >= 18.0.0
107
+ Current version: 1.1.0
102
108
 
103
109
  ## License
104
110
 
105
111
  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)
@@ -1,6 +1,13 @@
1
1
  import React from "react";
2
2
  import { useIntl } from "react-intl";
3
- import { Field, Flex, Box, Typography } from "@strapi/design-system";
3
+ import {
4
+ Field,
5
+ Flex,
6
+ Box,
7
+ Typography,
8
+ SingleSelect,
9
+ SingleSelectOption,
10
+ } from "@strapi/design-system";
4
11
  import { useFetchClient } from "@strapi/strapi/admin";
5
12
  import { useLocation, useNavigate } from "react-router-dom";
6
13
 
@@ -11,6 +18,8 @@ const FilteredRelationInput = (props) => {
11
18
  const navigate = useNavigate();
12
19
  const [items, setItems] = React.useState([]);
13
20
  const [loading, setLoading] = React.useState(false);
21
+ const [statusOptions, setStatusOptions] = React.useState([]);
22
+ const [updatingStatus, setUpdatingStatus] = React.useState({});
14
23
 
15
24
  if (!props) {
16
25
  return null;
@@ -19,12 +28,9 @@ const FilteredRelationInput = (props) => {
19
28
  const { attribute, name, error, value } = props;
20
29
 
21
30
  // Extract documentId from URL path
22
- // URL format: /admin/content-manager/collection-types/api::meeting.meeting/bn0n8em49y7vljg5lrfh2aij
23
31
  const getDocumentIdFromUrl = () => {
24
32
  const pathParts = location.pathname.split("/");
25
- // Get the last part of the URL which should be the documentId
26
33
  const lastPart = pathParts[pathParts.length - 1];
27
- // Check if it's not 'create' (for new documents)
28
34
  return lastPart !== "create" ? lastPart : null;
29
35
  };
30
36
 
@@ -37,16 +43,13 @@ const FilteredRelationInput = (props) => {
37
43
  filterField2,
38
44
  filterValue2,
39
45
  displayField = "id",
46
+ statusField,
40
47
  } = attribute?.options || {};
41
48
 
42
49
  // Parse collection name to API ID
43
- // "Meeting Participation Status" -> "api::meeting-participation-status.meeting-participation-status"
44
50
  const parseCollectionName = (name) => {
45
51
  if (!name) return null;
46
- // If already API ID format, return as is
47
52
  if (name.startsWith("api::")) return name;
48
-
49
- // Convert display name to API ID
50
53
  const slug = name
51
54
  .toLowerCase()
52
55
  .replace(/\s+/g, "-")
@@ -57,10 +60,8 @@ const FilteredRelationInput = (props) => {
57
60
  const targetModel = parseCollectionName(targetModelInput);
58
61
 
59
62
  // Parse displayField to get relation model
60
- // displayField is like "investors" -> "api::investor.investor"
61
63
  const getRelationModel = (fieldName) => {
62
64
  if (!fieldName) return null;
63
- // Remove 's' at the end for plural -> singular
64
65
  const singular = fieldName.endsWith("s")
65
66
  ? fieldName.slice(0, -1)
66
67
  : fieldName;
@@ -73,19 +74,16 @@ const FilteredRelationInput = (props) => {
73
74
  // Build filter params with dynamic values
74
75
  const filterParams = {};
75
76
  if (filterField1 && filterValue1) {
76
- // Support {{documentId}} placeholder for current entity
77
77
  const value1 =
78
78
  filterValue1 === "{{documentId}}" ? documentId : filterValue1;
79
79
  filterParams[filterField1] = value1;
80
80
  }
81
81
  if (filterField2 && filterValue2) {
82
- // Support {{documentId}} placeholder for current entity
83
82
  const value2 =
84
83
  filterValue2 === "{{documentId}}" ? documentId : filterValue2;
85
84
  filterParams[filterField2] = value2;
86
85
  }
87
86
 
88
- // Get the content type from props
89
87
  const contentType = props.contentType || props.model;
90
88
 
91
89
  const saveFieldValue = async (newValue) => {
@@ -105,128 +103,311 @@ const FilteredRelationInput = (props) => {
105
103
  }
106
104
  };
107
105
 
106
+ // Fetch status options from target model schema
108
107
  React.useEffect(() => {
109
- const fetchFilteredData = async () => {
110
- if (!targetModel || Object.keys(filterParams).length === 0) {
108
+ const fetchStatusOptions = async () => {
109
+ if (!targetModel || !statusField) {
111
110
  return;
112
111
  }
113
112
 
114
- setLoading(true);
115
113
  try {
116
- const modelParts = targetModel.split("::");
117
- if (modelParts.length !== 2) {
118
- return;
114
+ let enumValues = null;
115
+
116
+ // Try 1: Get from content-type-builder (most reliable for schema)
117
+ try {
118
+ const builderResponse = await get(
119
+ `/content-type-builder/content-types/${targetModel}`,
120
+ );
121
+ const builderSchema = builderResponse?.data?.data?.schema;
122
+ enumValues = builderSchema?.attributes?.[statusField]?.enum;
123
+ } catch (e) {
124
+ // Endpoint might not be accessible, try next
119
125
  }
120
126
 
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)}`;
127
+ if (enumValues && Array.isArray(enumValues)) {
128
+ setStatusOptions(enumValues);
129
+ }
130
+ } catch (err) {
131
+ console.error("[FilteredRelation] Error fetching status options:", err);
132
+ }
133
+ };
134
+
135
+ fetchStatusOptions();
136
+ }, [targetModel, statusField]);
137
+
138
+ const handleStatusChange = async (itemId, newStatus, e) => {
139
+ // Prevent navigation when clicking dropdown
140
+ e?.stopPropagation();
141
+
142
+ if (!statusField || !targetModel || !displayField) {
143
+ return;
144
+ }
145
+
146
+ const item = items.find((i) => i.originalId === itemId);
147
+ if (!item) {
148
+ console.error("[FilteredRelation] Item not found:", itemId);
149
+ return;
150
+ }
151
+
152
+ setUpdatingStatus((prev) => ({ ...prev, [itemId]: true }));
153
+
154
+ try {
155
+ // Step 1: Get current Meeting Participation Status record with ALL fields
156
+ const currentRecordUrl = `/content-manager/collection-types/${targetModel}/${itemId}?populate=*`;
157
+ const currentRecord = await get(currentRecordUrl);
158
+ const currentData = currentRecord?.data?.data || {};
159
+ const currentInvestors = currentData?.[displayField] || [];
160
+
161
+ // Step 2: Find the investor to disconnect (need full object)
162
+ const investorToRemove = Array.isArray(currentInvestors)
163
+ ? currentInvestors.find((inv) => {
164
+ const invDocId = inv?.documentId || inv?.id;
165
+ return invDocId === item.relatedDocumentId;
132
166
  })
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
- });
167
+ : null;
168
+
169
+ if (!investorToRemove) {
170
+ console.error(
171
+ "[FilteredRelation] Investor not found in current record",
172
+ );
173
+ alert("Investor not found in current record");
174
+ return;
175
+ }
176
+
177
+ // Build full payload like UI admin does
178
+ const updatePayload = {
179
+ documentId: currentData.documentId,
180
+ [displayField]: {
181
+ connect: [],
182
+ disconnect: [
183
+ {
184
+ id: investorToRemove.id,
185
+ documentId: investorToRemove.documentId,
186
+ },
187
+ ],
188
+ },
189
+ participantStatus: currentData.participantStatus,
190
+ };
191
+
192
+ // Use disconnect with full payload
193
+ const removeResponse = await put(
194
+ `/content-manager/collection-types/${targetModel}/${itemId}`,
195
+ updatePayload,
196
+ );
197
+
198
+ // Step 3: Find Meeting Participation Status with new status (same meeting)
199
+ const newFilterParams = {};
200
+ if (filterField1) {
201
+ newFilterParams[filterField1] = newStatus;
202
+ }
203
+ if (filterField2 && filterValue2) {
204
+ const value2 =
205
+ filterValue2 === "{{documentId}}" ? documentId : filterValue2;
206
+ newFilterParams[filterField2] = value2;
207
+ }
208
+
209
+ const filterQuery = Object.entries(newFilterParams)
210
+ .map(([key, value], index) => {
211
+ if (key.includes(".")) {
212
+ const [relationField, nestedField] = key.split(".");
213
+ return `filters[$and][${index}][${relationField}][${nestedField}][$eq]=${encodeURIComponent(value)}`;
214
+ }
215
+ return `filters[$and][${index}][${key}][$eq]=${encodeURIComponent(value)}`;
216
+ })
217
+ .join("&");
218
+
219
+ const searchUrl = `/content-manager/collection-types/${targetModel}?${filterQuery}&populate[${displayField}]=*`;
220
+ const searchResponse = await get(searchUrl);
221
+ const targetRecords =
222
+ searchResponse?.data?.results ||
223
+ searchResponse?.results ||
224
+ searchResponse?.data ||
225
+ [];
226
+
227
+ let targetRecordId;
228
+ let targetInvestors = [];
229
+
230
+ if (targetRecords.length === 0) {
231
+ console.error(
232
+ "[FilteredRelation] No target record found with new status for this meeting.",
233
+ );
234
+ const collectionDisplayName = targetModelInput || "record";
235
+ alert(
236
+ `No ${collectionDisplayName} found with status "${newStatus}" for this meeting. Please create one first.`,
237
+ );
238
+ return;
239
+ }
240
+
241
+ // Get full target record data
242
+ const targetRecord = targetRecords[0];
243
+ targetRecordId = targetRecord.documentId || targetRecord.id;
244
+
245
+ // Get target record with all fields
246
+ const targetRecordUrl = `/content-manager/collection-types/${targetModel}/${targetRecordId}?populate=*`;
247
+ const targetRecordFull = await get(targetRecordUrl);
248
+ const targetData = targetRecordFull?.data?.data || {};
249
+
250
+ // Step 4: Add investor to target record
251
+ // Build full payload like UI admin does
252
+ const addPayload = {
253
+ documentId: targetData.documentId,
254
+ [displayField]: {
255
+ connect: [
256
+ {
257
+ id: investorToRemove.id,
258
+ documentId: investorToRemove.documentId,
259
+ },
260
+ ],
261
+ disconnect: [],
262
+ },
263
+ participantStatus: targetData.participantStatus,
264
+ };
265
+
266
+ // Use connect with full payload
267
+ const addResponse = await put(
268
+ `/content-manager/collection-types/${targetModel}/${targetRecordId}`,
269
+ addPayload,
270
+ );
271
+
272
+ // Dispatch custom event to trigger refresh of all filtered relation fields
273
+ window.dispatchEvent(
274
+ new CustomEvent("filtered-relation-updated", {
275
+ detail: { documentId, targetModel },
276
+ }),
277
+ );
278
+
279
+ // Refresh current field
280
+ await fetchFilteredData();
281
+ } catch (err) {
282
+ console.error("[FilteredRelation] Error moving investor:", err);
283
+ alert("Failed to change status. Check console for details.");
284
+ } finally {
285
+ setUpdatingStatus((prev) => ({ ...prev, [itemId]: false }));
286
+ }
287
+ };
288
+
289
+ const fetchFilteredData = async () => {
290
+ if (!targetModel || Object.keys(filterParams).length === 0) {
291
+ return;
292
+ }
293
+
294
+ setLoading(true);
295
+ try {
296
+ const modelParts = targetModel.split("::");
297
+ if (modelParts.length !== 2) {
298
+ return;
299
+ }
300
+
301
+ // Build filter query string
302
+ const filterQuery = Object.entries(filterParams)
303
+ .map(([key, value], index) => {
304
+ if (key.includes(".")) {
305
+ const [relationField, nestedField] = key.split(".");
306
+ return `filters[$and][${index}][${relationField}][${nestedField}][$eq]=${encodeURIComponent(value)}`;
307
+ }
308
+ return `filters[$and][${index}][${key}][$eq]=${encodeURIComponent(value)}`;
309
+ })
310
+ .join("&");
311
+
312
+ const populateFields = [displayField];
313
+ if (statusField) {
314
+ populateFields.push(statusField);
315
+ }
316
+
317
+ const apiUrl = `/content-manager/collection-types/${targetModel}?${filterQuery}&populate[${displayField}][populate]=*`;
318
+ const response = await get(apiUrl);
319
+ const items =
320
+ response?.data?.results || response?.results || response?.data || [];
321
+
322
+ if (items.length > 0) {
323
+ const formattedItems = [];
324
+
325
+ items.forEach((item) => {
326
+ if (displayField && item[displayField]) {
327
+ if (Array.isArray(item[displayField])) {
328
+ item[displayField].forEach((rel) => {
329
+ const displayValue =
330
+ rel.fullName ||
331
+ rel.name ||
332
+ rel.displayName ||
333
+ rel.firstName ||
334
+ `ID: ${rel.documentId || rel.id}`;
335
+
336
+ formattedItems.push({
337
+ id: `${item.documentId || item.id}-${rel.documentId || rel.id}`,
338
+ label: displayValue,
339
+ originalId: item.documentId || item.id,
340
+ relatedDocumentId: rel.documentId || rel.id,
341
+ relationModel: relationModel,
342
+ currentStatus: item[statusField],
162
343
  });
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
344
  });
345
+ } else if (
346
+ typeof item[displayField] === "object" &&
347
+ item[displayField] !== null
348
+ ) {
349
+ if (item[displayField].count !== undefined) {
350
+ formattedItems.push({
351
+ id: item.id,
352
+ label: `${item[displayField].count} items`,
353
+ currentStatus: item[statusField],
354
+ });
355
+ } else {
356
+ const displayValue =
357
+ item[displayField].fullName ||
358
+ item[displayField].name ||
359
+ item[displayField].displayName ||
360
+ item[displayField].firstName ||
361
+ `ID: ${item[displayField].id}`;
362
+
363
+ formattedItems.push({
364
+ id: item.id,
365
+ label: displayValue,
366
+ originalId: item.documentId || item.id,
367
+ relatedDocumentId:
368
+ item[displayField].documentId || item[displayField].id,
369
+ relationModel: relationModel,
370
+ currentStatus: item[statusField],
371
+ });
372
+ }
201
373
  }
202
- });
374
+ } else {
375
+ formattedItems.push({
376
+ id: item.id,
377
+ label: `ID: ${item.id}`,
378
+ originalId: item.documentId || item.id,
379
+ relatedDocumentId: item.documentId || item.id,
380
+ relationModel: targetModel,
381
+ currentStatus: item[statusField],
382
+ });
383
+ }
384
+ });
203
385
 
204
- setItems(formattedItems);
386
+ setItems(formattedItems);
205
387
 
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);
388
+ // Auto-save to database
389
+ const documentIds = formattedItems.map(
390
+ (item) => item.relatedDocumentId || item.id,
391
+ );
392
+ const newValue = JSON.stringify(documentIds);
211
393
 
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
- }
394
+ if (documentId && value !== newValue) {
395
+ await saveFieldValue(newValue);
396
+ }
397
+ } else {
398
+ setItems([]);
399
+ if (documentId && value) {
400
+ await saveFieldValue(null);
222
401
  }
223
- } catch (err) {
224
- console.error("Error fetching filtered relations:", err);
225
- } finally {
226
- setLoading(false);
227
402
  }
228
- };
403
+ } catch (err) {
404
+ console.error("Error fetching filtered relations:", err);
405
+ } finally {
406
+ setLoading(false);
407
+ }
408
+ };
229
409
 
410
+ React.useEffect(() => {
230
411
  fetchFilteredData();
231
412
  }, [
232
413
  targetModel,
@@ -239,6 +420,21 @@ const FilteredRelationInput = (props) => {
239
420
  value,
240
421
  ]);
241
422
 
423
+ // Listen for updates from other filtered relation fields
424
+ React.useEffect(() => {
425
+ const handleUpdate = (event) => {
426
+ // Refresh if same document
427
+ if (event.detail?.documentId === documentId) {
428
+ fetchFilteredData();
429
+ }
430
+ };
431
+
432
+ window.addEventListener("filtered-relation-updated", handleUpdate);
433
+ return () => {
434
+ window.removeEventListener("filtered-relation-updated", handleUpdate);
435
+ };
436
+ }, [documentId]);
437
+
242
438
  const fieldLabel = props.label || name;
243
439
  const itemCount = items.length;
244
440
 
@@ -258,8 +454,8 @@ const FilteredRelationInput = (props) => {
258
454
  ) : items.length > 0 ? (
259
455
  <Flex direction="column" alignItems="stretch" gap={1}>
260
456
  {items.map((item) => {
261
- // Use relation model (e.g., api::investor.investor) with relatedDocumentId
262
457
  const editUrl = `/content-manager/collection-types/${item.relationModel}/${item.relatedDocumentId}`;
458
+ const isUpdating = updatingStatus[item.originalId];
263
459
 
264
460
  return (
265
461
  <Box
@@ -275,15 +471,38 @@ const FilteredRelationInput = (props) => {
275
471
  paddingRight={4}
276
472
  style={{
277
473
  cursor: "pointer",
278
- height: "50px",
474
+ minHeight: "50px",
279
475
  display: "flex",
280
476
  alignItems: "center",
477
+ justifyContent: "space-between",
281
478
  }}
282
479
  onClick={() => navigate(editUrl)}
283
480
  >
284
481
  <Typography variant="omega" textColor="primary600">
285
482
  {item.label}
286
483
  </Typography>
484
+
485
+ {statusField && statusOptions.length > 0 && (
486
+ <Box
487
+ onClick={(e) => e.stopPropagation()}
488
+ style={{ minWidth: "150px" }}
489
+ >
490
+ <SingleSelect
491
+ size="S"
492
+ value={item.currentStatus}
493
+ onChange={(value) =>
494
+ handleStatusChange(item.originalId, value, null)
495
+ }
496
+ disabled={isUpdating}
497
+ >
498
+ {statusOptions.map((status) => (
499
+ <SingleSelectOption key={status} value={status}>
500
+ {status}
501
+ </SingleSelectOption>
502
+ ))}
503
+ </SingleSelect>
504
+ </Box>
505
+ )}
287
506
  </Box>
288
507
  );
289
508
  })}
@@ -301,7 +520,6 @@ const FilteredRelationInput = (props) => {
301
520
  {Object.keys(filterParams).length > 0
302
521
  ? `Filters: ${Object.entries(filterParams)
303
522
  .map(([k, v]) => {
304
- // Show placeholder name if it's a dynamic value
305
523
  const displayValue =
306
524
  v === documentId ? "{{current documentId}}" : v;
307
525
  return `${k} = ${displayValue}`;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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",
3
+ "version": "1.1.0",
4
+ "description": "A Strapi custom field plugin for displaying filtered relations with dynamic placeholder support and status change capability",
5
5
  "keywords": [
6
6
  "strapi",
7
7
  "strapi-plugin",
@@ -10,18 +10,10 @@
10
10
  "filter",
11
11
  "strapi5"
12
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
13
  "strapi": {
22
14
  "name": "filtered-relation",
23
15
  "displayName": "Filtered Relation",
24
- "description": "Relation field with filtering capability and dynamic placeholders",
16
+ "description": "Relation field with filtering capability, dynamic placeholders, and status change",
25
17
  "kind": "plugin"
26
18
  },
27
19
  "dependencies": {
@@ -39,12 +31,6 @@
39
31
  "name": "Tung HTML",
40
32
  "email": "tunghtml@example.com"
41
33
  },
42
- "maintainers": [
43
- {
44
- "name": "Tung HTML",
45
- "email": "tunghtml@example.com"
46
- }
47
- ],
48
34
  "engines": {
49
35
  "node": ">=18.0.0 <=22.x.x",
50
36
  "npm": ">=6.0.0"
@@ -1,3 +1,5 @@
1
+ "use strict";
2
+
1
3
  module.exports = ({ strapi }) => {
2
4
  strapi.customFields.register({
3
5
  name: "filtered-relation",
@@ -69,8 +71,6 @@ async function updateRelatedFilteredFields(event, strapi) {
69
71
  const targetModel = parseCollectionName(targetModelInput);
70
72
 
71
73
  // 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
74
  const filterParams = {};
75
75
  if (
76
76
  filterField1 &&
package/strapi-admin.js CHANGED
@@ -36,7 +36,8 @@ export default {
36
36
  },
37
37
  intlDescription: {
38
38
  id: "filtered-relation.description",
39
- defaultMessage: "Relation field with filtering by field value",
39
+ defaultMessage:
40
+ "Relation field with filtering by field value and status change",
40
41
  },
41
42
  components: {
42
43
  Input: async () => {
@@ -153,6 +154,31 @@ export default {
153
154
  },
154
155
  ],
155
156
  },
157
+ {
158
+ sectionTitle: {
159
+ id: "filtered-relation.section.statusChange",
160
+ defaultMessage: "Status Change Configuration",
161
+ },
162
+ items: [
163
+ {
164
+ name: "options.statusField",
165
+ type: "text",
166
+ intlLabel: {
167
+ id: "filtered-relation.statusField.label",
168
+ defaultMessage: "Status Field Name",
169
+ },
170
+ description: {
171
+ id: "filtered-relation.statusField.description",
172
+ defaultMessage:
173
+ "Field name in target model that contains status (e.g., 'participantStatus'). Leave empty to disable status change.",
174
+ },
175
+ placeholder: {
176
+ id: "filtered-relation.statusField.placeholder",
177
+ defaultMessage: "participantStatus",
178
+ },
179
+ },
180
+ ],
181
+ },
156
182
  ],
157
183
  },
158
184
  });
package/strapi-server.js CHANGED
@@ -1,11 +1,5 @@
1
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
2
 
6
3
  module.exports = {
7
4
  register,
8
- controllers,
9
- routes,
10
- services,
11
5
  };
@@ -1,26 +0,0 @@
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
- };
@@ -1,5 +0,0 @@
1
- const contentTypes = require("./content-types");
2
-
3
- module.exports = {
4
- contentTypes,
5
- };
@@ -1,11 +0,0 @@
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
- ];
@@ -1 +0,0 @@
1
- module.exports = {};