@tunghtml/strapi-plugin-filtered-relation 1.1.6 → 1.1.7

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.1.7] - 2026-10-19
4
+
5
+ ### Fixed
6
+
7
+ - Removed the auto-save that could never run in Strapi 5 (it needed `contentType`/`model` props the Content Manager does not pass) and sent a wrongly shaped payload
8
+ - Hooks no longer run after an early return
9
+
10
+ ### Changed
11
+
12
+ - The lifecycle hook collects filtered-relation fields once instead of scanning every content type on each database write
13
+ - Split the hook into small functions, removed unused variables and the nested ternary in the field render
14
+
3
15
  ## [1.1.3] - 2024-12-02
4
16
 
5
17
  ### Changed
@@ -22,10 +22,6 @@ const FilteredRelationInput = (props) => {
22
22
  const [statusOptions, setStatusOptions] = React.useState([]);
23
23
  const [updatingStatus, setUpdatingStatus] = React.useState({});
24
24
 
25
- if (!props) {
26
- return null;
27
- }
28
-
29
25
  const { attribute, name, error, value } = props;
30
26
 
31
27
  // Extract documentId from URL path
@@ -85,25 +81,6 @@ const FilteredRelationInput = (props) => {
85
81
  filterParams[filterField2] = value2;
86
82
  }
87
83
 
88
- const contentType = props.contentType || props.model;
89
-
90
- const saveFieldValue = async (newValue) => {
91
- if (!documentId || !contentType) return;
92
-
93
- try {
94
- await put(
95
- `/content-manager/collection-types/${contentType}/${documentId}`,
96
- {
97
- data: {
98
- [name]: newValue,
99
- },
100
- }
101
- );
102
- } catch (err) {
103
- console.error("Error auto-saving filtered relation:", err);
104
- }
105
- };
106
-
107
84
  // Fetch status options from target model schema
108
85
  React.useEffect(() => {
109
86
  const fetchStatusOptions = async () => {
@@ -121,8 +98,8 @@ const FilteredRelationInput = (props) => {
121
98
  );
122
99
  const builderSchema = builderResponse?.data?.data?.schema;
123
100
  enumValues = builderSchema?.attributes?.[statusField]?.enum;
124
- } catch (e) {
125
- // Endpoint might not be accessible, try next
101
+ } catch {
102
+ // Endpoint might not be accessible: no status options then
126
103
  }
127
104
 
128
105
  if (enumValues && Array.isArray(enumValues)) {
@@ -254,7 +231,7 @@ const FilteredRelationInput = (props) => {
254
231
  };
255
232
 
256
233
  // Use disconnect with full payload
257
- const removeResponse = await put(
234
+ await put(
258
235
  `/content-manager/collection-types/${targetModel}/${itemId}`,
259
236
  updatePayload
260
237
  );
@@ -276,7 +253,7 @@ const FilteredRelationInput = (props) => {
276
253
  };
277
254
 
278
255
  // Use connect with full payload
279
- const addResponse = await put(
256
+ await put(
280
257
  `/content-manager/collection-types/${targetModel}/${targetRecordId}`,
281
258
  addPayload
282
259
  );
@@ -329,11 +306,6 @@ const FilteredRelationInput = (props) => {
329
306
  })
330
307
  .join("&");
331
308
 
332
- const populateFields = [displayField];
333
- if (statusField) {
334
- populateFields.push(statusField);
335
- }
336
-
337
309
  const apiUrl = `/content-manager/collection-types/${targetModel}?${filterQuery}&populate[${displayField}][populate]=*`;
338
310
  const response = await get(apiUrl);
339
311
  const items =
@@ -404,21 +376,8 @@ const FilteredRelationInput = (props) => {
404
376
  });
405
377
 
406
378
  setItems(formattedItems);
407
-
408
- // Auto-save to database
409
- const documentIds = formattedItems.map(
410
- (item) => item.relatedDocumentId || item.id
411
- );
412
- const newValue = JSON.stringify(documentIds);
413
-
414
- if (documentId && value !== newValue) {
415
- await saveFieldValue(newValue);
416
- }
417
379
  } else {
418
380
  setItems([]);
419
- if (documentId && value) {
420
- await saveFieldValue(null);
421
- }
422
381
  }
423
382
  } catch (err) {
424
383
  console.error("Error fetching filtered relations:", err);
@@ -458,83 +417,92 @@ const FilteredRelationInput = (props) => {
458
417
  const fieldLabel = props.label || name;
459
418
  const itemCount = items.length;
460
419
 
420
+ let content;
421
+ if (loading) {
422
+ content = (
423
+ <Typography variant="omega" textColor="neutral600">
424
+ {formatMessage({
425
+ id: "filtered-relation.loading",
426
+ defaultMessage: "Loading...",
427
+ })}
428
+ </Typography>
429
+ );
430
+ } else if (items.length > 0) {
431
+ content = (
432
+ <Flex direction="column" alignItems="stretch" gap={1}>
433
+ {items.map((item) => {
434
+ const editUrl = `/content-manager/collection-types/${item.relationModel}/${item.relatedDocumentId}`;
435
+ const isUpdating = updatingStatus[item.originalId];
436
+
437
+ return (
438
+ <Box
439
+ key={item.id}
440
+ background="neutral0"
441
+ borderColor="neutral200"
442
+ borderStyle="solid"
443
+ borderWidth="1px"
444
+ hasRadius
445
+ paddingTop={2}
446
+ paddingBottom={2}
447
+ paddingLeft={4}
448
+ paddingRight={4}
449
+ style={{
450
+ cursor: "pointer",
451
+ minHeight: "50px",
452
+ display: "flex",
453
+ alignItems: "center",
454
+ justifyContent: "space-between",
455
+ }}
456
+ onClick={() => navigate(editUrl)}
457
+ >
458
+ <Typography variant="omega" textColor="primary600">
459
+ {item.label}
460
+ </Typography>
461
+
462
+ {statusField && statusOptions.length > 0 && (
463
+ <Box
464
+ onClick={(e) => e.stopPropagation()}
465
+ style={{ minWidth: "150px" }}
466
+ >
467
+ <SingleSelect
468
+ size="S"
469
+ value={item.currentStatus}
470
+ onChange={(value) =>
471
+ handleStatusChange(item.originalId, value, null)
472
+ }
473
+ disabled={isUpdating}
474
+ >
475
+ {statusOptions.map((status) => (
476
+ <SingleSelectOption key={status} value={status}>
477
+ {status}
478
+ </SingleSelectOption>
479
+ ))}
480
+ </SingleSelect>
481
+ </Box>
482
+ )}
483
+ </Box>
484
+ );
485
+ })}
486
+ </Flex>
487
+ );
488
+ } else {
489
+ content = (
490
+ <Typography variant="omega" textColor="neutral600">
491
+ {formatMessage({
492
+ id: "filtered-relation.no-results",
493
+ defaultMessage: "No matching records found",
494
+ })}
495
+ </Typography>
496
+ );
497
+ }
498
+
461
499
  return (
462
500
  <Field.Root name={name} error={error}>
463
501
  <Flex direction="column" alignItems="stretch" gap={1}>
464
502
  <Field.Label>
465
503
  {fieldLabel} {itemCount > 0 && `(${itemCount})`}
466
504
  </Field.Label>
467
- {loading ? (
468
- <Typography variant="omega" textColor="neutral600">
469
- {formatMessage({
470
- id: "filtered-relation.loading",
471
- defaultMessage: "Loading...",
472
- })}
473
- </Typography>
474
- ) : items.length > 0 ? (
475
- <Flex direction="column" alignItems="stretch" gap={1}>
476
- {items.map((item) => {
477
- const editUrl = `/content-manager/collection-types/${item.relationModel}/${item.relatedDocumentId}`;
478
- const isUpdating = updatingStatus[item.originalId];
479
-
480
- return (
481
- <Box
482
- key={item.id}
483
- background="neutral0"
484
- borderColor="neutral200"
485
- borderStyle="solid"
486
- borderWidth="1px"
487
- hasRadius
488
- paddingTop={2}
489
- paddingBottom={2}
490
- paddingLeft={4}
491
- paddingRight={4}
492
- style={{
493
- cursor: "pointer",
494
- minHeight: "50px",
495
- display: "flex",
496
- alignItems: "center",
497
- justifyContent: "space-between",
498
- }}
499
- onClick={() => navigate(editUrl)}
500
- >
501
- <Typography variant="omega" textColor="primary600">
502
- {item.label}
503
- </Typography>
504
-
505
- {statusField && statusOptions.length > 0 && (
506
- <Box
507
- onClick={(e) => e.stopPropagation()}
508
- style={{ minWidth: "150px" }}
509
- >
510
- <SingleSelect
511
- size="S"
512
- value={item.currentStatus}
513
- onChange={(value) =>
514
- handleStatusChange(item.originalId, value, null)
515
- }
516
- disabled={isUpdating}
517
- >
518
- {statusOptions.map((status) => (
519
- <SingleSelectOption key={status} value={status}>
520
- {status}
521
- </SingleSelectOption>
522
- ))}
523
- </SingleSelect>
524
- </Box>
525
- )}
526
- </Box>
527
- );
528
- })}
529
- </Flex>
530
- ) : (
531
- <Typography variant="omega" textColor="neutral600">
532
- {formatMessage({
533
- id: "filtered-relation.no-results",
534
- defaultMessage: "No matching records found",
535
- })}
536
- </Typography>
537
- )}
505
+ {content}
538
506
  <Field.Hint>
539
507
  <Typography variant="pi" textColor="neutral600">
540
508
  {Object.keys(filterParams).length > 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tunghtml/strapi-plugin-filtered-relation",
3
- "version": "1.1.6",
3
+ "version": "1.1.7",
4
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",
@@ -23,93 +23,93 @@ module.exports = ({ strapi }) => {
23
23
  });
24
24
  };
25
25
 
26
+ const FILTERED_RELATION_UID = "plugin::filtered-relation.filtered-relation";
27
+
28
+ // Parse collection name to API ID
29
+ const parseCollectionName = (name) => {
30
+ if (!name) return null;
31
+ if (name.startsWith("api::")) return name;
32
+ const slug = name
33
+ .toLowerCase()
34
+ .replace(/\s+/g, "-")
35
+ .replace(/[^a-z0-9-]/g, "");
36
+ return `api::${slug}.${slug}`;
37
+ };
38
+
39
+ // Static filters only: values bound to the current document cannot be resolved here
40
+ const buildStaticFilters = ({
41
+ filterField1,
42
+ filterValue1,
43
+ filterField2,
44
+ filterValue2,
45
+ }) => {
46
+ const filterParams = {};
47
+ if (filterField1 && filterValue1 && filterValue1 !== "{{documentId}}") {
48
+ filterParams[filterField1] = filterValue1;
49
+ }
50
+ if (filterField2 && filterValue2 && filterValue2 !== "{{documentId}}") {
51
+ filterParams[filterField2] = filterValue2;
52
+ }
53
+ return filterParams;
54
+ };
55
+
56
+ // Schemas are fixed once Strapi is running, so the filtered-relation fields are collected once
57
+ let filteredRelationFields = null;
58
+
59
+ function getFilteredRelationFields(strapi) {
60
+ if (filteredRelationFields) return filteredRelationFields;
61
+
62
+ filteredRelationFields = [];
63
+ for (const uid of Object.keys(strapi.contentTypes)) {
64
+ if (!uid.startsWith("api::")) continue;
65
+
66
+ const schema = strapi.getModel(uid);
67
+ for (const [fieldName, fieldConfig] of Object.entries(
68
+ schema?.attributes || {},
69
+ )) {
70
+ if (fieldConfig.customField !== FILTERED_RELATION_UID) continue;
71
+
72
+ const options = fieldConfig.options || {};
73
+ filteredRelationFields.push({
74
+ uid,
75
+ schema,
76
+ fieldName,
77
+ targetModel: parseCollectionName(options.targetModel),
78
+ filterParams: buildStaticFilters(options),
79
+ displayField: options.displayField,
80
+ });
81
+ }
82
+ }
83
+ return filteredRelationFields;
84
+ }
85
+
26
86
  async function updateRelatedFilteredFields(event, strapi) {
27
87
  const { model } = event;
28
88
  if (!model) return;
29
89
 
30
- const currentModelUid = model.uid;
31
-
32
90
  try {
33
- // Get all content types to find which ones have filtered-relation fields
34
- const contentTypes = strapi.contentTypes;
35
-
36
- for (const [uid, contentType] of Object.entries(contentTypes)) {
37
- // Skip non-API content types
38
- if (!uid.startsWith("api::")) continue;
39
-
40
- const schema = strapi.getModel(uid);
41
- if (!schema?.attributes) continue;
42
-
43
- // Find filtered-relation fields that target the current model
44
- for (const [fieldName, fieldConfig] of Object.entries(
45
- schema.attributes,
46
- )) {
47
- if (
48
- fieldConfig.customField ===
49
- "plugin::filtered-relation.filtered-relation"
50
- ) {
51
- const {
52
- targetModel: targetModelInput,
53
- filterField1,
54
- filterValue1,
55
- filterField2,
56
- filterValue2,
57
- displayField,
58
- } = fieldConfig.options || {};
59
-
60
- // Parse collection name to API ID
61
- const parseCollectionName = (name) => {
62
- if (!name) return null;
63
- if (name.startsWith("api::")) return name;
64
- const slug = name
65
- .toLowerCase()
66
- .replace(/\s+/g, "-")
67
- .replace(/[^a-z0-9-]/g, "");
68
- return `api::${slug}.${slug}`;
69
- };
70
-
71
- const targetModel = parseCollectionName(targetModelInput);
72
-
73
- // Build filter params
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
- }
91
+ const fields = getFilteredRelationFields(strapi).filter(
92
+ (field) => field.targetModel === model.uid,
93
+ );
94
+
95
+ for (const field of fields) {
96
+ // Find all related entities that need updating
97
+ const relatedEntities = await findRelatedEntities(
98
+ event,
99
+ field.schema,
100
+ field.uid,
101
+ );
102
+
103
+ for (const entityId of relatedEntities) {
104
+ await updateFilteredField(
105
+ strapi,
106
+ field.uid,
107
+ entityId,
108
+ field.fieldName,
109
+ field.targetModel,
110
+ field.filterParams,
111
+ field.displayField,
112
+ );
113
113
  }
114
114
  }
115
115
  } catch (error) {