@podoba/react 0.0.7 → 0.0.9
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 +3 -3
- package/src/components/brand-page-header.tsx +189 -0
- package/src/components/collapsible-card.tsx +121 -0
- package/src/components/combobox.tsx +78 -28
- package/src/components/dashboard-grid.tsx +50 -0
- package/src/components/date-picker.tsx +79 -65
- package/src/components/delivery/delivery-status-module.tsx +113 -0
- package/src/components/delivery/download-modal.tsx +204 -0
- package/src/components/delivery/publish-modal.tsx +170 -0
- package/src/components/delivery/send-to-print-modal.tsx +216 -0
- package/src/components/file-upload.tsx +4 -1
- package/src/components/focus-context.ts +11 -0
- package/src/components/focus-field.tsx +213 -0
- package/src/components/multiselect.tsx +55 -37
- package/src/components/request-changes-modal.tsx +134 -0
- package/src/components/rich-text-editor.tsx +5 -1
- package/src/components/select.tsx +53 -38
- package/src/components/stats-card.tsx +146 -0
- package/src/components/task-approval-modal.tsx +123 -0
- package/src/index.ts +18 -3
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @app/ui — delivery status module (issue 17).
|
|
2
|
+
//
|
|
3
|
+
// PRESENTATIONAL "black status module" — the signature delivery-UI surface ported
|
|
4
|
+
// from the gs-platform `FinalOutputsHandoffPanel` (`.statusModule`): a dark
|
|
5
|
+
// inverted surface (white-on-#242423) carrying a status header row, muted status
|
|
6
|
+
// text, an optional progress track with a GREEN progress bar, and optional
|
|
7
|
+
// trailing "ready" pills. Used by BOTH the final-outputs handoff panel and the
|
|
8
|
+
// live delivery-status cards so the two read as one visual language.
|
|
9
|
+
//
|
|
10
|
+
// APP-AGNOSTIC (hard rule #1): no apps/web imports, no API hooks, no i18n. Every
|
|
11
|
+
// string is passed in; the owning surface in apps/web supplies the i18n copy and
|
|
12
|
+
// wires behaviour. Pure markup + Tailwind classes bound to @app/tokens CSS vars.
|
|
13
|
+
//
|
|
14
|
+
// ── Styling (hard rule #3) ────────────────────────────────────────────────────
|
|
15
|
+
// Tailwind + @app/tokens only — NO SCSS, NO raw hex.
|
|
16
|
+
// gs-platform `--color-text-primary` (#000 module surface) → our `surface-inverted`
|
|
17
|
+
// gs-platform `--color-background` (white text) → our `fg-inverted`
|
|
18
|
+
// gs-platform `--color-brand-green` (#22c55e progress) → our `success`
|
|
19
|
+
// gs-platform `--radius-full` (track / pills) → `rounded-full`
|
|
20
|
+
// gs-platform `--radius-panel-sm` (module) → `rounded-md`
|
|
21
|
+
//
|
|
22
|
+
// ── a11y ──────────────────────────────────────────────────────────────────────
|
|
23
|
+
// White (#fff) on the dark surface (#242423) ≈ 14.9:1 — passes WCAG AA. The muted
|
|
24
|
+
// status text uses `text-white/70` (white at 70% over the dark surface) ≈ 8.6:1 —
|
|
25
|
+
// still AA. (We use the stock `white`/opacity utilities for the muted tints rather
|
|
26
|
+
// than `fg-inverted/NN`: Tailwind cannot inject an alpha channel into an arbitrary
|
|
27
|
+
// `var(--color-…)`, so an opacity modifier on `fg-inverted` would be a no-op; the
|
|
28
|
+
// `fg-inverted` token IS white, so `text-white` is the same colour, alpha-capable.)
|
|
29
|
+
// The green bar (#22c55e on #242423) is a NON-TEXT graphical indicator (~5.6:1,
|
|
30
|
+
// above the 3:1 non-text minimum). The progress track is `aria-hidden`; when a
|
|
31
|
+
// numeric value matters, expose it in the header (text), not the bar.
|
|
32
|
+
|
|
33
|
+
import type { ReactNode } from 'react'
|
|
34
|
+
import { uic } from '../../utils/uic'
|
|
35
|
+
|
|
36
|
+
/** The dark status-module shell (inverted surface + white text). */
|
|
37
|
+
export const DeliveryStatusModuleRoot = uic('div', {
|
|
38
|
+
displayName: 'DeliveryStatusModule',
|
|
39
|
+
baseClass:
|
|
40
|
+
'grid gap-3 rounded-md bg-surface-inverted p-4 text-fg-inverted',
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export interface DeliveryStatusModuleProps {
|
|
44
|
+
/** Status header — left-aligned headline (e.g. "Files ready"). */
|
|
45
|
+
heading: ReactNode
|
|
46
|
+
/** Optional right-aligned header annotation (e.g. "50 %", "3 ready"). */
|
|
47
|
+
headingMeta?: ReactNode
|
|
48
|
+
/** Muted status sentence under the header. */
|
|
49
|
+
statusText?: ReactNode
|
|
50
|
+
/**
|
|
51
|
+
* Optional progress fraction in [0, 1]. When provided, renders the green
|
|
52
|
+
* progress track. Clamped; the track itself is decorative (`aria-hidden`).
|
|
53
|
+
*/
|
|
54
|
+
progress?: number
|
|
55
|
+
/** Optional trailing pills / actions row (e.g. "ready" chips). */
|
|
56
|
+
children?: ReactNode
|
|
57
|
+
/** Forwarded test id. */
|
|
58
|
+
'data-testid'?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Black status module with an optional green progress bar. The visual heart of the
|
|
63
|
+
* delivery UI — stack it under a white output card or use it as a job-status card.
|
|
64
|
+
*/
|
|
65
|
+
export function DeliveryStatusModule({
|
|
66
|
+
heading,
|
|
67
|
+
headingMeta,
|
|
68
|
+
statusText,
|
|
69
|
+
progress,
|
|
70
|
+
children,
|
|
71
|
+
'data-testid': testId,
|
|
72
|
+
}: DeliveryStatusModuleProps): React.ReactNode {
|
|
73
|
+
const hasProgress = progress != null
|
|
74
|
+
const pct = hasProgress ? Math.max(0, Math.min(1, progress)) * 100 : 0
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<DeliveryStatusModuleRoot data-testid={testId}>
|
|
78
|
+
<div className="flex items-center justify-between gap-4 text-sm font-medium">
|
|
79
|
+
<span>{heading}</span>
|
|
80
|
+
{headingMeta != null ? <span>{headingMeta}</span> : null}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{statusText != null ? (
|
|
84
|
+
<p className="m-0 text-xs text-white/70">{statusText}</p>
|
|
85
|
+
) : null}
|
|
86
|
+
|
|
87
|
+
{hasProgress ? (
|
|
88
|
+
<div
|
|
89
|
+
className="h-2 overflow-hidden rounded-full bg-white/25"
|
|
90
|
+
aria-hidden="true"
|
|
91
|
+
>
|
|
92
|
+
<span
|
|
93
|
+
className="block h-full rounded-full bg-success transition-[width]"
|
|
94
|
+
style={{ width: `${pct}%` }}
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
) : null}
|
|
98
|
+
|
|
99
|
+
{children != null ? (
|
|
100
|
+
<div className="flex flex-wrap items-center gap-2 text-xs text-white/80">
|
|
101
|
+
{children}
|
|
102
|
+
</div>
|
|
103
|
+
) : null}
|
|
104
|
+
</DeliveryStatusModuleRoot>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A hairline "ready" pill for the trailing actions row (inverted palette). */
|
|
109
|
+
export const DeliveryStatusPill = uic('span', {
|
|
110
|
+
displayName: 'DeliveryStatusPill',
|
|
111
|
+
baseClass:
|
|
112
|
+
'inline-flex min-h-5 items-center rounded-full border border-white/30 px-3',
|
|
113
|
+
})
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// @app/ui — delivery download modal (issue 14).
|
|
2
|
+
//
|
|
3
|
+
// PRESENTATIONAL download-confirmation dialog built on the @app/ui <Dialog>
|
|
4
|
+
// (React Aria Modal — focus trap, Esc, aria-modal). The user picks a format
|
|
5
|
+
// (PNG / PDF), confirms, and a `download` delivery job is created. While the
|
|
6
|
+
// job's artifact is generating the modal shows a progress note; once the
|
|
7
|
+
// artifact is ready the modal surfaces a download link to the artifact URL.
|
|
8
|
+
//
|
|
9
|
+
// APP-AGNOSTIC (hard rule #1: @app/ui never imports apps/web or calls API
|
|
10
|
+
// hooks). Every string comes through `labels`; the action is delegated via
|
|
11
|
+
// `onConfirm(format)`; the job status / artifact URL are passed in by the
|
|
12
|
+
// owning surface in apps/web, which wires the i18n strings + the REST mutation
|
|
13
|
+
// (`useDeliveryJobMutations().create`) + invalidation.
|
|
14
|
+
//
|
|
15
|
+
// a11y: the format radiogroup is label-associated; the progress note is
|
|
16
|
+
// role="status"; the server error is role="alert"; confirm shows a pending
|
|
17
|
+
// state and disables while in flight.
|
|
18
|
+
|
|
19
|
+
import { useState } from 'react'
|
|
20
|
+
import {
|
|
21
|
+
Label,
|
|
22
|
+
Radio,
|
|
23
|
+
RadioGroup,
|
|
24
|
+
type Key,
|
|
25
|
+
} from 'react-aria-components'
|
|
26
|
+
import { Button } from '../button'
|
|
27
|
+
import { Dialog } from '../dialog'
|
|
28
|
+
|
|
29
|
+
/** The two download formats a `download` job can produce. */
|
|
30
|
+
export const DOWNLOAD_FORMATS = ['png', 'pdf'] as const
|
|
31
|
+
export type DownloadFormat = (typeof DOWNLOAD_FORMATS)[number]
|
|
32
|
+
|
|
33
|
+
/** Strings the download modal renders — supplied by the app (i18n), not hardcoded. */
|
|
34
|
+
export interface DownloadModalLabels {
|
|
35
|
+
/** Dialog heading. */
|
|
36
|
+
title: string
|
|
37
|
+
/** Explanatory body copy under the title. */
|
|
38
|
+
body: string
|
|
39
|
+
/** Label for the format radiogroup. */
|
|
40
|
+
formatLabel: string
|
|
41
|
+
/** Label for the PNG option. */
|
|
42
|
+
formatPng: string
|
|
43
|
+
/** Label for the PDF option. */
|
|
44
|
+
formatPdf: string
|
|
45
|
+
/** Cancel button. */
|
|
46
|
+
cancel: string
|
|
47
|
+
/** Confirm (create download job) button — idle state. */
|
|
48
|
+
confirm: string
|
|
49
|
+
/** Confirm button — pending state. */
|
|
50
|
+
submitting: string
|
|
51
|
+
/** Progress note while the artifact is generating. */
|
|
52
|
+
preparing: string
|
|
53
|
+
/** Download-ready action label (links to the artifact). */
|
|
54
|
+
download: string
|
|
55
|
+
/** Note shown once the artifact is ready. */
|
|
56
|
+
ready: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DownloadModalProps {
|
|
60
|
+
/** Controlled open state. */
|
|
61
|
+
isOpen: boolean
|
|
62
|
+
/** Notified on open/close (false on Esc / click-outside / cancel). */
|
|
63
|
+
onOpenChange: (isOpen: boolean) => void
|
|
64
|
+
/** Create the `download` delivery job for the chosen format. */
|
|
65
|
+
onConfirm: (format: DownloadFormat) => void
|
|
66
|
+
/** Whether the confirm mutation is in flight (disables + shows pending). */
|
|
67
|
+
isPending?: boolean
|
|
68
|
+
/** Server / mutation error message to surface inline (role="alert"). */
|
|
69
|
+
error?: string
|
|
70
|
+
/**
|
|
71
|
+
* Once the job is created and its artifact is ready, the URL to download.
|
|
72
|
+
* While null/undefined the modal shows the create form (or the preparing
|
|
73
|
+
* note when `isPreparing`).
|
|
74
|
+
*/
|
|
75
|
+
artifactUrl?: string | null
|
|
76
|
+
/** Whether a created job's artifact is still generating (shows the progress note). */
|
|
77
|
+
isPreparing?: boolean
|
|
78
|
+
/** App-supplied i18n strings. */
|
|
79
|
+
labels: DownloadModalLabels
|
|
80
|
+
/** Optional test id forwarded to the dialog. */
|
|
81
|
+
'data-testid'?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Download modal with a PNG/PDF format choice and a ready-artifact download link. */
|
|
85
|
+
export function DownloadModal({
|
|
86
|
+
isOpen,
|
|
87
|
+
onOpenChange,
|
|
88
|
+
onConfirm,
|
|
89
|
+
isPending = false,
|
|
90
|
+
error,
|
|
91
|
+
artifactUrl,
|
|
92
|
+
isPreparing = false,
|
|
93
|
+
labels,
|
|
94
|
+
'data-testid': testId,
|
|
95
|
+
}: DownloadModalProps): React.ReactNode {
|
|
96
|
+
const [format, setFormat] = useState<DownloadFormat>('png')
|
|
97
|
+
|
|
98
|
+
const handleConfirm = (): void => {
|
|
99
|
+
onConfirm(format)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const hasArtifact = artifactUrl != null && artifactUrl.length > 0
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Dialog
|
|
106
|
+
title={labels.title}
|
|
107
|
+
data-testid={testId ?? 'delivery-download-modal'}
|
|
108
|
+
isOpen={isOpen}
|
|
109
|
+
onOpenChange={onOpenChange}
|
|
110
|
+
>
|
|
111
|
+
<div className="flex flex-col gap-3">
|
|
112
|
+
<p className="text-sm text-fg-muted">{labels.body}</p>
|
|
113
|
+
|
|
114
|
+
{hasArtifact ? (
|
|
115
|
+
<>
|
|
116
|
+
<p data-testid="delivery-download-ready" role="status" className="text-sm text-fg">
|
|
117
|
+
{labels.ready}
|
|
118
|
+
</p>
|
|
119
|
+
<div className="mt-2 flex justify-end gap-2">
|
|
120
|
+
<Button variant="secondary" size="sm" onPress={() => onOpenChange(false)}>
|
|
121
|
+
{labels.cancel}
|
|
122
|
+
</Button>
|
|
123
|
+
{/* React Aria <Button> is a real <button>; a download is a navigation,
|
|
124
|
+
so we use a styled anchor (the only anchor in these modals). */}
|
|
125
|
+
<a
|
|
126
|
+
data-testid="delivery-download-link"
|
|
127
|
+
href={artifactUrl ?? undefined}
|
|
128
|
+
download
|
|
129
|
+
className="inline-flex h-8 items-center justify-center gap-2 rounded-md bg-brand-primary px-3 text-sm font-medium text-fg-inverted no-underline outline-none transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
130
|
+
>
|
|
131
|
+
{labels.download}
|
|
132
|
+
</a>
|
|
133
|
+
</div>
|
|
134
|
+
</>
|
|
135
|
+
) : isPreparing ? (
|
|
136
|
+
<>
|
|
137
|
+
<p data-testid="delivery-download-preparing" role="status" className="text-sm text-fg-muted">
|
|
138
|
+
{labels.preparing}
|
|
139
|
+
</p>
|
|
140
|
+
<div className="mt-2 flex justify-end gap-2">
|
|
141
|
+
<Button variant="secondary" size="sm" onPress={() => onOpenChange(false)}>
|
|
142
|
+
{labels.cancel}
|
|
143
|
+
</Button>
|
|
144
|
+
</div>
|
|
145
|
+
</>
|
|
146
|
+
) : (
|
|
147
|
+
<>
|
|
148
|
+
<RadioGroup
|
|
149
|
+
aria-label={labels.formatLabel}
|
|
150
|
+
value={format}
|
|
151
|
+
onChange={(value: string) => setFormat(value as DownloadFormat)}
|
|
152
|
+
className="flex flex-col gap-2"
|
|
153
|
+
>
|
|
154
|
+
<Label className="text-sm font-medium text-fg">{labels.formatLabel}</Label>
|
|
155
|
+
<div className="flex gap-2">
|
|
156
|
+
<DownloadFormatOption value="png" label={labels.formatPng} />
|
|
157
|
+
<DownloadFormatOption value="pdf" label={labels.formatPdf} />
|
|
158
|
+
</div>
|
|
159
|
+
</RadioGroup>
|
|
160
|
+
|
|
161
|
+
{error ? (
|
|
162
|
+
<p data-testid="delivery-download-error" role="alert" className="text-sm text-danger">
|
|
163
|
+
{error}
|
|
164
|
+
</p>
|
|
165
|
+
) : null}
|
|
166
|
+
|
|
167
|
+
<div className="mt-2 flex justify-end gap-2">
|
|
168
|
+
<Button
|
|
169
|
+
variant="secondary"
|
|
170
|
+
size="sm"
|
|
171
|
+
isDisabled={isPending}
|
|
172
|
+
onPress={() => onOpenChange(false)}
|
|
173
|
+
>
|
|
174
|
+
{labels.cancel}
|
|
175
|
+
</Button>
|
|
176
|
+
<Button
|
|
177
|
+
data-testid="delivery-download-confirm"
|
|
178
|
+
variant="primary"
|
|
179
|
+
size="sm"
|
|
180
|
+
isDisabled={isPending}
|
|
181
|
+
isPending={isPending}
|
|
182
|
+
onPress={handleConfirm}
|
|
183
|
+
>
|
|
184
|
+
{isPending ? labels.submitting : labels.confirm}
|
|
185
|
+
</Button>
|
|
186
|
+
</div>
|
|
187
|
+
</>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
</Dialog>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** One radio chip in the download-format group. */
|
|
195
|
+
function DownloadFormatOption({ value, label }: { value: Key; label: string }): React.ReactNode {
|
|
196
|
+
return (
|
|
197
|
+
<Radio
|
|
198
|
+
value={String(value)}
|
|
199
|
+
className="flex cursor-pointer items-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-sm text-fg outline-none transition-colors data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[selected]:border-brand-primary data-[selected]:font-medium"
|
|
200
|
+
>
|
|
201
|
+
{label}
|
|
202
|
+
</Radio>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// @app/ui — publish modal (issue 14).
|
|
2
|
+
//
|
|
3
|
+
// PRESENTATIONAL publish dialog built on the @app/ui <Dialog> (React Aria Modal
|
|
4
|
+
// — focus trap, Esc, aria-modal). Collects a destination + channel and, on
|
|
5
|
+
// confirm, the owning surface in apps/web creates a `publish` delivery job.
|
|
6
|
+
//
|
|
7
|
+
// VALIDATION: destination + channel are both required (non-blank, trimmed) —
|
|
8
|
+
// confirm stays DISABLED until both hold a value. The channel is a constrained
|
|
9
|
+
// choice (the app supplies the option list as `labels.channels`).
|
|
10
|
+
//
|
|
11
|
+
// APP-AGNOSTIC (hard rule #1): every string is a prop; the action is delegated
|
|
12
|
+
// via `onConfirm(payload)`; apps/web wires i18n + the REST mutation (`publish`
|
|
13
|
+
// kind) + invalidation.
|
|
14
|
+
//
|
|
15
|
+
// a11y: the destination input is label-associated; the channel select is a RAC
|
|
16
|
+
// Select (listbox ARIA); the server error is role="alert"; confirm shows a
|
|
17
|
+
// pending state and disables while in flight / invalid.
|
|
18
|
+
|
|
19
|
+
import { useState } from 'react'
|
|
20
|
+
import { Button } from '../button'
|
|
21
|
+
import { Dialog } from '../dialog'
|
|
22
|
+
import { Input } from '../input'
|
|
23
|
+
import { Select, SelectItem } from '../select'
|
|
24
|
+
|
|
25
|
+
/** One publish channel option (value + visible label), supplied by the app. */
|
|
26
|
+
export interface PublishChannelOption {
|
|
27
|
+
/** Stable channel id stored on the job (e.g. "instagram", "linkedin"). */
|
|
28
|
+
value: string
|
|
29
|
+
/** Translated, human-readable channel name. */
|
|
30
|
+
label: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The publish payload the modal yields on confirm. */
|
|
34
|
+
export interface PublishPayload {
|
|
35
|
+
/** Destination label (e.g. an account / page name) — non-blank. */
|
|
36
|
+
destination: string
|
|
37
|
+
/** Selected channel value. */
|
|
38
|
+
channel: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Strings the publish modal renders — supplied by the app (i18n). */
|
|
42
|
+
export interface PublishModalLabels {
|
|
43
|
+
/** Dialog heading. */
|
|
44
|
+
title: string
|
|
45
|
+
/** Explanatory body copy under the title. */
|
|
46
|
+
body: string
|
|
47
|
+
/** Destination field label + placeholder. */
|
|
48
|
+
destinationLabel: string
|
|
49
|
+
destinationPlaceholder: string
|
|
50
|
+
/** Channel select label + placeholder. */
|
|
51
|
+
channelLabel: string
|
|
52
|
+
channelPlaceholder: string
|
|
53
|
+
/** Cancel button. */
|
|
54
|
+
cancel: string
|
|
55
|
+
/** Confirm (create publish job) button — idle state. */
|
|
56
|
+
confirm: string
|
|
57
|
+
/** Confirm button — pending state. */
|
|
58
|
+
submitting: string
|
|
59
|
+
/** The channel options to choose from. */
|
|
60
|
+
channels: PublishChannelOption[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PublishModalProps {
|
|
64
|
+
/** Controlled open state. */
|
|
65
|
+
isOpen: boolean
|
|
66
|
+
/** Notified on open/close (false on Esc / click-outside / cancel). */
|
|
67
|
+
onOpenChange: (isOpen: boolean) => void
|
|
68
|
+
/** Create the `publish` delivery job with the chosen destination + channel. */
|
|
69
|
+
onConfirm: (payload: PublishPayload) => void
|
|
70
|
+
/** Whether the confirm mutation is in flight (disables + shows pending). */
|
|
71
|
+
isPending?: boolean
|
|
72
|
+
/** Server / mutation error message to surface inline (role="alert"). */
|
|
73
|
+
error?: string
|
|
74
|
+
/** App-supplied i18n strings. */
|
|
75
|
+
labels: PublishModalLabels
|
|
76
|
+
/** Optional test id forwarded to the dialog. */
|
|
77
|
+
'data-testid'?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Publish modal with a destination input + a channel choice. */
|
|
81
|
+
export function PublishModal({
|
|
82
|
+
isOpen,
|
|
83
|
+
onOpenChange,
|
|
84
|
+
onConfirm,
|
|
85
|
+
isPending = false,
|
|
86
|
+
error,
|
|
87
|
+
labels,
|
|
88
|
+
'data-testid': testId,
|
|
89
|
+
}: PublishModalProps): React.ReactNode {
|
|
90
|
+
const [destination, setDestination] = useState('')
|
|
91
|
+
const [channel, setChannel] = useState<string>('')
|
|
92
|
+
|
|
93
|
+
const reset = (): void => {
|
|
94
|
+
setDestination('')
|
|
95
|
+
setChannel('')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const canConfirm = !isPending && destination.trim().length > 0 && channel.trim().length > 0
|
|
99
|
+
|
|
100
|
+
const handleOpenChange = (open: boolean): void => {
|
|
101
|
+
if (!open) reset()
|
|
102
|
+
onOpenChange(open)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const handleConfirm = (): void => {
|
|
106
|
+
if (!canConfirm) return
|
|
107
|
+
onConfirm({ destination: destination.trim(), channel })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Dialog
|
|
112
|
+
title={labels.title}
|
|
113
|
+
data-testid={testId ?? 'delivery-publish-modal'}
|
|
114
|
+
isOpen={isOpen}
|
|
115
|
+
onOpenChange={handleOpenChange}
|
|
116
|
+
>
|
|
117
|
+
<div className="flex flex-col gap-3">
|
|
118
|
+
<p className="text-sm text-fg-muted">{labels.body}</p>
|
|
119
|
+
|
|
120
|
+
<Input
|
|
121
|
+
label={labels.destinationLabel}
|
|
122
|
+
placeholder={labels.destinationPlaceholder}
|
|
123
|
+
value={destination}
|
|
124
|
+
onChange={setDestination}
|
|
125
|
+
data-testid="delivery-publish-destination"
|
|
126
|
+
/>
|
|
127
|
+
<Select
|
|
128
|
+
label={labels.channelLabel}
|
|
129
|
+
placeholder={labels.channelPlaceholder}
|
|
130
|
+
selectedKey={channel.length > 0 ? channel : null}
|
|
131
|
+
onSelectionChange={(key) => setChannel(String(key))}
|
|
132
|
+
data-testid="delivery-publish-channel"
|
|
133
|
+
>
|
|
134
|
+
{labels.channels.map((option) => (
|
|
135
|
+
<SelectItem key={option.value} id={option.value}>
|
|
136
|
+
{option.label}
|
|
137
|
+
</SelectItem>
|
|
138
|
+
))}
|
|
139
|
+
</Select>
|
|
140
|
+
|
|
141
|
+
{error ? (
|
|
142
|
+
<p data-testid="delivery-publish-error" role="alert" className="text-sm text-danger">
|
|
143
|
+
{error}
|
|
144
|
+
</p>
|
|
145
|
+
) : null}
|
|
146
|
+
|
|
147
|
+
<div className="mt-2 flex justify-end gap-2">
|
|
148
|
+
<Button
|
|
149
|
+
variant="secondary"
|
|
150
|
+
size="sm"
|
|
151
|
+
isDisabled={isPending}
|
|
152
|
+
onPress={() => handleOpenChange(false)}
|
|
153
|
+
>
|
|
154
|
+
{labels.cancel}
|
|
155
|
+
</Button>
|
|
156
|
+
<Button
|
|
157
|
+
data-testid="delivery-publish-confirm"
|
|
158
|
+
variant="primary"
|
|
159
|
+
size="sm"
|
|
160
|
+
isDisabled={!canConfirm}
|
|
161
|
+
isPending={isPending}
|
|
162
|
+
onPress={handleConfirm}
|
|
163
|
+
>
|
|
164
|
+
{isPending ? labels.submitting : labels.confirm}
|
|
165
|
+
</Button>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</Dialog>
|
|
169
|
+
)
|
|
170
|
+
}
|