@liiift-studio/deploy-vercel-from-sanity 1.1.0 → 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 +33 -19
- package/dist/index.d.mts +39 -9
- package/dist/index.d.ts +39 -9
- package/dist/index.js +768 -413
- package/dist/index.mjs +756 -445
- package/package.json +13 -7
- package/src/compat/code.tsx +29 -0
- package/src/compat/index.ts +14 -0
- package/src/compat/menu.tsx +225 -0
- package/src/compat/primitives.tsx +93 -0
- package/src/compat/resolve.ts +55 -0
- package/src/compat/toast.tsx +173 -0
- package/src/compat/tooltip.tsx +78 -0
- package/src/components/DeployHistory.tsx +19 -17
- package/src/components/DeployItem.tsx +38 -13
- package/src/components/DeployTargetForm.tsx +42 -16
- package/src/components/DeployTool.tsx +30 -8
- package/src/components/StatusBadge.tsx +1 -1
- package/src/components/TokenSetup.tsx +7 -7
- package/src/icons.tsx +31 -24
- package/src/index.ts +8 -3
- package/src/lib/api.ts +10 -4
- package/src/lib/helpers.ts +17 -0
- package/src/schema/schemaIcon.tsx +38 -0
- package/src/schema/vercelConfig.ts +34 -0
- package/src/schema/vercelDeploy.ts +2 -2
- package/src/version.ts +1 -1
- package/src/ui.tsx +0 -337
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Hover/focus hint — Studio's Tooltip where available, an accessible local tooltip otherwise
|
|
2
|
+
import { useId, useState } from 'react'
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { UI, resolveExport } from './resolve'
|
|
5
|
+
import { Box, Card, Text } from './primitives'
|
|
6
|
+
import type { ComponentType } from 'react'
|
|
7
|
+
|
|
8
|
+
/** Props for the compat Tooltip — plain text rather than @sanity/ui's ReactNode `content`. */
|
|
9
|
+
export type TooltipProps = {
|
|
10
|
+
/** Hint text. Also used as the accessible description of the wrapped control. */
|
|
11
|
+
text: string
|
|
12
|
+
children: ReactNode
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The real Tooltip when the installed @sanity/ui still exports it, otherwise undefined. */
|
|
16
|
+
const InstalledTooltip = resolveExport<ComponentType<{
|
|
17
|
+
content: ReactNode
|
|
18
|
+
portal?: boolean
|
|
19
|
+
children: ReactNode
|
|
20
|
+
}>>(UI, 'Tooltip')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hint shown on hover and on keyboard focus.
|
|
24
|
+
*
|
|
25
|
+
* The fallback deliberately does not use the native `title` attribute: `title`
|
|
26
|
+
* never appears on keyboard focus, never appears on touch, cannot be dismissed,
|
|
27
|
+
* and browsers will not re-read it while the pointer is stationary — which
|
|
28
|
+
* silently broke the copy button's "Copied!" confirmation. This renders a real
|
|
29
|
+
* element instead and wires it to the wrapped control via `aria-describedby`,
|
|
30
|
+
* so the text is announced and updates when it changes.
|
|
31
|
+
*/
|
|
32
|
+
export function Tooltip({ text, children }: TooltipProps): React.JSX.Element {
|
|
33
|
+
const id = useId()
|
|
34
|
+
const [visible, setVisible] = useState(false)
|
|
35
|
+
|
|
36
|
+
if (InstalledTooltip) {
|
|
37
|
+
return (
|
|
38
|
+
<InstalledTooltip content={<Box padding={2}><Text size={1}>{text}</Text></Box>} portal>
|
|
39
|
+
{children}
|
|
40
|
+
</InstalledTooltip>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<span
|
|
46
|
+
style={{ position: 'relative', display: 'inline-flex' }}
|
|
47
|
+
aria-describedby={id}
|
|
48
|
+
onMouseEnter={() => setVisible(true)}
|
|
49
|
+
onMouseLeave={() => setVisible(false)}
|
|
50
|
+
onFocusCapture={() => setVisible(true)}
|
|
51
|
+
onBlurCapture={() => setVisible(false)}
|
|
52
|
+
onKeyDown={e => { if (e.key === 'Escape') setVisible(false) }}
|
|
53
|
+
>
|
|
54
|
+
{children}
|
|
55
|
+
<Card
|
|
56
|
+
id={id}
|
|
57
|
+
role="tooltip"
|
|
58
|
+
radius={2}
|
|
59
|
+
shadow={2}
|
|
60
|
+
padding={2}
|
|
61
|
+
style={{
|
|
62
|
+
position: 'absolute',
|
|
63
|
+
bottom: '100%',
|
|
64
|
+
left: '50%',
|
|
65
|
+
transform: 'translateX(-50%)',
|
|
66
|
+
marginBottom: 4,
|
|
67
|
+
whiteSpace: 'nowrap',
|
|
68
|
+
pointerEvents: 'none',
|
|
69
|
+
zIndex: 1000,
|
|
70
|
+
// Kept in the accessibility tree when hidden so `aria-describedby` still resolves.
|
|
71
|
+
visibility: visible ? 'visible' : 'hidden',
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<Text size={1}>{text}</Text>
|
|
75
|
+
</Card>
|
|
76
|
+
</span>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
// Deployment history modal — shows last 10 deployments for a target
|
|
2
2
|
import { useEffect, useState, useCallback } from 'react'
|
|
3
|
-
import {
|
|
4
|
-
Dialog, Card, Box, Flex, Text, Badge, Spinner, Button,
|
|
5
|
-
} from '@sanity/ui'
|
|
6
|
-
import { Stack } from '../ui'
|
|
7
3
|
import { LaunchIcon, CloseIcon } from '../icons'
|
|
8
4
|
import { listDeployments } from '../lib/api'
|
|
9
|
-
import { parseHookUrl, stateLabel, timeAgo, shortSha, safeHref } from '../lib/helpers'
|
|
5
|
+
import { parseHookUrl, stateLabel, timeAgo, shortSha, safeHref, deploymentHref } from '../lib/helpers'
|
|
10
6
|
import type { DeployTarget, VercelDeployment } from '../types'
|
|
7
|
+
import { Badge, Box, Button, Card, Dialog, Flex, Spinner, Stack, Text } from '../compat'
|
|
11
8
|
|
|
12
9
|
interface DeployHistoryProps {
|
|
13
10
|
target: DeployTarget
|
|
@@ -92,9 +89,9 @@ export function DeployHistory({ target, token, onClose }: DeployHistoryProps) {
|
|
|
92
89
|
<Flex gap={3} align="center">
|
|
93
90
|
{/* Preview URL */}
|
|
94
91
|
<Box flex={2} style={{ overflow: 'hidden' }}>
|
|
95
|
-
{d.url ? (
|
|
92
|
+
{deploymentHref(d.url) ? (
|
|
96
93
|
<a
|
|
97
|
-
href={
|
|
94
|
+
href={deploymentHref(d.url)}
|
|
98
95
|
target="_blank"
|
|
99
96
|
rel="noreferrer"
|
|
100
97
|
style={{ color: 'inherit' }}
|
|
@@ -130,18 +127,23 @@ export function DeployHistory({ target, token, onClose }: DeployHistoryProps) {
|
|
|
130
127
|
<Text size={1} muted>{timeAgo(d.created)}</Text>
|
|
131
128
|
</Box>
|
|
132
129
|
|
|
133
|
-
{/* Build logs link
|
|
130
|
+
{/* Build logs link. Rendered as a link, not a Button inside an anchor:
|
|
131
|
+
nesting interactive content in <a> gives two focus stops and Enter
|
|
132
|
+
activates the button, which has no handler, so the link never opens
|
|
133
|
+
for keyboard users. */}
|
|
134
134
|
<Box style={{ width: 64 }}>
|
|
135
135
|
{safeHref(d.inspectorUrl) ? (
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
136
|
+
<Button
|
|
137
|
+
as="a"
|
|
138
|
+
href={safeHref(d.inspectorUrl)}
|
|
139
|
+
target="_blank"
|
|
140
|
+
rel="noreferrer"
|
|
141
|
+
text="Logs"
|
|
142
|
+
mode="ghost"
|
|
143
|
+
tone="default"
|
|
144
|
+
icon={LaunchIcon}
|
|
145
|
+
style={{ fontSize: '12px' }}
|
|
146
|
+
/>
|
|
145
147
|
) : (
|
|
146
148
|
<Text size={1} muted>—</Text>
|
|
147
149
|
)}
|
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
// Per-deploy-target card — shows status, build timer, history, cancel, deploy, copy URL, and error logs
|
|
2
2
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
3
3
|
import { flushSync } from 'react-dom'
|
|
4
|
-
import {
|
|
5
|
-
Card, Box, Flex, Text, Button, Badge, Spinner,
|
|
6
|
-
} from '@sanity/ui'
|
|
7
|
-
import { Stack, useToast, Tooltip, ActionMenu, Code } from '../ui'
|
|
4
|
+
import { ActionMenu, Badge, Box, Button, Card, Code, Flex, Spinner, Stack, Text, Tooltip, useToast } from '../compat'
|
|
8
5
|
import {
|
|
9
6
|
ClockIcon, TrashIcon, EllipsisVerticalIcon, LaunchIcon,
|
|
10
|
-
CopyIcon, CheckmarkIcon, WarningOutlineIcon, ChevronDownIcon, ChevronUpIcon, EditIcon,
|
|
7
|
+
CopyIcon, CheckmarkIcon, WarningOutlineIcon, ChevronDownIcon, ChevronUpIcon, EditIcon,
|
|
11
8
|
} from '../icons'
|
|
12
9
|
import { listDeployments, cancelDeployment, triggerDeploy, getDeploymentEvents } from '../lib/api'
|
|
13
|
-
import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref, projectHref, githubCommitHref } from '../lib/helpers'
|
|
10
|
+
import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref, projectHref, githubCommitHref, deploymentHref } from '../lib/helpers'
|
|
14
11
|
import { StatusBadge } from './StatusBadge'
|
|
15
12
|
import { DeployHistory } from './DeployHistory'
|
|
16
|
-
import type { DeployTarget, VercelDeployment } from '../types'
|
|
13
|
+
import type { DeployTarget, VercelDeployment, VercelDeployState } from '../types'
|
|
17
14
|
|
|
18
15
|
const POLL_INTERVAL_MS = 5_000
|
|
19
16
|
const LABEL_WIDTH = 64
|
|
@@ -44,9 +41,15 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
|
|
|
44
41
|
const [errorLines, setErrorLines] = useState<string[]>([])
|
|
45
42
|
const [loadingLogs, setLoadingLogs] = useState(false)
|
|
46
43
|
const [logError, setLogError] = useState<string | null>(null)
|
|
44
|
+
const [pollError, setPollError] = useState<string | null>(null)
|
|
47
45
|
|
|
48
46
|
/** uid of the deployment that was latest when Deploy was clicked — lets us tell the optimistic state apart from a genuinely new deployment */
|
|
49
47
|
const triggeredFromUidRef = useRef<string | undefined>(undefined)
|
|
48
|
+
/** Monotonic request id — a slower earlier poll must not overwrite a newer response. */
|
|
49
|
+
const requestSeqRef = useRef(0)
|
|
50
|
+
/** False once the card unmounts, so in-flight responses stop updating state. */
|
|
51
|
+
const mountedRef = useRef(true)
|
|
52
|
+
useEffect(() => () => { mountedRef.current = false }, [])
|
|
50
53
|
|
|
51
54
|
const latest = deployments[0]
|
|
52
55
|
/** True between the click and the API returning a new deployment — drives the optimistic "Queued" state */
|
|
@@ -56,11 +59,19 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
|
|
|
56
59
|
// ── Fetch deployments ──────────────────────────────────────────────────────
|
|
57
60
|
const fetchDeployments = useCallback(async () => {
|
|
58
61
|
if (!projectId || !hookId || !token) return
|
|
62
|
+
const seq = ++requestSeqRef.current
|
|
59
63
|
try {
|
|
60
64
|
const data = await listDeployments({ projectId, hookId, token, teamId: target.teamId })
|
|
65
|
+
// Drop the response if a newer request has since been issued, or the card unmounted.
|
|
66
|
+
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
|
61
67
|
setDeployments(data)
|
|
68
|
+
setPollError(null)
|
|
62
69
|
} catch (err) {
|
|
63
|
-
|
|
70
|
+
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
|
71
|
+
// Surfaced in the card rather than only logged — the README documents rate-limit
|
|
72
|
+
// and auth failures as visible, and a silently frozen card looks identical to an idle one.
|
|
73
|
+
setPollError(err instanceof Error ? err.message : 'Could not reach the Vercel API')
|
|
74
|
+
console.error('Deploy-vercel-from-sanity: fetch error', err)
|
|
64
75
|
}
|
|
65
76
|
}, [projectId, hookId, token, target.teamId])
|
|
66
77
|
|
|
@@ -90,11 +101,11 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
|
|
|
90
101
|
}, [isPending])
|
|
91
102
|
|
|
92
103
|
//── Deploy-complete toast ─────────────────────────────────────────────────
|
|
93
|
-
const prevStateRef = useRef<
|
|
104
|
+
const prevStateRef = useRef<VercelDeployState | undefined>(undefined)
|
|
94
105
|
useEffect(() => {
|
|
95
106
|
const current = latest?.state
|
|
96
107
|
const prev = prevStateRef.current
|
|
97
|
-
if (prev && isActiveState(prev
|
|
108
|
+
if (prev && isActiveState(prev) && current && !isActiveState(current)) {
|
|
98
109
|
if (current === 'READY') {
|
|
99
110
|
toast.push({ status: 'success', title: `${target.name} deployed`, description: 'Build completed successfully' })
|
|
100
111
|
} else if (current === 'ERROR') {
|
|
@@ -164,7 +175,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
|
|
|
164
175
|
|
|
165
176
|
const copyUrl = useCallback(() => {
|
|
166
177
|
if (!latest?.url) return
|
|
167
|
-
const fullUrl =
|
|
178
|
+
const fullUrl = deploymentHref(latest.url) ?? ''
|
|
168
179
|
navigator.clipboard.writeText(fullUrl).then(() => {
|
|
169
180
|
setCopied(true)
|
|
170
181
|
setTimeout(() => setCopied(false), 2000)
|
|
@@ -208,7 +219,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
|
|
|
208
219
|
const commitMsg = latest?.meta?.githubCommitMessage?.split('\n')[0]
|
|
209
220
|
const sha = shortSha(latest?.meta?.githubCommitSha)
|
|
210
221
|
const fullSha = latest?.meta?.githubCommitSha
|
|
211
|
-
const commitHref = githubCommitHref(latest?.meta)
|
|
222
|
+
const commitHref = safeHref(githubCommitHref(latest?.meta) ?? undefined)
|
|
212
223
|
const creator = latest?.creator?.username
|
|
213
224
|
const deployedAt = latest?.created ? timeAgo(latest.created) : null
|
|
214
225
|
const vercelProjectUrl = projectHref(latest?.inspectorUrl)
|
|
@@ -272,6 +283,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
|
|
|
272
283
|
</Flex>
|
|
273
284
|
<ActionMenu
|
|
274
285
|
id={`menu-${target._id}`}
|
|
286
|
+
label={`Actions for ${target.name}`}
|
|
275
287
|
buttonIcon={EllipsisVerticalIcon}
|
|
276
288
|
items={[
|
|
277
289
|
{ text: 'Edit target', icon: EditIcon, onClick: () => onEdit(target) },
|
|
@@ -310,7 +322,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
|
|
|
310
322
|
{latest?.url && latest.state === 'READY' && (
|
|
311
323
|
<>
|
|
312
324
|
<a
|
|
313
|
-
href={
|
|
325
|
+
href={deploymentHref(latest.url)}
|
|
314
326
|
target="_blank"
|
|
315
327
|
rel="noreferrer"
|
|
316
328
|
style={{ color: 'inherit' }}
|
|
@@ -385,6 +397,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
|
|
|
385
397
|
{isError && (
|
|
386
398
|
<Stack space={2}>
|
|
387
399
|
<Button
|
|
400
|
+
aria-expanded={showErrorLogs}
|
|
388
401
|
text={showErrorLogs ? 'Hide error details' : 'Show error details'}
|
|
389
402
|
mode="ghost"
|
|
390
403
|
tone="critical"
|
|
@@ -449,6 +462,17 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
|
|
|
449
462
|
</Stack>
|
|
450
463
|
)}
|
|
451
464
|
|
|
465
|
+
{/* Polling failures were previously logged only, so a card that had stopped
|
|
466
|
+
updating looked identical to an idle one. */}
|
|
467
|
+
{pollError && (
|
|
468
|
+
<Card tone="caution" padding={3} radius={2} role="status">
|
|
469
|
+
<Flex align="center" gap={2}>
|
|
470
|
+
<WarningOutlineIcon aria-hidden="true" />
|
|
471
|
+
<Text size={1}>Status updates paused — {pollError}</Text>
|
|
472
|
+
</Flex>
|
|
473
|
+
</Card>
|
|
474
|
+
)}
|
|
475
|
+
|
|
452
476
|
</Stack>
|
|
453
477
|
|
|
454
478
|
{/* ── Details accordion — flush to left/bottom/right ─────── */}
|
|
@@ -456,6 +480,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
|
|
|
456
480
|
<Button
|
|
457
481
|
mode="ghost"
|
|
458
482
|
iconRight={showDetails ? ChevronUpIcon : ChevronDownIcon}
|
|
483
|
+
aria-expanded={showDetails}
|
|
459
484
|
text="Details"
|
|
460
485
|
fontSize={0}
|
|
461
486
|
padding={3}
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
// Dialog form for creating and editing vercel_deploy documents
|
|
2
|
-
import { useState, useCallback } from 'react'
|
|
2
|
+
import { useId, useState, useCallback } from 'react'
|
|
3
3
|
import { useClient } from 'sanity'
|
|
4
|
-
import {
|
|
5
|
-
Dialog, Box, Flex, Text, TextInput, Button, Switch, Label, Card,
|
|
6
|
-
} from '@sanity/ui'
|
|
7
|
-
import { Stack } from '../ui'
|
|
8
4
|
import { CheckmarkCircleIcon } from '../icons'
|
|
9
5
|
import type { DeployTarget } from '../types'
|
|
6
|
+
import { Box, Button, Card, Dialog, Flex, Label, Stack, Switch, Text, TextInput } from '../compat'
|
|
10
7
|
|
|
11
8
|
const VERCEL_HOOK_RE = /^https:\/\/api\.vercel\.com\/v1\/integrations\/deploy\//
|
|
12
9
|
|
|
@@ -21,6 +18,16 @@ export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetForm
|
|
|
21
18
|
const client = useClient({ apiVersion: '2025-01-01' })
|
|
22
19
|
const isEdit = Boolean(initial)
|
|
23
20
|
|
|
21
|
+
// Ids for label/description association. useId keeps them unique inside a published
|
|
22
|
+
// plugin, where a hardcoded id can collide with anything the host Studio renders.
|
|
23
|
+
const nameId = useId()
|
|
24
|
+
const urlId = useId()
|
|
25
|
+
const urlErrorId = useId()
|
|
26
|
+
const urlHelpId = useId()
|
|
27
|
+
const teamId_ = useId()
|
|
28
|
+
const teamHelpId = useId()
|
|
29
|
+
const disableId = useId()
|
|
30
|
+
|
|
24
31
|
const [name, setName] = useState(initial?.name ?? '')
|
|
25
32
|
const [url, setUrl] = useState(initial?.url ?? '')
|
|
26
33
|
const [teamId, setTeamId] = useState(initial?.teamId ?? '')
|
|
@@ -79,10 +86,16 @@ export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetForm
|
|
|
79
86
|
<Box padding={4}>
|
|
80
87
|
<Stack space={4}>
|
|
81
88
|
|
|
82
|
-
{/* Name
|
|
89
|
+
{/* Name. Label carries `as="label"` — @sanity/ui's Label renders a div otherwise,
|
|
90
|
+
so the input would have no accessible name. */}
|
|
83
91
|
<Stack space={2}>
|
|
84
|
-
<Label size={1}
|
|
92
|
+
<Label as="label" size={1} htmlFor={nameId}>
|
|
93
|
+
Name <span aria-hidden="true">*</span>
|
|
94
|
+
</Label>
|
|
85
95
|
<TextInput
|
|
96
|
+
id={nameId}
|
|
97
|
+
required
|
|
98
|
+
aria-required="true"
|
|
86
99
|
value={name}
|
|
87
100
|
onChange={e => setName((e.target as HTMLInputElement).value)}
|
|
88
101
|
placeholder="Production"
|
|
@@ -91,31 +104,44 @@ export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetForm
|
|
|
91
104
|
|
|
92
105
|
{/* Deploy hook URL */}
|
|
93
106
|
<Stack space={2}>
|
|
94
|
-
<Label size={1}
|
|
107
|
+
<Label as="label" size={1} htmlFor={urlId}>
|
|
108
|
+
Deploy hook URL <span aria-hidden="true">*</span>
|
|
109
|
+
</Label>
|
|
95
110
|
<TextInput
|
|
111
|
+
id={urlId}
|
|
112
|
+
required
|
|
113
|
+
aria-required="true"
|
|
114
|
+
aria-invalid={Boolean(url) && !urlValid}
|
|
115
|
+
aria-describedby={`${urlHelpId}${url && !urlValid ? ` ${urlErrorId}` : ''}`}
|
|
96
116
|
value={url}
|
|
97
117
|
onChange={e => setUrl((e.target as HTMLInputElement).value)}
|
|
98
118
|
placeholder="https://api.vercel.com/v1/integrations/deploy/…"
|
|
99
119
|
/>
|
|
100
120
|
{url && !urlValid && (
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
121
|
+
<Card tone="critical" padding={2} radius={2} role="alert">
|
|
122
|
+
<Text id={urlErrorId} size={1}>
|
|
123
|
+
Must be a Vercel deploy hook URL (api.vercel.com/v1/integrations/deploy/…)
|
|
124
|
+
</Text>
|
|
125
|
+
</Card>
|
|
104
126
|
)}
|
|
105
|
-
<Text size={0} muted>
|
|
127
|
+
<Text id={urlHelpId} size={0} muted>
|
|
106
128
|
Vercel dashboard → Project → Settings → Git → Deploy Hooks
|
|
107
129
|
</Text>
|
|
108
130
|
</Stack>
|
|
109
131
|
|
|
110
132
|
{/* Team ID */}
|
|
111
133
|
<Stack space={2}>
|
|
112
|
-
<Label size={1}
|
|
134
|
+
<Label as="label" size={1} htmlFor={teamId_}>
|
|
135
|
+
Team ID <span style={{ opacity: 0.5, fontWeight: 'normal' }}>— optional</span>
|
|
136
|
+
</Label>
|
|
113
137
|
<TextInput
|
|
138
|
+
id={teamId_}
|
|
139
|
+
aria-describedby={teamHelpId}
|
|
114
140
|
value={teamId}
|
|
115
141
|
onChange={e => setTeamId((e.target as HTMLInputElement).value)}
|
|
116
142
|
placeholder="team_xxxxxxxx"
|
|
117
143
|
/>
|
|
118
|
-
<Text size={0} muted>
|
|
144
|
+
<Text id={teamHelpId} size={0} muted>
|
|
119
145
|
Required for team-owned Vercel projects. Find it at Vercel → Settings → General → Team ID (starts with <code>team_</code>).
|
|
120
146
|
</Text>
|
|
121
147
|
</Stack>
|
|
@@ -125,10 +151,10 @@ export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetForm
|
|
|
125
151
|
<Switch
|
|
126
152
|
checked={disableDelete}
|
|
127
153
|
onChange={e => setDisableDelete((e.target as HTMLInputElement).checked)}
|
|
128
|
-
id=
|
|
154
|
+
id={disableId}
|
|
129
155
|
/>
|
|
130
156
|
<Stack space={1}>
|
|
131
|
-
<Label as="label" size={1} htmlFor=
|
|
157
|
+
<Label as="label" size={1} htmlFor={disableId}>Disable delete action</Label>
|
|
132
158
|
<Text size={0} muted>Hides the delete button for this target in the studio.</Text>
|
|
133
159
|
</Stack>
|
|
134
160
|
</Flex>
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
// Main deploy tool — fetches targets + token, renders per-project cards
|
|
2
2
|
import { useState, useEffect, useCallback } from 'react'
|
|
3
3
|
import { useClient } from 'sanity'
|
|
4
|
-
import {
|
|
5
|
-
Card, Box, Flex, Text, Heading, Spinner, Button, Dialog,
|
|
6
|
-
} from '@sanity/ui'
|
|
7
|
-
import { Stack, useToast, ToastViewport } from '../ui'
|
|
8
4
|
import { TokenIcon, TrashIcon, WarningOutlineIcon, AddIcon } from '../icons'
|
|
9
5
|
import { DeployItem } from './DeployItem'
|
|
10
6
|
import { TokenSetup } from './TokenSetup'
|
|
11
7
|
import { DeployTargetForm } from './DeployTargetForm'
|
|
12
8
|
import { VERSION } from '../version'
|
|
13
9
|
import type { DeployTarget } from '../types'
|
|
10
|
+
import { Box, Button, Card, Dialog, Flex, Heading, Spinner, Stack, Text, ToastViewport, useToast } from '../compat'
|
|
14
11
|
|
|
15
12
|
const TOKEN_QUERY = `*[_id == "config.vercelDeploy"][0].accessToken`
|
|
16
|
-
|
|
13
|
+
// `useClient` hands back a raw-perspective client, so drafts are returned alongside published
|
|
14
|
+
// documents. `vercel_deploy` is a registered type and therefore editable from Structure, which
|
|
15
|
+
// would otherwise render two cards for the same target.
|
|
16
|
+
const TARGETS_QUERY = `*[_type == "vercel_deploy" && !(_id in path("drafts.**"))] | order(_createdAt asc)`
|
|
17
17
|
|
|
18
18
|
export function DeployTool() {
|
|
19
19
|
const client = useClient({ apiVersion: '2025-01-01' })
|
|
@@ -49,7 +49,18 @@ export function DeployTool() {
|
|
|
49
49
|
|
|
50
50
|
// Inject responsive styles once on mount
|
|
51
51
|
useEffect(() => {
|
|
52
|
-
|
|
52
|
+
// Reference-counted: with two tools mounted, the one that skipped creation must not
|
|
53
|
+
// remove the stylesheet the other is still using.
|
|
54
|
+
const existing = document.getElementById('dvfs-styles')
|
|
55
|
+
if (existing) {
|
|
56
|
+
const n = Number(existing.dataset.refs ?? '1') + 1
|
|
57
|
+
existing.dataset.refs = String(n)
|
|
58
|
+
return () => {
|
|
59
|
+
const left = Number(existing.dataset.refs ?? '1') - 1
|
|
60
|
+
existing.dataset.refs = String(left)
|
|
61
|
+
if (left <= 0) existing.remove()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
53
64
|
const style = document.createElement('style')
|
|
54
65
|
style.id = 'dvfs-styles'
|
|
55
66
|
style.textContent = `
|
|
@@ -70,7 +81,12 @@ export function DeployTool() {
|
|
|
70
81
|
}
|
|
71
82
|
`
|
|
72
83
|
document.head.appendChild(style)
|
|
73
|
-
|
|
84
|
+
style.dataset.refs = '1'
|
|
85
|
+
return () => {
|
|
86
|
+
const left = Number(style.dataset.refs ?? '1') - 1
|
|
87
|
+
style.dataset.refs = String(left)
|
|
88
|
+
if (left <= 0) style.remove()
|
|
89
|
+
}
|
|
74
90
|
}, [])
|
|
75
91
|
|
|
76
92
|
// Live subscription — update targets when documents change
|
|
@@ -90,7 +106,10 @@ export function DeployTool() {
|
|
|
90
106
|
if (!pendingDelete) return
|
|
91
107
|
setDeleting(true)
|
|
92
108
|
try {
|
|
109
|
+
// Remove the draft counterpart as well; deleting only the published id leaves the
|
|
110
|
+
// target behind and it reappears on the next load.
|
|
93
111
|
await client.delete(pendingDelete._id)
|
|
112
|
+
await client.delete(`drafts.${pendingDelete._id}`).catch(() => {})
|
|
94
113
|
setTargets(prev => prev.filter(t => t._id !== pendingDelete._id))
|
|
95
114
|
toast.push({ status: 'success', title: `Deleted "${pendingDelete.name}"` })
|
|
96
115
|
} catch (err) {
|
|
@@ -223,10 +242,13 @@ export function DeployTool() {
|
|
|
223
242
|
</Box>
|
|
224
243
|
|
|
225
244
|
{/* ── Token setup dialog ──────────────────────────────────────────── */}
|
|
245
|
+
{/* The token dialog is always dismissable. Withholding Cancel on first run —
|
|
246
|
+
exactly when the user may not have a token to hand — made it a keyboard trap,
|
|
247
|
+
since Sanity's Dialog holds focus and Escape was wired to the absent handler. */}
|
|
226
248
|
{showTokenSetup && (
|
|
227
249
|
<TokenSetup
|
|
228
250
|
onSaved={() => { setShowTokenSetup(false); load() }}
|
|
229
|
-
onCancel={
|
|
251
|
+
onCancel={() => setShowTokenSetup(false)}
|
|
230
252
|
/>
|
|
231
253
|
)}
|
|
232
254
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Status badge for a Vercel deployment state
|
|
2
|
-
import { Badge, Spinner, Flex } from '@sanity/ui'
|
|
3
2
|
import { stateLabel } from '../lib/helpers'
|
|
4
3
|
import type { VercelDeployState } from '../types'
|
|
4
|
+
import { Badge, Flex, Spinner } from '../compat'
|
|
5
5
|
|
|
6
6
|
interface StatusBadgeProps {
|
|
7
7
|
state: VercelDeployState | undefined
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
// Vercel API token form — rendered inside a Dialog by DeployTool
|
|
2
|
-
import { useState, useCallback } from 'react'
|
|
2
|
+
import { useId, useState, useCallback } from 'react'
|
|
3
3
|
import { useClient } from 'sanity'
|
|
4
|
-
import {
|
|
5
|
-
Text, TextInput, Button, Card, Dialog, Flex,
|
|
6
|
-
} from '@sanity/ui'
|
|
7
|
-
import { Stack } from '../ui'
|
|
8
4
|
import { CheckmarkCircleIcon } from '../icons'
|
|
5
|
+
import { Button, Card, Dialog, Flex, Label, Stack, Text, TextInput } from '../compat'
|
|
9
6
|
|
|
10
7
|
interface TokenSetupProps {
|
|
11
8
|
/** Called after the token is successfully saved */
|
|
@@ -17,6 +14,7 @@ interface TokenSetupProps {
|
|
|
17
14
|
const TOKEN_DOC_ID = 'config.vercelDeploy'
|
|
18
15
|
|
|
19
16
|
export function TokenSetup({ onSaved, onCancel }: TokenSetupProps) {
|
|
17
|
+
const tokenId = useId()
|
|
20
18
|
const client = useClient({ apiVersion: '2025-01-01' })
|
|
21
19
|
const [token, setToken] = useState('')
|
|
22
20
|
const [saving, setSaving] = useState(false)
|
|
@@ -78,8 +76,10 @@ export function TokenSetup({ onSaved, onCancel }: TokenSetupProps) {
|
|
|
78
76
|
</Stack>
|
|
79
77
|
|
|
80
78
|
<Stack space={2}>
|
|
81
|
-
|
|
79
|
+
{/* A plain Text is not a label — @sanity/ui's Label needs `as="label"` to render one. */}
|
|
80
|
+
<Label as="label" size={1} htmlFor={tokenId}>Vercel API Token</Label>
|
|
82
81
|
<TextInput
|
|
82
|
+
id={tokenId}
|
|
83
83
|
value={token}
|
|
84
84
|
onChange={e => setToken((e.target as HTMLInputElement).value)}
|
|
85
85
|
placeholder="xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
@@ -88,7 +88,7 @@ export function TokenSetup({ onSaved, onCancel }: TokenSetupProps) {
|
|
|
88
88
|
</Stack>
|
|
89
89
|
|
|
90
90
|
{error && (
|
|
91
|
-
<Card tone="critical" padding={3} radius={2}>
|
|
91
|
+
<Card tone="critical" padding={3} radius={2} role="alert">
|
|
92
92
|
<Text size={1}>{error}</Text>
|
|
93
93
|
</Card>
|
|
94
94
|
)}
|
package/src/icons.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Version-agnostic access to @sanity/icons —
|
|
1
|
+
// Version-agnostic access to @sanity/icons — named exports on v2/v3/v4, <Icon symbol> on v5+
|
|
2
2
|
import { forwardRef } from 'react'
|
|
3
3
|
import type { ComponentType, SVGProps } from 'react'
|
|
4
|
-
import
|
|
4
|
+
import { ICONS, resolveExport } from './compat/resolve'
|
|
5
5
|
|
|
6
6
|
/** Props every Sanity icon accepts — it renders a plain sized SVG. */
|
|
7
7
|
export type IconProps = SVGProps<SVGSVGElement>
|
|
@@ -9,48 +9,56 @@ export type IconProps = SVGProps<SVGSVGElement>
|
|
|
9
9
|
/** An icon component, whichever shape the installed @sanity/icons exposes it in. */
|
|
10
10
|
export type IconComponent = ComponentType<IconProps>
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* The installed @sanity/icons namespace, read through an index signature.
|
|
14
|
-
*
|
|
15
|
-
* icons v3 and v4 export one named component per glyph (`RocketIcon`). v5.0.0
|
|
16
|
-
* dropped those from the barrel and replaced them with a single `<Icon symbol>`
|
|
17
|
-
* that lazy-loads from an internal map. Its `index.d.ts` still declares the old
|
|
18
|
-
* named exports, so the mismatch is invisible to TypeScript and only surfaces at
|
|
19
|
-
* runtime — reading the namespace dynamically lets one build serve every major.
|
|
20
|
-
*/
|
|
21
|
-
const INSTALLED = sanityIcons as unknown as Record<string, unknown>
|
|
22
|
-
|
|
23
12
|
/** Sizing of a Sanity icon glyph — 1em square on a 25-unit viewBox, matching @sanity/icons. */
|
|
24
13
|
const GLYPH = { width: '1em', height: '1em', viewBox: '0 0 25 25', fill: 'none' } as const
|
|
25
14
|
|
|
26
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Placeholder for when the installed @sanity/icons exposes neither shape. Holds
|
|
17
|
+
* layout, draws nothing, and is hidden from assistive tech since every icon in
|
|
18
|
+
* this plugin is decorative.
|
|
19
|
+
*/
|
|
27
20
|
const MissingIcon = forwardRef<SVGSVGElement, IconProps>(function MissingIcon(props, ref) {
|
|
28
|
-
return <svg {...GLYPH} xmlns="http://www.w3.org/2000/svg" {...props} ref={ref} />
|
|
21
|
+
return <svg {...GLYPH} xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" {...props} ref={ref} />
|
|
29
22
|
})
|
|
30
23
|
|
|
31
|
-
/** The v5+ `<Icon>` component, absent on icons v3 and v4. */
|
|
24
|
+
/** The v5+ `<Icon>` component, absent on icons v2, v3 and v4. */
|
|
32
25
|
type SymbolIcon = ComponentType<IconProps & { symbol: string }>
|
|
33
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The symbol map v5+ exposes alongside `Icon`. Used to check a symbol exists
|
|
29
|
+
* before relying on it: `<Icon>` renders `null` for an unrecognised symbol, so
|
|
30
|
+
* a renamed glyph would otherwise vanish silently rather than reaching
|
|
31
|
+
* {@link MissingIcon}.
|
|
32
|
+
*/
|
|
33
|
+
const SYMBOL_MAP = resolveExport<Record<string, unknown>>(ICONS, 'icons')
|
|
34
|
+
|
|
34
35
|
/**
|
|
35
36
|
* Resolve one glyph against whichever @sanity/icons the host Studio installed.
|
|
36
|
-
* Always reads from the host package, so new and revised artwork is picked up
|
|
37
|
-
*
|
|
37
|
+
* Always reads from the host package, so new and revised artwork is picked up on
|
|
38
|
+
* the consumer's next @sanity/icons update without a release here.
|
|
39
|
+
*
|
|
40
|
+
* Note the named exports are not merely absent on v5 — they are declared `never`
|
|
41
|
+
* with a deprecation note pointing at per-icon subpaths, so a static named import
|
|
42
|
+
* is a type error as well as a runtime one. Subpaths only exist from v4.1.0,
|
|
43
|
+
* which is why this resolves at runtime instead.
|
|
38
44
|
*
|
|
39
|
-
* @param name Named export used by icons v3 and v4, e.g. `RocketIcon`.
|
|
45
|
+
* @param name Named export used by icons v2, v3 and v4, e.g. `RocketIcon`.
|
|
40
46
|
* @param symbol Kebab-case symbol used by the v5+ `<Icon>` component, e.g. `rocket`.
|
|
41
47
|
*/
|
|
42
48
|
function resolveIcon(name: string, symbol: string): IconComponent {
|
|
43
|
-
const named =
|
|
49
|
+
const named = resolveExport<IconComponent>(ICONS, name)
|
|
44
50
|
if (named) return named
|
|
45
51
|
|
|
46
|
-
const Icon =
|
|
52
|
+
const Icon = resolveExport<SymbolIcon>(ICONS, 'Icon')
|
|
47
53
|
if (!Icon) return MissingIcon
|
|
54
|
+
// An unknown symbol would render nothing at all; fall back to the sized placeholder instead.
|
|
55
|
+
if (SYMBOL_MAP && !(symbol in SYMBOL_MAP)) return MissingIcon
|
|
48
56
|
|
|
49
57
|
const Resolved = forwardRef<SVGSVGElement, IconProps>(function SanityIcon(props, ref) {
|
|
50
|
-
return <Icon symbol={symbol} {...props} ref={ref
|
|
58
|
+
return <Icon symbol={symbol} {...props} ref={ref} />
|
|
51
59
|
})
|
|
52
60
|
Resolved.displayName = name
|
|
53
|
-
return Resolved
|
|
61
|
+
return Resolved
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
export const AddIcon = resolveIcon('AddIcon', 'add')
|
|
@@ -65,7 +73,6 @@ export const EditIcon = resolveIcon('EditIcon', 'edit')
|
|
|
65
73
|
export const EllipsisVerticalIcon = resolveIcon('EllipsisVerticalIcon', 'ellipsis-vertical')
|
|
66
74
|
export const LaunchIcon = resolveIcon('LaunchIcon', 'launch')
|
|
67
75
|
export const RocketIcon = resolveIcon('RocketIcon', 'rocket')
|
|
68
|
-
export const SchemaIcon = resolveIcon('SchemaIcon', 'schema')
|
|
69
76
|
export const TokenIcon = resolveIcon('TokenIcon', 'token')
|
|
70
77
|
export const TrashIcon = resolveIcon('TrashIcon', 'trash')
|
|
71
78
|
export const WarningOutlineIcon = resolveIcon('WarningOutlineIcon', 'warning-outline')
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
// deploy-vercel-from-sanity — Sanity Studio
|
|
1
|
+
// deploy-vercel-from-sanity — Sanity Studio plugin for Vercel deployments (Studio v3.30 through v6)
|
|
2
2
|
import { definePlugin } from 'sanity'
|
|
3
3
|
import { RocketIcon } from './icons'
|
|
4
4
|
import { DeployTool } from './components/DeployTool'
|
|
5
5
|
import { vercelDeploySchema } from './schema/vercelDeploy'
|
|
6
|
+
import { vercelConfigSchema } from './schema/vercelConfig'
|
|
6
7
|
import type { VercelDeployPluginConfig } from './types'
|
|
7
8
|
|
|
8
9
|
export { vercelDeploySchema } from './schema/vercelDeploy'
|
|
10
|
+
export { vercelConfigSchema } from './schema/vercelConfig'
|
|
9
11
|
export type { VercelDeployPluginConfig, DeployTarget, VercelDeployment, VercelDeployState } from './types'
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
|
-
* Sanity Studio
|
|
14
|
+
* Sanity Studio plugin — trigger and monitor Vercel deployments.
|
|
15
|
+
*
|
|
16
|
+
* Supports Studio v3.30 through v6 from a single build; see the compatibility
|
|
17
|
+
* table in the README for how @sanity/ui and @sanity/icons are resolved.
|
|
13
18
|
*
|
|
14
19
|
* @example
|
|
15
20
|
* // sanity.config.ts
|
|
@@ -28,7 +33,7 @@ export const vercelDeploy = definePlugin<VercelDeployPluginConfig | void>(option
|
|
|
28
33
|
return {
|
|
29
34
|
name: 'deploy-vercel-from-sanity',
|
|
30
35
|
schema: {
|
|
31
|
-
types: [vercelDeploySchema],
|
|
36
|
+
types: [vercelDeploySchema, vercelConfigSchema],
|
|
32
37
|
},
|
|
33
38
|
tools: [
|
|
34
39
|
{
|