js/src/Components/Widgets/LinkWidget.js
import React, {useState} from 'react';
import {FormHelperText} from "@mui/material";
import TextField from '@mui/material/TextField';
import FormGroup from '@mui/material/FormGroup';
import FormControl from '@mui/material/FormControl';
import FormLabel from '@mui/material/FormLabel';
import Autocomplete from '@mui/material/Autocomplete';
import {UrlFix} from "../../utils/UrlFix";
export const LinkWidget = ({fieldId, defaultValue, onFieldChange, settings}) => {
let timeout;
let initialCondition = defaultValue;
try {
// Checking for the length of 0 will catch any null, non-arrays, or empty
// arrays. This ensures the initial state is always in a good structure.
if (initialCondition.length === 0) {
throw 'Default condition failed';
}
}
catch (e) {
initialCondition = [{uri: '', title: '', options: {}}];
}
const [urlSuggestions, setSuggestions] = useState([]);
const [fieldValues, setValues] = useState(initialCondition)
const alterValues = (values) => {
const newState = [...fieldValues];
newState[values.delta].title = values.title;
newState[values.delta].uri = values.uri;
newState[values.delta].options = cleanOptions(newState[values.delta].options);
onFieldChange(newState);
}
const attributeChanged = (delta, attributeKey, value) => {
const newState = [...fieldValues];
const attributes = newState[delta]?.options?.attributes ?? {};
attributes[attributeKey] = value;
newState[delta].options = {
attributes: {...attributes}
}
newState[delta].options = cleanOptions(newState[delta].options);
onFieldChange(newState);
}
const cleanOptions = (options = {}) => {
if (options.hasOwnProperty('attributes')) {
delete options.attributes.content;
delete options.attributes.headers;
if (Object.keys(options.attributes).length === 0) {
delete options.attributes;
}
}
return options;
}
/**
* When the uri changes, use a timer like a debounce and fetch some
* suggestions from the linkit module.
*/
const uriChanged = (newUri) => {
clearTimeout(timeout);
// Make a new timeout set to go off in 800ms
timeout = setTimeout(() => {
// If the user enters a url that starts with some characters, we dont
// want to fetch suggestions that will just be empty anyways. This
// includes absolute urls, <front> and relative urls.
if (
newUri.substring(0, 1) === '/' ||
newUri.substring(0, 1) === '<' ||
parseUrl(newUri, 'PHP_URL_HOST')
) {
setSuggestions([]);
return;
}
fetch(UrlFix(`${settings.autocomplete}?q=${newUri}`))
.then(response => response.json())
.then(suggestionResults => setSuggestions(suggestionResults));
}, 600);
};
/**
* Similar to php parse_url function, split up the url into its parts.
*
* @see https://locutus.io/php/url/parse_url/
*
* @param url
* @param component
* @returns object|string
*/
const parseUrl = (url, component) => {
let query
let mode = 'php'
let key = [
'source',
'scheme',
'authority',
'userInfo',
'user',
'pass',
'host',
'port',
'relative',
'path',
'directory',
'file',
'query',
'fragment'
]
// For loose we added one optional slash to post-scheme to catch file:///
// (should restrict this)
let parser = {
php: new RegExp([
'(?:([^:\\/?#]+):)?',
'(?:\\/\\/()(?:(?:()(?:([^:@\\/]*):?([^:@\\/]*))?@)?([^:\\/?#]*)(?::(\\d*))?))?',
'()',
'(?:(()(?:(?:[^?#\\/]*\\/)*)()(?:[^?#]*))(?:\\?([^#]*))?(?:#(.*))?)'
].join('')),
strict: new RegExp([
'(?:([^:\\/?#]+):)?',
'(?:\\/\\/((?:(([^:@\\/]*):?([^:@\\/]*))?@)?([^:\\/?#]*)(?::(\\d*))?))?',
'((((?:[^?#\\/]*\\/)*)([^?#]*))(?:\\?([^#]*))?(?:#(.*))?)'
].join('')),
loose: new RegExp([
'(?:(?![^:@]+:[^:@\\/]*@)([^:\\/?#.]+):)?',
'(?:\\/\\/\\/?)?',
'((?:(([^:@\\/]*):?([^:@\\/]*))?@)?([^:\\/?#]*)(?::(\\d*))?)',
'(((\\/(?:[^?#](?![^?#\\/]*\\.[^?#\\/.]+(?:[?#]|$)))*\\/?)?([^?#\\/]*))',
'(?:\\?([^#]*))?(?:#(.*))?)'
].join(''))
}
let m = parser[mode].exec(url)
let uri = {}
let i = 14
while (i--) {
if (m[i]) {
uri[key[i]] = m[i]
}
}
if (component) {
return uri[component.replace('PHP_URL_', '').toLowerCase()]
}
if (mode !== 'php') {
let name = 'queryKey'
parser = /(?:^|&)([^&=]*)=?([^&]*)/g
uri[name] = {}
query = uri[key[12]] || ''
query.replace(parser, function ($0, $1, $2) {
if ($1) {
uri[name][$1] = $2
}
})
}
delete uri.source
return uri
};
/**
* Similar to the link field widget function in PHP.
*
* @see \Drupal\link\Plugin\Field\FieldWidget\LinkWidget::getUserEnteredStringAsUri()
*
* @param string
* @returns {*}
*/
const getUserEnteredStringAsUri = (string) => {
// By default, assume the entered string is an URI.
let uri = string.trim();
// Detect entity autocomplete string, map to 'entity:' URI.
const entity_id = extractEntityIdFromAutocompleteInput(uri);
if (entity_id !== null) {
// @todo Support entity types other than 'node'. Will be fixed in
// https://www.drupal.org/node/2423093.
uri = 'entity:node/' + entity_id;
}
// Support linking to nothing.
else if (['<nolink>', '<none>'].includes(string)) {
uri = 'route:' + string;
}
// Detect a schemeless string, map to 'internal:' URI.
else if (string.length > 0 && parseUrl(string, 'PHP_URL_SCHEME') === undefined) {
// @todo '<front>' is valid input for BC reasons, may be removed by
// https://www.drupal.org/node/2421941
// - '<front>' -> '/'
// - '<front>#foo' -> '/#foo'
if (string.indexOf('<front>') === 0) {
string = '/' + string.substring(7);
}
// This validation in the normal link widget occurs on submit. We'll just
// force all user entered links to have the first character be valid.
if (!['/', '?', '#'].includes(string.substring(0, 1))) {
string = '/' + string;
}
uri = 'internal:' + string;
}
else if (string.length > 0 && parseUrl(string, 'PHP_URL_HOST') === window.location.host) {
// Drupal core does not do this. To prevent unwanted domain change issues,
// force all entered urls of the same domain to be relative links.
uri = 'internal:' + uri.substring(uri.indexOf(window.location.host) + window.location.host.length);
}
return uri;
};
/**
* Similar to what the link field widget calls in the entity autocomplete.
*
* @see \Drupal\Core\Entity\Element\EntityAutocomplete::extractEntityIdFromAutocompleteInput()
*
* @param input
* @returns {null}
*/
const extractEntityIdFromAutocompleteInput = (input) => {
let match = null;
if (input.match(/.+\s\(([^\)]+)\)/)) {
match = input.match(/.+\s\(([^\)]+)\)/)[1];
}
return match;
}
/**
* Similar to the link field widget function in php.
*
* @see \Drupal\link\Plugin\Field\FieldWidget\LinkWidget::getUriAsDisplayableString()
*
* @param uri
* @returns {*}
*/
const getUriAsDisplayableString = (uri) => {
const scheme = parseUrl(uri, 'PHP_URL_SCHEME');
// By default, the displayable string is the URI.
let displayable_string = uri;
// A different displayable string may be chosen in case of the 'internal:'
// or 'entity:' built-in schemes.
if (scheme === 'internal') {
let uri_reference = uri.split(':', 2)[1];
// @todo '<front>' is valid input for BC reasons, may be removed by
// https://www.drupal.org/node/2421941
let path = parseUrl(uri, 'PHP_URL_PATH')
if (path === '/') {
uri_reference = '<front>' + uri_reference.substring(1);
}
displayable_string = uri_reference;
}
else if (scheme === 'entity') {
const [entity_type, entity_id] = uri.substring(7).split('/', 2);
// Since we can't load the entity directly here, we'll just display the
// entity type and id.
displayable_string = `/${entity_type}/${entity_id}`
}
else if (scheme === 'route' && displayable_string.indexOf('route:') === 0) {
displayable_string = displayable_string.replace('route:', '');
}
return displayable_string;
};
/**
* A suggestion was picked, pass that up to the manager to store the value.
*/
const suggestionPicked = (e, selectedValue, delta) => {
alterValues({
title: fieldValues[delta].title,
uri: getUserEnteredStringAsUri(selectedValue === null ? '' : selectedValue.value),
delta: delta
});
};
/**
* When the textfield on the URI blurs, that's when we want to trigger the
* field change and pass it up to the manager.
*/
const onUriBlur = (e, delta) => {
alterValues({
title: fieldValues[delta].title,
uri: getUserEnteredStringAsUri(e.target.value),
delta: delta
});
}
/**
* To support cardinality, we will need an "Add another" button.
*/
const addAnotherButton = () => {
if ((settings.cardinality === -1) || (settings.cardinality > fieldValues.length)) {
return (
<div>
<button
type="button"
className="button m-2.5"
onClick={addAnother}
>
Add Another Link
</button>
</div>
);
}
}
/**
* Handler for the addAnotherButton
*/
const addAnother = () => {
const newState = fieldValues.concat({uri: '', title: ''});
setValues(newState);
}
/**
* If we add more links, we need a way of removing them.
*/
const removeLinkButton = (delta) => {
if (fieldValues.length > 1) {
return (
<button
type="button"
className="button m-2.5"
onClick={() => removeLink(delta)}
>
Remove
</button>
);
}
}
/**
* Handler for removing a link.
*/
const removeLink = (delta) => {
if (typeof fieldValues[delta] !== undefined) {
fieldValues.splice(delta, 1);
if (fieldValues.length < 1) {
addAnother();
}
onFieldChange(fieldValues);
}
}
return (
<FormGroup className="clearfix">
<FormLabel component="legend" sx={{padding: '10px'}}>
{settings.label}
</FormLabel>
{fieldValues.map((link, delta) =>
<div className="mb-5" key={delta}>
<FormControl sx={{marginBottom: '5px'}}>
<Autocomplete
freeSolo
id={`${fieldId}-uri-${delta}`}
options={urlSuggestions}
getOptionLabel={option => typeof option.label !== 'undefined' ? option.label : getUriAsDisplayableString(option)}
onChange={(e, newValue) => suggestionPicked(e, newValue, delta)}
value={getUriAsDisplayableString(fieldValues[delta].uri)}
renderInput={params =>
<TextField
{...params}
fullWidth
label="URL"
variant="outlined"
helperText="Start typing the title of a piece of content to select it. You can also enter an internal path such as /foo/bar or an external URL such as https://example.com. Enter <front> to link to the front page."
onChange={(e) => uriChanged(e.target.value)}
required={settings.required}
onBlur={(e) => onUriBlur(e, delta)}
inputProps={{
...params.inputProps,
maxLength: 2048
}}
/>
}
/>
</FormControl>
{settings.title !== 0 &&
<>
<FormControl sx={{marginBottom:'5px', width:'100%'}}>
<TextField
id={`${fieldId}-title-${delta}`}
label="Link text"
value={fieldValues[delta].title}
inputProps={{maxLength: 255}}
onChange={e => alterValues({
title: e.target.value,
uri: fieldValues[delta].uri,
delta: delta
})}
variant="outlined"
required={typeof fieldValues[delta].uri !== 'undefined' && fieldValues[delta].uri.length >= 1}
fullWidth
/>
</FormControl>
{settings.help.length > 1 &&
<FormHelperText
classes={{root: 'p-2.5'}}
dangerouslySetInnerHTML={{__html: settings.help}}
/>
}
{/*Link Attributes modules fields*/}
{Object.keys(settings.attributes).map(attributeKey =>
<FormControl key={`${delta}-${attributeKey}`} sx={{marginBottom:'5px', width: '100%'}}>
<TextField
id={`${fieldId}-${attributeKey}-${delta}`}
label={settings.attributes[attributeKey].label}
value={fieldValues[delta]?.options?.attributes?.[attributeKey] ?? ''}
inputProps={{maxLength: 255}}
onChange={e => attributeChanged(delta, attributeKey, e.target.value)}
variant="outlined"
fullWidth
/>
<FormHelperText>{settings.attributes?.[attributeKey]?.help}</FormHelperText>
</FormControl>
)}
</>
}
{removeLinkButton(delta)}
</div>
)}
{addAnotherButton(settings.cardinality)}
</FormGroup>
)
};