sanity-plugin-pte-interpolation 1.1.1 → 1.2.1

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/README.md CHANGED
@@ -16,33 +16,44 @@ npm install sanity-plugin-pte-interpolation
16
16
 
17
17
  ### Peer dependencies
18
18
 
19
- - `sanity` ^3.0.0 || ^4.0.0 || ^5.0.0
20
- - `react` ^18.0.0 || ^19.0.0
21
- - `@sanity/ui` ^2.0.0 || ^3.0.0
22
- - `@sanity/icons` ^3.0.0
19
+ - `sanity ^3.0.0 || ^4.0.0 || ^5.0.0`
20
+ - `react ^18.0.0 || ^19.0.0`
21
+ - `@sanity/ui ^2.0.0 || ^3.0.0`
22
+ - `@sanity/icons ^3.0.0`
23
23
 
24
24
  ## Usage
25
25
 
26
- Call `interpolationVariables()` inside the `of` array of a Portable Text field. It returns a `block` array member with the variable inline object type injected.
26
+ Call `interpolationVariables()` inside the `of` array of a Portable Text field. It returns a `block` array member augmented with the `pteInterpolationVariable` inline type.
27
27
 
28
28
  ```ts
29
29
  import {defineType, defineField} from 'sanity'
30
30
  import {interpolationVariables} from 'sanity-plugin-pte-interpolation'
31
31
 
32
32
  export default defineType({
33
- name: 'email',
34
- title: 'Email',
33
+ name: 'promoCard',
34
+ title: 'Promo card',
35
35
  type: 'document',
36
36
  fields: [
37
37
  defineField({
38
- name: 'body',
39
- title: 'Body',
38
+ name: 'message',
39
+ title: 'Message',
40
+ description:
41
+ 'Personalised card message. Use the variable picker to insert recipient-specific values.',
40
42
  type: 'array',
41
43
  of: [
42
44
  interpolationVariables([
43
- {id: 'firstName', name: 'First name', description: 'First name of the recipient'},
44
- {id: 'lastName', name: 'Last name', description: 'Last name of the recipient'},
45
- {id: 'email', name: 'Email address', description: 'Email address of the recipient'},
45
+ {id: 'firstName', name: 'First name', description: "Recipient's first name"},
46
+ {
47
+ id: 'vouchersRemaining',
48
+ name: 'Vouchers remaining',
49
+ description: 'Number of vouchers still available for this recipient',
50
+ },
51
+ {
52
+ id: 'totalVouchers',
53
+ name: 'Total vouchers',
54
+ description: 'Total number of vouchers allocated',
55
+ },
56
+ {id: 'expiryDate', name: 'Expiry date', description: 'Date the vouchers expire'},
46
57
  ]),
47
58
  ],
48
59
  }),
@@ -54,7 +65,7 @@ Each variable requires an `id` (the lookup key used at render time) and a `name`
54
65
 
55
66
  ### With a custom block definition
56
67
 
57
- If you already have a customised `block` definition, pass it as the second argument and the variable type will be appended to its existing `of` array:
68
+ If you already have a customised `block` definition, pass it as the second argument and `interpolationVariables` appends the variable type to its existing `of` array:
58
69
 
59
70
  ```ts
60
71
  import {defineArrayMember} from 'sanity'
@@ -71,30 +82,70 @@ const customBlock = defineArrayMember({
71
82
  interpolationVariables([{id: 'firstName', name: 'First name'}], customBlock)
72
83
  ```
73
84
 
74
- ## Rendering Variables
85
+ ## Stale variable detection
86
+
87
+ When a variable's `id` no longer exists in the `variables` array - for example after a developer renames or removes it from the schema config - the Studio surfaces warnings in three places:
88
+
89
+ - **Inline block in the PTE editor** - a warning icon and a "Stale" badge appear next to the variable name, and a tooltip explains the issue.
90
+ - **Autocomplete input when editing the block** - the autocomplete field shows a red border, and a caution card below it reads `Variable "..." is no longer defined. Please select a valid variable.`
91
+ - **Document-level validation** - the Studio raises a validation warning, not an error. Publishing is not blocked, since stale variables are typically caused by a developer schema change rather than an editor mistake.
92
+
93
+ Editors can resolve the warning by opening the variable block and selecting a currently defined variable from the picker.
94
+
95
+ ## How It Works
96
+
97
+ Think mail merge for rich text - an editor writes "Hello, `{firstName}`!" and the frontend substitutes the actual value at runtime.
98
+
99
+ ```
100
+ AUTHORING (Sanity Studio) RENDERING (React)
101
+ ─────────────────────────── ─────────────────────────────────
102
+ Editor writes: App provides values:
103
+ "Hi [firstName], you have { firstName: "Sarah",
104
+ [vouchersRemaining] of vouchersRemaining: "3",
105
+ [totalVouchers] vouchers totalVouchers: "5",
106
+ remaining until [expiryDate]." expiryDate: "30 Feb 2026" }
107
+
108
+ Stored as Portable Text with Rendered as:
109
+ inline pteInterpolationVariable "Hi Sarah, you have 3 of 5
110
+ objects containing variableKey vouchers remaining until
111
+ 30 Feb 2026."
112
+ ```
113
+
114
+ ## Related Packages
75
115
 
76
116
  This package handles the **authoring** side. To resolve variables to actual values in your frontend, use [`pte-interpolation-react`](https://www.npmjs.com/package/pte-interpolation-react):
77
117
 
78
118
  ```tsx
79
119
  import {InterpolatedPortableText} from 'pte-interpolation-react'
80
- ;<InterpolatedPortableText
81
- value={body}
82
- interpolationValues={{
83
- firstName: 'Jo',
84
- lastName: 'Smith',
85
- email: 'jo@example.com',
86
- }}
87
- />
120
+
121
+ function PromoCard({message, recipient}) {
122
+ return (
123
+ <InterpolatedPortableText
124
+ value={message}
125
+ interpolationValues={{
126
+ firstName: recipient.firstName,
127
+ vouchersRemaining: String(recipient.vouchersRemaining),
128
+ totalVouchers: String(recipient.totalVouchers),
129
+ expiryDate: recipient.expiryDate,
130
+ }}
131
+ />
132
+ )
133
+ }
88
134
  ```
89
135
 
90
136
  For framework-agnostic use cases - plain string output, variable key extraction, server-side rendering, or any non-React environment - use [`pte-interpolation-core`](https://www.npmjs.com/package/pte-interpolation-core) directly:
91
137
 
92
138
  ```ts
93
- import {interpolateToString, extractVariableKeys} from 'pte-interpolation-core'
94
-
95
- const keys = extractVariableKeys(blocks) // ['firstName', 'email']
96
- const text = interpolateToString(blocks, {firstName: 'Jo', email: 'jo@example.com'})
97
- // "Hello, Jo! Your email is jo@example.com."
139
+ import {
140
+ interpolateToString,
141
+ extractVariableKeys,
142
+ getMissingVariableKeys,
143
+ } from 'pte-interpolation-core'
144
+
145
+ const keys = extractVariableKeys(blocks) // ['firstName', 'vouchersRemaining']
146
+ const missing = getMissingVariableKeys(blocks, {firstName: 'Sarah'}) // ['vouchersRemaining']
147
+ const text = interpolateToString(blocks, {firstName: 'Sarah', vouchersRemaining: '3'})
148
+ // "Hi, Sarah! You have 3 vouchers remaining."
98
149
  ```
99
150
 
100
151
  ## Data Shape
@@ -105,9 +156,9 @@ Variables are stored as inline objects within Portable Text blocks:
105
156
  {
106
157
  "_type": "block",
107
158
  "children": [
108
- {"_type": "span", "text": "Hello, "},
159
+ {"_type": "span", "text": "Hi, "},
109
160
  {"_type": "pteInterpolationVariable", "variableKey": "firstName"},
110
- {"_type": "span", "text": "!"}
161
+ {"_type": "span", "text": ","}
111
162
  ]
112
163
  }
113
164
  ```
package/dist/index.cjs CHANGED
@@ -1,6 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: !0 });
3
3
  var icons = require("@sanity/icons"), sanity = require("sanity"), jsxRuntime = require("react/jsx-runtime"), ui = require("@sanity/ui"), react = require("react");
4
+ function isStaleVariable(variableKey, variables) {
5
+ return typeof variableKey > "u" || variableKey.length === 0 ? !1 : variables.some((variable) => variable.id === variableKey) === !1;
6
+ }
7
+ function staleVariableMessage(variableKey) {
8
+ return `Variable "${variableKey}" is no longer defined. Please select a valid variable.`;
9
+ }
4
10
  function VariableKeyField(props) {
5
11
  return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: props.children });
6
12
  }
@@ -11,7 +17,7 @@ function createVariableKeyInput(variables) {
11
17
  description: variable.description
12
18
  }));
13
19
  return function(props) {
14
- const autocompleteId = react.useId(), selectedVariable = variables.find((variable) => variable.id === props.value), handleChange = react.useCallback(
20
+ const autocompleteId = react.useId(), selectedVariable = variables.find((variable) => variable.id === props.value), variableKey = typeof props.value == "string" ? props.value : void 0, stale = isStaleVariable(variableKey, variables), handleChange = react.useCallback(
15
21
  (selectedValue) => {
16
22
  props.onChange(selectedValue ? sanity.set(selectedValue) : sanity.unset());
17
23
  },
@@ -26,7 +32,7 @@ function createVariableKeyInput(variables) {
26
32
  {
27
33
  id: autocompleteId,
28
34
  options,
29
- value: typeof props.value == "string" ? props.value : void 0,
35
+ value: variableKey,
30
36
  onChange: handleChange,
31
37
  filterOption,
32
38
  renderOption,
@@ -35,19 +41,27 @@ function createVariableKeyInput(variables) {
35
41
  icon: icons.SearchIcon,
36
42
  placeholder: "Search variables...",
37
43
  fontSize: 1,
38
- padding: 3
44
+ padding: 3,
45
+ customValidity: stale && variableKey ? staleVariableMessage(variableKey) : void 0
39
46
  }
40
47
  ),
48
+ stale && variableKey && /* @__PURE__ */ jsxRuntime.jsx(ui.Card, { tone: "caution", padding: 3, marginTop: 2, radius: 2, children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Flex, { align: "center", gap: 2, children: [
49
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: 1, children: /* @__PURE__ */ jsxRuntime.jsx(icons.WarningOutlineIcon, {}) }),
50
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: 1, children: staleVariableMessage(variableKey) })
51
+ ] }) }),
41
52
  selectedVariable?.description && /* @__PURE__ */ jsxRuntime.jsx(ui.Box, { marginTop: 2, children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: 1, muted: !0, children: selectedVariable.description }) })
42
53
  ] });
43
54
  };
44
55
  }
45
56
  function createVariableInlineBlock(variables) {
46
57
  return function(props) {
47
- const variableKey = props.value?.variableKey, variable = variables.find((candidate) => candidate.id === variableKey);
58
+ const variableKey = props.value?.variableKey, variable = variables.find((candidate) => candidate.id === variableKey), stale = isStaleVariable(variableKey, variables);
48
59
  return props.renderDefault({
49
60
  ...props,
50
- renderPreview: () => /* @__PURE__ */ jsxRuntime.jsx(ui.Box, { padding: 2, children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: 0, weight: "medium", children: variable?.name ?? variableKey ?? "Select variable" }) })
61
+ renderPreview: () => /* @__PURE__ */ jsxRuntime.jsx(ui.Box, { padding: 2, children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Flex, { align: "center", gap: 2, children: [
62
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: 0, weight: "medium", children: variable?.name ?? variableKey ?? "Select variable" }),
63
+ stale && /* @__PURE__ */ jsxRuntime.jsx(ui.Badge, { tone: "caution", fontSize: 0, children: "Stale" })
64
+ ] }) })
51
65
  });
52
66
  };
53
67
  }
@@ -66,7 +80,10 @@ function interpolationVariables(variables, block) {
66
80
  name: "variableKey",
67
81
  title: "Variable",
68
82
  type: "string",
69
- validation: (rule) => rule.required(),
83
+ validation: (rule) => [
84
+ rule.required(),
85
+ rule.custom((value) => isStaleVariable(typeof value == "string" ? value : void 0, variables) === !1 ? !0 : staleVariableMessage(value)).warning()
86
+ ],
70
87
  components: {
71
88
  field: VariableKeyField,
72
89
  input: createVariableKeyInput(variables)
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","sources":["../src/components/VariableInlineBlock.tsx","../src/interpolationVariables.ts"],"sourcesContent":["import {SearchIcon} from '@sanity/icons'\nimport {Autocomplete, Box, Card, Text} from '@sanity/ui'\nimport {useCallback, useId} from 'react'\nimport {set, unset} from 'sanity'\nimport type {BlockProps, FieldProps, InputProps} from 'sanity'\nimport type {InterpolationVariable} from '../types'\n\ninterface VariableOption {\n value: string\n name: string\n description?: string\n}\n\nexport function VariableKeyField(props: FieldProps) {\n return <>{props.children}</>\n}\n\nexport function createVariableKeyInput(variables: InterpolationVariable[]) {\n const options: VariableOption[] = variables.map((variable) => ({\n value: variable.id,\n name: variable.name,\n description: variable.description,\n }))\n\n return function VariableKeyInput(props: InputProps) {\n const autocompleteId = useId()\n const selectedVariable = variables.find((variable) => variable.id === props.value)\n\n const handleChange = useCallback(\n (selectedValue: string) => {\n props.onChange(selectedValue ? set(selectedValue) : unset())\n },\n [props],\n )\n\n const filterOption = useCallback((query: string, option: VariableOption) => {\n return option.name.toLowerCase().includes(query.toLowerCase())\n }, [])\n\n const renderOption = useCallback((option: VariableOption) => {\n return (\n <Card as=\"button\" padding={3}>\n <Text size={1} weight=\"medium\">\n {option.name}\n </Text>\n {option.description && (\n <Box marginTop={2}>\n <Text size={0} muted>\n {option.description}\n </Text>\n </Box>\n )}\n </Card>\n )\n }, [])\n\n const renderValue = useCallback((_value: string, option?: VariableOption) => {\n if (option) return option.name\n const matchedOption = options.find((candidate) => candidate.value === _value)\n return matchedOption?.name ?? _value\n }, [])\n\n return (\n <>\n <Autocomplete\n id={autocompleteId}\n options={options}\n value={typeof props.value === 'string' ? props.value : undefined}\n onChange={handleChange}\n filterOption={filterOption}\n renderOption={renderOption}\n renderValue={renderValue}\n openButton\n icon={SearchIcon}\n placeholder=\"Search variables...\"\n fontSize={1}\n padding={3}\n />\n {selectedVariable?.description && (\n <Box marginTop={2}>\n <Text size={1} muted>\n {selectedVariable.description}\n </Text>\n </Box>\n )}\n </>\n )\n }\n}\n\nexport function createVariableInlineBlock(variables: InterpolationVariable[]) {\n return function VariableInlineBlock(props: BlockProps) {\n const value = props.value as {variableKey?: string}\n const variableKey = value?.variableKey\n const variable = variables.find((candidate) => candidate.id === variableKey)\n\n return props.renderDefault({\n ...props,\n renderPreview: () => (\n <Box padding={2}>\n <Text size={0} weight=\"medium\">\n {variable?.name ?? variableKey ?? 'Select variable'}\n </Text>\n </Box>\n ),\n })\n }\n}\n","import {TagIcon} from '@sanity/icons'\nimport {defineArrayMember, defineField} from 'sanity'\nimport {\n createVariableInlineBlock,\n createVariableKeyInput,\n VariableKeyField,\n} from './components/VariableInlineBlock'\nimport type {InterpolationVariable} from './types'\n\n/** @public */\nexport const VARIABLE_TYPE_PREFIX = 'pteInterpolationVariable'\n\n/** @public */\nexport function interpolationVariables(\n variables: InterpolationVariable[],\n block?: ReturnType<typeof defineArrayMember>,\n) {\n const variableType = defineArrayMember({\n type: 'object',\n name: VARIABLE_TYPE_PREFIX,\n title: 'Variable',\n icon: TagIcon,\n options: {\n modal: {width: 0},\n },\n fields: [\n defineField({\n name: 'variableKey',\n title: 'Variable',\n type: 'string',\n validation: (rule) => rule.required(),\n components: {\n field: VariableKeyField,\n input: createVariableKeyInput(variables),\n },\n }),\n ],\n components: {\n inlineBlock: createVariableInlineBlock(variables),\n },\n })\n\n const baseBlock = block ?? defineArrayMember({type: 'block'})\n\n return {\n ...baseBlock,\n of: [...((baseBlock as {of?: unknown[]}).of ?? []), variableType],\n }\n}\n"],"names":["jsx","Fragment","useId","useCallback","set","unset","jsxs","Card","Text","Box","Autocomplete","SearchIcon","defineArrayMember","TagIcon","defineField"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","sources":["../src/isStaleVariable.ts","../src/components/VariableInlineBlock.tsx","../src/interpolationVariables.ts"],"sourcesContent":["import type {InterpolationVariable} from './types'\n\nexport function isStaleVariable(\n variableKey: string | undefined,\n variables: InterpolationVariable[],\n): boolean {\n if (typeof variableKey === 'undefined' || variableKey.length === 0) return false\n const hasMatchingVariable = variables.some((variable) => variable.id === variableKey)\n return hasMatchingVariable === false\n}\n\nexport function staleVariableMessage(variableKey: string): string {\n return `Variable \"${variableKey}\" is no longer defined. Please select a valid variable.`\n}\n","import {SearchIcon, WarningOutlineIcon} from '@sanity/icons'\nimport {Autocomplete, Badge, Box, Card, Flex, Text} from '@sanity/ui'\nimport {useCallback, useId} from 'react'\nimport {set, unset} from 'sanity'\nimport type {BlockProps, FieldProps, InputProps} from 'sanity'\nimport {isStaleVariable, staleVariableMessage} from '../isStaleVariable'\nimport type {InterpolationVariable} from '../types'\n\ninterface VariableOption {\n value: string\n name: string\n description?: string\n}\n\nexport function VariableKeyField(props: FieldProps) {\n return <>{props.children}</>\n}\n\nexport function createVariableKeyInput(variables: InterpolationVariable[]) {\n const options: VariableOption[] = variables.map((variable) => ({\n value: variable.id,\n name: variable.name,\n description: variable.description,\n }))\n\n return function VariableKeyInput(props: InputProps) {\n const autocompleteId = useId()\n const selectedVariable = variables.find((variable) => variable.id === props.value)\n const variableKey = typeof props.value === 'string' ? props.value : undefined\n const stale = isStaleVariable(variableKey, variables)\n\n const handleChange = useCallback(\n (selectedValue: string) => {\n props.onChange(selectedValue ? set(selectedValue) : unset())\n },\n [props],\n )\n\n const filterOption = useCallback((query: string, option: VariableOption) => {\n return option.name.toLowerCase().includes(query.toLowerCase())\n }, [])\n\n const renderOption = useCallback((option: VariableOption) => {\n return (\n <Card as=\"button\" padding={3}>\n <Text size={1} weight=\"medium\">\n {option.name}\n </Text>\n {option.description && (\n <Box marginTop={2}>\n <Text size={0} muted>\n {option.description}\n </Text>\n </Box>\n )}\n </Card>\n )\n }, [])\n\n const renderValue = useCallback((_value: string, option?: VariableOption) => {\n if (option) return option.name\n const matchedOption = options.find((candidate) => candidate.value === _value)\n return matchedOption?.name ?? _value\n }, [])\n\n return (\n <>\n <Autocomplete\n id={autocompleteId}\n options={options}\n value={variableKey}\n onChange={handleChange}\n filterOption={filterOption}\n renderOption={renderOption}\n renderValue={renderValue}\n openButton\n icon={SearchIcon}\n placeholder=\"Search variables...\"\n fontSize={1}\n padding={3}\n customValidity={stale && variableKey ? staleVariableMessage(variableKey) : undefined}\n />\n {stale && variableKey && (\n <Card tone=\"caution\" padding={3} marginTop={2} radius={2}>\n <Flex align=\"center\" gap={2}>\n <Text size={1}>\n <WarningOutlineIcon />\n </Text>\n <Text size={1}>{staleVariableMessage(variableKey)}</Text>\n </Flex>\n </Card>\n )}\n {selectedVariable?.description && (\n <Box marginTop={2}>\n <Text size={1} muted>\n {selectedVariable.description}\n </Text>\n </Box>\n )}\n </>\n )\n }\n}\n\nexport function createVariableInlineBlock(variables: InterpolationVariable[]) {\n return function VariableInlineBlock(props: BlockProps) {\n const value = props.value as {variableKey?: string}\n const variableKey = value?.variableKey\n const variable = variables.find((candidate) => candidate.id === variableKey)\n const stale = isStaleVariable(variableKey, variables)\n\n return props.renderDefault({\n ...props,\n renderPreview: () => (\n <Box padding={2}>\n <Flex align=\"center\" gap={2}>\n <Text size={0} weight=\"medium\">\n {variable?.name ?? variableKey ?? 'Select variable'}\n </Text>\n {stale && (\n <Badge tone=\"caution\" fontSize={0}>\n Stale\n </Badge>\n )}\n </Flex>\n </Box>\n ),\n })\n }\n}\n","import {TagIcon} from '@sanity/icons'\nimport {defineArrayMember, defineField} from 'sanity'\nimport {\n createVariableInlineBlock,\n createVariableKeyInput,\n VariableKeyField,\n} from './components/VariableInlineBlock'\nimport {isStaleVariable, staleVariableMessage} from './isStaleVariable'\nimport type {InterpolationVariable} from './types'\n\n/** @public */\nexport const VARIABLE_TYPE_PREFIX = 'pteInterpolationVariable'\n\n/** @public */\nexport function interpolationVariables(\n variables: InterpolationVariable[],\n block?: ReturnType<typeof defineArrayMember>,\n) {\n const variableType = defineArrayMember({\n type: 'object',\n name: VARIABLE_TYPE_PREFIX,\n title: 'Variable',\n icon: TagIcon,\n options: {\n modal: {width: 0},\n },\n fields: [\n defineField({\n name: 'variableKey',\n title: 'Variable',\n type: 'string',\n validation: (rule) => [\n rule.required(),\n rule\n .custom((value) => {\n const variableKey = typeof value === 'string' ? value : undefined\n if (isStaleVariable(variableKey, variables) === false) return true\n return staleVariableMessage(value as string)\n })\n .warning(),\n ],\n components: {\n field: VariableKeyField,\n input: createVariableKeyInput(variables),\n },\n }),\n ],\n components: {\n inlineBlock: createVariableInlineBlock(variables),\n },\n })\n\n const baseBlock = block ?? defineArrayMember({type: 'block'})\n\n return {\n ...baseBlock,\n of: [...((baseBlock as {of?: unknown[]}).of ?? []), variableType],\n }\n}\n"],"names":["jsx","Fragment","useId","useCallback","set","unset","jsxs","Card","Text","Box","Autocomplete","SearchIcon","Flex","WarningOutlineIcon","Badge","defineArrayMember","TagIcon","defineField"],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,8 +1,14 @@
1
- import { SearchIcon, TagIcon } from "@sanity/icons";
1
+ import { SearchIcon, WarningOutlineIcon, TagIcon } from "@sanity/icons";
2
2
  import { set, unset, defineArrayMember, defineField } from "sanity";
3
- import { jsx, Fragment, jsxs } from "react/jsx-runtime";
4
- import { Box, Text, Card, Autocomplete } from "@sanity/ui";
3
+ import { jsx, jsxs, Fragment } from "react/jsx-runtime";
4
+ import { Box, Flex, Text, Badge, Card, Autocomplete } from "@sanity/ui";
5
5
  import { useId, useCallback } from "react";
6
+ function isStaleVariable(variableKey, variables) {
7
+ return typeof variableKey > "u" || variableKey.length === 0 ? !1 : variables.some((variable) => variable.id === variableKey) === !1;
8
+ }
9
+ function staleVariableMessage(variableKey) {
10
+ return `Variable "${variableKey}" is no longer defined. Please select a valid variable.`;
11
+ }
6
12
  function VariableKeyField(props) {
7
13
  return /* @__PURE__ */ jsx(Fragment, { children: props.children });
8
14
  }
@@ -13,7 +19,7 @@ function createVariableKeyInput(variables) {
13
19
  description: variable.description
14
20
  }));
15
21
  return function(props) {
16
- const autocompleteId = useId(), selectedVariable = variables.find((variable) => variable.id === props.value), handleChange = useCallback(
22
+ const autocompleteId = useId(), selectedVariable = variables.find((variable) => variable.id === props.value), variableKey = typeof props.value == "string" ? props.value : void 0, stale = isStaleVariable(variableKey, variables), handleChange = useCallback(
17
23
  (selectedValue) => {
18
24
  props.onChange(selectedValue ? set(selectedValue) : unset());
19
25
  },
@@ -28,7 +34,7 @@ function createVariableKeyInput(variables) {
28
34
  {
29
35
  id: autocompleteId,
30
36
  options,
31
- value: typeof props.value == "string" ? props.value : void 0,
37
+ value: variableKey,
32
38
  onChange: handleChange,
33
39
  filterOption,
34
40
  renderOption,
@@ -37,19 +43,27 @@ function createVariableKeyInput(variables) {
37
43
  icon: SearchIcon,
38
44
  placeholder: "Search variables...",
39
45
  fontSize: 1,
40
- padding: 3
46
+ padding: 3,
47
+ customValidity: stale && variableKey ? staleVariableMessage(variableKey) : void 0
41
48
  }
42
49
  ),
50
+ stale && variableKey && /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 3, marginTop: 2, radius: 2, children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [
51
+ /* @__PURE__ */ jsx(Text, { size: 1, children: /* @__PURE__ */ jsx(WarningOutlineIcon, {}) }),
52
+ /* @__PURE__ */ jsx(Text, { size: 1, children: staleVariableMessage(variableKey) })
53
+ ] }) }),
43
54
  selectedVariable?.description && /* @__PURE__ */ jsx(Box, { marginTop: 2, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: selectedVariable.description }) })
44
55
  ] });
45
56
  };
46
57
  }
47
58
  function createVariableInlineBlock(variables) {
48
59
  return function(props) {
49
- const variableKey = props.value?.variableKey, variable = variables.find((candidate) => candidate.id === variableKey);
60
+ const variableKey = props.value?.variableKey, variable = variables.find((candidate) => candidate.id === variableKey), stale = isStaleVariable(variableKey, variables);
50
61
  return props.renderDefault({
51
62
  ...props,
52
- renderPreview: () => /* @__PURE__ */ jsx(Box, { padding: 2, children: /* @__PURE__ */ jsx(Text, { size: 0, weight: "medium", children: variable?.name ?? variableKey ?? "Select variable" }) })
63
+ renderPreview: () => /* @__PURE__ */ jsx(Box, { padding: 2, children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [
64
+ /* @__PURE__ */ jsx(Text, { size: 0, weight: "medium", children: variable?.name ?? variableKey ?? "Select variable" }),
65
+ stale && /* @__PURE__ */ jsx(Badge, { tone: "caution", fontSize: 0, children: "Stale" })
66
+ ] }) })
53
67
  });
54
68
  };
55
69
  }
@@ -68,7 +82,10 @@ function interpolationVariables(variables, block) {
68
82
  name: "variableKey",
69
83
  title: "Variable",
70
84
  type: "string",
71
- validation: (rule) => rule.required(),
85
+ validation: (rule) => [
86
+ rule.required(),
87
+ rule.custom((value) => isStaleVariable(typeof value == "string" ? value : void 0, variables) === !1 ? !0 : staleVariableMessage(value)).warning()
88
+ ],
72
89
  components: {
73
90
  field: VariableKeyField,
74
91
  input: createVariableKeyInput(variables)
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../src/components/VariableInlineBlock.tsx","../src/interpolationVariables.ts"],"sourcesContent":["import {SearchIcon} from '@sanity/icons'\nimport {Autocomplete, Box, Card, Text} from '@sanity/ui'\nimport {useCallback, useId} from 'react'\nimport {set, unset} from 'sanity'\nimport type {BlockProps, FieldProps, InputProps} from 'sanity'\nimport type {InterpolationVariable} from '../types'\n\ninterface VariableOption {\n value: string\n name: string\n description?: string\n}\n\nexport function VariableKeyField(props: FieldProps) {\n return <>{props.children}</>\n}\n\nexport function createVariableKeyInput(variables: InterpolationVariable[]) {\n const options: VariableOption[] = variables.map((variable) => ({\n value: variable.id,\n name: variable.name,\n description: variable.description,\n }))\n\n return function VariableKeyInput(props: InputProps) {\n const autocompleteId = useId()\n const selectedVariable = variables.find((variable) => variable.id === props.value)\n\n const handleChange = useCallback(\n (selectedValue: string) => {\n props.onChange(selectedValue ? set(selectedValue) : unset())\n },\n [props],\n )\n\n const filterOption = useCallback((query: string, option: VariableOption) => {\n return option.name.toLowerCase().includes(query.toLowerCase())\n }, [])\n\n const renderOption = useCallback((option: VariableOption) => {\n return (\n <Card as=\"button\" padding={3}>\n <Text size={1} weight=\"medium\">\n {option.name}\n </Text>\n {option.description && (\n <Box marginTop={2}>\n <Text size={0} muted>\n {option.description}\n </Text>\n </Box>\n )}\n </Card>\n )\n }, [])\n\n const renderValue = useCallback((_value: string, option?: VariableOption) => {\n if (option) return option.name\n const matchedOption = options.find((candidate) => candidate.value === _value)\n return matchedOption?.name ?? _value\n }, [])\n\n return (\n <>\n <Autocomplete\n id={autocompleteId}\n options={options}\n value={typeof props.value === 'string' ? props.value : undefined}\n onChange={handleChange}\n filterOption={filterOption}\n renderOption={renderOption}\n renderValue={renderValue}\n openButton\n icon={SearchIcon}\n placeholder=\"Search variables...\"\n fontSize={1}\n padding={3}\n />\n {selectedVariable?.description && (\n <Box marginTop={2}>\n <Text size={1} muted>\n {selectedVariable.description}\n </Text>\n </Box>\n )}\n </>\n )\n }\n}\n\nexport function createVariableInlineBlock(variables: InterpolationVariable[]) {\n return function VariableInlineBlock(props: BlockProps) {\n const value = props.value as {variableKey?: string}\n const variableKey = value?.variableKey\n const variable = variables.find((candidate) => candidate.id === variableKey)\n\n return props.renderDefault({\n ...props,\n renderPreview: () => (\n <Box padding={2}>\n <Text size={0} weight=\"medium\">\n {variable?.name ?? variableKey ?? 'Select variable'}\n </Text>\n </Box>\n ),\n })\n }\n}\n","import {TagIcon} from '@sanity/icons'\nimport {defineArrayMember, defineField} from 'sanity'\nimport {\n createVariableInlineBlock,\n createVariableKeyInput,\n VariableKeyField,\n} from './components/VariableInlineBlock'\nimport type {InterpolationVariable} from './types'\n\n/** @public */\nexport const VARIABLE_TYPE_PREFIX = 'pteInterpolationVariable'\n\n/** @public */\nexport function interpolationVariables(\n variables: InterpolationVariable[],\n block?: ReturnType<typeof defineArrayMember>,\n) {\n const variableType = defineArrayMember({\n type: 'object',\n name: VARIABLE_TYPE_PREFIX,\n title: 'Variable',\n icon: TagIcon,\n options: {\n modal: {width: 0},\n },\n fields: [\n defineField({\n name: 'variableKey',\n title: 'Variable',\n type: 'string',\n validation: (rule) => rule.required(),\n components: {\n field: VariableKeyField,\n input: createVariableKeyInput(variables),\n },\n }),\n ],\n components: {\n inlineBlock: createVariableInlineBlock(variables),\n },\n })\n\n const baseBlock = block ?? defineArrayMember({type: 'block'})\n\n return {\n ...baseBlock,\n of: [...((baseBlock as {of?: unknown[]}).of ?? []), variableType],\n }\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sources":["../src/isStaleVariable.ts","../src/components/VariableInlineBlock.tsx","../src/interpolationVariables.ts"],"sourcesContent":["import type {InterpolationVariable} from './types'\n\nexport function isStaleVariable(\n variableKey: string | undefined,\n variables: InterpolationVariable[],\n): boolean {\n if (typeof variableKey === 'undefined' || variableKey.length === 0) return false\n const hasMatchingVariable = variables.some((variable) => variable.id === variableKey)\n return hasMatchingVariable === false\n}\n\nexport function staleVariableMessage(variableKey: string): string {\n return `Variable \"${variableKey}\" is no longer defined. Please select a valid variable.`\n}\n","import {SearchIcon, WarningOutlineIcon} from '@sanity/icons'\nimport {Autocomplete, Badge, Box, Card, Flex, Text} from '@sanity/ui'\nimport {useCallback, useId} from 'react'\nimport {set, unset} from 'sanity'\nimport type {BlockProps, FieldProps, InputProps} from 'sanity'\nimport {isStaleVariable, staleVariableMessage} from '../isStaleVariable'\nimport type {InterpolationVariable} from '../types'\n\ninterface VariableOption {\n value: string\n name: string\n description?: string\n}\n\nexport function VariableKeyField(props: FieldProps) {\n return <>{props.children}</>\n}\n\nexport function createVariableKeyInput(variables: InterpolationVariable[]) {\n const options: VariableOption[] = variables.map((variable) => ({\n value: variable.id,\n name: variable.name,\n description: variable.description,\n }))\n\n return function VariableKeyInput(props: InputProps) {\n const autocompleteId = useId()\n const selectedVariable = variables.find((variable) => variable.id === props.value)\n const variableKey = typeof props.value === 'string' ? props.value : undefined\n const stale = isStaleVariable(variableKey, variables)\n\n const handleChange = useCallback(\n (selectedValue: string) => {\n props.onChange(selectedValue ? set(selectedValue) : unset())\n },\n [props],\n )\n\n const filterOption = useCallback((query: string, option: VariableOption) => {\n return option.name.toLowerCase().includes(query.toLowerCase())\n }, [])\n\n const renderOption = useCallback((option: VariableOption) => {\n return (\n <Card as=\"button\" padding={3}>\n <Text size={1} weight=\"medium\">\n {option.name}\n </Text>\n {option.description && (\n <Box marginTop={2}>\n <Text size={0} muted>\n {option.description}\n </Text>\n </Box>\n )}\n </Card>\n )\n }, [])\n\n const renderValue = useCallback((_value: string, option?: VariableOption) => {\n if (option) return option.name\n const matchedOption = options.find((candidate) => candidate.value === _value)\n return matchedOption?.name ?? _value\n }, [])\n\n return (\n <>\n <Autocomplete\n id={autocompleteId}\n options={options}\n value={variableKey}\n onChange={handleChange}\n filterOption={filterOption}\n renderOption={renderOption}\n renderValue={renderValue}\n openButton\n icon={SearchIcon}\n placeholder=\"Search variables...\"\n fontSize={1}\n padding={3}\n customValidity={stale && variableKey ? staleVariableMessage(variableKey) : undefined}\n />\n {stale && variableKey && (\n <Card tone=\"caution\" padding={3} marginTop={2} radius={2}>\n <Flex align=\"center\" gap={2}>\n <Text size={1}>\n <WarningOutlineIcon />\n </Text>\n <Text size={1}>{staleVariableMessage(variableKey)}</Text>\n </Flex>\n </Card>\n )}\n {selectedVariable?.description && (\n <Box marginTop={2}>\n <Text size={1} muted>\n {selectedVariable.description}\n </Text>\n </Box>\n )}\n </>\n )\n }\n}\n\nexport function createVariableInlineBlock(variables: InterpolationVariable[]) {\n return function VariableInlineBlock(props: BlockProps) {\n const value = props.value as {variableKey?: string}\n const variableKey = value?.variableKey\n const variable = variables.find((candidate) => candidate.id === variableKey)\n const stale = isStaleVariable(variableKey, variables)\n\n return props.renderDefault({\n ...props,\n renderPreview: () => (\n <Box padding={2}>\n <Flex align=\"center\" gap={2}>\n <Text size={0} weight=\"medium\">\n {variable?.name ?? variableKey ?? 'Select variable'}\n </Text>\n {stale && (\n <Badge tone=\"caution\" fontSize={0}>\n Stale\n </Badge>\n )}\n </Flex>\n </Box>\n ),\n })\n }\n}\n","import {TagIcon} from '@sanity/icons'\nimport {defineArrayMember, defineField} from 'sanity'\nimport {\n createVariableInlineBlock,\n createVariableKeyInput,\n VariableKeyField,\n} from './components/VariableInlineBlock'\nimport {isStaleVariable, staleVariableMessage} from './isStaleVariable'\nimport type {InterpolationVariable} from './types'\n\n/** @public */\nexport const VARIABLE_TYPE_PREFIX = 'pteInterpolationVariable'\n\n/** @public */\nexport function interpolationVariables(\n variables: InterpolationVariable[],\n block?: ReturnType<typeof defineArrayMember>,\n) {\n const variableType = defineArrayMember({\n type: 'object',\n name: VARIABLE_TYPE_PREFIX,\n title: 'Variable',\n icon: TagIcon,\n options: {\n modal: {width: 0},\n },\n fields: [\n defineField({\n name: 'variableKey',\n title: 'Variable',\n type: 'string',\n validation: (rule) => [\n rule.required(),\n rule\n .custom((value) => {\n const variableKey = typeof value === 'string' ? value : undefined\n if (isStaleVariable(variableKey, variables) === false) return true\n return staleVariableMessage(value as string)\n })\n .warning(),\n ],\n components: {\n field: VariableKeyField,\n input: createVariableKeyInput(variables),\n },\n }),\n ],\n components: {\n inlineBlock: createVariableInlineBlock(variables),\n },\n })\n\n const baseBlock = block ?? defineArrayMember({type: 'block'})\n\n return {\n ...baseBlock,\n of: [...((baseBlock as {of?: unknown[]}).of ?? []), variableType],\n }\n}\n"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sanity-plugin-pte-interpolation",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Sanity Studio plugin for interpolating values into Portable Text Editor fields",
@@ -50,6 +50,9 @@
50
50
  "@sanity/pkg-utils": "^10.4.4",
51
51
  "@sanity/plugin-kit": "^4.0.20",
52
52
  "@sanity/ui": "^3.1.13",
53
+ "@testing-library/react": "^16.3.2",
54
+ "@testing-library/user-event": "^14.6.1",
55
+ "react-dom": "^19.2.4",
53
56
  "rimraf": "^6.1.3",
54
57
  "typescript": "~5.9.3"
55
58
  },
@@ -1,108 +1,174 @@
1
- import {type ReactElement} from 'react'
2
- import {describe, expect, it, vi} from 'vitest'
3
- import {createVariableInlineBlock, createVariableKeyInput} from '../components/VariableInlineBlock'
4
- import type {BlockProps, InputProps} from 'sanity'
1
+ import {type ComponentType, type ReactElement, type ReactNode} from 'react'
2
+ import {cleanup, render, screen} from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import {ThemeProvider} from '@sanity/ui'
5
+ import {buildTheme} from '@sanity/ui/theme'
6
+ import {afterEach, describe, expect, it, vi} from 'vitest'
7
+ import {
8
+ VariableKeyField,
9
+ createVariableInlineBlock,
10
+ createVariableKeyInput,
11
+ } from '../components/VariableInlineBlock'
5
12
  import type {InterpolationVariable} from '../types'
6
13
 
7
- vi.mock('react', async () => {
8
- const actual = await vi.importActual('react')
9
- return {
10
- ...actual,
11
- useId: () => 'test-autocomplete-id',
12
- useCallback: (callback: unknown) => callback,
13
- }
14
+ // jsdom does not implement window.matchMedia, which @sanity/ui components depend on
15
+ Object.defineProperty(window, 'matchMedia', {
16
+ writable: true,
17
+ value: vi.fn().mockImplementation((query: string) => ({
18
+ matches: false,
19
+ media: query,
20
+ onchange: null,
21
+ addListener: vi.fn(),
22
+ removeListener: vi.fn(),
23
+ addEventListener: vi.fn(),
24
+ removeEventListener: vi.fn(),
25
+ dispatchEvent: vi.fn(),
26
+ })),
14
27
  })
15
28
 
29
+ afterEach(cleanup)
30
+
31
+ const theme = buildTheme()
32
+
16
33
  const testVariables: InterpolationVariable[] = [
17
34
  {id: 'firstName', name: 'First name', description: 'First name of the recipient'},
18
35
  {id: 'email', name: 'Email address'},
19
36
  ]
20
37
 
21
- function findTextInElement(element: unknown): string[] {
22
- if (typeof element === 'string') return [element]
23
- if (!element || typeof element !== 'object') return []
24
- const el = element as {props?: Record<string, unknown>}
25
- if (!el.props) return []
26
- const {children} = el.props
27
- if (Array.isArray(children)) return children.flatMap(findTextInElement)
28
- return findTextInElement(children)
29
- }
30
-
31
- function findPropInElement(element: unknown, propName: string): unknown {
32
- if (!element || typeof element !== 'object') return undefined
33
- const el = element as {props?: Record<string, unknown>}
34
- if (!el.props) return undefined
35
- if (propName in el.props) return el.props[propName]
36
- const {children} = el.props
37
- if (Array.isArray(children)) {
38
- for (const child of children) {
39
- const found = findPropInElement(child, propName)
40
- if (found !== undefined) return found
41
- }
42
- }
43
- return findPropInElement(children, propName)
44
- }
45
-
46
38
  describe('createVariableInlineBlock', () => {
47
- function captureRenderPreview(variableKey: string | undefined): ReactElement | undefined {
39
+ function renderPreview(variableKey: string | undefined) {
48
40
  const VariableInlineBlock = createVariableInlineBlock(testVariables)
49
- let capturedRenderPreview: ((...args: unknown[]) => ReactElement) | undefined
50
-
51
- VariableInlineBlock({
52
- value: {variableKey},
53
- renderDefault: (props: unknown) => {
54
- capturedRenderPreview = (props as {renderPreview?: (...args: unknown[]) => ReactElement})
55
- .renderPreview
56
- return null as unknown as ReactElement
57
- },
58
- } as unknown as BlockProps)
59
-
60
- return capturedRenderPreview?.()
41
+ const Block = VariableInlineBlock as unknown as ComponentType<{
42
+ value: {variableKey?: string}
43
+ renderDefault: (props: {renderPreview?: () => ReactElement}) => ReactElement
44
+ }>
45
+
46
+ return render(
47
+ <ThemeProvider theme={theme}>
48
+ <Block
49
+ value={{variableKey}}
50
+ renderDefault={(props) => (props.renderPreview?.() ?? null) as unknown as ReactElement}
51
+ />
52
+ </ThemeProvider>,
53
+ )
61
54
  }
62
55
 
63
56
  it('renders the readable name when variableKey matches a variable id', () => {
64
- expect(findTextInElement(captureRenderPreview('firstName'))).toContain('First name')
57
+ renderPreview('firstName')
58
+ expect(screen.getByText('First name')).toBeDefined()
65
59
  })
66
60
 
67
61
  it('renders the raw variableKey when no variable matches', () => {
68
- expect(findTextInElement(captureRenderPreview('unknown'))).toContain('unknown')
62
+ renderPreview('unknown')
63
+ expect(screen.getByText('unknown')).toBeDefined()
69
64
  })
70
65
 
71
66
  it('renders "Select variable" when variableKey is undefined', () => {
72
- expect(findTextInElement(captureRenderPreview(undefined))).toContain('Select variable')
67
+ renderPreview(undefined)
68
+ expect(screen.getByText('Select variable')).toBeDefined()
69
+ })
70
+
71
+ it('renders a "Stale" badge when variableKey does not match any variable', () => {
72
+ renderPreview('staleKey')
73
+ expect(screen.getByText('Stale')).toBeDefined()
74
+ })
75
+
76
+ it('does not render a "Stale" badge when variableKey matches', () => {
77
+ renderPreview('firstName')
78
+ expect(screen.queryByText('Stale')).toBeNull()
73
79
  })
74
80
  })
75
81
 
76
82
  describe('createVariableKeyInput', () => {
77
- function renderInput(value: string | undefined): ReactElement {
83
+ function renderInput(value: string | undefined, onChange = vi.fn()) {
78
84
  const VariableKeyInput = createVariableKeyInput(testVariables)
79
- return VariableKeyInput({
80
- value,
81
- onChange: vi.fn(),
82
- } as unknown as InputProps) as ReactElement
85
+ const Input = VariableKeyInput as unknown as ComponentType<{
86
+ value?: string
87
+ onChange: () => void
88
+ }>
89
+
90
+ render(
91
+ <ThemeProvider theme={theme}>
92
+ <Input value={value} onChange={onChange} />
93
+ </ThemeProvider>,
94
+ )
95
+
96
+ return {onChange}
83
97
  }
84
98
 
85
99
  it('includes the description when the selected variable has one', () => {
86
- expect(findTextInElement(renderInput('firstName'))).toContain('First name of the recipient')
100
+ renderInput('firstName')
101
+ expect(screen.getByText('First name of the recipient')).toBeDefined()
87
102
  })
88
103
 
89
- it('does not include description when the selected variable has none', () => {
90
- const element = renderInput('email')
91
- const allText = findTextInElement(element)
92
- const textsOutsideAutocomplete = allText.filter((text) => text !== 'Search variables...')
93
- expect(textsOutsideAutocomplete).toHaveLength(0)
104
+ it('does not include the description for a variable that has none', () => {
105
+ renderInput('email')
106
+ expect(screen.queryByText('First name of the recipient')).toBeNull()
94
107
  })
95
108
 
96
- it('does not include description when no variable is selected', () => {
97
- const element = renderInput(undefined)
98
- const allText = findTextInElement(element)
99
- const textsOutsideAutocomplete = allText.filter((text) => text !== 'Search variables...')
100
- expect(textsOutsideAutocomplete).toHaveLength(0)
109
+ it('does not include the description when no variable is selected', () => {
110
+ renderInput(undefined)
111
+ expect(screen.queryByText('First name of the recipient')).toBeNull()
101
112
  })
102
113
 
103
114
  it('renders a search placeholder', () => {
104
- const element = renderInput(undefined)
105
- const placeholder = findPropInElement(element, 'placeholder')
106
- expect(placeholder).toBe('Search variables...')
115
+ renderInput(undefined)
116
+ expect(screen.getByPlaceholderText('Search variables...')).toBeDefined()
117
+ })
118
+
119
+ it('renders a stale warning when variableKey is stale', () => {
120
+ renderInput('staleKey')
121
+ expect(screen.getByText(/is no longer defined/)).toBeDefined()
122
+ })
123
+
124
+ it('does not render a stale warning for a matched key or undefined value', () => {
125
+ renderInput('firstName')
126
+ expect(screen.queryByText(/is no longer defined/)).toBeNull()
127
+ cleanup()
128
+
129
+ renderInput(undefined)
130
+ expect(screen.queryByText(/is no longer defined/)).toBeNull()
131
+ })
132
+
133
+ it('filters options by name when typing', async () => {
134
+ const user = userEvent.setup()
135
+ renderInput(undefined)
136
+
137
+ const input = screen.getByPlaceholderText('Search variables...')
138
+ await user.type(input, 'Email')
139
+
140
+ expect(screen.getByText('Email address')).toBeDefined()
141
+ expect(screen.queryByText('First name')).toBeNull()
142
+ })
143
+
144
+ it('calls onChange when selecting an option', async () => {
145
+ const user = userEvent.setup()
146
+ const {onChange} = renderInput(undefined)
147
+
148
+ const input = screen.getByPlaceholderText('Search variables...')
149
+ await user.type(input, 'First')
150
+
151
+ const option = await screen.findByText('First name')
152
+ await user.click(option)
153
+
154
+ expect(onChange).toHaveBeenCalled()
155
+ })
156
+ })
157
+
158
+ describe('VariableKeyField', () => {
159
+ const Field = VariableKeyField as unknown as ComponentType<{children: ReactNode}>
160
+
161
+ it('renders its children', () => {
162
+ render(<Field>child content</Field>)
163
+ expect(screen.getByText('child content')).toBeDefined()
164
+ })
165
+
166
+ it('renders nested element children', () => {
167
+ render(
168
+ <Field>
169
+ <span>nested child</span>
170
+ </Field>,
171
+ )
172
+ expect(screen.getByText('nested child')).toBeDefined()
107
173
  })
108
174
  })
@@ -93,4 +93,39 @@ describe('interpolationVariables', () => {
93
93
  name: VARIABLE_TYPE_PREFIX,
94
94
  })
95
95
  })
96
+
97
+ describe('variableKey custom validation', () => {
98
+ function extractCustomValidator(variables: typeof testVariables): (value: unknown) => unknown {
99
+ const result = interpolationVariables(variables)
100
+ const ofArray = result.of as Array<{fields?: Array<{validation?: unknown}>}>
101
+ const variableKeyField = ofArray[0].fields?.[0]
102
+
103
+ let capturedCustomValidator: ((value: unknown) => unknown) | undefined
104
+ const mockRule = {
105
+ required: () => mockRule,
106
+ custom: (validator: (value: unknown) => unknown) => {
107
+ capturedCustomValidator = validator
108
+ return mockRule
109
+ },
110
+ warning: () => mockRule,
111
+ }
112
+
113
+ ;(variableKeyField?.validation as (rule: typeof mockRule) => unknown[])(mockRule)
114
+
115
+ if (!capturedCustomValidator) throw new Error('Custom validator was not captured')
116
+ return capturedCustomValidator
117
+ }
118
+
119
+ it('validation returns a message string for a stale variableKey', () => {
120
+ const validator = extractCustomValidator(testVariables)
121
+ const validationResult = validator('removedVariable')
122
+ expect(validationResult).toBeTypeOf('string')
123
+ expect(validationResult).toContain('removedVariable')
124
+ })
125
+
126
+ it('validation returns true for a valid variableKey', () => {
127
+ const validator = extractCustomValidator(testVariables)
128
+ expect(validator('firstName')).toBe(true)
129
+ })
130
+ })
96
131
  })
@@ -0,0 +1,30 @@
1
+ import {describe, expect, it} from 'vitest'
2
+ import {isStaleVariable} from '../isStaleVariable'
3
+ import type {InterpolationVariable} from '../types'
4
+
5
+ const testVariables: InterpolationVariable[] = [
6
+ {id: 'firstName', name: 'First name'},
7
+ {id: 'email', name: 'Email address'},
8
+ ]
9
+
10
+ describe('isStaleVariable', () => {
11
+ it('returns false when variableKey is undefined', () => {
12
+ expect(isStaleVariable(undefined, testVariables)).toBe(false)
13
+ })
14
+
15
+ it('returns false when variableKey is empty string', () => {
16
+ expect(isStaleVariable('', testVariables)).toBe(false)
17
+ })
18
+
19
+ it('returns false when variableKey matches a variable id', () => {
20
+ expect(isStaleVariable('firstName', testVariables)).toBe(false)
21
+ })
22
+
23
+ it('returns true when variableKey matches no variable id', () => {
24
+ expect(isStaleVariable('removed', testVariables)).toBe(true)
25
+ })
26
+
27
+ it('returns true when variables array is empty', () => {
28
+ expect(isStaleVariable('anything', [])).toBe(true)
29
+ })
30
+ })
@@ -1,8 +1,9 @@
1
- import {SearchIcon} from '@sanity/icons'
2
- import {Autocomplete, Box, Card, Text} from '@sanity/ui'
1
+ import {SearchIcon, WarningOutlineIcon} from '@sanity/icons'
2
+ import {Autocomplete, Badge, Box, Card, Flex, Text} from '@sanity/ui'
3
3
  import {useCallback, useId} from 'react'
4
4
  import {set, unset} from 'sanity'
5
5
  import type {BlockProps, FieldProps, InputProps} from 'sanity'
6
+ import {isStaleVariable, staleVariableMessage} from '../isStaleVariable'
6
7
  import type {InterpolationVariable} from '../types'
7
8
 
8
9
  interface VariableOption {
@@ -25,6 +26,8 @@ export function createVariableKeyInput(variables: InterpolationVariable[]) {
25
26
  return function VariableKeyInput(props: InputProps) {
26
27
  const autocompleteId = useId()
27
28
  const selectedVariable = variables.find((variable) => variable.id === props.value)
29
+ const variableKey = typeof props.value === 'string' ? props.value : undefined
30
+ const stale = isStaleVariable(variableKey, variables)
28
31
 
29
32
  const handleChange = useCallback(
30
33
  (selectedValue: string) => {
@@ -65,7 +68,7 @@ export function createVariableKeyInput(variables: InterpolationVariable[]) {
65
68
  <Autocomplete
66
69
  id={autocompleteId}
67
70
  options={options}
68
- value={typeof props.value === 'string' ? props.value : undefined}
71
+ value={variableKey}
69
72
  onChange={handleChange}
70
73
  filterOption={filterOption}
71
74
  renderOption={renderOption}
@@ -75,7 +78,18 @@ export function createVariableKeyInput(variables: InterpolationVariable[]) {
75
78
  placeholder="Search variables..."
76
79
  fontSize={1}
77
80
  padding={3}
81
+ customValidity={stale && variableKey ? staleVariableMessage(variableKey) : undefined}
78
82
  />
83
+ {stale && variableKey && (
84
+ <Card tone="caution" padding={3} marginTop={2} radius={2}>
85
+ <Flex align="center" gap={2}>
86
+ <Text size={1}>
87
+ <WarningOutlineIcon />
88
+ </Text>
89
+ <Text size={1}>{staleVariableMessage(variableKey)}</Text>
90
+ </Flex>
91
+ </Card>
92
+ )}
79
93
  {selectedVariable?.description && (
80
94
  <Box marginTop={2}>
81
95
  <Text size={1} muted>
@@ -93,14 +107,22 @@ export function createVariableInlineBlock(variables: InterpolationVariable[]) {
93
107
  const value = props.value as {variableKey?: string}
94
108
  const variableKey = value?.variableKey
95
109
  const variable = variables.find((candidate) => candidate.id === variableKey)
110
+ const stale = isStaleVariable(variableKey, variables)
96
111
 
97
112
  return props.renderDefault({
98
113
  ...props,
99
114
  renderPreview: () => (
100
115
  <Box padding={2}>
101
- <Text size={0} weight="medium">
102
- {variable?.name ?? variableKey ?? 'Select variable'}
103
- </Text>
116
+ <Flex align="center" gap={2}>
117
+ <Text size={0} weight="medium">
118
+ {variable?.name ?? variableKey ?? 'Select variable'}
119
+ </Text>
120
+ {stale && (
121
+ <Badge tone="caution" fontSize={0}>
122
+ Stale
123
+ </Badge>
124
+ )}
125
+ </Flex>
104
126
  </Box>
105
127
  ),
106
128
  })
@@ -5,6 +5,7 @@ import {
5
5
  createVariableKeyInput,
6
6
  VariableKeyField,
7
7
  } from './components/VariableInlineBlock'
8
+ import {isStaleVariable, staleVariableMessage} from './isStaleVariable'
8
9
  import type {InterpolationVariable} from './types'
9
10
 
10
11
  /** @public */
@@ -28,7 +29,16 @@ export function interpolationVariables(
28
29
  name: 'variableKey',
29
30
  title: 'Variable',
30
31
  type: 'string',
31
- validation: (rule) => rule.required(),
32
+ validation: (rule) => [
33
+ rule.required(),
34
+ rule
35
+ .custom((value) => {
36
+ const variableKey = typeof value === 'string' ? value : undefined
37
+ if (isStaleVariable(variableKey, variables) === false) return true
38
+ return staleVariableMessage(value as string)
39
+ })
40
+ .warning(),
41
+ ],
32
42
  components: {
33
43
  field: VariableKeyField,
34
44
  input: createVariableKeyInput(variables),
@@ -0,0 +1,14 @@
1
+ import type {InterpolationVariable} from './types'
2
+
3
+ export function isStaleVariable(
4
+ variableKey: string | undefined,
5
+ variables: InterpolationVariable[],
6
+ ): boolean {
7
+ if (typeof variableKey === 'undefined' || variableKey.length === 0) return false
8
+ const hasMatchingVariable = variables.some((variable) => variable.id === variableKey)
9
+ return hasMatchingVariable === false
10
+ }
11
+
12
+ export function staleVariableMessage(variableKey: string): string {
13
+ return `Variable "${variableKey}" is no longer defined. Please select a valid variable.`
14
+ }