strapi-relation-names 0.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/LICENSE +21 -0
- package/README.md +171 -0
- package/dist/admin/SettingsPage-BQo5mpAY.mjs +199 -0
- package/dist/admin/SettingsPage-C1nFFJjH.js +199 -0
- package/dist/admin/de-BBrO1tf0.js +16 -0
- package/dist/admin/de-a3KtzZ7N.mjs +16 -0
- package/dist/admin/en-8w3y1FSw.mjs +16 -0
- package/dist/admin/en-DlY6ZGdo.js +16 -0
- package/dist/admin/index-CJO9wtUk.mjs +125 -0
- package/dist/admin/index-SsQXjQq0.js +124 -0
- package/dist/admin/index.js +4 -0
- package/dist/admin/index.mjs +4 -0
- package/dist/admin/src/index.d.ts +3 -0
- package/dist/admin/src/pages/SettingsPage.d.ts +2 -0
- package/dist/admin/src/pluginId.d.ts +1 -0
- package/dist/admin/src/types.d.ts +28 -0
- package/dist/admin/src/utils/getTranslation.d.ts +2 -0
- package/dist/admin/src/utils/layout.d.ts +3 -0
- package/dist/admin/src/utils/template.d.ts +5 -0
- package/dist/server/index.js +564 -0
- package/dist/server/index.mjs +564 -0
- package/dist/server/src/bootstrap.d.ts +5 -0
- package/dist/server/src/config/index.d.ts +12 -0
- package/dist/server/src/constants.d.ts +1 -0
- package/dist/server/src/content-types/index.d.ts +2 -0
- package/dist/server/src/controllers/index.d.ts +9 -0
- package/dist/server/src/controllers/settings.d.ts +8 -0
- package/dist/server/src/destroy.d.ts +5 -0
- package/dist/server/src/index.d.ts +66 -0
- package/dist/server/src/middlewares/index.d.ts +2 -0
- package/dist/server/src/policies/index.d.ts +2 -0
- package/dist/server/src/register.d.ts +5 -0
- package/dist/server/src/routes/admin/index.d.ts +12 -0
- package/dist/server/src/routes/content-api/index.d.ts +5 -0
- package/dist/server/src/routes/index.d.ts +18 -0
- package/dist/server/src/services/index.d.ts +19 -0
- package/dist/server/src/services/relation-labels.d.ts +14 -0
- package/dist/server/src/services/settings.d.ts +11 -0
- package/dist/server/src/services/utils/collections.d.ts +1 -0
- package/dist/server/src/services/utils/relation-labels.d.ts +27 -0
- package/dist/server/src/services/utils/relation-values.d.ts +6 -0
- package/dist/server/src/types.d.ts +5 -0
- package/package.json +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christoph Tupi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# 🔗 strapi-relation-names
|
|
2
|
+
|
|
3
|
+
**Clear and customizable relation labels for Strapi v5**
|
|
4
|
+
|
|
5
|
+
Display Strapi relations using readable labels built from your content fields instead of relying on the default relation name.
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/strapi-relation-names)
|
|
8
|
+

|
|
9
|
+

|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## ✨ Features
|
|
15
|
+
|
|
16
|
+
- 🏷️ **Custom relation labels**
|
|
17
|
+
- 🧩 Combine multiple fields in a single label
|
|
18
|
+
- ✏️ Add static text around field values
|
|
19
|
+
- 🔁 Supports repeated placeholders
|
|
20
|
+
- 📦 Supports scalar fields such as strings, numbers, booleans, and dates
|
|
21
|
+
- 🛡️ Falls back to Strapi's default relation label when necessary
|
|
22
|
+
- 🔎 Keeps Strapi's existing relation search and sorting behavior
|
|
23
|
+
- 🖥️ Admin-only — no changes to your Content API or stored data
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 🚀 Installation
|
|
28
|
+
|
|
29
|
+
Install via npm:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install strapi-relation-names
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
or yarn:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
yarn add strapi-relation-names
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
or pnpm:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pnpm add strapi-relation-names
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Enable the plugin:
|
|
48
|
+
|
|
49
|
+
```javascript
|
|
50
|
+
// config/plugins.{js,ts}
|
|
51
|
+
'strapi-relation-names': {
|
|
52
|
+
enabled: true,
|
|
53
|
+
},
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Quickstart
|
|
59
|
+
|
|
60
|
+
After installing and enabling the plugin, open the Strapi Admin settings panel:
|
|
61
|
+
|
|
62
|
+
**Settings → Global → Relation Names**
|
|
63
|
+
|
|
64
|
+
Each relation field is displayed together with the scalar fields available on its related content type or component.
|
|
65
|
+
|
|
66
|
+
Create a label using placeholders:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
{firstName} {lastName}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
For example, given:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
firstName = John
|
|
76
|
+
lastName = Doe
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
the relation will be displayed as:
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
John Doe
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
You can also mix placeholders with static text:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
{firstName} {lastName} ({customerNumber})
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
After saving the settings, the Strapi Admin reloads automatically so open Content Manager views use the updated labels.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## ⚙️ Templates
|
|
96
|
+
|
|
97
|
+
Templates use direct field placeholders:
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
{fieldName}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Multiple placeholders can be combined:
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
{firstName} {lastName}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Placeholders can also be repeated:
|
|
110
|
+
|
|
111
|
+
```text
|
|
112
|
+
{name} - {name}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Static text can be included anywhere:
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
Customer: {firstName} {lastName}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## 🗂️ How It Works
|
|
124
|
+
|
|
125
|
+
The plugin changes how relations are displayed inside the Strapi Admin panel.
|
|
126
|
+
|
|
127
|
+
The first placeholder in a template is used internally by Strapi's Admin relation renderer.
|
|
128
|
+
|
|
129
|
+
Strapi's existing relation search and sorting continue to use the configured default main field.
|
|
130
|
+
|
|
131
|
+
Missing field values are replaced with an empty string.
|
|
132
|
+
|
|
133
|
+
For example:
|
|
134
|
+
|
|
135
|
+
```text
|
|
136
|
+
{firstName} {lastName}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
with a missing `lastName` becomes:
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
John
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
If the complete rendered label is blank, Strapi automatically falls back to its default relation label.
|
|
146
|
+
|
|
147
|
+
The default label is also used when:
|
|
148
|
+
|
|
149
|
+
- No template is configured
|
|
150
|
+
- The configured template is invalid
|
|
151
|
+
- The rendered template contains no usable value
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## 🔮 Planned Features
|
|
156
|
+
|
|
157
|
+
- [ ] Live label previews while editing templates
|
|
158
|
+
- [ ] Define default relation templates in plugin configuration
|
|
159
|
+
- [ ] Support nested field paths in templates
|
|
160
|
+
- [ ] Import and export relation-label settings
|
|
161
|
+
- [ ] Conditional labels and configurable fallback rules
|
|
162
|
+
|
|
163
|
+
If you have any feature requests or suggestions, please open a dedicated issue.
|
|
164
|
+
|
|
165
|
+
## 🛑 Problems
|
|
166
|
+
|
|
167
|
+
If you encounter any issues, please feel free to open an issue on the [GitHub repo](https://github.com/TupiC/strapi-relation-names/issues/new).
|
|
168
|
+
|
|
169
|
+
## 🛠️ Contributing
|
|
170
|
+
|
|
171
|
+
Contributions are welcome! If you have suggestions or improvements, please open an issue or submit a pull request to the `dev` branch.
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Main, Box, Typography, Field, TextInput, Flex, Button } from "@strapi/design-system";
|
|
3
|
+
import { useState, useCallback, useEffect, useMemo } from "react";
|
|
4
|
+
import { useIntl } from "react-intl";
|
|
5
|
+
import { useFetchClient } from "@strapi/strapi/admin";
|
|
6
|
+
import { g as getTranslation } from "./index-CJO9wtUk.mjs";
|
|
7
|
+
const FIELD_TYPES = /* @__PURE__ */ new Set([
|
|
8
|
+
"string",
|
|
9
|
+
"text",
|
|
10
|
+
"email",
|
|
11
|
+
"uid",
|
|
12
|
+
"enumeration",
|
|
13
|
+
"integer",
|
|
14
|
+
"biginteger",
|
|
15
|
+
"decimal",
|
|
16
|
+
"float",
|
|
17
|
+
"date",
|
|
18
|
+
"datetime",
|
|
19
|
+
"time",
|
|
20
|
+
"boolean"
|
|
21
|
+
]);
|
|
22
|
+
const getPlaceholders = (template) => {
|
|
23
|
+
if (!template.trim()) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const placeholders = [];
|
|
27
|
+
const matches = template.matchAll(/\{([^{}]*)\}/g);
|
|
28
|
+
let cursor = 0;
|
|
29
|
+
for (const match of matches) {
|
|
30
|
+
if (match.index !== cursor && template.slice(cursor, match.index).includes("{")) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const name = match[1];
|
|
34
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
placeholders.push(name);
|
|
38
|
+
cursor = match.index + match[0].length;
|
|
39
|
+
}
|
|
40
|
+
if (template.slice(cursor).includes("{") || template.slice(cursor).includes("}")) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return placeholders.length > 0 ? placeholders : null;
|
|
44
|
+
};
|
|
45
|
+
const validateTemplate = (template, attributes) => {
|
|
46
|
+
const placeholders = getPlaceholders(template);
|
|
47
|
+
if (!placeholders) {
|
|
48
|
+
return "Use at least one valid {fieldName} placeholder.";
|
|
49
|
+
}
|
|
50
|
+
const invalidField = placeholders.find(
|
|
51
|
+
(field) => !FIELD_TYPES.has(attributes?.[field]?.type ?? "")
|
|
52
|
+
);
|
|
53
|
+
return invalidField ? `“${invalidField}” is not an available scalar field.` : null;
|
|
54
|
+
};
|
|
55
|
+
const PLUGIN_SETTINGS_PATH = "/strapi-relation-names/settings";
|
|
56
|
+
const getRelationTarget = (attribute) => attribute.targetModel ?? attribute.target;
|
|
57
|
+
const SettingsPage = () => {
|
|
58
|
+
const { formatMessage } = useIntl();
|
|
59
|
+
const { get, put } = useFetchClient();
|
|
60
|
+
const [schemas, setSchemas] = useState([]);
|
|
61
|
+
const [settings, setSettings] = useState({
|
|
62
|
+
collections: [],
|
|
63
|
+
relations: {}
|
|
64
|
+
});
|
|
65
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
66
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
67
|
+
const [error, setError] = useState(null);
|
|
68
|
+
const load = useCallback(async () => {
|
|
69
|
+
setIsLoading(true);
|
|
70
|
+
setError(null);
|
|
71
|
+
try {
|
|
72
|
+
const [initResponse, settingsResponse] = await Promise.all([
|
|
73
|
+
get("/content-manager/init"),
|
|
74
|
+
get(PLUGIN_SETTINGS_PATH)
|
|
75
|
+
]);
|
|
76
|
+
const nextSettings = settingsResponse.data.data;
|
|
77
|
+
const contentTypes = initResponse.data.data.contentTypes.filter(
|
|
78
|
+
(schema) => nextSettings.collections.length === 0 || nextSettings.collections.includes(schema.uid)
|
|
79
|
+
);
|
|
80
|
+
setSchemas([...contentTypes, ...initResponse.data.data.components]);
|
|
81
|
+
setSettings(nextSettings);
|
|
82
|
+
} catch {
|
|
83
|
+
setError(formatMessage({ id: getTranslation("settings.loadError") }));
|
|
84
|
+
} finally {
|
|
85
|
+
setIsLoading(false);
|
|
86
|
+
}
|
|
87
|
+
}, [formatMessage, get]);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
void load();
|
|
90
|
+
}, [load]);
|
|
91
|
+
const schemaMap = useMemo(
|
|
92
|
+
() => new Map(schemas.map((schema) => [schema.uid, schema])),
|
|
93
|
+
[schemas]
|
|
94
|
+
);
|
|
95
|
+
const rows = useMemo(
|
|
96
|
+
() => schemas.filter((schema) => schema.isDisplayed !== false).flatMap(
|
|
97
|
+
(source) => Object.entries(source.attributes ?? {}).filter(
|
|
98
|
+
([, attribute]) => attribute.type === "relation" && !attribute.relationType?.toLowerCase().includes("morph")
|
|
99
|
+
).map(([fieldName, attribute]) => ({
|
|
100
|
+
source,
|
|
101
|
+
fieldName,
|
|
102
|
+
target: getRelationTarget(attribute) ? schemaMap.get(getRelationTarget(attribute)) : void 0
|
|
103
|
+
}))
|
|
104
|
+
).filter((row) => row.target),
|
|
105
|
+
[schemaMap, schemas]
|
|
106
|
+
);
|
|
107
|
+
const updateTemplate = (sourceUid, fieldName, template) => {
|
|
108
|
+
setSettings((current) => {
|
|
109
|
+
const sourceRelations = { ...current.relations[sourceUid] ?? {} };
|
|
110
|
+
if (template.length === 0) {
|
|
111
|
+
delete sourceRelations[fieldName];
|
|
112
|
+
} else {
|
|
113
|
+
sourceRelations[fieldName] = template;
|
|
114
|
+
}
|
|
115
|
+
const relations = { ...current.relations };
|
|
116
|
+
if (Object.keys(sourceRelations).length === 0) {
|
|
117
|
+
delete relations[sourceUid];
|
|
118
|
+
} else {
|
|
119
|
+
relations[sourceUid] = sourceRelations;
|
|
120
|
+
}
|
|
121
|
+
return { ...current, relations };
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
const save = async () => {
|
|
125
|
+
setIsSaving(true);
|
|
126
|
+
setError(null);
|
|
127
|
+
try {
|
|
128
|
+
await put(PLUGIN_SETTINGS_PATH, settings);
|
|
129
|
+
window.location.reload();
|
|
130
|
+
} catch {
|
|
131
|
+
setError(formatMessage({ id: getTranslation("settings.saveError") }));
|
|
132
|
+
setIsSaving(false);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
return /* @__PURE__ */ jsx(Main, { "aria-labelledby": "relation-names-title", children: /* @__PURE__ */ jsxs(Box, { padding: 8, children: [
|
|
136
|
+
/* @__PURE__ */ jsx(Typography, { variant: "alpha", tag: "h1", id: "relation-names-title", children: formatMessage({ id: getTranslation("settings.title") }) }),
|
|
137
|
+
/* @__PURE__ */ jsx(Box, { paddingTop: 2, paddingBottom: 6, children: /* @__PURE__ */ jsx(Typography, { textColor: "neutral600", children: formatMessage({ id: getTranslation("settings.description") }) }) }),
|
|
138
|
+
isLoading ? /* @__PURE__ */ jsx(Typography, { children: formatMessage({ id: getTranslation("settings.loading") }) }) : null,
|
|
139
|
+
error ? /* @__PURE__ */ jsx(Typography, { textColor: "danger600", tag: "p", children: error }) : null,
|
|
140
|
+
!isLoading && rows.length === 0 ? /* @__PURE__ */ jsx(Typography, { children: formatMessage({ id: getTranslation("settings.noRelations") }) }) : null,
|
|
141
|
+
!isLoading ? rows.map(({ source, fieldName, target }) => {
|
|
142
|
+
const targetAttributes = target?.attributes ?? {};
|
|
143
|
+
const template = settings.relations[source.uid]?.[fieldName] ?? "";
|
|
144
|
+
const validationError = template ? validateTemplate(template, targetAttributes) : null;
|
|
145
|
+
return /* @__PURE__ */ jsxs(
|
|
146
|
+
Box,
|
|
147
|
+
{
|
|
148
|
+
padding: 4,
|
|
149
|
+
marginBottom: 3,
|
|
150
|
+
background: "neutral0",
|
|
151
|
+
shadow: "tableShadow",
|
|
152
|
+
hasRadius: true,
|
|
153
|
+
children: [
|
|
154
|
+
/* @__PURE__ */ jsxs(Typography, { variant: "beta", tag: "h2", children: [
|
|
155
|
+
source.info?.displayName ?? source.uid,
|
|
156
|
+
" · ",
|
|
157
|
+
fieldName
|
|
158
|
+
] }),
|
|
159
|
+
/* @__PURE__ */ jsx(Box, { paddingTop: 1, paddingBottom: 3, children: /* @__PURE__ */ jsx(Typography, { textColor: "neutral600", children: formatMessage(
|
|
160
|
+
{ id: getTranslation("settings.target") },
|
|
161
|
+
{ target: target?.info?.displayName ?? target?.uid }
|
|
162
|
+
) }) }),
|
|
163
|
+
/* @__PURE__ */ jsxs(
|
|
164
|
+
Field.Root,
|
|
165
|
+
{
|
|
166
|
+
error: validationError ?? void 0,
|
|
167
|
+
name: `${source.uid}.${fieldName}`,
|
|
168
|
+
children: [
|
|
169
|
+
/* @__PURE__ */ jsx(Field.Label, { children: formatMessage({ id: getTranslation("settings.template") }) }),
|
|
170
|
+
/* @__PURE__ */ jsx(
|
|
171
|
+
TextInput,
|
|
172
|
+
{
|
|
173
|
+
value: template,
|
|
174
|
+
placeholder: "{firstName} {lastName}",
|
|
175
|
+
onChange: (event) => updateTemplate(source.uid, fieldName, event.target.value)
|
|
176
|
+
}
|
|
177
|
+
),
|
|
178
|
+
/* @__PURE__ */ jsx(Field.Hint, {}),
|
|
179
|
+
/* @__PURE__ */ jsx(Field.Error, {})
|
|
180
|
+
]
|
|
181
|
+
}
|
|
182
|
+
),
|
|
183
|
+
/* @__PURE__ */ jsx(Box, { paddingTop: 2, children: /* @__PURE__ */ jsxs(Typography, { variant: "pi", textColor: "neutral600", children: [
|
|
184
|
+
formatMessage({ id: getTranslation("settings.availableFields") }),
|
|
185
|
+
":",
|
|
186
|
+
" ",
|
|
187
|
+
Object.keys(targetAttributes).join(", ")
|
|
188
|
+
] }) })
|
|
189
|
+
]
|
|
190
|
+
},
|
|
191
|
+
`${source.uid}.${fieldName}`
|
|
192
|
+
);
|
|
193
|
+
}) : null,
|
|
194
|
+
!isLoading && rows.length > 0 ? /* @__PURE__ */ jsx(Flex, { justifyContent: "flex-end", paddingTop: 4, children: /* @__PURE__ */ jsx(Button, { loading: isSaving, disabled: isSaving, onClick: () => void save(), children: formatMessage({ id: getTranslation("settings.save") }) }) }) : null
|
|
195
|
+
] }) });
|
|
196
|
+
};
|
|
197
|
+
export {
|
|
198
|
+
SettingsPage
|
|
199
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const jsxRuntime = require("react/jsx-runtime");
|
|
4
|
+
const designSystem = require("@strapi/design-system");
|
|
5
|
+
const react = require("react");
|
|
6
|
+
const reactIntl = require("react-intl");
|
|
7
|
+
const admin = require("@strapi/strapi/admin");
|
|
8
|
+
const index = require("./index-SsQXjQq0.js");
|
|
9
|
+
const FIELD_TYPES = /* @__PURE__ */ new Set([
|
|
10
|
+
"string",
|
|
11
|
+
"text",
|
|
12
|
+
"email",
|
|
13
|
+
"uid",
|
|
14
|
+
"enumeration",
|
|
15
|
+
"integer",
|
|
16
|
+
"biginteger",
|
|
17
|
+
"decimal",
|
|
18
|
+
"float",
|
|
19
|
+
"date",
|
|
20
|
+
"datetime",
|
|
21
|
+
"time",
|
|
22
|
+
"boolean"
|
|
23
|
+
]);
|
|
24
|
+
const getPlaceholders = (template) => {
|
|
25
|
+
if (!template.trim()) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const placeholders = [];
|
|
29
|
+
const matches = template.matchAll(/\{([^{}]*)\}/g);
|
|
30
|
+
let cursor = 0;
|
|
31
|
+
for (const match of matches) {
|
|
32
|
+
if (match.index !== cursor && template.slice(cursor, match.index).includes("{")) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const name = match[1];
|
|
36
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
placeholders.push(name);
|
|
40
|
+
cursor = match.index + match[0].length;
|
|
41
|
+
}
|
|
42
|
+
if (template.slice(cursor).includes("{") || template.slice(cursor).includes("}")) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return placeholders.length > 0 ? placeholders : null;
|
|
46
|
+
};
|
|
47
|
+
const validateTemplate = (template, attributes) => {
|
|
48
|
+
const placeholders = getPlaceholders(template);
|
|
49
|
+
if (!placeholders) {
|
|
50
|
+
return "Use at least one valid {fieldName} placeholder.";
|
|
51
|
+
}
|
|
52
|
+
const invalidField = placeholders.find(
|
|
53
|
+
(field) => !FIELD_TYPES.has(attributes?.[field]?.type ?? "")
|
|
54
|
+
);
|
|
55
|
+
return invalidField ? `“${invalidField}” is not an available scalar field.` : null;
|
|
56
|
+
};
|
|
57
|
+
const PLUGIN_SETTINGS_PATH = "/strapi-relation-names/settings";
|
|
58
|
+
const getRelationTarget = (attribute) => attribute.targetModel ?? attribute.target;
|
|
59
|
+
const SettingsPage = () => {
|
|
60
|
+
const { formatMessage } = reactIntl.useIntl();
|
|
61
|
+
const { get, put } = admin.useFetchClient();
|
|
62
|
+
const [schemas, setSchemas] = react.useState([]);
|
|
63
|
+
const [settings, setSettings] = react.useState({
|
|
64
|
+
collections: [],
|
|
65
|
+
relations: {}
|
|
66
|
+
});
|
|
67
|
+
const [isLoading, setIsLoading] = react.useState(true);
|
|
68
|
+
const [isSaving, setIsSaving] = react.useState(false);
|
|
69
|
+
const [error, setError] = react.useState(null);
|
|
70
|
+
const load = react.useCallback(async () => {
|
|
71
|
+
setIsLoading(true);
|
|
72
|
+
setError(null);
|
|
73
|
+
try {
|
|
74
|
+
const [initResponse, settingsResponse] = await Promise.all([
|
|
75
|
+
get("/content-manager/init"),
|
|
76
|
+
get(PLUGIN_SETTINGS_PATH)
|
|
77
|
+
]);
|
|
78
|
+
const nextSettings = settingsResponse.data.data;
|
|
79
|
+
const contentTypes = initResponse.data.data.contentTypes.filter(
|
|
80
|
+
(schema) => nextSettings.collections.length === 0 || nextSettings.collections.includes(schema.uid)
|
|
81
|
+
);
|
|
82
|
+
setSchemas([...contentTypes, ...initResponse.data.data.components]);
|
|
83
|
+
setSettings(nextSettings);
|
|
84
|
+
} catch {
|
|
85
|
+
setError(formatMessage({ id: index.getTranslation("settings.loadError") }));
|
|
86
|
+
} finally {
|
|
87
|
+
setIsLoading(false);
|
|
88
|
+
}
|
|
89
|
+
}, [formatMessage, get]);
|
|
90
|
+
react.useEffect(() => {
|
|
91
|
+
void load();
|
|
92
|
+
}, [load]);
|
|
93
|
+
const schemaMap = react.useMemo(
|
|
94
|
+
() => new Map(schemas.map((schema) => [schema.uid, schema])),
|
|
95
|
+
[schemas]
|
|
96
|
+
);
|
|
97
|
+
const rows = react.useMemo(
|
|
98
|
+
() => schemas.filter((schema) => schema.isDisplayed !== false).flatMap(
|
|
99
|
+
(source) => Object.entries(source.attributes ?? {}).filter(
|
|
100
|
+
([, attribute]) => attribute.type === "relation" && !attribute.relationType?.toLowerCase().includes("morph")
|
|
101
|
+
).map(([fieldName, attribute]) => ({
|
|
102
|
+
source,
|
|
103
|
+
fieldName,
|
|
104
|
+
target: getRelationTarget(attribute) ? schemaMap.get(getRelationTarget(attribute)) : void 0
|
|
105
|
+
}))
|
|
106
|
+
).filter((row) => row.target),
|
|
107
|
+
[schemaMap, schemas]
|
|
108
|
+
);
|
|
109
|
+
const updateTemplate = (sourceUid, fieldName, template) => {
|
|
110
|
+
setSettings((current) => {
|
|
111
|
+
const sourceRelations = { ...current.relations[sourceUid] ?? {} };
|
|
112
|
+
if (template.length === 0) {
|
|
113
|
+
delete sourceRelations[fieldName];
|
|
114
|
+
} else {
|
|
115
|
+
sourceRelations[fieldName] = template;
|
|
116
|
+
}
|
|
117
|
+
const relations = { ...current.relations };
|
|
118
|
+
if (Object.keys(sourceRelations).length === 0) {
|
|
119
|
+
delete relations[sourceUid];
|
|
120
|
+
} else {
|
|
121
|
+
relations[sourceUid] = sourceRelations;
|
|
122
|
+
}
|
|
123
|
+
return { ...current, relations };
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
const save = async () => {
|
|
127
|
+
setIsSaving(true);
|
|
128
|
+
setError(null);
|
|
129
|
+
try {
|
|
130
|
+
await put(PLUGIN_SETTINGS_PATH, settings);
|
|
131
|
+
window.location.reload();
|
|
132
|
+
} catch {
|
|
133
|
+
setError(formatMessage({ id: index.getTranslation("settings.saveError") }));
|
|
134
|
+
setIsSaving(false);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
return /* @__PURE__ */ jsxRuntime.jsx(designSystem.Main, { "aria-labelledby": "relation-names-title", children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { padding: 8, children: [
|
|
138
|
+
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "alpha", tag: "h1", id: "relation-names-title", children: formatMessage({ id: index.getTranslation("settings.title") }) }),
|
|
139
|
+
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { paddingTop: 2, paddingBottom: 6, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { textColor: "neutral600", children: formatMessage({ id: index.getTranslation("settings.description") }) }) }),
|
|
140
|
+
isLoading ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { children: formatMessage({ id: index.getTranslation("settings.loading") }) }) : null,
|
|
141
|
+
error ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { textColor: "danger600", tag: "p", children: error }) : null,
|
|
142
|
+
!isLoading && rows.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { children: formatMessage({ id: index.getTranslation("settings.noRelations") }) }) : null,
|
|
143
|
+
!isLoading ? rows.map(({ source, fieldName, target }) => {
|
|
144
|
+
const targetAttributes = target?.attributes ?? {};
|
|
145
|
+
const template = settings.relations[source.uid]?.[fieldName] ?? "";
|
|
146
|
+
const validationError = template ? validateTemplate(template, targetAttributes) : null;
|
|
147
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
148
|
+
designSystem.Box,
|
|
149
|
+
{
|
|
150
|
+
padding: 4,
|
|
151
|
+
marginBottom: 3,
|
|
152
|
+
background: "neutral0",
|
|
153
|
+
shadow: "tableShadow",
|
|
154
|
+
hasRadius: true,
|
|
155
|
+
children: [
|
|
156
|
+
/* @__PURE__ */ jsxRuntime.jsxs(designSystem.Typography, { variant: "beta", tag: "h2", children: [
|
|
157
|
+
source.info?.displayName ?? source.uid,
|
|
158
|
+
" · ",
|
|
159
|
+
fieldName
|
|
160
|
+
] }),
|
|
161
|
+
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { paddingTop: 1, paddingBottom: 3, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { textColor: "neutral600", children: formatMessage(
|
|
162
|
+
{ id: index.getTranslation("settings.target") },
|
|
163
|
+
{ target: target?.info?.displayName ?? target?.uid }
|
|
164
|
+
) }) }),
|
|
165
|
+
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
166
|
+
designSystem.Field.Root,
|
|
167
|
+
{
|
|
168
|
+
error: validationError ?? void 0,
|
|
169
|
+
name: `${source.uid}.${fieldName}`,
|
|
170
|
+
children: [
|
|
171
|
+
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Label, { children: formatMessage({ id: index.getTranslation("settings.template") }) }),
|
|
172
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
173
|
+
designSystem.TextInput,
|
|
174
|
+
{
|
|
175
|
+
value: template,
|
|
176
|
+
placeholder: "{firstName} {lastName}",
|
|
177
|
+
onChange: (event) => updateTemplate(source.uid, fieldName, event.target.value)
|
|
178
|
+
}
|
|
179
|
+
),
|
|
180
|
+
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Hint, {}),
|
|
181
|
+
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Error, {})
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
),
|
|
185
|
+
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { paddingTop: 2, children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Typography, { variant: "pi", textColor: "neutral600", children: [
|
|
186
|
+
formatMessage({ id: index.getTranslation("settings.availableFields") }),
|
|
187
|
+
":",
|
|
188
|
+
" ",
|
|
189
|
+
Object.keys(targetAttributes).join(", ")
|
|
190
|
+
] }) })
|
|
191
|
+
]
|
|
192
|
+
},
|
|
193
|
+
`${source.uid}.${fieldName}`
|
|
194
|
+
);
|
|
195
|
+
}) : null,
|
|
196
|
+
!isLoading && rows.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { justifyContent: "flex-end", paddingTop: 4, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Button, { loading: isSaving, disabled: isSaving, onClick: () => void save(), children: formatMessage({ id: index.getTranslation("settings.save") }) }) }) : null
|
|
197
|
+
] }) });
|
|
198
|
+
};
|
|
199
|
+
exports.SettingsPage = SettingsPage;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const de = {
|
|
4
|
+
"plugin.name": "Relationsnamen",
|
|
5
|
+
"settings.title": "Relationsnamen",
|
|
6
|
+
"settings.description": "Legen Sie fest, wie verknüpfte Einträge im Content Manager angezeigt werden. Verwenden Sie ein oder mehrere skalare Felder des verknüpften Inhaltstyps, zum Beispiel Vor- und Nachname.",
|
|
7
|
+
"settings.target": "Verknüpfter Inhalt: {target}",
|
|
8
|
+
"settings.template": "Bezeichnungsvorlage",
|
|
9
|
+
"settings.availableFields": "Verfügbare Felder",
|
|
10
|
+
"settings.save": "Einstellungen speichern",
|
|
11
|
+
"settings.loading": "Relationsfelder werden geladen…",
|
|
12
|
+
"settings.noRelations": "Keine Relationsfelder verfügbar.",
|
|
13
|
+
"settings.loadError": "Die Einstellungen für Relationsnamen konnten nicht geladen werden.",
|
|
14
|
+
"settings.saveError": "Die Einstellungen für Relationsnamen konnten nicht gespeichert werden."
|
|
15
|
+
};
|
|
16
|
+
exports.default = de;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const de = {
|
|
2
|
+
"plugin.name": "Relationsnamen",
|
|
3
|
+
"settings.title": "Relationsnamen",
|
|
4
|
+
"settings.description": "Legen Sie fest, wie verknüpfte Einträge im Content Manager angezeigt werden. Verwenden Sie ein oder mehrere skalare Felder des verknüpften Inhaltstyps, zum Beispiel Vor- und Nachname.",
|
|
5
|
+
"settings.target": "Verknüpfter Inhalt: {target}",
|
|
6
|
+
"settings.template": "Bezeichnungsvorlage",
|
|
7
|
+
"settings.availableFields": "Verfügbare Felder",
|
|
8
|
+
"settings.save": "Einstellungen speichern",
|
|
9
|
+
"settings.loading": "Relationsfelder werden geladen…",
|
|
10
|
+
"settings.noRelations": "Keine Relationsfelder verfügbar.",
|
|
11
|
+
"settings.loadError": "Die Einstellungen für Relationsnamen konnten nicht geladen werden.",
|
|
12
|
+
"settings.saveError": "Die Einstellungen für Relationsnamen konnten nicht gespeichert werden."
|
|
13
|
+
};
|
|
14
|
+
export {
|
|
15
|
+
de as default
|
|
16
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const en = {
|
|
2
|
+
"plugin.name": "Relation Names",
|
|
3
|
+
"settings.title": "Relation Names",
|
|
4
|
+
"settings.description": "Choose how related entries are displayed in the Content Manager. Use one or more scalar fields from the related content type, such as a first and last name.",
|
|
5
|
+
"settings.target": "Related content: {target}",
|
|
6
|
+
"settings.template": "Label template",
|
|
7
|
+
"settings.availableFields": "Available fields",
|
|
8
|
+
"settings.save": "Save settings",
|
|
9
|
+
"settings.loading": "Loading relation fields…",
|
|
10
|
+
"settings.noRelations": "No relation fields are available.",
|
|
11
|
+
"settings.loadError": "Relation Names settings could not be loaded.",
|
|
12
|
+
"settings.saveError": "Relation Names settings could not be saved."
|
|
13
|
+
};
|
|
14
|
+
export {
|
|
15
|
+
en as default
|
|
16
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const en = {
|
|
4
|
+
"plugin.name": "Relation Names",
|
|
5
|
+
"settings.title": "Relation Names",
|
|
6
|
+
"settings.description": "Choose how related entries are displayed in the Content Manager. Use one or more scalar fields from the related content type, such as a first and last name.",
|
|
7
|
+
"settings.target": "Related content: {target}",
|
|
8
|
+
"settings.template": "Label template",
|
|
9
|
+
"settings.availableFields": "Available fields",
|
|
10
|
+
"settings.save": "Save settings",
|
|
11
|
+
"settings.loading": "Loading relation fields…",
|
|
12
|
+
"settings.noRelations": "No relation fields are available.",
|
|
13
|
+
"settings.loadError": "Relation Names settings could not be loaded.",
|
|
14
|
+
"settings.saveError": "Relation Names settings could not be saved."
|
|
15
|
+
};
|
|
16
|
+
exports.default = en;
|