@liiift-studio/deploy-vercel-from-sanity 1.0.9 → 1.1.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@liiift-studio/deploy-vercel-from-sanity",
3
- "version": "1.0.9",
4
- "description": "Sanity Studio plugin — trigger and monitor Vercel deployments with full status, history, and build logs. Supports v3, v4, and v5.",
3
+ "version": "1.1.0",
4
+ "description": "Sanity Studio plugin — trigger and monitor Vercel deployments with full status, history, and build logs. Supports Studio v3 through v6.",
5
5
  "license": "MIT",
6
6
  "author": "Liiift Studio",
7
7
  "keywords": [
@@ -39,15 +39,18 @@
39
39
  "prepublishOnly": "npm run build"
40
40
  },
41
41
  "peerDependencies": {
42
+ "@sanity/icons": ">=3",
43
+ "@sanity/ui": ">=2",
42
44
  "react": ">=18",
43
45
  "sanity": ">=3"
44
46
  },
45
47
  "devDependencies": {
46
- "@sanity/icons": "^3",
47
- "@sanity/ui": "^3",
48
+ "@sanity/icons": "^5",
49
+ "@sanity/ui": "^4",
48
50
  "@types/react": "^19",
51
+ "@types/react-dom": "^19.2.4",
49
52
  "react": "^19",
50
- "sanity": "^5",
53
+ "sanity": "^6",
51
54
  "tsup": "^8",
52
55
  "typescript": "^5"
53
56
  },
@@ -1,9 +1,10 @@
1
1
  // Deployment history modal — shows last 10 deployments for a target
2
2
  import { useEffect, useState, useCallback } from 'react'
3
3
  import {
4
- Dialog, Card, Box, Stack, Flex, Text, Badge, Spinner, Button,
4
+ Dialog, Card, Box, Flex, Text, Badge, Spinner, Button,
5
5
  } from '@sanity/ui'
6
- import { LaunchIcon, CloseIcon } from '@sanity/icons'
6
+ import { Stack } from '../ui'
7
+ import { LaunchIcon, CloseIcon } from '../icons'
7
8
  import { listDeployments } from '../lib/api'
8
9
  import { parseHookUrl, stateLabel, timeAgo, shortSha, safeHref } from '../lib/helpers'
9
10
  import type { DeployTarget, VercelDeployment } from '../types'
@@ -2,13 +2,13 @@
2
2
  import { useState, useEffect, useCallback, useRef } from 'react'
3
3
  import { flushSync } from 'react-dom'
4
4
  import {
5
- Card, Box, Stack, Flex, Text, Button, Tooltip, Badge, Spinner,
6
- MenuButton, Menu, MenuItem, Code, useToast,
5
+ Card, Box, Flex, Text, Button, Badge, Spinner,
7
6
  } from '@sanity/ui'
7
+ import { Stack, useToast, Tooltip, ActionMenu, Code } from '../ui'
8
8
  import {
9
9
  ClockIcon, TrashIcon, EllipsisVerticalIcon, LaunchIcon,
10
10
  CopyIcon, CheckmarkIcon, WarningOutlineIcon, ChevronDownIcon, ChevronUpIcon, EditIcon, SchemaIcon
11
- } from '@sanity/icons'
11
+ } from '../icons'
12
12
  import { listDeployments, cancelDeployment, triggerDeploy, getDeploymentEvents } from '../lib/api'
13
13
  import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref, projectHref, githubCommitHref } from '../lib/helpers'
14
14
  import { StatusBadge } from './StatusBadge'
@@ -17,6 +17,8 @@ import type { DeployTarget, VercelDeployment } from '../types'
17
17
 
18
18
  const POLL_INTERVAL_MS = 5_000
19
19
  const LABEL_WIDTH = 64
20
+ /** Max time (ms) to hold the optimistic pending state before giving up and deferring to real API status */
21
+ const PENDING_TIMEOUT_MS = 60_000
20
22
 
21
23
  interface DeployItemProps {
22
24
  target: DeployTarget
@@ -31,7 +33,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
31
33
 
32
34
  const [deployments, setDeployments] = useState<VercelDeployment[]>([])
33
35
  const [loadingInitial, setLoadingInitial] = useState(true)
34
- const [triggering, setTriggering] = useState(false)
36
+ const [pendingSince, setPendingSince] = useState<number | null>(null)
35
37
  const [canceling, setCanceling] = useState(false)
36
38
  const [deployError, setDeployError] = useState<string | null>(null)
37
39
  const [showHistory, setShowHistory] = useState(false)
@@ -43,8 +45,13 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
43
45
  const [loadingLogs, setLoadingLogs] = useState(false)
44
46
  const [logError, setLogError] = useState<string | null>(null)
45
47
 
46
- const latest = deployments[0]
47
- const isActive = triggering || isActiveState(latest?.state)
48
+ /** uid of the deployment that was latest when Deploy was clicked — lets us tell the optimistic state apart from a genuinely new deployment */
49
+ const triggeredFromUidRef = useRef<string | undefined>(undefined)
50
+
51
+ const latest = deployments[0]
52
+ /** True between the click and the API returning a new deployment — drives the optimistic "Queued" state */
53
+ const isPending = pendingSince !== null
54
+ const isActive = isPending || isActiveState(latest?.state)
48
55
 
49
56
  // ── Fetch deployments ──────────────────────────────────────────────────────
50
57
  const fetchDeployments = useCallback(async () => {
@@ -67,9 +74,20 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
67
74
  return () => clearInterval(id)
68
75
  }, [isActive, fetchDeployments])
69
76
 
77
+ // Hand off from the optimistic state only once the API returns a deployment
78
+ // that is not the one which was already latest when Deploy was clicked.
79
+ useEffect(() => {
80
+ if (!isPending) return
81
+ if (latest?.uid && latest.uid !== triggeredFromUidRef.current) setPendingSince(null)
82
+ }, [isPending, latest?.uid])
83
+
84
+ // Safety net — never strand the card in the optimistic state if the new
85
+ // deployment never appears (hook accepted but nothing was queued).
70
86
  useEffect(() => {
71
- if (triggering && latest && latest.state !== undefined) setTriggering(false)
72
- }, [triggering, latest])
87
+ if (!isPending) return
88
+ const id = setTimeout(() => setPendingSince(null), PENDING_TIMEOUT_MS)
89
+ return () => clearTimeout(id)
90
+ }, [isPending])
73
91
 
74
92
  //── Deploy-complete toast ─────────────────────────────────────────────────
75
93
  const prevStateRef = useRef<string | undefined>(undefined)
@@ -98,7 +116,8 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
98
116
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
99
117
  useEffect(() => {
100
118
  if (isActive) {
101
- const start = latest?.created ?? Date.now()
119
+ // While optimistic, count from the click; once real, count from the deployment's own timestamp
120
+ const start = pendingSince ?? latest?.created ?? Date.now()
102
121
  setElapsed(Math.floor((Date.now() - start) / 1000))
103
122
  timerRef.current = setInterval(() => {
104
123
  setElapsed(Math.floor((Date.now() - start) / 1000))
@@ -108,24 +127,27 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
108
127
  if (timerRef.current) clearInterval(timerRef.current)
109
128
  }
110
129
  return () => { if (timerRef.current) clearInterval(timerRef.current) }
111
- }, [isActive, latest?.created])
130
+ }, [isActive, pendingSince, latest?.created])
112
131
 
113
132
  // ── Actions ───────────────────────────────────────────────────────────────
114
133
  const deploy = useCallback(() => {
134
+ // Paint the optimistic "Queued" state in the same frame as the click,
135
+ // before the hook request is even sent
115
136
  flushSync(() => {
116
137
  setDeployError(null)
117
- setTriggering(true)
138
+ triggeredFromUidRef.current = latest?.uid
139
+ setPendingSince(Date.now())
118
140
  })
119
141
  void (async () => {
120
142
  try {
121
143
  await triggerDeploy(target.url)
122
144
  setTimeout(fetchDeployments, 2000)
123
145
  } catch (err) {
124
- setTriggering(false)
146
+ setPendingSince(null)
125
147
  setDeployError(err instanceof Error ? err.message : 'Deploy failed')
126
148
  }
127
149
  })()
128
- }, [target.url, fetchDeployments])
150
+ }, [target.url, fetchDeployments, latest?.uid])
129
151
 
130
152
  const cancel = useCallback(async () => {
131
153
  if (!latest?.uid) return
@@ -218,8 +240,8 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
218
240
  )}
219
241
  {token && !loadingInitial && (
220
242
  <>
221
- {triggering ? (
222
- <Badge tone="caution" padding={2}>Triggering…</Badge>
243
+ {isPending ? (
244
+ <StatusBadge state="QUEUED" />
223
245
  ) : (
224
246
  <StatusBadge state={latest?.state} />
225
247
  )}
@@ -234,63 +256,36 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
234
256
  style={{ cursor: 'pointer' }}
235
257
  />
236
258
  )}
237
- {isActive && elapsed > 0 ? (
259
+ {isPending ? (
260
+ /* Optimistic — dimmed spinner only; no elapsed time until a real build exists */
261
+ <Spinner muted style={{ marginLeft: 4, opacity: 0.5 }} />
262
+ ) : isActive ? (
238
263
  <Flex align="center" gap={1}>
239
264
  <Spinner muted style={{ marginLeft: 4 }} />
240
265
  <Text size={1} muted>{formatDuration(elapsed)}</Text>
241
266
  </Flex>
242
- ) : (!isActive && deployedAt) ? (
267
+ ) : deployedAt ? (
243
268
  <Text size={1} muted>{deployedAt}</Text>
244
269
  ) : null}
245
270
  </>
246
271
  )}
247
272
  </Flex>
248
- <MenuButton
249
- button={<Button mode="ghost" icon={EllipsisVerticalIcon} padding={2} />}
273
+ <ActionMenu
250
274
  id={`menu-${target._id}`}
251
- menu={
252
- <Menu>
253
- <MenuItem
254
- text="Edit target"
255
- icon={EditIcon}
256
- onClick={() => onEdit(target)}
257
- />
258
- <MenuItem
259
- text="History"
260
- icon={ClockIcon}
261
- onClick={() => setShowHistory(true)}
262
- />
263
- {safeHref(latest?.inspectorUrl) && (
264
- <MenuItem
265
- text="Build logs"
266
- icon={LaunchIcon}
267
- as="a"
268
- href={safeHref(latest?.inspectorUrl)}
269
- target="_blank"
270
- rel="noreferrer"
271
- />
272
- )}
273
- {vercelProjectUrl && (
274
- <MenuItem
275
- text="Open in Vercel"
276
- icon={LaunchIcon}
277
- as="a"
278
- href={vercelProjectUrl}
279
- target="_blank"
280
- rel="noreferrer"
281
- />
282
- )}
283
- {!target.disableDeleteAction && (
284
- <MenuItem
285
- text="Delete"
286
- icon={TrashIcon}
287
- tone="critical"
288
- onClick={() => onDelete(target)}
289
- />
290
- )}
291
- </Menu>
292
- }
293
- popover={{ placement: 'bottom-end' }}
275
+ buttonIcon={EllipsisVerticalIcon}
276
+ items={[
277
+ { text: 'Edit target', icon: EditIcon, onClick: () => onEdit(target) },
278
+ { text: 'History', icon: ClockIcon, onClick: () => setShowHistory(true) },
279
+ ...(safeHref(latest?.inspectorUrl)
280
+ ? [{ text: 'Build logs', icon: LaunchIcon, href: safeHref(latest?.inspectorUrl)! }]
281
+ : []),
282
+ ...(vercelProjectUrl
283
+ ? [{ text: 'Open in Vercel', icon: LaunchIcon, href: vercelProjectUrl }]
284
+ : []),
285
+ ...(!target.disableDeleteAction
286
+ ? [{ text: 'Delete', icon: TrashIcon, tone: 'critical' as const, onClick: () => onDelete(target) }]
287
+ : []),
288
+ ]}
294
289
  />
295
290
  </Flex>
296
291
 
@@ -329,14 +324,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
329
324
 
330
325
  {/* Commit SHA — links to GitHub if repo info available, tooltip shows full message */}
331
326
  {sha && (
332
- <Tooltip
333
- content={
334
- <Box padding={2}>
335
- <Text size={1}>{commitMsg ?? sha}</Text>
336
- </Box>
337
- }
338
- portal
339
- >
327
+ <Tooltip text={commitMsg ?? sha}>
340
328
  {commitHref ? (
341
329
  <a
342
330
  href={commitHref}
@@ -367,14 +355,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
367
355
  {/* Visit link + copy URL */}
368
356
  {latest?.url && latest.state === 'READY' && (
369
357
  <>
370
- <Tooltip
371
- content={
372
- <Box padding={2}>
373
- <Text size={1}>{copied ? 'Copied!' : 'Copy URL'}</Text>
374
- </Box>
375
- }
376
- portal
377
- >
358
+ <Tooltip text={copied ? 'Copied!' : 'Copy URL'}>
378
359
  <Button
379
360
  mode="ghost"
380
361
  icon={copied ? CheckmarkIcon : CopyIcon}
@@ -438,7 +419,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
438
419
  <Stack space={2}>
439
420
  <Box style={{ maxHeight: 240, overflowY: 'auto', fontFamily: 'monospace', fontSize: 13, lineHeight: 1.6 }}>
440
421
  {errorLines.map((line, i) => (
441
- <Code key={i} size={1} style={{ display: 'block', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
422
+ <Code key={i} style={{ display: 'block', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
442
423
  {line}
443
424
  </Code>
444
425
  ))}
@@ -562,6 +543,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
562
543
  <Button
563
544
  text="Deploy"
564
545
  tone="primary"
546
+ loading={isPending}
565
547
  disabled={isActive}
566
548
  onClick={deploy}
567
549
  style={{
@@ -2,9 +2,10 @@
2
2
  import { useState, useCallback } from 'react'
3
3
  import { useClient } from 'sanity'
4
4
  import {
5
- Dialog, Box, Stack, Flex, Text, TextInput, Button, Switch, Label, Card,
5
+ Dialog, Box, Flex, Text, TextInput, Button, Switch, Label, Card,
6
6
  } from '@sanity/ui'
7
- import { CheckmarkCircleIcon } from '@sanity/icons'
7
+ import { Stack } from '../ui'
8
+ import { CheckmarkCircleIcon } from '../icons'
8
9
  import type { DeployTarget } from '../types'
9
10
 
10
11
  const VERCEL_HOOK_RE = /^https:\/\/api\.vercel\.com\/v1\/integrations\/deploy\//
@@ -34,10 +35,10 @@ export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetForm
34
35
  if (!canSave) return
35
36
  setSaving(true)
36
37
  setError(null)
37
- const fields = {
38
+ const fields: { name: string; url: string; teamId: string | null; disableDeleteAction: boolean } = {
38
39
  name: name.trim(),
39
40
  url: url.trim(),
40
- ...(teamId.trim() ? { teamId: teamId.trim() } : { teamId: null }),
41
+ teamId: teamId.trim() || null,
41
42
  disableDeleteAction: disableDelete,
42
43
  }
43
44
  try {
@@ -127,7 +128,7 @@ export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetForm
127
128
  id="disable-delete"
128
129
  />
129
130
  <Stack space={1}>
130
- <Label size={1} htmlFor="disable-delete">Disable delete action</Label>
131
+ <Label as="label" size={1} htmlFor="disable-delete">Disable delete action</Label>
131
132
  <Text size={0} muted>Hides the delete button for this target in the studio.</Text>
132
133
  </Stack>
133
134
  </Flex>
@@ -2,9 +2,10 @@
2
2
  import { useState, useEffect, useCallback } from 'react'
3
3
  import { useClient } from 'sanity'
4
4
  import {
5
- Card, Box, Stack, Flex, Text, Heading, Spinner, Button, Dialog, useToast,
5
+ Card, Box, Flex, Text, Heading, Spinner, Button, Dialog,
6
6
  } from '@sanity/ui'
7
- import { TokenIcon, TrashIcon, WarningOutlineIcon, AddIcon } from '@sanity/icons'
7
+ import { Stack, useToast, ToastViewport } from '../ui'
8
+ import { TokenIcon, TrashIcon, WarningOutlineIcon, AddIcon } from '../icons'
8
9
  import { DeployItem } from './DeployItem'
9
10
  import { TokenSetup } from './TokenSetup'
10
11
  import { DeployTargetForm } from './DeployTargetForm'
@@ -287,6 +288,9 @@ export function DeployTool() {
287
288
  </Box>
288
289
  </Dialog>
289
290
  )}
291
+
292
+ {/* Fallback toast surface — renders nothing when @sanity/ui exports its own useToast */}
293
+ <ToastViewport />
290
294
  </Card>
291
295
  )
292
296
  }
@@ -1,8 +1,11 @@
1
1
  // Vercel API token form — rendered inside a Dialog by DeployTool
2
2
  import { useState, useCallback } from 'react'
3
3
  import { useClient } from 'sanity'
4
- import { Stack, Text, TextInput, Button, Card, Dialog, Flex } from '@sanity/ui'
5
- import { CheckmarkCircleIcon } from '@sanity/icons'
4
+ import {
5
+ Text, TextInput, Button, Card, Dialog, Flex,
6
+ } from '@sanity/ui'
7
+ import { Stack } from '../ui'
8
+ import { CheckmarkCircleIcon } from '../icons'
6
9
 
7
10
  interface TokenSetupProps {
8
11
  /** Called after the token is successfully saved */
package/src/icons.tsx ADDED
@@ -0,0 +1,71 @@
1
+ // Version-agnostic access to @sanity/icons — resolves named exports (icons v3/v4) or <Icon symbol> (icons v5+)
2
+ import { forwardRef } from 'react'
3
+ import type { ComponentType, SVGProps } from 'react'
4
+ import * as sanityIcons from '@sanity/icons'
5
+
6
+ /** Props every Sanity icon accepts — it renders a plain sized SVG. */
7
+ export type IconProps = SVGProps<SVGSVGElement>
8
+
9
+ /** An icon component, whichever shape the installed @sanity/icons exposes it in. */
10
+ export type IconComponent = ComponentType<IconProps>
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
+ /** Sizing of a Sanity icon glyph — 1em square on a 25-unit viewBox, matching @sanity/icons. */
24
+ const GLYPH = { width: '1em', height: '1em', viewBox: '0 0 25 25', fill: 'none' } as const
25
+
26
+ /** Last-resort placeholder when the installed @sanity/icons exposes neither shape. Holds layout, draws nothing. */
27
+ const MissingIcon = forwardRef<SVGSVGElement, IconProps>(function MissingIcon(props, ref) {
28
+ return <svg {...GLYPH} xmlns="http://www.w3.org/2000/svg" {...props} ref={ref} />
29
+ })
30
+
31
+ /** The v5+ `<Icon>` component, absent on icons v3 and v4. */
32
+ type SymbolIcon = ComponentType<IconProps & { symbol: string }>
33
+
34
+ /**
35
+ * 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
+ * on the consumer's next `@sanity/icons` update without a release here.
38
+ *
39
+ * @param name Named export used by icons v3 and v4, e.g. `RocketIcon`.
40
+ * @param symbol Kebab-case symbol used by the v5+ `<Icon>` component, e.g. `rocket`.
41
+ */
42
+ function resolveIcon(name: string, symbol: string): IconComponent {
43
+ const named = INSTALLED[name] as IconComponent | undefined
44
+ if (named) return named
45
+
46
+ const Icon = INSTALLED.Icon as SymbolIcon | undefined
47
+ if (!Icon) return MissingIcon
48
+
49
+ const Resolved = forwardRef<SVGSVGElement, IconProps>(function SanityIcon(props, ref) {
50
+ return <Icon symbol={symbol} {...props} ref={ref as never} />
51
+ })
52
+ Resolved.displayName = name
53
+ return Resolved as IconComponent
54
+ }
55
+
56
+ export const AddIcon = resolveIcon('AddIcon', 'add')
57
+ export const CheckmarkCircleIcon = resolveIcon('CheckmarkCircleIcon', 'checkmark-circle')
58
+ export const CheckmarkIcon = resolveIcon('CheckmarkIcon', 'checkmark')
59
+ export const ChevronDownIcon = resolveIcon('ChevronDownIcon', 'chevron-down')
60
+ export const ChevronUpIcon = resolveIcon('ChevronUpIcon', 'chevron-up')
61
+ export const ClockIcon = resolveIcon('ClockIcon', 'clock')
62
+ export const CloseIcon = resolveIcon('CloseIcon', 'close')
63
+ export const CopyIcon = resolveIcon('CopyIcon', 'copy')
64
+ export const EditIcon = resolveIcon('EditIcon', 'edit')
65
+ export const EllipsisVerticalIcon = resolveIcon('EllipsisVerticalIcon', 'ellipsis-vertical')
66
+ export const LaunchIcon = resolveIcon('LaunchIcon', 'launch')
67
+ export const RocketIcon = resolveIcon('RocketIcon', 'rocket')
68
+ export const SchemaIcon = resolveIcon('SchemaIcon', 'schema')
69
+ export const TokenIcon = resolveIcon('TokenIcon', 'token')
70
+ export const TrashIcon = resolveIcon('TrashIcon', 'trash')
71
+ export const WarningOutlineIcon = resolveIcon('WarningOutlineIcon', 'warning-outline')
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // deploy-vercel-from-sanity — Sanity Studio v5 plugin for Vercel deployments
2
2
  import { definePlugin } from 'sanity'
3
- import { RocketIcon } from '@sanity/icons'
3
+ import { RocketIcon } from './icons'
4
4
  import { DeployTool } from './components/DeployTool'
5
5
  import { vercelDeploySchema } from './schema/vercelDeploy'
6
6
  import type { VercelDeployPluginConfig } from './types'
@@ -1,6 +1,6 @@
1
1
  // Sanity schema for vercel_deploy documents — stores deploy hook targets
2
2
  import { defineField, defineType } from 'sanity'
3
- import { RocketIcon } from '@sanity/icons'
3
+ import { RocketIcon } from '../icons'
4
4
 
5
5
  export const vercelDeploySchema = defineType({
6
6
  name: 'vercel_deploy',