mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 16:02:59 +00:00
fix: default schedule handlers, unique trigger draft paths, zoned cadence, valid script names
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
77c5df6289
commit
146f770626
@@ -21,7 +21,7 @@
|
||||
import { layoutAssetGraph, PIPELINE_NODE_EXTRA_ROW } from './assetGraphLayout'
|
||||
import { assetsOnlyView, type AssetUpstream } from './assetsOnlyView'
|
||||
import { TRIGGER_NODE_STYLE } from './TriggerNode.svelte'
|
||||
import { describeCron } from '$lib/utils/describeCron'
|
||||
import { describeSchedule } from '$lib/utils/describeCron'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { computeMutedReadKeys, dbtAssociations } from './resolveGraph'
|
||||
import { buildDownstreamMap } from './graphTraversal'
|
||||
@@ -732,6 +732,7 @@
|
||||
missing: boolean
|
||||
draft: boolean
|
||||
schedule?: string
|
||||
timezone?: string
|
||||
summary?: string
|
||||
// First target script (drives the per-script create/edit flows).
|
||||
runnable_path?: string
|
||||
@@ -751,6 +752,7 @@
|
||||
draft: boolean,
|
||||
runnable_path?: string,
|
||||
schedule?: string,
|
||||
timezone?: string,
|
||||
summary?: string
|
||||
) {
|
||||
const prev = triggerSourceNodes.get(id)
|
||||
@@ -762,6 +764,7 @@
|
||||
missing,
|
||||
draft,
|
||||
schedule,
|
||||
timezone,
|
||||
summary,
|
||||
runnable_path,
|
||||
runnable_paths: runnable_path ? [runnable_path] : []
|
||||
@@ -820,6 +823,7 @@
|
||||
// on every attached native trigger.
|
||||
t.runnable_path,
|
||||
t.schedule,
|
||||
t.timezone,
|
||||
t.summary
|
||||
)
|
||||
edges.push({
|
||||
@@ -850,6 +854,7 @@
|
||||
missing: info.missing,
|
||||
draft: info.draft,
|
||||
schedule: info.schedule,
|
||||
timezone: info.timezone,
|
||||
summary: info.summary,
|
||||
runnable_path: info.runnable_path,
|
||||
// data_upload nodes go green once a file is staged for their
|
||||
@@ -910,7 +915,7 @@
|
||||
const style = TRIGGER_NODE_STYLE[t.kind as TriggerNodeKind]
|
||||
const label =
|
||||
t.kind === 'schedule' && t.data?.schedule
|
||||
? (describeCron(t.data.schedule) ?? `Schedule (${t.data.schedule})`)
|
||||
? (describeSchedule(t.data.schedule, t.data.timezone) ?? `Schedule (${t.data.schedule})`)
|
||||
: (style?.label ?? t.kind)
|
||||
return {
|
||||
label,
|
||||
@@ -1450,7 +1455,7 @@
|
||||
{#if assetsOnlyToggle}
|
||||
<Panel
|
||||
position="top-left"
|
||||
class="!m-3 px-2 py-1 rounded-md bg-surface border border-gray-200 dark:border-gray-700 shadow-sm"
|
||||
class="!m-3"
|
||||
>
|
||||
<Toggle bind:checked={assetsOnly} size="xs" options={{ right: 'Assets only' }} />
|
||||
</Panel>
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
import DateTimeInput from '$lib/components/DateTimeInput.svelte'
|
||||
import ScheduleAdvancedOptions, {
|
||||
emptyScheduleAdvanced,
|
||||
loadDefaultScheduleAdvanced,
|
||||
scheduleAdvancedCfg
|
||||
} from '$lib/components/triggers/schedules/ScheduleAdvancedOptions.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
@@ -147,14 +148,31 @@
|
||||
tableEdited: false
|
||||
})
|
||||
let config = $state(buildEmptyConfig())
|
||||
loadAdvancedDefaults()
|
||||
|
||||
// The workspace's default handlers, as the schedule drawer applies to a new schedule.
|
||||
function loadAdvancedDefaults() {
|
||||
const ws = $workspaceStore
|
||||
if (!ws) return
|
||||
const target = config
|
||||
loadDefaultScheduleAdvanced(ws)
|
||||
.then((d) => {
|
||||
if (config === target) config.advanced = d
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function resetWizard() {
|
||||
selected = buildEmptySelected()
|
||||
config = buildEmptyConfig()
|
||||
loadAdvancedDefaults()
|
||||
step = 0
|
||||
}
|
||||
|
||||
const TABLE_NAME_RE = /^[^/\s]+$/
|
||||
// The path grammar of `Path.svelte`: word segments, no empty or trailing ones.
|
||||
const SCRIPT_NAME_RE = /^[\w-]+(\/[\w-]+)*$/
|
||||
let scriptNameValid = $derived(SCRIPT_NAME_RE.test(selected.scriptPath.trim()))
|
||||
let assetValid = $derived(
|
||||
!!config.asset?.store && TABLE_NAME_RE.test(config.asset.table.trim())
|
||||
)
|
||||
@@ -163,7 +181,7 @@
|
||||
? config.validCron && !!config.schedule.trim() && !config.schedulePathError
|
||||
: currentConfigStep === 'asset'
|
||||
? assetValid
|
||||
: !!selected.scriptPath.trim()
|
||||
: scriptNameValid && !!selected.triggerId && !!selected.language && !!selected.outputId
|
||||
)
|
||||
|
||||
function enterAssetStep() {
|
||||
@@ -428,14 +446,30 @@
|
||||
{#snippet bottomSection(close: () => void)}
|
||||
<Label label="Path">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div bind:this={pathEl} class="flex" onkeydown={selectAndAdvanceTo(() => aiPromptEl ?? saveEl)}>
|
||||
<div
|
||||
bind:this={pathEl}
|
||||
class="flex"
|
||||
onkeydown={(e) =>
|
||||
(e.metaKey || e.ctrlKey) && e.key === 'Enter'
|
||||
? (e.preventDefault(), e.stopPropagation(), confirm(close))
|
||||
: selectAndAdvanceTo(() => aiPromptEl ?? saveEl)(e)}
|
||||
>
|
||||
<div
|
||||
class="border rounded-md rounded-r-none border-r-0 text-xs w-fit shrink-0 whitespace-nowrap flex items-center px-2 text-secondary bg-surface-input"
|
||||
>
|
||||
{pathPrefix}
|
||||
</div>
|
||||
<TextInput bind:value={selected.scriptPath} class="rounded-l-none" />
|
||||
<TextInput
|
||||
bind:value={selected.scriptPath}
|
||||
class="rounded-l-none"
|
||||
error={!scriptNameValid && !!selected.scriptPath.trim()}
|
||||
/>
|
||||
</div>
|
||||
{#if !scriptNameValid && selected.scriptPath.trim()}
|
||||
<span class="text-2xs text-red-600 dark:text-red-400">
|
||||
Letters, digits, <code>_</code> and <code>-</code>, in <code>/</code>-separated segments
|
||||
</span>
|
||||
{/if}
|
||||
</Label>
|
||||
{#if !$copilotInfo.workspaceDisabled}
|
||||
<Label label="AI Prompt (optional)">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import type { NativeTriggerKind, PipelineTriggerDraftKind } from './types'
|
||||
import {
|
||||
@@ -23,6 +24,10 @@
|
||||
import GcpTriggerEditor from '$lib/components/triggers/gcp/GcpTriggerEditor.svelte'
|
||||
import EmailTriggerEditor from '$lib/components/triggers/email/EmailTriggerEditor.svelte'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
import {
|
||||
loadDefaultScheduleAdvanced,
|
||||
scheduleAdvancedCfg
|
||||
} from '$lib/components/triggers/schedules/ScheduleAdvancedOptions.svelte'
|
||||
import WebhookEditor from '$lib/components/triggers/webhook/WebhookEditor.svelte'
|
||||
import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
@@ -111,21 +116,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** The kind's full editor over a pipeline-local draft; Save hands the config back.
|
||||
/** The kind's full editor over a pipeline-local draft; Save hands the config back,
|
||||
* and the drawer stays open when `onSave` refuses it.
|
||||
* `saved` is the draft being edited (absent for a new one), applied over `defaults`. */
|
||||
export function openTriggerDraft(
|
||||
kind: PipelineTriggerDraftKind,
|
||||
scriptPath: string,
|
||||
defaults: Record<string, any>,
|
||||
saved: Record<string, any> | undefined,
|
||||
onSave: (cfg: Record<string, any>) => void
|
||||
onSave: (cfg: Record<string, any>) => boolean
|
||||
) {
|
||||
switch (kind) {
|
||||
case 'schedule':
|
||||
return scheduleEditor?.openDraft(
|
||||
{ is_flow: false, args: {}, ...defaults, ...saved, script_path: scriptPath } as Schedule,
|
||||
onSave
|
||||
)
|
||||
case 'schedule': {
|
||||
// A new draft starts from the workspace's default handlers, like a new
|
||||
// schedule does; the drawer only applies them when it opens without one.
|
||||
const open = (advanced: Record<string, any>) =>
|
||||
scheduleEditor?.openDraft(
|
||||
{
|
||||
is_flow: false,
|
||||
args: {},
|
||||
...advanced,
|
||||
...defaults,
|
||||
...saved,
|
||||
script_path: scriptPath
|
||||
} as Schedule,
|
||||
onSave
|
||||
)
|
||||
if (saved || !$workspaceStore) return open({})
|
||||
return loadDefaultScheduleAdvanced($workspaceStore)
|
||||
.then((a) => open(scheduleAdvancedCfg(a)))
|
||||
.catch(() => open({}))
|
||||
}
|
||||
case 'kafka':
|
||||
return kafkaEditor?.openDraft(scriptPath, defaults, saved, onSave)
|
||||
case 'mqtt':
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<script lang="ts">
|
||||
import { Handle, Position } from '@xyflow/svelte'
|
||||
import PipelineNodeCard from './PipelineNodeCard.svelte'
|
||||
import { describeCron } from '$lib/utils/describeCron'
|
||||
import { describeSchedule } from '$lib/utils/describeCron'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { AlertTriangle, CheckCircle2, EllipsisVertical, Target, Trash2 } from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
@@ -75,6 +75,7 @@
|
||||
draft?: boolean
|
||||
// The cron, for a schedule: the node names its cadence instead of "Schedule".
|
||||
schedule?: string
|
||||
timezone?: string
|
||||
// Shown as the title over the trigger's path, when the trigger has one.
|
||||
summary?: string
|
||||
runnable_path?: string
|
||||
@@ -210,7 +211,7 @@
|
||||
// when it has no plain reading); every other kind names its kind.
|
||||
let kindName = $derived(
|
||||
data.kind === 'schedule' && data.schedule
|
||||
? (describeCron(data.schedule) ?? `Schedule (${data.schedule})`)
|
||||
? (describeSchedule(data.schedule, data.timezone) ?? `Schedule (${data.schedule})`)
|
||||
: style.label
|
||||
)
|
||||
|
||||
|
||||
@@ -142,13 +142,17 @@ export class PipelineEditorState {
|
||||
})
|
||||
}
|
||||
|
||||
/** Add or replace a trigger draft; `previousKey` drops the entry it was saved
|
||||
* under when an edit renamed it. */
|
||||
setTriggerDraft = (d: PipelineTriggerDraft, previousKey?: string) => {
|
||||
/** Add or replace a trigger draft; `previousKey` is the entry being edited, which
|
||||
* a rename drops. False, and nothing changes, when the path is another draft's:
|
||||
* both would deploy to one trigger. */
|
||||
setTriggerDraft = (d: PipelineTriggerDraft, previousKey?: string): boolean => {
|
||||
const key = triggerDraftKey(d.kind, d.config.path)
|
||||
if (key !== previousKey && this.triggerDrafts.has(key)) return false
|
||||
const next = new Map(this.triggerDrafts)
|
||||
if (previousKey) next.delete(previousKey)
|
||||
next.set(triggerDraftKey(d.kind, d.config.path), d)
|
||||
next.set(key, d)
|
||||
this.triggerDrafts = next
|
||||
return true
|
||||
}
|
||||
|
||||
discardTriggerDraft = (key: string) => {
|
||||
|
||||
@@ -124,4 +124,17 @@ describe('PipelineEditorState trigger drafts', () => {
|
||||
pe.discardDraft('f/x/m')
|
||||
expect(pe.triggerDrafts.size).toBe(0)
|
||||
})
|
||||
|
||||
it('refuse a path another draft holds, but let a draft keep its own', () => {
|
||||
const pe = new PipelineEditorState()
|
||||
const a = { kind: 'kafka' as const, config: { path: 'f/x/k', script_path: 'f/x/a' } }
|
||||
expect(pe.setTriggerDraft(a)).toBe(true)
|
||||
expect(
|
||||
pe.setTriggerDraft({ kind: 'kafka', config: { path: 'f/x/k', script_path: 'f/x/b' } })
|
||||
).toBe(false)
|
||||
expect(pe.triggerDrafts.get('kafka:f/x/k')?.config.script_path).toBe('f/x/a')
|
||||
expect(pe.setTriggerDraft({ ...a, config: { ...a.config, topics: ['t'] } }, 'kafka:f/x/k')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -309,7 +309,9 @@ function withTriggerDrafts(
|
||||
runnable_path: d.config.script_path,
|
||||
unsaved: true,
|
||||
draft: true,
|
||||
...(d.kind === 'schedule' ? { schedule: d.config.schedule as string } : {}),
|
||||
...(d.kind === 'schedule'
|
||||
? { schedule: d.config.schedule as string, timezone: d.config.timezone as string }
|
||||
: {}),
|
||||
...(d.config.summary ? { summary: d.config.summary as string } : {})
|
||||
}))
|
||||
]
|
||||
|
||||
@@ -214,8 +214,9 @@ export type AssetGraphTrigger =
|
||||
// A pipeline-local `PipelineTriggerDraft`: `path` is the trigger it
|
||||
// will be deployed as, and no row exists yet.
|
||||
draft?: boolean
|
||||
// A schedule's cron and summary, filled in client-side, for the node's labels.
|
||||
// A schedule's cron, zone and summary, filled in client-side, for the node's labels.
|
||||
schedule?: string
|
||||
timezone?: string
|
||||
summary?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
scriptPath: string,
|
||||
defaults: Record<string, any>,
|
||||
saved: Record<string, any> | undefined,
|
||||
onSaveDraft: (cfg: Record<string, any>) => void
|
||||
onSaveDraft: (cfg: Record<string, any>) => boolean
|
||||
) {
|
||||
open = true
|
||||
await tickPainted()
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
|
||||
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
|
||||
// draft, so Save hands it the config instead of writing it.
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
|
||||
|
||||
export async function openEdit(
|
||||
ePath: string,
|
||||
@@ -219,7 +219,7 @@
|
||||
fixedScriptPath_?: string,
|
||||
defaultValues?: Record<string, any>,
|
||||
opts: {
|
||||
onSaveDraft?: (cfg: Record<string, any>) => void
|
||||
onSaveDraft?: (cfg: Record<string, any>) => boolean
|
||||
/** A config this editor saved before, re-applied over the defaults. */
|
||||
draftConfig?: Record<string, any>
|
||||
} = {}
|
||||
@@ -348,7 +348,7 @@
|
||||
|
||||
async function updateTrigger(): Promise<void> {
|
||||
if (saveDraftHandler) {
|
||||
saveDraftHandler($state.snapshot(getSaveCfg()))
|
||||
if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return
|
||||
drawer?.closeDrawer()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
scriptPath: string,
|
||||
defaults: Record<string, any>,
|
||||
saved: Record<string, any> | undefined,
|
||||
onSaveDraft: (cfg: Record<string, any>) => void
|
||||
onSaveDraft: (cfg: Record<string, any>) => boolean
|
||||
) {
|
||||
open = true
|
||||
await tickPainted()
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
|
||||
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
|
||||
// draft, so Save hands it the config instead of writing it.
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
|
||||
|
||||
export async function openEdit(
|
||||
ePath: string,
|
||||
@@ -195,7 +195,7 @@
|
||||
fixedScriptPath_?: string,
|
||||
defaultValues?: Partial<EmailTrigger>,
|
||||
opts: {
|
||||
onSaveDraft?: (cfg: Record<string, any>) => void
|
||||
onSaveDraft?: (cfg: Record<string, any>) => boolean
|
||||
/** A config this editor saved before, re-applied over the defaults. */
|
||||
draftConfig?: Record<string, any>
|
||||
} = {}
|
||||
@@ -289,7 +289,7 @@
|
||||
|
||||
async function triggerScript(): Promise<void> {
|
||||
if (saveDraftHandler) {
|
||||
saveDraftHandler($state.snapshot(getEmailTriggerConfig()))
|
||||
if (!saveDraftHandler($state.snapshot(getEmailTriggerConfig()))) return
|
||||
drawer?.closeDrawer()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
scriptPath: string,
|
||||
defaults: Record<string, any>,
|
||||
saved: Record<string, any> | undefined,
|
||||
onSaveDraft: (cfg: Record<string, any>) => void
|
||||
onSaveDraft: (cfg: Record<string, any>) => boolean
|
||||
) {
|
||||
open = true
|
||||
await tickPainted()
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
|
||||
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
|
||||
// draft, so Save hands it the config instead of writing it.
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
|
||||
|
||||
export async function openEdit(
|
||||
ePath: string,
|
||||
@@ -208,7 +208,7 @@
|
||||
fixedScriptPath_?: string,
|
||||
defaultValues?: Record<string, any>,
|
||||
opts: {
|
||||
onSaveDraft?: (cfg: Record<string, any>) => void
|
||||
onSaveDraft?: (cfg: Record<string, any>) => boolean
|
||||
/** A config this editor saved before, re-applied over the defaults. */
|
||||
draftConfig?: Record<string, any>
|
||||
} = {}
|
||||
@@ -320,7 +320,7 @@
|
||||
|
||||
async function updateTrigger(): Promise<void> {
|
||||
if (saveDraftHandler) {
|
||||
saveDraftHandler($state.snapshot(getGcpConfig()))
|
||||
if (!saveDraftHandler($state.snapshot(getGcpConfig()))) return
|
||||
drawer?.closeDrawer()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
scriptPath: string,
|
||||
defaults: Record<string, any>,
|
||||
saved: Record<string, any> | undefined,
|
||||
onSaveDraft: (cfg: Record<string, any>) => void
|
||||
onSaveDraft: (cfg: Record<string, any>) => boolean
|
||||
) {
|
||||
open = true
|
||||
await tickPainted()
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
|
||||
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
|
||||
// draft, so Save hands it the config instead of writing it.
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
|
||||
|
||||
export async function openEdit(
|
||||
ePath: string,
|
||||
@@ -224,7 +224,7 @@
|
||||
fixedScriptPath_?: string,
|
||||
nDefaultValues?: Record<string, any>,
|
||||
opts: {
|
||||
onSaveDraft?: (cfg: Record<string, any>) => void
|
||||
onSaveDraft?: (cfg: Record<string, any>) => boolean
|
||||
/** A config this editor saved before, re-applied over the defaults. */
|
||||
draftConfig?: Record<string, any>
|
||||
} = {}
|
||||
@@ -347,7 +347,7 @@
|
||||
|
||||
async function updateTrigger(): Promise<void> {
|
||||
if (saveDraftHandler) {
|
||||
saveDraftHandler($state.snapshot(getSaveCfg()))
|
||||
if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return
|
||||
drawer?.closeDrawer()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
scriptPath: string,
|
||||
defaults: Record<string, any>,
|
||||
saved: Record<string, any> | undefined,
|
||||
onSaveDraft: (cfg: Record<string, any>) => void
|
||||
onSaveDraft: (cfg: Record<string, any>) => boolean
|
||||
) {
|
||||
open = true
|
||||
await tickPainted()
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
|
||||
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
|
||||
// draft, so Save hands it the config instead of writing it.
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
|
||||
|
||||
export async function openEdit(
|
||||
ePath: string,
|
||||
@@ -219,7 +219,7 @@
|
||||
fixedScriptPath_?: string,
|
||||
defaultValues?: Record<string, any>,
|
||||
opts: {
|
||||
onSaveDraft?: (cfg: Record<string, any>) => void
|
||||
onSaveDraft?: (cfg: Record<string, any>) => boolean
|
||||
/** A config this editor saved before, re-applied over the defaults. */
|
||||
draftConfig?: Record<string, any>
|
||||
} = {}
|
||||
@@ -360,7 +360,7 @@
|
||||
|
||||
async function updateTrigger(): Promise<void> {
|
||||
if (saveDraftHandler) {
|
||||
saveDraftHandler($state.snapshot(getSaveCfg()))
|
||||
if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return
|
||||
drawer?.closeDrawer()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
scriptPath: string,
|
||||
defaults: Record<string, any>,
|
||||
saved: Record<string, any> | undefined,
|
||||
onSaveDraft: (cfg: Record<string, any>) => void
|
||||
onSaveDraft: (cfg: Record<string, any>) => boolean
|
||||
) {
|
||||
open = true
|
||||
await tickPainted()
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
|
||||
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
|
||||
// draft, so Save hands it the config instead of writing it.
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
|
||||
|
||||
export async function openEdit(
|
||||
ePath: string,
|
||||
@@ -213,7 +213,7 @@
|
||||
fixedScriptPath_?: string,
|
||||
nDefaultValues?: Record<string, any>,
|
||||
opts: {
|
||||
onSaveDraft?: (cfg: Record<string, any>) => void
|
||||
onSaveDraft?: (cfg: Record<string, any>) => boolean
|
||||
/** A config this editor saved before, re-applied over the defaults. */
|
||||
draftConfig?: Record<string, any>
|
||||
} = {}
|
||||
@@ -339,7 +339,7 @@
|
||||
|
||||
async function updateTrigger(): Promise<void> {
|
||||
if (saveDraftHandler) {
|
||||
saveDraftHandler($state.snapshot(getSaveCfg()))
|
||||
if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return
|
||||
drawer?.closeDrawer()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
scriptPath: string,
|
||||
defaults: Record<string, any>,
|
||||
saved: Record<string, any> | undefined,
|
||||
onSaveDraft: (cfg: Record<string, any>) => void
|
||||
onSaveDraft: (cfg: Record<string, any>) => boolean
|
||||
) {
|
||||
open = true
|
||||
await tickPainted()
|
||||
|
||||
@@ -260,7 +260,7 @@
|
||||
|
||||
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
|
||||
// draft, so Save hands it the config instead of writing it.
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
|
||||
|
||||
export async function openEdit(
|
||||
ePath: string,
|
||||
@@ -314,7 +314,7 @@
|
||||
defaultValues?: Record<string, any>,
|
||||
newDraft?: boolean,
|
||||
opts: {
|
||||
onSaveDraft?: (cfg: Record<string, any>) => void
|
||||
onSaveDraft?: (cfg: Record<string, any>) => boolean
|
||||
/** A config this editor saved before, re-applied over the defaults. */
|
||||
draftConfig?: Record<string, any>
|
||||
} = {}
|
||||
@@ -490,7 +490,7 @@
|
||||
|
||||
async function updateTrigger(): Promise<void> {
|
||||
if (saveDraftHandler) {
|
||||
saveDraftHandler($state.snapshot(getSaveCfg()))
|
||||
if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return
|
||||
drawer?.closeDrawer()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" module>
|
||||
import { handlerFullPath } from '$lib/components/ErrorOrRecoveryHandler.svelte'
|
||||
import type { ErrorHandler, Retry } from '$lib/gen'
|
||||
import { SettingService, type ErrorHandler, type Retry } from '$lib/gen'
|
||||
|
||||
export type ScheduleAdvancedState = {
|
||||
errorHandlerSelected: ErrorHandler
|
||||
@@ -48,6 +48,79 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Which built-in handler a hub path is, or `custom`. */
|
||||
export function getHandlerType(
|
||||
isHandler: 'error' | 'recovery' | 'success',
|
||||
scriptPath: string
|
||||
): ErrorHandler {
|
||||
const handlerMap = {
|
||||
error: {
|
||||
teams: '/workspace-or-schedule-error-handler-teams',
|
||||
slack: '/workspace-or-schedule-error-handler-slack',
|
||||
email: '/workspace-or-error-handler-email'
|
||||
},
|
||||
recovery: {
|
||||
teams: '/schedule-recovery-handler-teams',
|
||||
slack: '/schedule-recovery-handler-slack',
|
||||
email: '/workspace-or-error-handler-email'
|
||||
},
|
||||
success: {
|
||||
teams: '/schedule-success-handler-teams',
|
||||
slack: '/schedule-success-handler-slack',
|
||||
email: '/workspace-or-error-handler-email'
|
||||
}
|
||||
}
|
||||
|
||||
for (const [type, suffix] of Object.entries(handlerMap[isHandler])) {
|
||||
if (scriptPath.startsWith('hub/') && scriptPath.endsWith(suffix)) {
|
||||
return type as ErrorHandler
|
||||
}
|
||||
}
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
const splitHandler = (path: string) => {
|
||||
const [kind, ...rest] = path.split('/')
|
||||
return { kind: kind as 'flow' | 'script', path: rest.join('/') }
|
||||
}
|
||||
|
||||
/** The Advanced tabs of a new schedule: the workspace's default error, recovery
|
||||
* and success handlers, where it sets them. */
|
||||
export async function loadDefaultScheduleAdvanced(wsId: string): Promise<ScheduleAdvancedState> {
|
||||
const a = emptyScheduleAdvanced()
|
||||
const [err, rec, suc] = (await Promise.all(
|
||||
['error', 'recovery', 'success'].map((k) =>
|
||||
SettingService.getGlobal({ key: `default_${k}_handler_${wsId}` })
|
||||
)
|
||||
)) as any[]
|
||||
if (err) {
|
||||
const h = splitHandler(err.errorHandlerPath)
|
||||
a.wsErrorHandlerMuted = err.wsErrorHandlerMuted
|
||||
a.errorHandleritemKind = h.kind
|
||||
a.errorHandlerPath = h.path
|
||||
a.errorHandlerExtraArgs = err.errorHandlerExtraArgs
|
||||
a.errorHandlerSelected = getHandlerType('error', h.path)
|
||||
a.failedTimes = err.failedTimes
|
||||
a.failedExact = err.failedExact
|
||||
}
|
||||
if (rec) {
|
||||
const h = splitHandler(rec.recoveryHandlerPath)
|
||||
a.recoveryHandlerItemKind = h.kind
|
||||
a.recoveryHandlerPath = h.path
|
||||
a.recoveryHandlerExtraArgs = rec.recoveryHandlerExtraArgs
|
||||
a.recoveryHandlerSelected = getHandlerType('recovery', h.path)
|
||||
a.recoveredTimes = rec.recoveredTimes
|
||||
}
|
||||
if (suc) {
|
||||
const h = splitHandler(suc.successHandlerPath)
|
||||
a.successHandlerItemKind = h.kind
|
||||
a.successHandlerPath = h.path
|
||||
a.successHandlerExtraArgs = suc.successHandlerExtraArgs
|
||||
a.successHandlerSelected = getHandlerType('success', h.path)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
/** The schedule fields the Advanced tabs own, as the schedule API takes them. */
|
||||
export function scheduleAdvancedCfg(a: ScheduleAdvancedState) {
|
||||
return {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
/** Edit a schedule that exists only as the caller's draft; Save hands it back. */
|
||||
export async function openDraft(
|
||||
cfg: Schedule,
|
||||
onSaveDraft: (cfg: Record<string, any>) => void
|
||||
onSaveDraft: (cfg: Record<string, any>) => boolean
|
||||
) {
|
||||
open = true
|
||||
await loadInner()
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
} from '$lib/components/sessions/pageDrawerSession'
|
||||
import { SCHEDULES_PATH } from '$lib/components/sessions/previewPaths'
|
||||
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
|
||||
import ScheduleAdvancedOptions, { scheduleAdvancedCfg } from './ScheduleAdvancedOptions.svelte'
|
||||
import ScheduleAdvancedOptions, {
|
||||
emptyScheduleAdvanced,
|
||||
getHandlerType,
|
||||
loadDefaultScheduleAdvanced,
|
||||
scheduleAdvancedCfg
|
||||
} from './ScheduleAdvancedOptions.svelte'
|
||||
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import CronInput from '$lib/components/CronInput.svelte'
|
||||
@@ -24,7 +29,6 @@
|
||||
type Script,
|
||||
ScriptService,
|
||||
type Flow,
|
||||
SettingService,
|
||||
type Retry,
|
||||
type Schedule,
|
||||
type ErrorHandler
|
||||
@@ -129,7 +133,7 @@
|
||||
// Set by `openNew({ onSaveDraft })`: the caller keeps the schedule as its own
|
||||
// draft, so Save hands it the config instead of writing it, and nothing may
|
||||
// autosave a workspace trigger draft for a path that is not deployed.
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
|
||||
let permissionedAs = $state<string | undefined>(undefined)
|
||||
let selectedPermissionedAs = $state<string | undefined>(undefined)
|
||||
let preservePermissionedAs = $state(false)
|
||||
@@ -248,66 +252,23 @@
|
||||
successHandlerExtraArgs = {}
|
||||
}
|
||||
} else {
|
||||
let defaultErrorHandlerMaybe = undefined
|
||||
let defaultRecoveryHandlerMaybe = undefined
|
||||
let defaultSuccessHandlerMaybe = undefined
|
||||
if (wsId) {
|
||||
defaultErrorHandlerMaybe = (await SettingService.getGlobal({
|
||||
key: 'default_error_handler_' + wsId!
|
||||
})) as any
|
||||
defaultRecoveryHandlerMaybe = (await SettingService.getGlobal({
|
||||
key: 'default_recovery_handler_' + wsId!
|
||||
})) as any
|
||||
defaultSuccessHandlerMaybe = (await SettingService.getGlobal({
|
||||
key: 'default_success_handler_' + wsId!
|
||||
})) as any
|
||||
}
|
||||
|
||||
if (defaultErrorHandlerMaybe !== undefined && defaultErrorHandlerMaybe !== null) {
|
||||
wsErrorHandlerMuted = defaultErrorHandlerMaybe['wsErrorHandlerMuted']
|
||||
let splitted = (defaultErrorHandlerMaybe['errorHandlerPath'] as string).split('/')
|
||||
errorHandleritemKind = splitted[0] as 'flow' | 'script'
|
||||
errorHandlerPath = splitted.slice(1)?.join('/')
|
||||
errorHandlerExtraArgs = defaultErrorHandlerMaybe['errorHandlerExtraArgs']
|
||||
errorHandlerSelected = getHandlerType('error', errorHandlerPath)
|
||||
failedTimes = defaultErrorHandlerMaybe['failedTimes']
|
||||
failedExact = defaultErrorHandlerMaybe['failedExact']
|
||||
} else {
|
||||
wsErrorHandlerMuted = false
|
||||
errorHandlerPath = undefined
|
||||
errorHandleritemKind = 'script'
|
||||
errorHandlerExtraArgs = {}
|
||||
errorHandlerSelected = 'slack'
|
||||
failedTimes = 1
|
||||
failedExact = false
|
||||
}
|
||||
if (defaultRecoveryHandlerMaybe !== undefined && defaultRecoveryHandlerMaybe !== null) {
|
||||
let splitted = (defaultRecoveryHandlerMaybe['recoveryHandlerPath'] as string).split('/')
|
||||
recoveryHandlerItemKind = splitted[0] as 'flow' | 'script'
|
||||
recoveryHandlerPath = splitted.slice(1)?.join('/')
|
||||
recoveryHandlerExtraArgs = defaultRecoveryHandlerMaybe['recoveryHandlerExtraArgs']
|
||||
recoveryHandlerSelected = getHandlerType('recovery', recoveryHandlerPath)
|
||||
recoveredTimes = defaultRecoveryHandlerMaybe['recoveredTimes']
|
||||
} else {
|
||||
recoveryHandlerPath = undefined
|
||||
recoveryHandlerItemKind = 'script'
|
||||
recoveryHandlerExtraArgs = {}
|
||||
recoveryHandlerSelected = 'slack'
|
||||
recoveredTimes = 1
|
||||
}
|
||||
if (defaultSuccessHandlerMaybe !== undefined && defaultSuccessHandlerMaybe !== null) {
|
||||
let splitted = (defaultSuccessHandlerMaybe['successHandlerPath'] as string).split('/')
|
||||
successHandlerItemKind = splitted[0] as 'flow' | 'script'
|
||||
successHandlerPath = splitted.slice(1)?.join('/')
|
||||
successHandlerExtraArgs = defaultSuccessHandlerMaybe['successHandlerExtraArgs']
|
||||
successHandlerSelected = getHandlerType('success', successHandlerPath)
|
||||
recoveredTimes = defaultSuccessHandlerMaybe['recoveredTimes']
|
||||
} else {
|
||||
successHandlerPath = undefined
|
||||
successHandlerItemKind = 'script'
|
||||
successHandlerExtraArgs = {}
|
||||
successHandlerSelected = 'slack'
|
||||
}
|
||||
const d = wsId ? await loadDefaultScheduleAdvanced(wsId) : emptyScheduleAdvanced()
|
||||
wsErrorHandlerMuted = d.wsErrorHandlerMuted
|
||||
errorHandlerPath = d.errorHandlerPath
|
||||
errorHandleritemKind = d.errorHandleritemKind
|
||||
errorHandlerExtraArgs = d.errorHandlerExtraArgs
|
||||
errorHandlerSelected = d.errorHandlerSelected
|
||||
failedTimes = d.failedTimes
|
||||
failedExact = d.failedExact
|
||||
recoveryHandlerPath = d.recoveryHandlerPath
|
||||
recoveryHandlerItemKind = d.recoveryHandlerItemKind
|
||||
recoveryHandlerExtraArgs = d.recoveryHandlerExtraArgs
|
||||
recoveryHandlerSelected = d.recoveryHandlerSelected
|
||||
recoveredTimes = d.recoveredTimes
|
||||
successHandlerPath = d.successHandlerPath
|
||||
successHandlerItemKind = d.successHandlerItemKind
|
||||
successHandlerExtraArgs = d.successHandlerExtraArgs
|
||||
successHandlerSelected = d.successHandlerSelected
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +278,7 @@
|
||||
defaultValues?: Schedule,
|
||||
schedule_path?: string,
|
||||
fixedScriptPath_?: string,
|
||||
opts: { getDraft?: boolean; onSaveDraft?: (cfg: Record<string, any>) => void } = {}
|
||||
opts: { getDraft?: boolean; onSaveDraft?: (cfg: Record<string, any>) => boolean } = {}
|
||||
) {
|
||||
const getDraft = opts.getDraft ?? true
|
||||
saveDraftHandler = opts.onSaveDraft
|
||||
@@ -536,7 +497,7 @@
|
||||
const previousPath = initialPath
|
||||
const scheduleCfg = getScheduleCfg()
|
||||
if (saveDraftHandler) {
|
||||
saveDraftHandler($state.snapshot(scheduleCfg))
|
||||
if (!saveDraftHandler($state.snapshot(scheduleCfg))) return
|
||||
drawer?.closeDrawer()
|
||||
return
|
||||
}
|
||||
@@ -550,35 +511,6 @@
|
||||
deploymentLoading = false
|
||||
}
|
||||
|
||||
function getHandlerType(
|
||||
isHandler: 'error' | 'recovery' | 'success',
|
||||
scriptPath: string
|
||||
): ErrorHandler {
|
||||
const handlerMap = {
|
||||
error: {
|
||||
teams: '/workspace-or-schedule-error-handler-teams',
|
||||
slack: '/workspace-or-schedule-error-handler-slack',
|
||||
email: '/workspace-or-error-handler-email'
|
||||
},
|
||||
recovery: {
|
||||
teams: '/schedule-recovery-handler-teams',
|
||||
slack: '/schedule-recovery-handler-slack',
|
||||
email: '/workspace-or-error-handler-email'
|
||||
},
|
||||
success: {
|
||||
teams: '/schedule-success-handler-teams',
|
||||
slack: '/schedule-success-handler-slack',
|
||||
email: '/workspace-or-error-handler-email'
|
||||
}
|
||||
}
|
||||
|
||||
for (const [type, suffix] of Object.entries(handlerMap[isHandler])) {
|
||||
if (scriptPath.startsWith('hub/') && scriptPath.endsWith(suffix)) {
|
||||
return type as ErrorHandler
|
||||
}
|
||||
}
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
scriptPath: string,
|
||||
defaults: Record<string, any>,
|
||||
saved: Record<string, any> | undefined,
|
||||
onSaveDraft: (cfg: Record<string, any>) => void
|
||||
onSaveDraft: (cfg: Record<string, any>) => boolean
|
||||
) {
|
||||
open = true
|
||||
await tickPainted()
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
|
||||
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
|
||||
// draft, so Save hands it the config instead of writing it.
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
|
||||
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
|
||||
|
||||
export async function openEdit(
|
||||
ePath: string,
|
||||
@@ -206,7 +206,7 @@
|
||||
fixedScriptPath_?: string,
|
||||
defaultValues?: Record<string, any>,
|
||||
opts: {
|
||||
onSaveDraft?: (cfg: Record<string, any>) => void
|
||||
onSaveDraft?: (cfg: Record<string, any>) => boolean
|
||||
/** A config this editor saved before, re-applied over the defaults. */
|
||||
draftConfig?: Record<string, any>
|
||||
} = {}
|
||||
@@ -349,7 +349,7 @@
|
||||
|
||||
async function updateTrigger(): Promise<void> {
|
||||
if (saveDraftHandler) {
|
||||
saveDraftHandler($state.snapshot(getSaveCfg()))
|
||||
if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return
|
||||
drawer?.closeDrawer()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describeCron } from './describeCron'
|
||||
import { describeCron, describeSchedule } from './describeCron'
|
||||
|
||||
describe('describeCron', () => {
|
||||
it.each([
|
||||
@@ -28,3 +28,10 @@ describe('describeCron', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('describeSchedule', () => {
|
||||
it('names the zone of a wall-clock time only', () => {
|
||||
expect(describeSchedule('0 0 12 * * *', 'UTC')).toBe('Every day at 12:00 UTC')
|
||||
expect(describeSchedule('0 0 * * * *', 'UTC')).toBe('Every hour')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -106,3 +106,23 @@ export function describeCron(cron: string): string | undefined {
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function shortZone(timezone: string): string {
|
||||
try {
|
||||
return (
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'short' })
|
||||
.formatToParts(new Date())
|
||||
.find((p) => p.type === 'timeZoneName')?.value ?? timezone
|
||||
)
|
||||
} catch {
|
||||
return timezone
|
||||
}
|
||||
}
|
||||
|
||||
/** `describeCron` of a schedule, naming its zone whenever the text states a
|
||||
* wall-clock time: "Every day at 12:00 UTC". */
|
||||
export function describeSchedule(cron: string, timezone?: string): string | undefined {
|
||||
const text = describeCron(cron)
|
||||
if (!text || !timezone || !/ at \d/.test(text)) return text
|
||||
return `${text} ${shortZone(timezone)}`
|
||||
}
|
||||
|
||||
@@ -452,14 +452,17 @@
|
||||
pe.activeDraftPath = scriptPath
|
||||
pe.selection = undefined
|
||||
if (options?.schedule) {
|
||||
pe.setTriggerDraft({
|
||||
const path = options.schedule.path || defaultTriggerPath(scriptPath, 'schedule')
|
||||
const saved = pe.setTriggerDraft({
|
||||
kind: 'schedule',
|
||||
config: {
|
||||
...options.schedule,
|
||||
path: options.schedule.path || defaultTriggerPath(scriptPath, 'schedule'),
|
||||
script_path: scriptPath
|
||||
}
|
||||
config: { ...options.schedule, path, script_path: scriptPath }
|
||||
})
|
||||
if (!saved) {
|
||||
sendUserToast(
|
||||
`Another draft schedule already uses the path ${path}; the new script has no schedule`,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Follow the new node with a smooth pan. The id matches the runnable
|
||||
@@ -2372,11 +2375,13 @@
|
||||
const defaults = { path: defaultTriggerPath(scriptPath, kind) }
|
||||
triggerEditors?.openTriggerDraft(kind, scriptPath, defaults, draft?.config, (cfg) => {
|
||||
const { extra_perms: _, ...config } = cfg
|
||||
pe.setTriggerDraft(
|
||||
const saved = pe.setTriggerDraft(
|
||||
{ kind, config: { ...config, path: cfg.path, script_path: scriptPath, is_flow: false } },
|
||||
// The editor's Path field can rename it; drafts are keyed by path.
|
||||
draft ? triggerDraftKey(kind, draft.config.path) : undefined
|
||||
)
|
||||
if (!saved) sendUserToast(`Another draft trigger already uses the path ${cfg.path}`, true)
|
||||
return saved
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2467,12 +2472,12 @@
|
||||
}
|
||||
)
|
||||
|
||||
// Cron and summary of the deployed schedules on the graph, for their nodes'
|
||||
// Cron, zone and summary of the deployed schedules on the graph, for their nodes'
|
||||
// labels. Re-read with every graph load, so an edit in the schedule drawer shows.
|
||||
let scheduleInfo = resource(
|
||||
[() => $workspaceStore, () => graphRes.current],
|
||||
async ([ws, g]) => {
|
||||
const info = new Map<string, { schedule: string; summary?: string }>()
|
||||
const info = new Map<string, { schedule: string; timezone: string; summary?: string }>()
|
||||
if (!ws || !g) return info
|
||||
const paths = new Set<string>()
|
||||
for (const t of g.triggers) {
|
||||
@@ -2482,7 +2487,11 @@
|
||||
[...paths].map(async (path) => {
|
||||
try {
|
||||
const s = await ScheduleService.getSchedule({ workspace: ws, path })
|
||||
info.set(path, { schedule: s.schedule, summary: s.summary || undefined })
|
||||
info.set(path, {
|
||||
schedule: s.schedule,
|
||||
timezone: s.timezone,
|
||||
summary: s.summary || undefined
|
||||
})
|
||||
} catch {
|
||||
// Unreadable schedule: its node keeps the plain "Schedule" label.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user