sanity-plugin-pte-interpolation 1.1.1 → 1.2.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/README.md +80 -29
- package/dist/index.cjs +23 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +26 -9
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
- package/src/__tests__/VariableInlineBlock.test.tsx +137 -71
- package/src/__tests__/interpolationVariables.test.ts +35 -0
- package/src/__tests__/isStaleVariable.test.ts +30 -0
- package/src/components/VariableInlineBlock.tsx +28 -6
- package/src/interpolationVariables.ts +11 -1
- package/src/isStaleVariable.ts +14 -0
package/README.md
CHANGED
|
@@ -16,33 +16,44 @@ npm install sanity-plugin-pte-interpolation
|
|
|
16
16
|
|
|
17
17
|
### Peer dependencies
|
|
18
18
|
|
|
19
|
-
- `sanity
|
|
20
|
-
- `react
|
|
21
|
-
- `@sanity/ui
|
|
22
|
-
- `@sanity/icons
|
|
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
|
|
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: '
|
|
34
|
-
title: '
|
|
33
|
+
name: 'promoCard',
|
|
34
|
+
title: 'Promo card',
|
|
35
35
|
type: 'document',
|
|
36
36
|
fields: [
|
|
37
37
|
defineField({
|
|
38
|
-
name: '
|
|
39
|
-
title: '
|
|
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: '
|
|
44
|
-
{
|
|
45
|
-
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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": "
|
|
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:
|
|
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.
|
|
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) =>
|
|
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)
|
package/dist/index.cjs.map
CHANGED
|
@@ -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":";;;AAaO,SAAS,iBAAiB,OAAmB;AAClD,SAAOA,2BAAAA,IAAAC,WAAAA,UAAA,EAAG,gBAAM,SAAA,CAAS;AAC3B;AAEO,SAAS,uBAAuB,WAAoC;AACzE,QAAM,UAA4B,UAAU,IAAI,CAAC,cAAc;AAAA,IAC7D,OAAO,SAAS;AAAA,IAChB,MAAM,SAAS;AAAA,IACf,aAAa,SAAS;AAAA,EAAA,EACtB;AAEF,SAAO,SAA0B,OAAmB;AAClD,UAAM,iBAAiBC,MAAAA,MAAA,GACjB,mBAAmB,UAAU,KAAK,CAAC,aAAa,SAAS,OAAO,MAAM,KAAK,GAE3E,eAAeC,MAAAA;AAAAA,MACnB,CAAC,kBAA0B;AACzB,cAAM,SAAS,gBAAgBC,OAAAA,IAAI,aAAa,IAAIC,OAAAA,OAAO;AAAA,MAC7D;AAAA,MACA,CAAC,KAAK;AAAA,IAAA,GAGF,eAAeF,MAAAA,YAAY,CAAC,OAAe,WACxC,OAAO,KAAK,YAAA,EAAc,SAAS,MAAM,YAAA,CAAa,GAC5D,CAAA,CAAE,GAEC,eAAeA,kBAAY,CAAC,WAE9BG,2BAAAA,KAACC,SAAA,EAAK,IAAG,UAAS,SAAS,GACzB,UAAA;AAAA,MAAAP,+BAACQ,GAAAA,QAAK,MAAM,GAAG,QAAO,UACnB,iBAAO,MACV;AAAA,MACC,OAAO,eACNR,+BAACS,GAAAA,KAAA,EAAI,WAAW,GACd,UAAAT,2BAAAA,IAACQ,GAAAA,MAAA,EAAK,MAAM,GAAG,OAAK,IACjB,UAAA,OAAO,aACV,EAAA,CACF;AAAA,IAAA,GAEJ,GAED,EAAE,GAEC,cAAcL,MAAAA,YAAY,CAAC,QAAgB,WAC3C,SAAe,OAAO,OACJ,QAAQ,KAAK,CAAC,cAAc,UAAU,UAAU,MAAM,GACtD,QAAQ,QAC7B,EAAE;AAEL,WACEG,2BAAAA,KAAAL,qBAAA,EACE,UAAA;AAAA,MAAAD,2BAAAA;AAAAA,QAACU,GAAAA;AAAAA,QAAA;AAAA,UACC,IAAI;AAAA,UACJ;AAAA,UACA,OAAO,OAAO,MAAM,SAAU,WAAW,MAAM,QAAQ;AAAA,UACvD,UAAU;AAAA,UACV;AAAA,UACA;AAAA,UACA;AAAA,UACA,YAAU;AAAA,UACV,MAAMC,MAAAA;AAAAA,UACN,aAAY;AAAA,UACZ,UAAU;AAAA,UACV,SAAS;AAAA,QAAA;AAAA,MAAA;AAAA,MAEV,kBAAkB,eACjBX,+BAACS,GAAAA,KAAA,EAAI,WAAW,GACd,UAAAT,2BAAAA,IAACQ,GAAAA,MAAA,EAAK,MAAM,GAAG,OAAK,IACjB,UAAA,iBAAiB,aACpB,EAAA,CACF;AAAA,IAAA,GAEJ;AAAA,EAEJ;AACF;AAEO,SAAS,0BAA0B,WAAoC;AAC5E,SAAO,SAA6B,OAAmB;AAErD,UAAM,cADQ,MAAM,OACO,aACrB,WAAW,UAAU,KAAK,CAAC,cAAc,UAAU,OAAO,WAAW;AAE3E,WAAO,MAAM,cAAc;AAAA,MACzB,GAAG;AAAA,MACH,eAAe,MACbR,2BAAAA,IAACS,QAAA,EAAI,SAAS,GACZ,UAAAT,2BAAAA,IAACQ,SAAA,EAAK,MAAM,GAAG,QAAO,UACnB,UAAA,UAAU,QAAQ,eAAe,mBACpC,EAAA,CACF;AAAA,IAAA,CAEH;AAAA,EACH;AACF;ACjGO,MAAM,uBAAuB;AAG7B,SAAS,uBACd,WACA,OACA;AACA,QAAM,eAAeI,OAAAA,kBAAkB;AAAA,IACrC,MAAM;AAAA,IACN,MAAM;AAAA,IACN,OAAO;AAAA,IACP,MAAMC,MAAAA;AAAAA,IACN,SAAS;AAAA,MACP,OAAO,EAAC,OAAO,EAAA;AAAA,IAAC;AAAA,IAElB,QAAQ;AAAA,MACNC,mBAAY;AAAA,QACV,MAAM;AAAA,QACN,OAAO;AAAA,QACP,MAAM;AAAA,QACN,YAAY,CAAC,SAAS,KAAK,SAAA;AAAA,QAC3B,YAAY;AAAA,UACV,OAAO;AAAA,UACP,OAAO,uBAAuB,SAAS;AAAA,QAAA;AAAA,MACzC,CACD;AAAA,IAAA;AAAA,IAEH,YAAY;AAAA,MACV,aAAa,0BAA0B,SAAS;AAAA,IAAA;AAAA,EAClD,CACD,GAEK,YAAY,SAASF,OAAAA,kBAAkB,EAAC,MAAM,SAAQ;AAE5D,SAAO;AAAA,IACL,GAAG;AAAA,IACH,IAAI,CAAC,GAAK,UAA+B,MAAM,CAAA,GAAK,YAAY;AAAA,EAAA;AAEpE;;;"}
|
|
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,
|
|
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:
|
|
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__ */
|
|
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) =>
|
|
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={
|
|
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":";;;;;AAEO,SAAS,gBACd,aACA,WACS;AACT,SAAI,OAAO,cAAgB,OAAe,YAAY,WAAW,IAAU,KAC/C,UAAU,KAAK,CAAC,aAAa,SAAS,OAAO,WAAW,MACrD;AACjC;AAEO,SAAS,qBAAqB,aAA6B;AAChE,SAAO,aAAa,WAAW;AACjC;ACCO,SAAS,iBAAiB,OAAmB;AAClD,SAAO,oBAAA,UAAA,EAAG,gBAAM,SAAA,CAAS;AAC3B;AAEO,SAAS,uBAAuB,WAAoC;AACzE,QAAM,UAA4B,UAAU,IAAI,CAAC,cAAc;AAAA,IAC7D,OAAO,SAAS;AAAA,IAChB,MAAM,SAAS;AAAA,IACf,aAAa,SAAS;AAAA,EAAA,EACtB;AAEF,SAAO,SAA0B,OAAmB;AAClD,UAAM,iBAAiB,SACjB,mBAAmB,UAAU,KAAK,CAAC,aAAa,SAAS,OAAO,MAAM,KAAK,GAC3E,cAAc,OAAO,MAAM,SAAU,WAAW,MAAM,QAAQ,QAC9D,QAAQ,gBAAgB,aAAa,SAAS,GAE9C,eAAe;AAAA,MACnB,CAAC,kBAA0B;AACzB,cAAM,SAAS,gBAAgB,IAAI,aAAa,IAAI,OAAO;AAAA,MAC7D;AAAA,MACA,CAAC,KAAK;AAAA,IAAA,GAGF,eAAe,YAAY,CAAC,OAAe,WACxC,OAAO,KAAK,YAAA,EAAc,SAAS,MAAM,YAAA,CAAa,GAC5D,CAAA,CAAE,GAEC,eAAe,YAAY,CAAC,WAE9B,qBAAC,MAAA,EAAK,IAAG,UAAS,SAAS,GACzB,UAAA;AAAA,MAAA,oBAAC,QAAK,MAAM,GAAG,QAAO,UACnB,iBAAO,MACV;AAAA,MACC,OAAO,eACN,oBAAC,KAAA,EAAI,WAAW,GACd,UAAA,oBAAC,MAAA,EAAK,MAAM,GAAG,OAAK,IACjB,UAAA,OAAO,aACV,EAAA,CACF;AAAA,IAAA,GAEJ,GAED,EAAE,GAEC,cAAc,YAAY,CAAC,QAAgB,WAC3C,SAAe,OAAO,OACJ,QAAQ,KAAK,CAAC,cAAc,UAAU,UAAU,MAAM,GACtD,QAAQ,QAC7B,EAAE;AAEL,WACE,qBAAA,UAAA,EACE,UAAA;AAAA,MAAA;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,IAAI;AAAA,UACJ;AAAA,UACA,OAAO;AAAA,UACP,UAAU;AAAA,UACV;AAAA,UACA;AAAA,UACA;AAAA,UACA,YAAU;AAAA,UACV,MAAM;AAAA,UACN,aAAY;AAAA,UACZ,UAAU;AAAA,UACV,SAAS;AAAA,UACT,gBAAgB,SAAS,cAAc,qBAAqB,WAAW,IAAI;AAAA,QAAA;AAAA,MAAA;AAAA,MAE5E,SAAS,eACR,oBAAC,MAAA,EAAK,MAAK,WAAU,SAAS,GAAG,WAAW,GAAG,QAAQ,GACrD,UAAA,qBAAC,QAAK,OAAM,UAAS,KAAK,GACxB,UAAA;AAAA,QAAA,oBAAC,MAAA,EAAK,MAAM,GACV,UAAA,oBAAC,sBAAmB,GACtB;AAAA,4BACC,MAAA,EAAK,MAAM,GAAI,UAAA,qBAAqB,WAAW,EAAA,CAAE;AAAA,MAAA,EAAA,CACpD,EAAA,CACF;AAAA,MAED,kBAAkB,eACjB,oBAAC,KAAA,EAAI,WAAW,GACd,UAAA,oBAAC,MAAA,EAAK,MAAM,GAAG,OAAK,IACjB,UAAA,iBAAiB,aACpB,EAAA,CACF;AAAA,IAAA,GAEJ;AAAA,EAEJ;AACF;AAEO,SAAS,0BAA0B,WAAoC;AAC5E,SAAO,SAA6B,OAAmB;AAErD,UAAM,cADQ,MAAM,OACO,aACrB,WAAW,UAAU,KAAK,CAAC,cAAc,UAAU,OAAO,WAAW,GACrE,QAAQ,gBAAgB,aAAa,SAAS;AAEpD,WAAO,MAAM,cAAc;AAAA,MACzB,GAAG;AAAA,MACH,eAAe,MACb,oBAAC,KAAA,EAAI,SAAS,GACZ,UAAA,qBAAC,MAAA,EAAK,OAAM,UAAS,KAAK,GACxB,UAAA;AAAA,QAAA,oBAAC,MAAA,EAAK,MAAM,GAAG,QAAO,UACnB,UAAA,UAAU,QAAQ,eAAe,kBAAA,CACpC;AAAA,QACC,SACC,oBAAC,OAAA,EAAM,MAAK,WAAU,UAAU,GAAG,UAAA,QAAA,CAEnC;AAAA,MAAA,EAAA,CAEJ,EAAA,CACF;AAAA,IAAA,CAEH;AAAA,EACH;AACF;ACtHO,MAAM,uBAAuB;AAG7B,SAAS,uBACd,WACA,OACA;AACA,QAAM,eAAe,kBAAkB;AAAA,IACrC,MAAM;AAAA,IACN,MAAM;AAAA,IACN,OAAO;AAAA,IACP,MAAM;AAAA,IACN,SAAS;AAAA,MACP,OAAO,EAAC,OAAO,EAAA;AAAA,IAAC;AAAA,IAElB,QAAQ;AAAA,MACN,YAAY;AAAA,QACV,MAAM;AAAA,QACN,OAAO;AAAA,QACP,MAAM;AAAA,QACN,YAAY,CAAC,SAAS;AAAA,UACpB,KAAK,SAAA;AAAA,UACL,KACG,OAAO,CAAC,UAEH,gBADgB,OAAO,SAAU,WAAW,QAAQ,QACvB,SAAS,MAAM,KAAc,KACvD,qBAAqB,KAAe,CAC5C,EACA,QAAA;AAAA,QAAQ;AAAA,QAEb,YAAY;AAAA,UACV,OAAO;AAAA,UACP,OAAO,uBAAuB,SAAS;AAAA,QAAA;AAAA,MACzC,CACD;AAAA,IAAA;AAAA,IAEH,YAAY;AAAA,MACV,aAAa,0BAA0B,SAAS;AAAA,IAAA;AAAA,EAClD,CACD,GAEK,YAAY,SAAS,kBAAkB,EAAC,MAAM,SAAQ;AAE5D,SAAO;AAAA,IACL,GAAG;AAAA,IACH,IAAI,CAAC,GAAK,UAA+B,MAAM,CAAA,GAAK,YAAY;AAAA,EAAA;AAEpE;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sanity-plugin-pte-interpolation",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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 {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
39
|
+
function renderPreview(variableKey: string | undefined) {
|
|
48
40
|
const VariableInlineBlock = createVariableInlineBlock(testVariables)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
+
renderPreview('unknown')
|
|
63
|
+
expect(screen.getByText('unknown')).toBeDefined()
|
|
69
64
|
})
|
|
70
65
|
|
|
71
66
|
it('renders "Select variable" when variableKey is undefined', () => {
|
|
72
|
-
|
|
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)
|
|
83
|
+
function renderInput(value: string | undefined, onChange = vi.fn()) {
|
|
78
84
|
const VariableKeyInput = createVariableKeyInput(testVariables)
|
|
79
|
-
|
|
80
|
-
value
|
|
81
|
-
onChange:
|
|
82
|
-
}
|
|
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
|
-
|
|
100
|
+
renderInput('firstName')
|
|
101
|
+
expect(screen.getByText('First name of the recipient')).toBeDefined()
|
|
87
102
|
})
|
|
88
103
|
|
|
89
|
-
it('does not include description
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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={
|
|
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
|
-
<
|
|
102
|
-
{
|
|
103
|
-
|
|
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) =>
|
|
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
|
+
}
|