Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,17 @@ module.exports = {
enabled: true,
config: {
relations: {
'api::article.article': {
urlTemplate: '/articles/{slug}',
},
'api::page.page': {
urlTemplate: '/pages/{slug}',
},
'api::product.product': {
urlTemplate: '/products/{id}',
},
// there is also the support for nested subpaths within the same entity (as long as they exist)
'api::article.article': {
urlTemplate:
'/articles/{article_category.article_category_group.slug}/{article_category.slug}/{slug}',
},
},
},
},
Expand All @@ -41,7 +43,7 @@ Template variables are replaced with actual values from the related content:
- `{id}` → Content ID
- `{slug}` → Content slug
- `{title}` → Content title
- Any other field name from the content
- Any other field name from the content (it can be also a reference to a relation with other entities, as long as you specify the final field eg. slug, documentId)

## Data Structure

Expand All @@ -65,6 +67,34 @@ type LinkFieldData = {
}
```

## Batch Update

Anytime you want to change the plugin configuration, so that the url template changes, you can use the "Regenerate Urls" button on the list view of the defined entities in order to batch update all of the entities that are using the Link custom field in their schema (also in 1 level nested relations such as dynamic-zones).
This will update all of the "published" documents (in case of a draft/publish enabled content-type), since the drafts will always require a manual check.

## Localizations support

This plugin supports also the i18n native plugin offered by Strapi. The related entities will automatically pick the correct entities based on the locale provided by the admin panel.
To do so you can extend the configuration like this:

```javascript
config: {
relations: {
'api::page.page': {
urlTemplate: '/pages/{slug}',
},
'api::article.article': {
urlTemplate: {
en: '/articles/{slug}',
it: '/articoli/{slug}',
},
},
}
}
```

By changing the value of `urlTemplate` from string to an object, the plugin will automatically recognize that you're setting up different values for each locale, where the key of the object must be the ISO-code value of the locale present in the Strapi installation.

## License

[MIT License](LICENSE)
Expand Down
39 changes: 27 additions & 12 deletions admin/src/components/LinkField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { File } from '@strapi/icons'
import { useFetchClient, useStrapiApp } from '@strapi/strapi/admin'
import { Checkbox } from '@strapi/design-system'
import { useQueryParams } from '@strapi/strapi/admin'

interface LinkFieldProps {
name: string
Expand Down Expand Up @@ -45,13 +46,16 @@ const LinkField: React.FC<LinkFieldProps> = (props) => {
} as LinkFieldValue)

const { get, post } = useFetchClient()
const [{ query }] = useQueryParams()
const locale = ((query as any).plugins as any)?.i18n?.locale as string | undefined

// State for resources
const [relations, setRelations] = useState<string[]>([])
const [relationOptions, setRelationOptions] = useState<LinkFieldRelatedData[]>([])
const [isInitialized, setIsInitialized] = useState(false)
const [isLoadingRelations, setIsLoadingRelations] = useState(false)
const [showMediaLibrary, setShowMediaLibrary] = useState(false)
const [isGeneratingUrl, setIsGeneratingUrl] = useState(false)

// Refs for URL generation debouncing and loop prevention
const isGeneratingUrlRef = useRef(false)
Expand Down Expand Up @@ -94,15 +98,17 @@ const LinkField: React.FC<LinkFieldProps> = (props) => {
const fetchRelationOptions = useCallback(
async (relationKey: string, search?: string) => {
try {
const params = search ? `?search=${encodeURIComponent(search)}` : ''
const params = search
? `?search=${encodeURIComponent(search)}&locale=${locale}`
: `?locale=${locale}`
const { data } = await get(`/link-field/relations/${relationKey}/options${params}`)
setRelationOptions(data.options || [])
} catch (error) {
console.error('Failed to fetch relation options:', error)
setRelationOptions([])
}
},
[get],
[get, locale],
)

// Generate URL for resources only
Expand All @@ -129,10 +135,12 @@ const LinkField: React.FC<LinkFieldProps> = (props) => {

isGeneratingUrlRef.current = true
lastGeneratedValueRef.current = currentValueHash
setIsGeneratingUrl(true)

try {
const { data } = await post('/link-field/generate-url', {
fieldData: value,
locale,
})
const newGeneratedUrl = data.generatedUrl || ''

Expand All @@ -150,8 +158,9 @@ const LinkField: React.FC<LinkFieldProps> = (props) => {
}
} finally {
isGeneratingUrlRef.current = false
setIsGeneratingUrl(false)
}
}, [value, post, name, onChange])
}, [value, post, name, onChange, locale])

// Initialize relations on mount
useEffect(() => {
Expand All @@ -170,7 +179,7 @@ const LinkField: React.FC<LinkFieldProps> = (props) => {
} else {
setRelationOptions([])
}
}, [value.linkType, relations, fetchRelationOptions])
}, [value.linkType, relations, fetchRelationOptions, locale])

// Handle URL generation and file URL setting
useEffect(() => {
Expand Down Expand Up @@ -279,13 +288,13 @@ const LinkField: React.FC<LinkFieldProps> = (props) => {
</Flex>
)
default:
if (value.linkType && relations.includes(value.linkType)) {
if (value.linkType && relations.includes(value.linkType) && !isLoadingRelations) {
return (
<SingleSelect
placeholder='Select item'
value={value.relatedData?.id?.toString() || ''}
onChange={(selectedId: string) => {
const selected = relationOptions.find((opt) => opt.id?.toString() === selectedId)
value={value.relatedData?.id}
onChange={(selectedId: number) => {
const selected = relationOptions.find((opt) => +(opt.id as number) === +selectedId)
handleChange('relatedData', selected)
}}
disabled={disabled}
Expand All @@ -294,7 +303,7 @@ const LinkField: React.FC<LinkFieldProps> = (props) => {
}}
>
{relationOptions.map((option) => (
<SingleSelectOption key={option.id} value={option.id?.toString()}>
<SingleSelectOption key={option.id} value={+(option.id as number)}>
{option.title || option.name || option.slug || `Item ${option.id}`}
</SingleSelectOption>
))}
Expand Down Expand Up @@ -368,9 +377,15 @@ const LinkField: React.FC<LinkFieldProps> = (props) => {
Open in new tab
</Checkbox>
<Box>
<Typography variant='pi' textColor='neutral600' fontWeight='bold'>
{value.url}
</Typography>
{isGeneratingUrl ? (
<Typography variant='pi' textColor='neutral500'>
Generating…
</Typography>
) : (
<Typography variant='pi' textColor='neutral600' fontWeight='bold'>
{value.url}
</Typography>
)}
</Box>
</Flex>
</Flex>
Expand Down
76 changes: 76 additions & 0 deletions admin/src/components/RegenerateUrlsButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Button, Flex, Typography } from '@strapi/design-system'
import { useFetchClient, useQueryParams } from '@strapi/strapi/admin'
import { unstable_useContentManagerContext as useContentManagerContext } from '@strapi/strapi/admin'

interface RegenerateResult {
updated: number
skipped: number
errors: number
}

const RegenerateUrlsButton: React.FC = () => {
const { post, get } = useFetchClient()
const [{ query }] = useQueryParams()
const locale = ((query as any).plugins as any)?.i18n?.locale as string | undefined
const { model } = useContentManagerContext()

const [configuredRelations, setConfiguredRelations] = useState<string[]>([])
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle')
const [result, setResult] = useState<RegenerateResult | null>(null)

useEffect(() => {
get('/link-field/relations')
.then(({ data }) => setConfiguredRelations(data.relations || []))
.catch(() => setConfiguredRelations([]))
}, [get])

const handleRegenerate = useCallback(async () => {
if (!model) return
setStatus('loading')
setResult(null)
try {
const { data } = await post(`/link-field/relations/${model}/regenerate-urls`, {
locale,
})
setResult(data)
setStatus('done')
} catch {
setStatus('error')
}
}, [model, post, locale])

// Only render when the current list view is a configured relation
if (!model || !configuredRelations.includes(model)) {
return null
}

return (
<Flex gap={2} alignItems='center'>
<Button
variant='secondary'
size='S'
onClick={handleRegenerate}
loading={status === 'loading'}
disabled={status === 'loading'}
>
Regenerate URLs
</Button>

{status === 'done' && result && (
<Typography variant='pi' textColor='success600'>
{result.updated} updated
{result.errors > 0 && `, ${result.errors} errors`}
</Typography>
)}

{status === 'error' && (
<Typography variant='pi' textColor='danger600'>
Failed — check server logs
</Typography>
)}
</Flex>
)
}

export default RegenerateUrlsButton
8 changes: 8 additions & 0 deletions admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getTranslation } from './utils/getTranslation'
import { PLUGIN_ID } from './pluginId'
import { Initializer } from './components/Initializer'
import { PluginIcon } from './components/PluginIcon'
import RegenerateUrlsButton from './components/RegenerateUrlsButton'
import { StrapiApp } from '@strapi/strapi/admin'

export default {
Expand Down Expand Up @@ -58,6 +59,13 @@ export default {
})
},

bootstrap(app: StrapiApp) {
app.getPlugin('content-manager').injectComponent('listView', 'actions', {
name: 'link-field-regenerate-urls',
Component: RegenerateUrlsButton,
})
},

async registerTrads({ locales }: { locales: string[] }) {
return Promise.all(
locales.map(async (locale) => {
Expand Down
3 changes: 2 additions & 1 deletion admin/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"include": ["./src", "./custom.d.ts"],
"compilerOptions": {
"rootDir": "../",
"baseUrl": "."
"baseUrl": ".",
"noImplicitAny": false
}
}
5 changes: 5 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@ export default [
js.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
]
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "1.0.0",
"version": "1.2.0",
"name": "@fresh.codes/strapi-plugin-link-field",
"description": "A custom field that provides link details to a resource, a file, or a plain text url.",
"license": "MIT",
Expand Down
17 changes: 12 additions & 5 deletions server/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { UID } from '@strapi/types'

export interface PluginConfig {
relations: Record<UID.ContentType, { urlTemplate: string }>
relations: Record<UID.ContentType, { urlTemplate: string | Record<string, string> }>
}

export default {
Expand All @@ -14,14 +14,21 @@ export default {
}

for (const [contentType, relationConfig] of Object.entries(config.relations || {})) {
if (!relationConfig.urlTemplate || typeof relationConfig.urlTemplate !== 'string') {
if (
!relationConfig.urlTemplate ||
(typeof relationConfig.urlTemplate !== 'string' &&
typeof relationConfig.urlTemplate !== 'object')
) {
throw new Error(
`urlTemplate is required and must be a string for content type: ${contentType}`,
`urlTemplate is required and must be a string or an object for content type: ${contentType}`,
)
}

// Validate that urlTemplate contains at least one placeholder
if (!relationConfig.urlTemplate.includes('{') || !relationConfig.urlTemplate.includes('}')) {
// Validate that urlTemplate in string mode contains at least one placeholder
if (
typeof relationConfig.urlTemplate === 'string' &&
(!relationConfig.urlTemplate.includes('{') || !relationConfig.urlTemplate.includes('}'))
) {
throw new Error(
`urlTemplate must contain at least one placeholder (e.g., {slug}, {id}) for content type: ${contentType}`,
)
Expand Down
Loading