sanity-plugin-jev-fields 0.1.0 → 0.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 +110 -41
- package/dist/index.d.ts +25 -25
- package/dist/index.js +127 -127
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -39,7 +39,7 @@ function JevFormProvider({ config, children }) {
|
|
|
39
39
|
}
|
|
40
40
|
function useJevForm() {
|
|
41
41
|
let context = useContext(Context);
|
|
42
|
-
if (!context) throw Error("Jev
|
|
42
|
+
if (!context) throw Error("Jev questions need the jev() plugin in sanity.config.");
|
|
43
43
|
return context;
|
|
44
44
|
}
|
|
45
45
|
/**
|
|
@@ -137,12 +137,12 @@ function rangeViolation(value, rule, format) {
|
|
|
137
137
|
if (rule?.atLeast !== void 0 && value < rule.atLeast) return `below ${format(rule.atLeast)}`;
|
|
138
138
|
if (rule?.atMost !== void 0 && value > rule.atMost) return `above ${format(rule.atMost)}`;
|
|
139
139
|
}
|
|
140
|
-
/** Why a choice
|
|
141
|
-
function choiceProblem(
|
|
142
|
-
if (options.length < 2) return "A choice
|
|
143
|
-
if (options.length > 255) return "A choice
|
|
140
|
+
/** Why a choice question can't be asked, if it can't. */
|
|
141
|
+
function choiceProblem(question, options) {
|
|
142
|
+
if (options.length < 2) return "A choice question needs at least two options.";
|
|
143
|
+
if (options.length > 255) return "A choice question allows at most 255 options.";
|
|
144
144
|
for (let level of ["warn", "require"]) {
|
|
145
|
-
let rule =
|
|
145
|
+
let rule = question[level];
|
|
146
146
|
if (!rule) continue;
|
|
147
147
|
if (!Array.isArray(rule.oneOf) || rule.oneOf.length === 0) return `\`${level}.oneOf\` must list at least one option.`;
|
|
148
148
|
let unknown = rule.oneOf.find((option) => !options.includes(option));
|
|
@@ -150,9 +150,9 @@ function choiceProblem(signal, options) {
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
/** One option from a named set. @public */
|
|
153
|
-
const choice = (
|
|
153
|
+
const choice = (question) => ({
|
|
154
154
|
type: "choice",
|
|
155
|
-
...
|
|
155
|
+
...question
|
|
156
156
|
}), choiceSchemaTypes = [defineType({
|
|
157
157
|
name: TYPE_NAMES.choiceProbability,
|
|
158
158
|
title: "Jev choice probability",
|
|
@@ -189,14 +189,14 @@ const choice = (signal) => ({
|
|
|
189
189
|
...bookkeepingFields
|
|
190
190
|
]
|
|
191
191
|
})];
|
|
192
|
-
function bindChoice(
|
|
193
|
-
let criteria =
|
|
192
|
+
function bindChoice(question) {
|
|
193
|
+
let criteria = question.criteria ?? {}, problem = choiceProblem(question, Object.keys(criteria));
|
|
194
194
|
return {
|
|
195
195
|
typeName: TYPE_NAMES.choice,
|
|
196
|
-
|
|
196
|
+
gatewayQuestion: problem ? void 0 : {
|
|
197
197
|
type: "choice",
|
|
198
|
-
instructions:
|
|
199
|
-
criteria:
|
|
198
|
+
instructions: question.instructions,
|
|
199
|
+
criteria: question.criteria
|
|
200
200
|
},
|
|
201
201
|
problem,
|
|
202
202
|
toStored(answer) {
|
|
@@ -235,17 +235,17 @@ function bindChoice(signal) {
|
|
|
235
235
|
},
|
|
236
236
|
tone: "default",
|
|
237
237
|
aside: meaning ? { note: meaning } : void 0,
|
|
238
|
-
body: <ChoiceBody
|
|
238
|
+
body: <ChoiceBody question={question} choice={record.choice} probabilities={probabilities} />
|
|
239
239
|
};
|
|
240
240
|
},
|
|
241
241
|
check(level, stored, title) {
|
|
242
|
-
let record = storedOf(stored, TYPE_NAMES.choice), rule =
|
|
242
|
+
let record = storedOf(stored, TYPE_NAMES.choice), rule = question[level];
|
|
243
243
|
if (record && typeof record.choice == "string" && rule?.oneOf && !rule.oneOf.includes(record.choice)) return `${title} is "${record.choice}", not ${rule.oneOf.map((option) => `"${option}"`).join(" or ")}.`;
|
|
244
244
|
}
|
|
245
245
|
};
|
|
246
246
|
}
|
|
247
|
-
function ChoiceBody({
|
|
248
|
-
let options = Object.keys(
|
|
247
|
+
function ChoiceBody({ question, choice, probabilities }) {
|
|
248
|
+
let options = Object.keys(question.criteria), nameWidth = `${Math.max(...options.map((name) => name.length), 4) + 2}ch`;
|
|
249
249
|
return <Stack gap={3}>
|
|
250
250
|
{options.map((option) => {
|
|
251
251
|
let probability = probabilities.find((entry) => entry.option === option)?.probability ?? 0, winner = option === choice, percent = Math.round(probability * 100);
|
|
@@ -275,7 +275,7 @@ const JEV_NAMESPACE = "jev", jevLocaleBundle = defineLocaleResourceBundle({
|
|
|
275
275
|
resources: {
|
|
276
276
|
"strip.label": "Jev",
|
|
277
277
|
"strip.set-up": "Set up Jev",
|
|
278
|
-
"strip.evaluate-all": "Evaluate all
|
|
278
|
+
"strip.evaluate-all": "Evaluate all questions now",
|
|
279
279
|
"chip.empty": "–",
|
|
280
280
|
"chip.error": "Error",
|
|
281
281
|
"chip.out-of-date": "Out of date",
|
|
@@ -305,7 +305,7 @@ const JEV_NAMESPACE = "jev", jevLocaleBundle = defineLocaleResourceBundle({
|
|
|
305
305
|
"key-dialog.saving": "Saving…",
|
|
306
306
|
"key-dialog.save-failed": "Could not save the key: {{error}}",
|
|
307
307
|
"tool.title": "Jev",
|
|
308
|
-
"tool.intro": "Jev
|
|
308
|
+
"tool.intro": "Jev questions are answered by TypeSafe’s Jev model through Vercel AI Gateway.",
|
|
309
309
|
"tool.key.heading": "AI Gateway API key",
|
|
310
310
|
"tool.key.status.config": "From plugin config",
|
|
311
311
|
"tool.key.status.checking": "Checking",
|
|
@@ -316,11 +316,11 @@ const JEV_NAMESPACE = "jev", jevLocaleBundle = defineLocaleResourceBundle({
|
|
|
316
316
|
"tool.key.ends-with": "Key ending in <Code>{{last4}}</Code>",
|
|
317
317
|
"tool.key.changed": "Last changed {{date}}",
|
|
318
318
|
"tool.key.checking": "Checking for a stored key…",
|
|
319
|
-
"tool.key.none": "No key is stored, so Jev
|
|
319
|
+
"tool.key.none": "No key is stored, so Jev questions can’t be evaluated yet.",
|
|
320
320
|
"tool.key.set": "Set key",
|
|
321
321
|
"tool.key.change": "Change key",
|
|
322
322
|
"tool.key.remove": "Remove key",
|
|
323
|
-
"tool.key.remove-confirm": "Remove the key? Jev
|
|
323
|
+
"tool.key.remove-confirm": "Remove the key? Jev questions stop being evaluated until a new one is set.",
|
|
324
324
|
"tool.key.remove-cancel": "Cancel",
|
|
325
325
|
"tool.key.remove-confirm-button": "Remove",
|
|
326
326
|
"tool.key.remove-failed": "Could not remove the key: {{error}}",
|
|
@@ -330,9 +330,9 @@ const JEV_NAMESPACE = "jev", jevLocaleBundle = defineLocaleResourceBundle({
|
|
|
330
330
|
"tool.visibility.heading": "Who can see the key",
|
|
331
331
|
"tool.visibility.body": "The key is stored in this dataset in the document <Code>secrets.jev</Code>. It is not public, but anyone who can read the dataset can see it: every Studio user, and every API token with read access, such as a frontend’s read or preview token. It is also included in dataset exports and backups. Use a dedicated key with a spend limit. If editors and tokens must never see the key, set a <Code>transport</Code> that sends requests through your own server."
|
|
332
332
|
}
|
|
333
|
-
}), noul = (
|
|
333
|
+
}), noul = (question) => ({
|
|
334
334
|
type: "noul",
|
|
335
|
-
...
|
|
335
|
+
...question
|
|
336
336
|
}), noulSchemaTypes = [defineType({
|
|
337
337
|
name: TYPE_NAMES.noul,
|
|
338
338
|
title: "Jev yes/no",
|
|
@@ -348,16 +348,16 @@ const JEV_NAMESPACE = "jev", jevLocaleBundle = defineLocaleResourceBundle({
|
|
|
348
348
|
caution: "medium",
|
|
349
349
|
positive: "high"
|
|
350
350
|
};
|
|
351
|
-
function bindNoul(
|
|
352
|
-
let problem = !
|
|
351
|
+
function bindNoul(question) {
|
|
352
|
+
let problem = !question.true?.trim() || !question.false?.trim() ? "A noul question needs both `true` and `false` text." : rangeRuleProblem(question, 0, 1);
|
|
353
353
|
return {
|
|
354
354
|
typeName: TYPE_NAMES.noul,
|
|
355
|
-
|
|
355
|
+
gatewayQuestion: problem ? void 0 : {
|
|
356
356
|
type: "boolean",
|
|
357
|
-
instructions:
|
|
357
|
+
instructions: question.instructions,
|
|
358
358
|
criteria: {
|
|
359
|
-
true:
|
|
360
|
-
false:
|
|
359
|
+
true: question.true,
|
|
360
|
+
false: question.false
|
|
361
361
|
}
|
|
362
362
|
},
|
|
363
363
|
problem,
|
|
@@ -386,38 +386,38 @@ function bindNoul(signal) {
|
|
|
386
386
|
level: LEVELS$1[tone] ?? "medium",
|
|
387
387
|
tone
|
|
388
388
|
},
|
|
389
|
-
body: <NoulBody
|
|
389
|
+
body: <NoulBody question={question} probability={probability} />
|
|
390
390
|
};
|
|
391
391
|
},
|
|
392
392
|
check(level, stored, title) {
|
|
393
393
|
let record = storedOf(stored, TYPE_NAMES.noul);
|
|
394
394
|
if (!record || typeof record.probability != "number") return;
|
|
395
|
-
let percent = (value) => `${Math.round(value * 100)}%`, broken = rangeViolation(record.probability,
|
|
395
|
+
let percent = (value) => `${Math.round(value * 100)}%`, broken = rangeViolation(record.probability, question[level], percent);
|
|
396
396
|
return broken && `${title} is ${percent(record.probability)}, ${broken}.`;
|
|
397
397
|
}
|
|
398
398
|
};
|
|
399
399
|
}
|
|
400
|
-
function NoulBody({
|
|
400
|
+
function NoulBody({ question, probability }) {
|
|
401
401
|
let { t } = useTranslation("jev"), percent = Math.round(probability * 100);
|
|
402
402
|
return <Stack gap={4}>
|
|
403
403
|
<Flex align="baseline" gap={2}>
|
|
404
404
|
<Text size={4} weight="medium">
|
|
405
405
|
{percent}%
|
|
406
406
|
</Text>
|
|
407
|
-
{
|
|
408
|
-
{
|
|
407
|
+
{question.label && <Text size={1} muted>
|
|
408
|
+
{question.label}
|
|
409
409
|
</Text>}
|
|
410
410
|
</Flex>
|
|
411
|
-
<Bar fraction={probability} color={trafficColor(probability)} label={`${
|
|
411
|
+
<Bar fraction={probability} color={trafficColor(probability)} label={`${question.title ?? t("noul.yes")}: ${percent}%`} />
|
|
412
412
|
<Text size={1} muted>
|
|
413
|
-
{probability >= .5 ?
|
|
413
|
+
{probability >= .5 ? question.true : question.false}
|
|
414
414
|
</Text>
|
|
415
415
|
</Stack>;
|
|
416
416
|
}
|
|
417
417
|
/** A position on an ordered scale. @public */
|
|
418
|
-
const score = (
|
|
418
|
+
const score = (question) => ({
|
|
419
419
|
type: "score",
|
|
420
|
-
...
|
|
420
|
+
...question
|
|
421
421
|
}), scoreSchemaTypes = [defineType({
|
|
422
422
|
name: TYPE_NAMES.score,
|
|
423
423
|
title: "Jev score",
|
|
@@ -461,24 +461,24 @@ function segmentFills(score, count) {
|
|
|
461
461
|
return Array.from({ length: count }, (_, index) => Math.min(1, Math.max(0, score - index + 1)));
|
|
462
462
|
}
|
|
463
463
|
/** Colour and tone of a score, from the criterion it is nearest to. */
|
|
464
|
-
function scoreLook(
|
|
465
|
-
if (
|
|
464
|
+
function scoreLook(question, value, max) {
|
|
465
|
+
if (question.colors === "neutral") return {
|
|
466
466
|
color: NEUTRAL_COLOR,
|
|
467
467
|
tone: "primary"
|
|
468
468
|
};
|
|
469
|
-
let fraction = max > 0 ? Math.round(value) / max : 0, good =
|
|
469
|
+
let fraction = max > 0 ? Math.round(value) / max : 0, good = question.colors === "reverse" ? 1 - fraction : fraction;
|
|
470
470
|
return {
|
|
471
471
|
color: trafficColor(good),
|
|
472
472
|
tone: trafficTone(good)
|
|
473
473
|
};
|
|
474
474
|
}
|
|
475
|
-
function bindScore(
|
|
476
|
-
let { criteria } =
|
|
475
|
+
function bindScore(question) {
|
|
476
|
+
let { criteria } = question, problem = !Array.isArray(criteria) || criteria.length < 2 ? "A score question needs at least two criteria." : criteria.length > 10 ? "A score question allows at most ten criteria." : rangeRuleProblem(question, 0, criteria.length - 1);
|
|
477
477
|
return {
|
|
478
478
|
typeName: TYPE_NAMES.score,
|
|
479
|
-
|
|
479
|
+
gatewayQuestion: problem ? void 0 : {
|
|
480
480
|
type: "score",
|
|
481
|
-
instructions:
|
|
481
|
+
instructions: question.instructions,
|
|
482
482
|
criteria
|
|
483
483
|
},
|
|
484
484
|
problem,
|
|
@@ -496,7 +496,7 @@ function bindScore(signal) {
|
|
|
496
496
|
read(stored) {
|
|
497
497
|
let record = storedOf(stored, TYPE_NAMES.score);
|
|
498
498
|
if (!record || typeof record.score != "number" || typeof record.max != "number") return;
|
|
499
|
-
let { score: value, max } = record, label = text(record.label), look = scoreLook(
|
|
499
|
+
let { score: value, max } = record, label = text(record.label), look = scoreLook(question, value, max);
|
|
500
500
|
return {
|
|
501
501
|
value: {
|
|
502
502
|
...bookkeepingOf(record),
|
|
@@ -515,7 +515,7 @@ function bindScore(signal) {
|
|
|
515
515
|
badge: capitalize(label ?? shortLabel(criteria?.[Math.round(value)])),
|
|
516
516
|
tone: look.tone
|
|
517
517
|
},
|
|
518
|
-
body: <ScoreBody
|
|
518
|
+
body: <ScoreBody question={question} score={value} max={max} color={look.color} />
|
|
519
519
|
};
|
|
520
520
|
},
|
|
521
521
|
check(level, stored, title) {
|
|
@@ -524,16 +524,16 @@ function bindScore(signal) {
|
|
|
524
524
|
let describe = (position) => {
|
|
525
525
|
let label = shortLabel(criteria[Math.round(position)]), number = Number.isInteger(position) ? String(position) : position.toFixed(1);
|
|
526
526
|
return label ? `${number} (${label})` : number;
|
|
527
|
-
}, broken = rangeViolation(record.score,
|
|
527
|
+
}, broken = rangeViolation(record.score, question[level], describe);
|
|
528
528
|
return broken && `${title} is ${describe(record.score)}, ${broken}.`;
|
|
529
529
|
}
|
|
530
530
|
};
|
|
531
531
|
}
|
|
532
|
-
function ScoreBody({
|
|
533
|
-
let { t } = useTranslation("jev"), nearest = Math.round(score), fills = segmentFills(score,
|
|
532
|
+
function ScoreBody({ question, score, max, color }) {
|
|
533
|
+
let { t } = useTranslation("jev"), nearest = Math.round(score), fills = segmentFills(score, question.criteria.length), next = question.criteria[nearest + 1];
|
|
534
534
|
return <Stack gap={4}>
|
|
535
535
|
<Flex gap={1}>
|
|
536
|
-
{
|
|
536
|
+
{question.criteria.map((criterion, index) => <Stack key={index} gap={2} flex={1}>
|
|
537
537
|
<Bar fraction={fills[index]} color={color} label={`${index}: ${shortLabel(criterion)}`} />
|
|
538
538
|
<Text size={1} weight={index === nearest ? "medium" : "regular"} muted={index > nearest} style={index < nearest ? { color } : void 0} textOverflow="ellipsis">
|
|
539
539
|
{index} · {capitalize(shortLabel(criterion))}
|
|
@@ -542,7 +542,7 @@ function ScoreBody({ signal, score, max, color }) {
|
|
|
542
542
|
</Flex>
|
|
543
543
|
<Text size={1} muted>
|
|
544
544
|
{t("score.summary", {
|
|
545
|
-
meaning: capitalize(meaningOf(
|
|
545
|
+
meaning: capitalize(meaningOf(question.criteria[nearest])),
|
|
546
546
|
score: score.toFixed(1),
|
|
547
547
|
max
|
|
548
548
|
})}
|
|
@@ -556,23 +556,23 @@ const KIND_SCHEMA_TYPES = [
|
|
|
556
556
|
...scoreSchemaTypes,
|
|
557
557
|
...choiceSchemaTypes
|
|
558
558
|
];
|
|
559
|
-
/** Binds a
|
|
560
|
-
function kindOf(
|
|
561
|
-
let kind = bind(
|
|
562
|
-
return
|
|
559
|
+
/** Binds a question to its kind. The one place that tells Noul, Score and Choice apart. */
|
|
560
|
+
function kindOf(question) {
|
|
561
|
+
let kind = bind(question);
|
|
562
|
+
return question.instructions?.trim() ? kind : {
|
|
563
563
|
...kind,
|
|
564
|
-
|
|
565
|
-
problem: "Add instructions to this
|
|
564
|
+
gatewayQuestion: void 0,
|
|
565
|
+
problem: "Add instructions to this question."
|
|
566
566
|
};
|
|
567
567
|
}
|
|
568
|
-
function bind(
|
|
569
|
-
switch (
|
|
570
|
-
case "noul": return bindNoul(
|
|
571
|
-
case "score": return bindScore(
|
|
572
|
-
case "choice": return bindChoice(
|
|
568
|
+
function bind(question) {
|
|
569
|
+
switch (question.type) {
|
|
570
|
+
case "noul": return bindNoul(question);
|
|
571
|
+
case "score": return bindScore(question);
|
|
572
|
+
case "choice": return bindChoice(question);
|
|
573
573
|
default: return {
|
|
574
574
|
typeName: "unknown",
|
|
575
|
-
problem: "Unknown
|
|
575
|
+
problem: "Unknown question type.",
|
|
576
576
|
toStored: mismatch,
|
|
577
577
|
read: () => void 0,
|
|
578
578
|
check: () => void 0
|
|
@@ -580,19 +580,19 @@ function bind(signal) {
|
|
|
580
580
|
}
|
|
581
581
|
}
|
|
582
582
|
/** `readable` → `Readable`, `readingLevel` → `Reading level`. */
|
|
583
|
-
function titleOf(key,
|
|
584
|
-
if (
|
|
583
|
+
function titleOf(key, question) {
|
|
584
|
+
if (question.title) return question.title;
|
|
585
585
|
let words = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ");
|
|
586
586
|
return words.charAt(0).toUpperCase() + words.slice(1).toLowerCase();
|
|
587
587
|
}
|
|
588
588
|
const isRecord = (value) => !!value && typeof value == "object" && !Array.isArray(value);
|
|
589
|
-
function
|
|
589
|
+
function questionsOf(definition) {
|
|
590
590
|
if (!isRecord(definition) || !isRecord(definition.options)) return;
|
|
591
591
|
let { jev } = definition.options;
|
|
592
592
|
return isRecord(jev) ? jev : void 0;
|
|
593
593
|
}
|
|
594
594
|
/**
|
|
595
|
-
* Adds a field that stores each
|
|
595
|
+
* Adds a field that stores each question's answer next to every field with `options.jev`.
|
|
596
596
|
* Plugins can't see the Studio's own schema types, so wrap them in `sanity.config`:
|
|
597
597
|
*
|
|
598
598
|
* ```ts
|
|
@@ -612,8 +612,8 @@ function withAnswerFields(definition) {
|
|
|
612
612
|
of: definition.of.map(withAnswerFields)
|
|
613
613
|
}), Array.isArray(definition.fields)) {
|
|
614
614
|
let existing = new Map(definition.fields.filter(isRecord).map((field) => [field.name, field.type])), fields = definition.fields.flatMap((field) => {
|
|
615
|
-
let rewritten = withAnswerFields(field),
|
|
616
|
-
return !
|
|
615
|
+
let rewritten = withAnswerFields(field), questions = questionsOf(field);
|
|
616
|
+
return !questions || !isRecord(field) || !isRecord(rewritten) ? [rewritten] : [withRules(rewritten, questions), ...answerFieldsFor(field, questions, existing)];
|
|
617
617
|
});
|
|
618
618
|
result = {
|
|
619
619
|
...result,
|
|
@@ -622,14 +622,14 @@ function withAnswerFields(definition) {
|
|
|
622
622
|
}
|
|
623
623
|
return result;
|
|
624
624
|
}
|
|
625
|
-
function answerFieldsFor(field,
|
|
626
|
-
return Object.entries(
|
|
627
|
-
let type = TYPE_NAMES[
|
|
625
|
+
function answerFieldsFor(field, questions, existing) {
|
|
626
|
+
return Object.entries(questions).flatMap(([key, question]) => {
|
|
627
|
+
let type = TYPE_NAMES[question.type];
|
|
628
628
|
if (existing.get(key) === type) return [];
|
|
629
|
-
if (existing.has(key)) throw Error(`Jev: the
|
|
629
|
+
if (existing.has(key)) throw Error(`Jev: the question "${key}" on "${String(field.name)}" needs a field called "${key}" to store its answer, but that name is already taken. Rename the question.`);
|
|
630
630
|
return existing.set(key, type), [defineField({
|
|
631
631
|
name: key,
|
|
632
|
-
title: titleOf(key,
|
|
632
|
+
title: titleOf(key, question),
|
|
633
633
|
type,
|
|
634
634
|
group: typeof field.group == "string" || Array.isArray(field.group) ? field.group : void 0,
|
|
635
635
|
fieldset: typeof field.fieldset == "string" ? field.fieldset : void 0
|
|
@@ -638,14 +638,14 @@ function answerFieldsFor(field, signals, existing) {
|
|
|
638
638
|
}
|
|
639
639
|
const LEVELS = ["warn", "require"];
|
|
640
640
|
/**
|
|
641
|
-
* Adds a validation rule to the attached field for each
|
|
641
|
+
* Adds a validation rule to the attached field for each question's `warn` and `require`, after
|
|
642
642
|
* the field's own validation. Rules judge the stored answer, so they say nothing until there is
|
|
643
643
|
* one.
|
|
644
644
|
*/
|
|
645
|
-
function withRules(field,
|
|
646
|
-
let checks = Object.entries(
|
|
645
|
+
function withRules(field, questions) {
|
|
646
|
+
let checks = Object.entries(questions).flatMap(([key, question]) => LEVELS.filter((level) => question[level]).map((level) => ({
|
|
647
647
|
key,
|
|
648
|
-
|
|
648
|
+
question,
|
|
649
649
|
level
|
|
650
650
|
})));
|
|
651
651
|
if (checks.length === 0) return field;
|
|
@@ -653,8 +653,8 @@ function withRules(field, signals) {
|
|
|
653
653
|
let ownRules = (Array.isArray(own) ? own : own ? [own] : []).flatMap((entry) => {
|
|
654
654
|
let result = typeof entry == "function" ? entry(rule) : entry;
|
|
655
655
|
return Array.isArray(result) ? result : result ? [result] : [];
|
|
656
|
-
}), jevRules = checks.map(({ key,
|
|
657
|
-
let kind = kindOf(
|
|
656
|
+
}), jevRules = checks.map(({ key, question, level }) => {
|
|
657
|
+
let kind = kindOf(question), title = titleOf(key, question), custom = rule.custom((_value, context) => {
|
|
658
658
|
let stored = isRecord(context.parent) ? context.parent[key] : void 0;
|
|
659
659
|
return kind.check(level, stored, title) ?? !0;
|
|
660
660
|
});
|
|
@@ -768,12 +768,12 @@ const pulse = keyframes`
|
|
|
768
768
|
background: ${({ $color }) => $color};
|
|
769
769
|
animation: ${({ $pulse }) => $pulse ? pulse : "none"} 1s ease-in-out infinite;
|
|
770
770
|
`;
|
|
771
|
-
function Chip({
|
|
772
|
-
let { t } = useTranslation("jev"), { text, color } =
|
|
771
|
+
function Chip({ question, selected, onSelect }) {
|
|
772
|
+
let { t } = useTranslation("jev"), { text, color } = question.reading?.chip ?? {
|
|
773
773
|
text: t("chip.empty"),
|
|
774
774
|
color: void 0
|
|
775
|
-
}, failed = !!
|
|
776
|
-
return <Card as="button" type="button" radius={2} paddingX={2} paddingY={2} onClick={onSelect} aria-pressed={selected} title={
|
|
775
|
+
}, failed = !!question.error || !!question.problem;
|
|
776
|
+
return <Card as="button" type="button" radius={2} paddingX={2} paddingY={2} onClick={onSelect} aria-pressed={selected} title={question.stale ? t("chip.out-of-date") : void 0} style={{
|
|
777
777
|
display: "inline-flex",
|
|
778
778
|
flex: "none",
|
|
779
779
|
width: "auto",
|
|
@@ -782,15 +782,15 @@ function Chip({ signal, selected, onSelect }) {
|
|
|
782
782
|
background: selected ? "rgba(128, 128, 128, 0.16)" : void 0
|
|
783
783
|
}}>
|
|
784
784
|
<Flex align="center" gap={2}>
|
|
785
|
-
<Dot aria-hidden $color={failed ? "#f03e2f" : color ?? "hsl(215 12% 55%)"} $pulse={
|
|
785
|
+
<Dot aria-hidden $color={failed ? "#f03e2f" : color ?? "hsl(215 12% 55%)"} $pulse={question.loading} />
|
|
786
786
|
{}
|
|
787
787
|
<Text size={1}>
|
|
788
|
-
{
|
|
788
|
+
{question.title}
|
|
789
789
|
<span style={{
|
|
790
790
|
marginLeft: "0.6em",
|
|
791
791
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
|
792
792
|
color: failed ? "#f03e2f" : color,
|
|
793
|
-
opacity:
|
|
793
|
+
opacity: question.stale ? .5 : 1
|
|
794
794
|
}}>
|
|
795
795
|
{failed ? t("chip.error") : text}
|
|
796
796
|
</span>
|
|
@@ -798,8 +798,8 @@ function Chip({ signal, selected, onSelect }) {
|
|
|
798
798
|
</Flex>
|
|
799
799
|
</Card>;
|
|
800
800
|
}
|
|
801
|
-
/** The row of
|
|
802
|
-
function
|
|
801
|
+
/** The row of question chips under an attached field. */
|
|
802
|
+
function QuestionStrip({ questions, selected, onSelect, setup, canRun, loading, onRunAll, onSetUp }) {
|
|
803
803
|
let { t } = useTranslation("jev");
|
|
804
804
|
return <Card border padding={1} style={{
|
|
805
805
|
borderTop: 0,
|
|
@@ -809,7 +809,7 @@ function SignalStrip({ signals, selected, onSelect, setup, canRun, loading, onRu
|
|
|
809
809
|
}}>
|
|
810
810
|
<Flex align="center" gap={2}>
|
|
811
811
|
<Flex flex={1} align="center" gap={1} wrap="wrap">
|
|
812
|
-
{
|
|
812
|
+
{questions.map((question) => <Chip key={question.key} question={question} selected={selected === question.key} onSelect={() => onSelect(selected === question.key ? null : question.key)} />)}
|
|
813
813
|
</Flex>
|
|
814
814
|
{setup === "missing" ? <Button text={t("strip.set-up")} mode="ghost" tone="primary" fontSize={1} padding={2} onClick={onSetUp} /> : <Flex align="center" gap={1}>
|
|
815
815
|
<Text size={0} muted>
|
|
@@ -823,32 +823,32 @@ function SignalStrip({ signals, selected, onSelect, setup, canRun, loading, onRu
|
|
|
823
823
|
</Card>;
|
|
824
824
|
}
|
|
825
825
|
/** Expanded view of the selected chip. */
|
|
826
|
-
function
|
|
827
|
-
let { t } = useTranslation("jev"), { reading } =
|
|
826
|
+
function QuestionDetail({ question, empty, onRetry, onUpdateKey }) {
|
|
827
|
+
let { t } = useTranslation("jev"), { reading } = question, body = question.problem ? <Text size={1}>{question.problem}</Text> : reading ? reading.body : <Text size={1} muted>
|
|
828
828
|
{t(empty ? "detail.empty-field" : "detail.not-evaluated")}
|
|
829
829
|
</Text>, aside = reading?.aside ? "badge" in reading.aside ? <Badge tone={reading.aside.tone}>{reading.aside.badge}</Badge> : "level" in reading.aside ? <Badge tone={reading.aside.tone}>{t(`noul.level.${reading.aside.level}`)}</Badge> : <Text size={1} muted>
|
|
830
830
|
{reading.aside.note}
|
|
831
831
|
</Text> : null;
|
|
832
|
-
return <Card padding={4} radius={2} border tone={
|
|
832
|
+
return <Card padding={4} radius={2} border tone={question.problem ? "critical" : reading?.tone ?? "default"}>
|
|
833
833
|
<Stack gap={4}>
|
|
834
834
|
<Flex align="center" gap={3}>
|
|
835
835
|
<Box flex={1}>
|
|
836
836
|
<Text size={1} weight="semibold">
|
|
837
|
-
{
|
|
837
|
+
{question.title}
|
|
838
838
|
</Text>
|
|
839
839
|
</Box>
|
|
840
840
|
{aside}
|
|
841
841
|
</Flex>
|
|
842
842
|
{body}
|
|
843
|
-
{
|
|
843
|
+
{question.stale && !question.loading && <Text size={0} muted>
|
|
844
844
|
{t("detail.out-of-date")}
|
|
845
845
|
</Text>}
|
|
846
|
-
{
|
|
846
|
+
{question.error && <Card padding={3} radius={2} tone="critical" border>
|
|
847
847
|
<Flex align="center" gap={3} wrap="wrap">
|
|
848
848
|
<Box flex={1}>
|
|
849
|
-
<Text size={1}>{errorText(t,
|
|
849
|
+
<Text size={1}>{errorText(t, question.error)}</Text>
|
|
850
850
|
</Box>
|
|
851
|
-
{
|
|
851
|
+
{question.keyRejected && onUpdateKey ? <Button text={t("detail.update-key")} mode="ghost" fontSize={1} onClick={onUpdateKey} /> : <Button text={t("detail.try-again")} mode="ghost" fontSize={1} onClick={onRetry} />}
|
|
852
852
|
</Flex>
|
|
853
853
|
</Card>}
|
|
854
854
|
</Stack>
|
|
@@ -972,13 +972,13 @@ const realClock = {
|
|
|
972
972
|
return () => clearTimeout(timeout);
|
|
973
973
|
}
|
|
974
974
|
}, EMPTY = {
|
|
975
|
-
|
|
975
|
+
questions: [],
|
|
976
976
|
canRun: !1,
|
|
977
977
|
empty: !0,
|
|
978
978
|
loading: !1
|
|
979
979
|
};
|
|
980
980
|
/**
|
|
981
|
-
* The Evaluation lifecycle of the
|
|
981
|
+
* The Evaluation lifecycle of the questions on one attached field. Questions evaluate on their own
|
|
982
982
|
* shortly after a local edit, only while the field's state is one this Studio produced; work for
|
|
983
983
|
* a fingerprint that has since changed is cancelled and its answer dropped.
|
|
984
984
|
*/
|
|
@@ -986,16 +986,16 @@ function createLifecycle(clock = realClock) {
|
|
|
986
986
|
let inputs, entries = [], localState, expectLocal = !1, requests = /* @__PURE__ */ new Map(), statuses = /* @__PURE__ */ new Map(), attempted = /* @__PURE__ */ new Map(), scheduled, snapshot = EMPTY, dirty = !0, listeners = /* @__PURE__ */ new Set(), canRun = (current) => !!current.transport && !current.readOnly && current.state.trim() !== "";
|
|
987
987
|
function update(next) {
|
|
988
988
|
let previous = inputs;
|
|
989
|
-
inputs = next, next.state !== previous?.state && (localState = expectLocal ? next.state : void 0, expectLocal = !1), (!previous || next.
|
|
990
|
-
let kind = kindOf(
|
|
989
|
+
inputs = next, next.state !== previous?.state && (localState = expectLocal ? next.state : void 0, expectLocal = !1), (!previous || next.questions !== previous.questions || next.state !== previous.state || next.model !== previous.model) && (entries = Object.entries(next.questions).map(([key, question]) => {
|
|
990
|
+
let kind = kindOf(question);
|
|
991
991
|
return {
|
|
992
992
|
key,
|
|
993
|
-
|
|
993
|
+
question,
|
|
994
994
|
kind,
|
|
995
|
-
title: titleOf(key,
|
|
995
|
+
title: titleOf(key, question),
|
|
996
996
|
hash: fingerprint([
|
|
997
997
|
next.state,
|
|
998
|
-
kind.
|
|
998
|
+
kind.gatewayQuestion ?? null,
|
|
999
999
|
next.model
|
|
1000
1000
|
])
|
|
1001
1001
|
};
|
|
@@ -1007,11 +1007,11 @@ function createLifecycle(clock = realClock) {
|
|
|
1007
1007
|
for (let [key, status] of statuses) hashes.get(key) !== status.hash && statuses.delete(key);
|
|
1008
1008
|
for (let [key, hash] of attempted) hashes.get(key) !== hash && attempted.delete(key);
|
|
1009
1009
|
}
|
|
1010
|
-
/**
|
|
1010
|
+
/** Questions that would evaluate on their own: unanswered or stale, and not tried yet. */
|
|
1011
1011
|
function pendingKeys(current, views) {
|
|
1012
1012
|
return !canRun(current) || localState !== current.state ? [] : entries.filter((entry, index) => {
|
|
1013
1013
|
let view = views[index];
|
|
1014
|
-
return entry.kind.
|
|
1014
|
+
return entry.kind.gatewayQuestion && (!view.reading || view.stale) && attempted.get(entry.key) !== entry.hash;
|
|
1015
1015
|
}).map(({ key }) => key);
|
|
1016
1016
|
}
|
|
1017
1017
|
/** Rebuilds the snapshot and the debounce after anything they depend on changed. */
|
|
@@ -1023,7 +1023,7 @@ function createLifecycle(clock = realClock) {
|
|
|
1023
1023
|
let reading = entry.kind.read(current.answers[entry.key]), status = statuses.get(entry.key);
|
|
1024
1024
|
return {
|
|
1025
1025
|
key: entry.key,
|
|
1026
|
-
|
|
1026
|
+
question: entry.question,
|
|
1027
1027
|
title: entry.title,
|
|
1028
1028
|
problem: entry.kind.problem,
|
|
1029
1029
|
reading,
|
|
@@ -1035,7 +1035,7 @@ function createLifecycle(clock = realClock) {
|
|
|
1035
1035
|
}), pending = pendingKeys(current, views);
|
|
1036
1036
|
for (let view of views) pending.includes(view.key) && (view.loading = !0);
|
|
1037
1037
|
snapshot = {
|
|
1038
|
-
|
|
1038
|
+
questions: views,
|
|
1039
1039
|
canRun: canRun(current),
|
|
1040
1040
|
empty: current.state.trim() === "",
|
|
1041
1041
|
loading: views.some((view) => view.loading)
|
|
@@ -1057,8 +1057,8 @@ function createLifecycle(clock = realClock) {
|
|
|
1057
1057
|
if (!current?.transport || !canRun(current)) return;
|
|
1058
1058
|
let { transport, model, state } = current;
|
|
1059
1059
|
for (let entry of entries) {
|
|
1060
|
-
let
|
|
1061
|
-
if (keys && !keys.includes(entry.key) || !
|
|
1060
|
+
let gatewayQuestion = entry.kind.gatewayQuestion;
|
|
1061
|
+
if (keys && !keys.includes(entry.key) || !gatewayQuestion) continue;
|
|
1062
1062
|
requests.get(entry.key)?.controller.abort();
|
|
1063
1063
|
let controller = new AbortController();
|
|
1064
1064
|
requests.set(entry.key, {
|
|
@@ -1074,7 +1074,7 @@ function createLifecycle(clock = realClock) {
|
|
|
1074
1074
|
transport,
|
|
1075
1075
|
model,
|
|
1076
1076
|
state,
|
|
1077
|
-
question,
|
|
1077
|
+
question: gatewayQuestion,
|
|
1078
1078
|
tags: [...current.tags, tag],
|
|
1079
1079
|
signal: controller.signal
|
|
1080
1080
|
});
|
|
@@ -1123,7 +1123,7 @@ function createLifecycle(clock = realClock) {
|
|
|
1123
1123
|
}
|
|
1124
1124
|
const NO_ANSWERS = {};
|
|
1125
1125
|
/** Connects the Evaluation lifecycle of one attached field to the Studio's form. */
|
|
1126
|
-
function
|
|
1126
|
+
function useQuestions({ questions, path, state, readOnly }) {
|
|
1127
1127
|
let { config, writers } = useJevForm(), parentPath = useMemo(() => path.slice(0, -1), [path]), keySource = useKeySource(config), apiKey = keySource.apiKey, transport = useMemo(() => config.transport ?? (apiKey ? gatewayTransport(apiKey, config.endpoint) : void 0), [
|
|
1128
1128
|
config.transport,
|
|
1129
1129
|
apiKey,
|
|
@@ -1135,7 +1135,7 @@ function useSignals({ signals, path, state, readOnly }) {
|
|
|
1135
1135
|
}, [writers, parentPath]), tagPath = useMemo(() => [typeof documentType == "string" ? documentType : "", ...parentPath.map((segment) => typeof segment == "string" ? segment : "[]")], [documentType, parentPath]), [lifecycle] = useState(() => createLifecycle());
|
|
1136
1136
|
return useLayoutEffect(() => {
|
|
1137
1137
|
lifecycle.update({
|
|
1138
|
-
|
|
1138
|
+
questions,
|
|
1139
1139
|
state,
|
|
1140
1140
|
answers: isRecord$1(parent) ? parent : NO_ANSWERS,
|
|
1141
1141
|
transport,
|
|
@@ -1156,10 +1156,10 @@ function useSignals({ signals, path, state, readOnly }) {
|
|
|
1156
1156
|
localEdit: lifecycle.localEdit
|
|
1157
1157
|
};
|
|
1158
1158
|
}
|
|
1159
|
-
/** Renders a field with `options.jev` as usual, with its
|
|
1160
|
-
function AttachedInput({
|
|
1161
|
-
let { t } = useTranslation("jev"), parentOnChange = props.onChange, { value } = props, state = toText(value), jev =
|
|
1162
|
-
|
|
1159
|
+
/** Renders a field with `options.jev` as usual, with its question strip and details below. */
|
|
1160
|
+
function AttachedInput({ questions, ...props }) {
|
|
1161
|
+
let { t } = useTranslation("jev"), parentOnChange = props.onChange, { value } = props, state = toText(value), jev = useQuestions({
|
|
1162
|
+
questions,
|
|
1163
1163
|
path: props.path,
|
|
1164
1164
|
state,
|
|
1165
1165
|
readOnly: !!props.readOnly
|
|
@@ -1168,11 +1168,11 @@ function AttachedInput({ signals, ...props }) {
|
|
|
1168
1168
|
}, inputProps = {
|
|
1169
1169
|
...props,
|
|
1170
1170
|
onChange
|
|
1171
|
-
}, [selected, setSelected] = useState(null), [keyDialogOpen, setKeyDialogOpen] = useState(!1),
|
|
1171
|
+
}, [selected, setSelected] = useState(null), [keyDialogOpen, setKeyDialogOpen] = useState(!1), selectedQuestion = jev.questions.find((question) => question.key === selected);
|
|
1172
1172
|
return <Stack gap={2}>
|
|
1173
1173
|
<div>
|
|
1174
1174
|
{props.renderDefault(inputProps)}
|
|
1175
|
-
<
|
|
1175
|
+
<QuestionStrip questions={jev.questions} selected={selected} onSelect={setSelected} setup={jev.setup} canRun={jev.canRun} loading={jev.loading} onRunAll={jev.runAll} onSetUp={() => setKeyDialogOpen(!0)} />
|
|
1176
1176
|
</div>
|
|
1177
1177
|
{nearTokenLimit(state) && <Card padding={3} radius={2} tone="caution" border>
|
|
1178
1178
|
<Text size={1}>
|
|
@@ -1182,7 +1182,7 @@ function AttachedInput({ signals, ...props }) {
|
|
|
1182
1182
|
})}
|
|
1183
1183
|
</Text>
|
|
1184
1184
|
</Card>}
|
|
1185
|
-
{
|
|
1185
|
+
{selectedQuestion && <QuestionDetail question={selectedQuestion} empty={jev.empty} onRetry={() => jev.run(selectedQuestion.key)} onUpdateKey={jev.keyInSecrets ? () => setKeyDialogOpen(!0) : void 0} />}
|
|
1186
1186
|
{keyDialogOpen && <JevKeyDialog onClose={() => setKeyDialogOpen(!1)} />}
|
|
1187
1187
|
</Stack>;
|
|
1188
1188
|
}
|
|
@@ -1190,7 +1190,7 @@ const Code = ({ children }) => <code>{children}</code>, CONNECTION_TEST = kindOf
|
|
|
1190
1190
|
instructions: "Is this text a connection test?",
|
|
1191
1191
|
true: "It is a test",
|
|
1192
1192
|
false: "It is not a test"
|
|
1193
|
-
})).
|
|
1193
|
+
})).gatewayQuestion, asQuestionError = (error) => error instanceof JevError ? {
|
|
1194
1194
|
message: error.message,
|
|
1195
1195
|
kind: error.kind,
|
|
1196
1196
|
status: error.status,
|
|
@@ -1217,7 +1217,7 @@ function JevTool({ config }) {
|
|
|
1217
1217
|
} catch (error) {
|
|
1218
1218
|
setTest({
|
|
1219
1219
|
state: "failed",
|
|
1220
|
-
error:
|
|
1220
|
+
error: asQuestionError(error)
|
|
1221
1221
|
});
|
|
1222
1222
|
}
|
|
1223
1223
|
}
|
|
@@ -1311,7 +1311,7 @@ function KeyStatus({ fromConfig, loading, stored }) {
|
|
|
1311
1311
|
return fromConfig ? <Badge tone="primary">{t("tool.key.status.config")}</Badge> : loading ? <Badge>{t("tool.key.status.checking")}</Badge> : stored ? <Badge tone="positive">{t("tool.key.status.set")}</Badge> : <Badge tone="caution">{t("tool.key.status.not-set")}</Badge>;
|
|
1312
1312
|
}
|
|
1313
1313
|
/**
|
|
1314
|
-
* Jev
|
|
1314
|
+
* Jev questions for Sanity Studio: yes/no, score and choice questions attached to a field with
|
|
1315
1315
|
* `options.jev`, answered by TypeSafe's Jev model through Vercel AI Gateway. Pair it with
|
|
1316
1316
|
* `withJevAnswers(schemaTypes)` in `schema.types`.
|
|
1317
1317
|
* @public
|
|
@@ -1328,8 +1328,8 @@ const jev = definePlugin((config) => {
|
|
|
1328
1328
|
}],
|
|
1329
1329
|
form: { components: { input: (props) => {
|
|
1330
1330
|
if (props.path.length === 0) return <JevFormProvider config={pluginConfig}>{props.renderDefault(props)}</JevFormProvider>;
|
|
1331
|
-
let
|
|
1332
|
-
return
|
|
1331
|
+
let questions = questionsOf(props.schemaType);
|
|
1332
|
+
return questions ? <AttachedInput {...props} questions={questions} /> : props.renderDefault(props);
|
|
1333
1333
|
} } },
|
|
1334
1334
|
schema: { types: KIND_SCHEMA_TYPES },
|
|
1335
1335
|
i18n: { bundles: [jevLocaleBundle] }
|