@tunghtml/strapi-plugin-filtered-relation 1.0.0 → 1.1.1

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,72 @@
1
+ # Changelog
2
+
3
+ ## [1.1.1] - 2024-12-02
4
+
5
+ ### Fixed
6
+
7
+ - **State Refresh Issue**: Fixed "Investor not found in current record" error that occurred after status changes
8
+ - Now refreshes local state immediately after disconnect/connect operations
9
+ - Prevents stale data issues when performing multiple status changes without page reload
10
+ - Reordered refresh logic: local field first, then dispatch event to other fields
11
+ - **User Experience**: Replaced browser `alert()` with Strapi's native notification system
12
+ - Success notifications for successful status changes
13
+ - Warning notifications for missing records or data issues
14
+ - Danger notifications for errors
15
+ - All notifications use `useNotification` hook from `@strapi/strapi/admin`
16
+
17
+ ### Changed
18
+
19
+ - Improved notification messages to be more user-friendly
20
+ - Enhanced error handling with proper notification types
21
+
22
+ ## [1.1.0] - 2024-12-02
23
+
24
+ ### Added
25
+
26
+ - **Status Change Dropdown**: Added dropdown at the end of each item to change status
27
+ - Dropdown shows current status and all available enum options
28
+ - Options are dynamically fetched from target model schema (tries multiple endpoints)
29
+ - Clicking a different status moves the item between records using connect/disconnect API
30
+ - Dispatches custom events to refresh all filtered relation fields without page reload
31
+ - **New Configuration Option**: `statusField`
32
+ - Optional field to enable status change feature
33
+ - When configured, dropdown appears for each item
34
+ - Must be an enum field in target model
35
+ - Example: `"statusField": "participantStatus"`
36
+
37
+ ### Changed
38
+
39
+ - Updated UI to include status dropdown alongside item label
40
+ - Improved item box layout to accommodate dropdown (space-between)
41
+ - Enhanced click handling to prevent navigation when interacting with dropdown
42
+ - Uses proper Strapi 5 connect/disconnect API format with both id and documentId
43
+ - Sends full payload matching UI admin format for reliable updates
44
+
45
+ ### Technical Details
46
+
47
+ - Fetches enum values from schema via content-type-builder or content-manager endpoints
48
+ - Uses `SingleSelect` component from Strapi Design System
49
+ - Implements loading state during status update
50
+ - Uses connect/disconnect API with format: `{ id, documentId }`
51
+ - Dispatches `filtered-relation-updated` custom event for cross-field refresh
52
+ - All fields listen to event and refresh when same documentId
53
+ - Maintains backward compatibility with version 1.0.0
54
+
55
+ ### Use Case
56
+
57
+ Perfect for scenarios like Meeting Participation Status where:
58
+
59
+ - Items are filtered by status (Pending, Accepted, Rejected, etc.)
60
+ - Users need to quickly change status without navigating to detail page
61
+ - Status change should automatically move items between lists
62
+
63
+ ## [1.0.0] - 2024-11-XX
64
+
65
+ ### Initial Release
66
+
67
+ - Display filtered relations based on field values
68
+ - Support for dynamic placeholders (`{{documentId}}`)
69
+ - Configurable filter fields (filterField1, filterField2)
70
+ - Auto-save functionality
71
+ - Click to navigate to related record
72
+ - 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,16 +1,26 @@
1
1
  import React from "react";
2
2
  import { useIntl } from "react-intl";
3
- import { Field, Flex, Box, Typography } from "@strapi/design-system";
4
- import { useFetchClient } from "@strapi/strapi/admin";
3
+ import {
4
+ Field,
5
+ Flex,
6
+ Box,
7
+ Typography,
8
+ SingleSelect,
9
+ SingleSelectOption,
10
+ } from "@strapi/design-system";
11
+ import { useFetchClient, useNotification } from "@strapi/strapi/admin";
5
12
  import { useLocation, useNavigate } from "react-router-dom";
6
13
 
7
14
  const FilteredRelationInput = (props) => {
8
15
  const { formatMessage } = useIntl();
9
16
  const { get, put } = useFetchClient();
17
+ const { toggleNotification } = useNotification();
10
18
  const location = useLocation();
11
19
  const navigate = useNavigate();
12
20
  const [items, setItems] = React.useState([]);
13
21
  const [loading, setLoading] = React.useState(false);
22
+ const [statusOptions, setStatusOptions] = React.useState([]);
23
+ const [updatingStatus, setUpdatingStatus] = React.useState({});
14
24
 
15
25
  if (!props) {
16
26
  return null;
@@ -19,12 +29,9 @@ const FilteredRelationInput = (props) => {
19
29
  const { attribute, name, error, value } = props;
20
30
 
21
31
  // Extract documentId from URL path
22
- // URL format: /admin/content-manager/collection-types/api::meeting.meeting/bn0n8em49y7vljg5lrfh2aij
23
32
  const getDocumentIdFromUrl = () => {
24
33
  const pathParts = location.pathname.split("/");
25
- // Get the last part of the URL which should be the documentId
26
34
  const lastPart = pathParts[pathParts.length - 1];
27
- // Check if it's not 'create' (for new documents)
28
35
  return lastPart !== "create" ? lastPart : null;
29
36
  };
30
37
 
@@ -37,16 +44,13 @@ const FilteredRelationInput = (props) => {
37
44
  filterField2,
38
45
  filterValue2,
39
46
  displayField = "id",
47
+ statusField,
40
48
  } = attribute?.options || {};
41
49
 
42
50
  // Parse collection name to API ID
43
- // "Meeting Participation Status" -> "api::meeting-participation-status.meeting-participation-status"
44
51
  const parseCollectionName = (name) => {
45
52
  if (!name) return null;
46
- // If already API ID format, return as is
47
53
  if (name.startsWith("api::")) return name;
48
-
49
- // Convert display name to API ID
50
54
  const slug = name
51
55
  .toLowerCase()
52
56
  .replace(/\s+/g, "-")
@@ -57,10 +61,8 @@ const FilteredRelationInput = (props) => {
57
61
  const targetModel = parseCollectionName(targetModelInput);
58
62
 
59
63
  // Parse displayField to get relation model
60
- // displayField is like "investors" -> "api::investor.investor"
61
64
  const getRelationModel = (fieldName) => {
62
65
  if (!fieldName) return null;
63
- // Remove 's' at the end for plural -> singular
64
66
  const singular = fieldName.endsWith("s")
65
67
  ? fieldName.slice(0, -1)
66
68
  : fieldName;
@@ -73,19 +75,16 @@ const FilteredRelationInput = (props) => {
73
75
  // Build filter params with dynamic values
74
76
  const filterParams = {};
75
77
  if (filterField1 && filterValue1) {
76
- // Support {{documentId}} placeholder for current entity
77
78
  const value1 =
78
79
  filterValue1 === "{{documentId}}" ? documentId : filterValue1;
79
80
  filterParams[filterField1] = value1;
80
81
  }
81
82
  if (filterField2 && filterValue2) {
82
- // Support {{documentId}} placeholder for current entity
83
83
  const value2 =
84
84
  filterValue2 === "{{documentId}}" ? documentId : filterValue2;
85
85
  filterParams[filterField2] = value2;
86
86
  }
87
87
 
88
- // Get the content type from props
89
88
  const contentType = props.contentType || props.model;
90
89
 
91
90
  const saveFieldValue = async (newValue) => {
@@ -98,135 +97,331 @@ const FilteredRelationInput = (props) => {
98
97
  data: {
99
98
  [name]: newValue,
100
99
  },
101
- },
100
+ }
102
101
  );
103
102
  } catch (err) {
104
103
  console.error("Error auto-saving filtered relation:", err);
105
104
  }
106
105
  };
107
106
 
107
+ // Fetch status options from target model schema
108
108
  React.useEffect(() => {
109
- const fetchFilteredData = async () => {
110
- if (!targetModel || Object.keys(filterParams).length === 0) {
109
+ const fetchStatusOptions = async () => {
110
+ if (!targetModel || !statusField) {
111
111
  return;
112
112
  }
113
113
 
114
- setLoading(true);
115
114
  try {
116
- const modelParts = targetModel.split("::");
117
- if (modelParts.length !== 2) {
118
- return;
115
+ let enumValues = null;
116
+
117
+ // Try 1: Get from content-type-builder (most reliable for schema)
118
+ try {
119
+ const builderResponse = await get(
120
+ `/content-type-builder/content-types/${targetModel}`
121
+ );
122
+ const builderSchema = builderResponse?.data?.data?.schema;
123
+ enumValues = builderSchema?.attributes?.[statusField]?.enum;
124
+ } catch (e) {
125
+ // Endpoint might not be accessible, try next
119
126
  }
120
127
 
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)}`;
128
+ if (enumValues && Array.isArray(enumValues)) {
129
+ setStatusOptions(enumValues);
130
+ }
131
+ } catch (err) {
132
+ console.error("[FilteredRelation] Error fetching status options:", err);
133
+ }
134
+ };
135
+
136
+ fetchStatusOptions();
137
+ }, [targetModel, statusField]);
138
+
139
+ const handleStatusChange = async (itemId, newStatus, e) => {
140
+ // Prevent navigation when clicking dropdown
141
+ e?.stopPropagation();
142
+
143
+ if (!statusField || !targetModel || !displayField) {
144
+ return;
145
+ }
146
+
147
+ const item = items.find((i) => i.originalId === itemId);
148
+ if (!item) {
149
+ console.error("[FilteredRelation] Item not found:", itemId);
150
+ return;
151
+ }
152
+
153
+ setUpdatingStatus((prev) => ({ ...prev, [itemId]: true }));
154
+
155
+ try {
156
+ // Step 1: Get current Meeting Participation Status record with ALL fields
157
+ const currentRecordUrl = `/content-manager/collection-types/${targetModel}/${itemId}?populate=*`;
158
+ const currentRecord = await get(currentRecordUrl);
159
+ const currentData = currentRecord?.data?.data || {};
160
+ const currentInvestors = currentData?.[displayField] || [];
161
+
162
+ // Step 2: Find the investor to disconnect (need full object)
163
+ const investorToRemove = Array.isArray(currentInvestors)
164
+ ? currentInvestors.find((inv) => {
165
+ const invDocId = inv?.documentId || inv?.id;
166
+ return invDocId === item.relatedDocumentId;
132
167
  })
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
- });
168
+ : null;
169
+
170
+ if (!investorToRemove) {
171
+ console.error(
172
+ "[FilteredRelation] Investor not found in current record"
173
+ );
174
+ toggleNotification({
175
+ type: "warning",
176
+ message:
177
+ "Investor not found in current record. Please refresh the page.",
178
+ });
179
+ return;
180
+ }
181
+
182
+ // Build full payload like UI admin does
183
+ const updatePayload = {
184
+ documentId: currentData.documentId,
185
+ [displayField]: {
186
+ connect: [],
187
+ disconnect: [
188
+ {
189
+ id: investorToRemove.id,
190
+ documentId: investorToRemove.documentId,
191
+ },
192
+ ],
193
+ },
194
+ participantStatus: currentData.participantStatus,
195
+ };
196
+
197
+ // Use disconnect with full payload
198
+ const removeResponse = await put(
199
+ `/content-manager/collection-types/${targetModel}/${itemId}`,
200
+ updatePayload
201
+ );
202
+
203
+ // Step 3: Find Meeting Participation Status with new status (same meeting)
204
+ const newFilterParams = {};
205
+ if (filterField1) {
206
+ newFilterParams[filterField1] = newStatus;
207
+ }
208
+ if (filterField2 && filterValue2) {
209
+ const value2 =
210
+ filterValue2 === "{{documentId}}" ? documentId : filterValue2;
211
+ newFilterParams[filterField2] = value2;
212
+ }
213
+
214
+ const filterQuery = Object.entries(newFilterParams)
215
+ .map(([key, value], index) => {
216
+ if (key.includes(".")) {
217
+ const [relationField, nestedField] = key.split(".");
218
+ return `filters[$and][${index}][${relationField}][${nestedField}][$eq]=${encodeURIComponent(value)}`;
219
+ }
220
+ return `filters[$and][${index}][${key}][$eq]=${encodeURIComponent(value)}`;
221
+ })
222
+ .join("&");
223
+
224
+ const searchUrl = `/content-manager/collection-types/${targetModel}?${filterQuery}&populate[${displayField}]=*`;
225
+ const searchResponse = await get(searchUrl);
226
+ const targetRecords =
227
+ searchResponse?.data?.results ||
228
+ searchResponse?.results ||
229
+ searchResponse?.data ||
230
+ [];
231
+
232
+ let targetRecordId;
233
+ let targetInvestors = [];
234
+
235
+ if (targetRecords.length === 0) {
236
+ console.error(
237
+ "[FilteredRelation] No target record found with new status for this meeting."
238
+ );
239
+ const collectionDisplayName = targetModelInput || "record";
240
+ toggleNotification({
241
+ type: "warning",
242
+ message: `No ${collectionDisplayName} found with status "${newStatus}" for this meeting. Please create one first.`,
243
+ });
244
+ return;
245
+ }
246
+
247
+ // Get full target record data
248
+ const targetRecord = targetRecords[0];
249
+ targetRecordId = targetRecord.documentId || targetRecord.id;
250
+
251
+ // Get target record with all fields
252
+ const targetRecordUrl = `/content-manager/collection-types/${targetModel}/${targetRecordId}?populate=*`;
253
+ const targetRecordFull = await get(targetRecordUrl);
254
+ const targetData = targetRecordFull?.data?.data || {};
255
+
256
+ // Step 4: Add investor to target record
257
+ // Build full payload like UI admin does
258
+ const addPayload = {
259
+ documentId: targetData.documentId,
260
+ [displayField]: {
261
+ connect: [
262
+ {
263
+ id: investorToRemove.id,
264
+ documentId: investorToRemove.documentId,
265
+ },
266
+ ],
267
+ disconnect: [],
268
+ },
269
+ participantStatus: targetData.participantStatus,
270
+ };
271
+
272
+ // Use connect with full payload
273
+ const addResponse = await put(
274
+ `/content-manager/collection-types/${targetModel}/${targetRecordId}`,
275
+ addPayload
276
+ );
277
+
278
+ // Refresh current field first to update local state
279
+ await fetchFilteredData();
280
+
281
+ // Then dispatch custom event to trigger refresh of all other filtered relation fields
282
+ window.dispatchEvent(
283
+ new CustomEvent("filtered-relation-updated", {
284
+ detail: { documentId, targetModel },
285
+ })
286
+ );
287
+
288
+ toggleNotification({
289
+ type: "success",
290
+ message: "Status changed successfully",
291
+ });
292
+ } catch (err) {
293
+ console.error("[FilteredRelation] Error moving investor:", err);
294
+ toggleNotification({
295
+ type: "danger",
296
+ message: "Failed to change status. Please try again.",
297
+ });
298
+ } finally {
299
+ setUpdatingStatus((prev) => ({ ...prev, [itemId]: false }));
300
+ }
301
+ };
302
+
303
+ const fetchFilteredData = async () => {
304
+ if (!targetModel || Object.keys(filterParams).length === 0) {
305
+ return;
306
+ }
307
+
308
+ setLoading(true);
309
+ try {
310
+ const modelParts = targetModel.split("::");
311
+ if (modelParts.length !== 2) {
312
+ return;
313
+ }
314
+
315
+ // Build filter query string
316
+ const filterQuery = Object.entries(filterParams)
317
+ .map(([key, value], index) => {
318
+ if (key.includes(".")) {
319
+ const [relationField, nestedField] = key.split(".");
320
+ return `filters[$and][${index}][${relationField}][${nestedField}][$eq]=${encodeURIComponent(value)}`;
321
+ }
322
+ return `filters[$and][${index}][${key}][$eq]=${encodeURIComponent(value)}`;
323
+ })
324
+ .join("&");
325
+
326
+ const populateFields = [displayField];
327
+ if (statusField) {
328
+ populateFields.push(statusField);
329
+ }
330
+
331
+ const apiUrl = `/content-manager/collection-types/${targetModel}?${filterQuery}&populate[${displayField}][populate]=*`;
332
+ const response = await get(apiUrl);
333
+ const items =
334
+ response?.data?.results || response?.results || response?.data || [];
335
+
336
+ if (items.length > 0) {
337
+ const formattedItems = [];
338
+
339
+ items.forEach((item) => {
340
+ if (displayField && item[displayField]) {
341
+ if (Array.isArray(item[displayField])) {
342
+ item[displayField].forEach((rel) => {
343
+ const displayValue =
344
+ rel.fullName ||
345
+ rel.name ||
346
+ rel.displayName ||
347
+ rel.firstName ||
348
+ `ID: ${rel.documentId || rel.id}`;
349
+
350
+ formattedItems.push({
351
+ id: `${item.documentId || item.id}-${rel.documentId || rel.id}`,
352
+ label: displayValue,
353
+ originalId: item.documentId || item.id,
354
+ relatedDocumentId: rel.documentId || rel.id,
355
+ relationModel: relationModel,
356
+ currentStatus: item[statusField],
162
357
  });
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
358
  });
359
+ } else if (
360
+ typeof item[displayField] === "object" &&
361
+ item[displayField] !== null
362
+ ) {
363
+ if (item[displayField].count !== undefined) {
364
+ formattedItems.push({
365
+ id: item.id,
366
+ label: `${item[displayField].count} items`,
367
+ currentStatus: item[statusField],
368
+ });
369
+ } else {
370
+ const displayValue =
371
+ item[displayField].fullName ||
372
+ item[displayField].name ||
373
+ item[displayField].displayName ||
374
+ item[displayField].firstName ||
375
+ `ID: ${item[displayField].id}`;
376
+
377
+ formattedItems.push({
378
+ id: item.id,
379
+ label: displayValue,
380
+ originalId: item.documentId || item.id,
381
+ relatedDocumentId:
382
+ item[displayField].documentId || item[displayField].id,
383
+ relationModel: relationModel,
384
+ currentStatus: item[statusField],
385
+ });
386
+ }
201
387
  }
202
- });
388
+ } else {
389
+ formattedItems.push({
390
+ id: item.id,
391
+ label: `ID: ${item.id}`,
392
+ originalId: item.documentId || item.id,
393
+ relatedDocumentId: item.documentId || item.id,
394
+ relationModel: targetModel,
395
+ currentStatus: item[statusField],
396
+ });
397
+ }
398
+ });
203
399
 
204
- setItems(formattedItems);
400
+ setItems(formattedItems);
205
401
 
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);
402
+ // Auto-save to database
403
+ const documentIds = formattedItems.map(
404
+ (item) => item.relatedDocumentId || item.id
405
+ );
406
+ const newValue = JSON.stringify(documentIds);
211
407
 
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
- }
408
+ if (documentId && value !== newValue) {
409
+ await saveFieldValue(newValue);
410
+ }
411
+ } else {
412
+ setItems([]);
413
+ if (documentId && value) {
414
+ await saveFieldValue(null);
222
415
  }
223
- } catch (err) {
224
- console.error("Error fetching filtered relations:", err);
225
- } finally {
226
- setLoading(false);
227
416
  }
228
- };
417
+ } catch (err) {
418
+ console.error("Error fetching filtered relations:", err);
419
+ } finally {
420
+ setLoading(false);
421
+ }
422
+ };
229
423
 
424
+ React.useEffect(() => {
230
425
  fetchFilteredData();
231
426
  }, [
232
427
  targetModel,
@@ -239,6 +434,21 @@ const FilteredRelationInput = (props) => {
239
434
  value,
240
435
  ]);
241
436
 
437
+ // Listen for updates from other filtered relation fields
438
+ React.useEffect(() => {
439
+ const handleUpdate = (event) => {
440
+ // Refresh if same document
441
+ if (event.detail?.documentId === documentId) {
442
+ fetchFilteredData();
443
+ }
444
+ };
445
+
446
+ window.addEventListener("filtered-relation-updated", handleUpdate);
447
+ return () => {
448
+ window.removeEventListener("filtered-relation-updated", handleUpdate);
449
+ };
450
+ }, [documentId]);
451
+
242
452
  const fieldLabel = props.label || name;
243
453
  const itemCount = items.length;
244
454
 
@@ -258,8 +468,8 @@ const FilteredRelationInput = (props) => {
258
468
  ) : items.length > 0 ? (
259
469
  <Flex direction="column" alignItems="stretch" gap={1}>
260
470
  {items.map((item) => {
261
- // Use relation model (e.g., api::investor.investor) with relatedDocumentId
262
471
  const editUrl = `/content-manager/collection-types/${item.relationModel}/${item.relatedDocumentId}`;
472
+ const isUpdating = updatingStatus[item.originalId];
263
473
 
264
474
  return (
265
475
  <Box
@@ -275,15 +485,38 @@ const FilteredRelationInput = (props) => {
275
485
  paddingRight={4}
276
486
  style={{
277
487
  cursor: "pointer",
278
- height: "50px",
488
+ minHeight: "50px",
279
489
  display: "flex",
280
490
  alignItems: "center",
491
+ justifyContent: "space-between",
281
492
  }}
282
493
  onClick={() => navigate(editUrl)}
283
494
  >
284
495
  <Typography variant="omega" textColor="primary600">
285
496
  {item.label}
286
497
  </Typography>
498
+
499
+ {statusField && statusOptions.length > 0 && (
500
+ <Box
501
+ onClick={(e) => e.stopPropagation()}
502
+ style={{ minWidth: "150px" }}
503
+ >
504
+ <SingleSelect
505
+ size="S"
506
+ value={item.currentStatus}
507
+ onChange={(value) =>
508
+ handleStatusChange(item.originalId, value, null)
509
+ }
510
+ disabled={isUpdating}
511
+ >
512
+ {statusOptions.map((status) => (
513
+ <SingleSelectOption key={status} value={status}>
514
+ {status}
515
+ </SingleSelectOption>
516
+ ))}
517
+ </SingleSelect>
518
+ </Box>
519
+ )}
287
520
  </Box>
288
521
  );
289
522
  })}
@@ -301,7 +534,6 @@ const FilteredRelationInput = (props) => {
301
534
  {Object.keys(filterParams).length > 0
302
535
  ? `Filters: ${Object.entries(filterParams)
303
536
  .map(([k, v]) => {
304
- // Show placeholder name if it's a dynamic value
305
537
  const displayValue =
306
538
  v === documentId ? "{{current documentId}}" : v;
307
539
  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.1",
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"
@@ -0,0 +1,52 @@
1
+ #!/bin/bash
2
+
3
+ # Script to push plugin to GitHub
4
+ # Usage: ./push-to-github.sh
5
+
6
+ set -e
7
+
8
+ echo "🚀 Pushing strapi-plugin-filtered-relation to GitHub..."
9
+
10
+ # Check if we're in the right directory
11
+ if [ ! -f "package.json" ]; then
12
+ echo "❌ Error: package.json not found. Are you in the plugin directory?"
13
+ exit 1
14
+ fi
15
+
16
+ # Check if git is initialized
17
+ if [ ! -d ".git" ]; then
18
+ echo "📦 Initializing git repository..."
19
+ git init
20
+ git remote add origin https://github.com/finnwasabi/strapi-plugin-filtered-relation.git
21
+ fi
22
+
23
+ # Add all files
24
+ echo "📝 Adding files..."
25
+ git add .
26
+
27
+ # Commit
28
+ echo "💾 Committing changes..."
29
+ git commit -m "Release v1.1.0 - Add status change dropdown feature
30
+
31
+ - Add status change dropdown at end of each item
32
+ - Dynamically fetch enum options from target model schema
33
+ - Move items between records using connect/disconnect API
34
+ - Dispatch custom events to refresh all fields without page reload
35
+ - Remove all console.log statements
36
+ - Update README with detailed options table and descriptions
37
+ - Update CHANGELOG with technical details
38
+ - Fully dynamic and reusable for any use case"
39
+
40
+ # Tag the release
41
+ echo "🏷️ Creating tag v1.1.0..."
42
+ git tag -a v1.1.0 -m "Version 1.1.0"
43
+
44
+ # Push to GitHub
45
+ echo "⬆️ Pushing to GitHub..."
46
+ git push origin main
47
+ git push origin v1.1.0
48
+
49
+ echo "✅ Successfully pushed to GitHub!"
50
+ echo "📦 Package: @tunghtml/strapi-plugin-filtered-relation@1.1.0"
51
+ echo "🔗 GitHub: https://github.com/finnwasabi/strapi-plugin-filtered-relation"
52
+ echo "🔗 NPM: https://www.npmjs.com/package/@tunghtml/strapi-plugin-filtered-relation"
@@ -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 = {};