sanity-plugin-link-field
Version:
A custom Link field for Sanity Studio
382 lines (380 loc) • 14.5 kB
JavaScript
import { isInternalLink, isExternalLink, isEmailLink, isPhoneLink, isCustomLink } from "./helpers.mjs";
import { useWorkspace, useFormValue, set, ObjectInputMember, FormFieldValidationStatus, definePlugin, defineType, defineField } from "sanity";
import { jsx, jsxs, Fragment } from "react/jsx-runtime";
import { Spinner, Select, Box, Stack, Text, Flex, Button, MenuItem, MenuButton, Menu } from "@sanity/ui";
import { useState, useEffect } from "react";
import styled from "styled-components";
import { ChevronDownIcon } from "@sanity/icons";
import { LinkIcon, GlobeIcon, AtSignIcon, PhoneIcon } from "lucide-react";
const requiredLinkField = (field) => {
const link = field;
return !link || !link.type ? "Link is required" : isInternalLink(link) && !link.internalLink ? {
message: "Link is required",
path: "internalLink"
} : isExternalLink(link) && !link.url ? {
message: "URL is required",
path: "url"
} : isEmailLink(link) && !link.email ? {
message: "E-mail is required",
path: "email"
} : isPhoneLink(link) && !link.phone ? {
message: "Phone is required",
path: "phone"
} : isCustomLink(link) && !link.value ? {
message: "Value is required",
path: "value"
} : !0;
}, OptionsSpinner = styled(Spinner)`
margin-left: 0.5rem;
`;
function CustomLinkInput(props) {
const workspace = useWorkspace(), document = useFormValue([]), linkValue = useFormValue(props.path.slice(0, -1)), [options, setOptions] = useState(null), customLinkType = props.customLinkTypes.find((type) => type.value === linkValue.type);
return useEffect(() => {
customLinkType && (Array.isArray(customLinkType == null ? void 0 : customLinkType.options) ? setOptions(customLinkType.options) : customLinkType.options(document, props.path, workspace.currentUser).then((options2) => setOptions(options2)));
}, [customLinkType, props.path, workspace.currentUser]), options ? /* @__PURE__ */ jsx(
Select,
{
onChange: (e) => {
props.onChange(set(e.currentTarget.value || ""));
},
children: /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx("option", { value: "", selected: props.value === "", disabled: !0, hidden: !0 }),
options.map((option) => /* @__PURE__ */ jsx("option", { value: option.value, selected: props.value === option.value, children: option.title }, option.value))
] })
}
) : /* @__PURE__ */ jsx(OptionsSpinner, {});
}
const ValidationErrorWrapper = styled(Box)`
contain: size;
margin-bottom: 6px;
margin-left: auto;
margin-right: 12px;
`, FullWidthStack = styled(Stack)`
width: 100%;
`;
function LinkInput(props) {
var _a;
const [textField, typeField, linkField2, ...otherFields] = props.members, { options } = props.schemaType, {
field: {
validation: linkFieldValidation,
schemaType: { description: linkFieldDescription }
}
} = linkField2, description = (
// If a custom link type is used, use its description if it has one.
props.value && isCustomLink(props.value) ? (_a = props.customLinkTypes.find((type) => {
var _a2;
return type.value === ((_a2 = props.value) == null ? void 0 : _a2.type);
})) == null ? void 0 : _a.description : (
// Fallback to the description of the current link type field.
linkFieldDescription
)
), renderProps = {
renderAnnotation: props.renderAnnotation,
renderBlock: props.renderBlock,
renderField: props.renderField,
renderInlineBlock: props.renderInlineBlock,
renderInput: props.renderInput,
renderItem: props.renderItem,
renderPreview: props.renderPreview
};
return /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
(options == null ? void 0 : options.enableText) && /* @__PURE__ */ jsx(
ObjectInputMember,
{
member: {
...textField,
field: {
...textField.field,
schemaType: {
...textField.field.schemaType,
title: (options == null ? void 0 : options.textLabel) || textField.field.schemaType.title
}
}
},
...renderProps
}
),
/* @__PURE__ */ jsxs(Stack, { space: 3, children: [
(options == null ? void 0 : options.enableText) && /* @__PURE__ */ jsx(Text, { as: "label", weight: "medium", size: 1, children: "Link" }),
/* @__PURE__ */ jsxs(Flex, { gap: 2, align: "flex-start", children: [
/* @__PURE__ */ jsx(
ObjectInputMember,
{
member: {
...typeField,
field: {
...typeField.field,
schemaType: {
...typeField.field.schemaType,
title: void 0
}
}
},
...renderProps
}
),
/* @__PURE__ */ jsxs(FullWidthStack, { space: 2, children: [
/* @__PURE__ */ jsx(
ObjectInputMember,
{
member: {
...linkField2,
field: {
...linkField2.field,
schemaType: {
...linkField2.field.schemaType,
title: void 0
}
}
},
...renderProps
}
),
linkFieldValidation.length > 0 && /* @__PURE__ */ jsx(ValidationErrorWrapper, { children: /* @__PURE__ */ jsx(
FormFieldValidationStatus,
{
fontSize: 1,
placement: "top",
validation: linkFieldValidation
}
) })
] })
] }),
description && /* @__PURE__ */ jsx(Text, { muted: !0, size: 1, children: description })
] }),
otherFields.map((field) => /* @__PURE__ */ jsx(ObjectInputMember, { member: field, ...renderProps }, field.key))
] });
}
const defaultLinkTypes = [
{ title: "Internal", value: "internal", icon: LinkIcon },
{ title: "URL", value: "external", icon: GlobeIcon },
{ title: "Email", value: "email", icon: AtSignIcon },
{ title: "Phone", value: "phone", icon: PhoneIcon }
], LinkTypeButton = styled(Button)`
height: 35px;
svg.lucide {
width: 1rem;
height: 1rem;
}
`, LinkTypeMenuItem = styled(MenuItem)`
svg.lucide {
width: 1rem;
height: 1rem;
}
`;
function LinkTypeInput({
value,
onChange,
customLinkTypes = [],
linkableSchemaTypes
}) {
const linkTypes = [
// Disable internal links if not enabled for any schema types
...defaultLinkTypes.filter(
({ value: value2 }) => value2 !== "internal" || (linkableSchemaTypes == null ? void 0 : linkableSchemaTypes.length) > 0
),
...customLinkTypes
], selectedType = linkTypes.find((type) => type.value === value) || linkTypes[0];
return /* @__PURE__ */ jsx(
MenuButton,
{
button: /* @__PURE__ */ jsx(
LinkTypeButton,
{
type: "button",
mode: "ghost",
icon: selectedType.icon,
iconRight: ChevronDownIcon,
title: "Select link type",
"aria-label": `Select link type (currently: ${selectedType.title})`
}
),
id: "link-type",
menu: /* @__PURE__ */ jsx(Menu, { children: linkTypes.map((type) => /* @__PURE__ */ jsx(
LinkTypeMenuItem,
{
text: type.title,
icon: type.icon,
onClick: () => {
onChange(set(type.value));
}
},
type.value
)) })
}
);
}
const linkField = definePlugin((opts) => {
const {
linkableSchemaTypes = ["page"],
weakReferences = !1,
referenceFilterOptions,
descriptions = {
internal: "Link to another page or document on the website.",
external: "Link to an absolute URL to a page on another website.",
email: "Link to send an e-mail to the given address.",
phone: "Link to call the given phone number.",
advanced: "Optional. Add anchor links and custom parameters.",
parameters: "Optional. Add custom parameters to the URL, such as UTM tags.",
anchor: "Optional. Add an anchor to link to a specific section on the page."
},
enableLinkParameters = !0,
enableAnchorLinks = !0,
customLinkTypes = [],
icon,
preview
} = opts || {};
return {
name: "link-field",
schema: {
types: [defineType({
name: "link",
title: "Link",
type: "object",
icon,
preview,
fieldsets: [
{
name: "advanced",
title: "Advanced",
description: descriptions.advanced,
options: {
collapsible: !0,
collapsed: !0
}
}
],
fields: [
defineField({
name: "text",
type: "string",
description: descriptions.text
}),
defineField({
name: "type",
type: "string",
initialValue: "internal",
validation: (Rule) => Rule.required(),
components: {
input: (props) => LinkTypeInput({ customLinkTypes, linkableSchemaTypes, ...props })
}
}),
// Internal
defineField({
name: "internalLink",
type: "reference",
to: linkableSchemaTypes.map((type) => ({
type
})),
weak: weakReferences,
options: {
disableNew: !0,
...referenceFilterOptions
},
description: descriptions == null ? void 0 : descriptions.internal,
hidden: ({ parent }) => !!(parent != null && parent.type) && (parent == null ? void 0 : parent.type) !== "internal"
}),
// External
defineField({
name: "url",
type: "url",
description: descriptions == null ? void 0 : descriptions.external,
validation: (rule) => rule.uri({
allowRelative: !0,
scheme: ["https", "http"]
}),
hidden: ({ parent }) => (parent == null ? void 0 : parent.type) !== "external"
}),
// E-mail
defineField({
name: "email",
type: "email",
description: descriptions == null ? void 0 : descriptions.email,
hidden: ({ parent }) => (parent == null ? void 0 : parent.type) !== "email"
}),
// Phone
defineField({
name: "phone",
type: "string",
description: descriptions == null ? void 0 : descriptions.phone,
validation: (rule) => rule.custom((value, context) => {
var _a;
return !value || ((_a = context.parent) == null ? void 0 : _a.type) !== "phone" ? !0 : new RegExp(/^\+?[0-9\s-]*$/).test(value) && !value.startsWith("-") && !value.endsWith("-") || "Must be a valid phone number";
}),
hidden: ({ parent }) => (parent == null ? void 0 : parent.type) !== "phone"
}),
// Custom
defineField({
name: "value",
type: "string",
description: descriptions == null ? void 0 : descriptions.external,
hidden: ({ parent }) => !parent || !isCustomLink(parent),
components: {
input: (props) => CustomLinkInput({ customLinkTypes, ...props })
}
}),
// New tab
defineField({
title: "Open in new window",
name: "blank",
type: "boolean",
initialValue: !1,
description: descriptions.blank,
hidden: ({ parent }) => (parent == null ? void 0 : parent.type) === "email" || (parent == null ? void 0 : parent.type) === "phone"
}),
// Parameters
...enableLinkParameters || enableAnchorLinks ? [
...enableLinkParameters ? [
defineField({
title: "Parameters",
name: "parameters",
type: "string",
description: descriptions.parameters,
validation: (rule) => rule.custom((value, context) => {
var _a, _b;
return !value || // eslint-disable-next-line @typescript-eslint/no-explicit-any
((_a = context.parent) == null ? void 0 : _a.type) === "email" || // eslint-disable-next-line @typescript-eslint/no-explicit-any
((_b = context.parent) == null ? void 0 : _b.type) === "phone" ? !0 : value.indexOf("?") !== 0 ? "Must start with ?; eg. ?utm_source=example.com&utm_medium=referral" : value.length === 1 ? "Must contain at least one parameter" : !0;
}),
hidden: ({ parent }) => (parent == null ? void 0 : parent.type) === "email" || (parent == null ? void 0 : parent.type) === "phone",
fieldset: "advanced"
})
] : [],
// Anchor
...enableAnchorLinks ? [
defineField({
title: "Anchor",
name: "anchor",
type: "string",
description: descriptions.anchor,
validation: (rule) => rule.custom((value, context) => {
var _a, _b;
return !value || // eslint-disable-next-line @typescript-eslint/no-explicit-any
((_a = context.parent) == null ? void 0 : _a.type) === "email" || // eslint-disable-next-line @typescript-eslint/no-explicit-any
((_b = context.parent) == null ? void 0 : _b.type) === "phone" ? !0 : value.indexOf("#") !== 0 ? "Must start with #; eg. #page-section-1" : value.length === 1 ? "Must contain at least one character" : new RegExp(/^([-?/:@._~!$&'()*+,;=a-zA-Z0-9]|%[0-9a-fA-F]{2})*$/).test(
value.replace(/^#/, "")
) || "Invalid URL fragment";
}),
hidden: ({ parent }) => (parent == null ? void 0 : parent.type) === "email" || (parent == null ? void 0 : parent.type) === "phone",
fieldset: "advanced"
})
] : []
] : []
],
components: {
input: (props) => LinkInput({ customLinkTypes, ...props, value: props.value })
}
})]
}
};
});
export {
isCustomLink,
isEmailLink,
isExternalLink,
isInternalLink,
isPhoneLink,
linkField,
requiredLinkField
};
//# sourceMappingURL=index.mjs.map