Skip to content

Commit a0ded49

Browse files
committed
Add "New Job" drawer for creating jobs
Jobs can now be created directly from the Jobs page using a slide-out drawer. The form includes fields for worker, args, queue, priority, max attempts, scheduled time, and tags. After creation, the user is navigated to the new job's detail page. Closes #154, #136
1 parent ba3b7b3 commit a0ded49

File tree

15 files changed

+524
-70
lines changed

15 files changed

+524
-70
lines changed

lib/oban/web/components/icons.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ defmodule Oban.Web.Components.Icons do
140140

141141
~H"""
142142
<span
143-
class={["inline-block bg-current", @normalized_class]}
143+
class={["block bg-current", @normalized_class]}
144144
style={"mask-image: url(#{@icon_url}); -webkit-mask-image: url(#{@icon_url}); mask-size: contain; mask-repeat: no-repeat; mask-position: center;"}
145145
aria-hidden="true"
146146
{@rest}

lib/oban/web/components/layouts.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,11 @@ defmodule Oban.Web.Layouts do
119119
<div class="max-w-sm w-full bg-white dark:bg-black dark:bg-opacity-90 shadow-lg rounded-lg pointer-events-auto">
120120
<div class="rounded-lg ring-1 ring-black/5 overflow-hidden">
121121
<div class="p-4">
122-
<div class="flex items-start">
122+
<div class="flex items-center">
123123
<div class="flex-shrink-0 text-green-400">
124124
<Icons.check_circle />
125125
</div>
126-
<div class="ml-3 w-0 flex-1 pt-0.5">
126+
<div class="ml-3 w-0 flex-1">
127127
<p class="text-sm leading-5 font-medium text-gray-900 dark:text-gray-100">
128128
{Phoenix.Flash.get(assigns.flash, :info)}
129129
</p>

lib/oban/web/live/jobs/detail_component.ex

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,6 @@ defmodule Oban.Web.Jobs.DetailComponent do
152152
</div>
153153
154154
<div class="grid grid-cols-3 gap-4 mb-4 px-3">
155-
156155
<div class="flex flex-col">
157156
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
158157
Queue
@@ -242,7 +241,8 @@ defmodule Oban.Web.Jobs.DetailComponent do
242241
"px-2 py-1 cusror-pointer rounded-l-md border border-r-0 border-gray-300 dark:border-gray-600",
243242
if(@error_sort == :desc,
244243
do: "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200",
245-
else: "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-750"
244+
else:
245+
"bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-750"
246246
)
247247
]}
248248
>
@@ -257,7 +257,8 @@ defmodule Oban.Web.Jobs.DetailComponent do
257257
"px-2 py-1 cusror-pointer rounded-r-md border border-gray-300 dark:border-gray-600",
258258
if(@error_sort == :asc,
259259
do: "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200",
260-
else: "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-750"
260+
else:
261+
"bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-750"
261262
)
262263
]}
263264
>
@@ -276,7 +277,8 @@ defmodule Oban.Web.Jobs.DetailComponent do
276277
"p-1 rounded",
277278
if(@error_index == 0,
278279
do: "text-gray-300 dark:text-gray-600 cursor-not-allowed",
279-
else: "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
280+
else:
281+
"text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
280282
)
281283
]}
282284
>
@@ -295,7 +297,8 @@ defmodule Oban.Web.Jobs.DetailComponent do
295297
"p-1 rounded",
296298
if(@error_index >= length(@job.errors) - 1,
297299
do: "text-gray-300 dark:text-gray-600 cursor-not-allowed",
298-
else: "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
300+
else:
301+
"text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
299302
)
300303
]}
301304
>
@@ -330,7 +333,11 @@ defmodule Oban.Web.Jobs.DetailComponent do
330333
331334
<div id="meta-content" class="mt-3">
332335
<div class="grid grid-cols-3 gap-4">
333-
<div id="job-args" tabindex="-1" class="relative bg-gray-50 dark:bg-gray-800 rounded-md p-4 focus:outline-none">
336+
<div
337+
id="job-args"
338+
tabindex="-1"
339+
class="relative bg-gray-50 dark:bg-gray-800 rounded-md p-4 focus:outline-none"
340+
>
334341
<div class="flex justify-between items-start mb-2">
335342
<h4 class="font-medium text-xs uppercase text-gray-500 dark:text-gray-400">
336343
Args
@@ -619,7 +626,10 @@ defmodule Oban.Web.Jobs.DetailComponent do
619626
<div class="mb-8 p-4 bg-gray-50 dark:bg-gray-800 rounded-md">
620627
<div class="flex items-center justify-between mb-3 text-sm text-gray-500 dark:text-gray-400">
621628
<span>Attempt {@error["attempt"]}</span>
622-
<span>{Timing.datetime_to_words(@error["at"])} <span class="text-gray-400 dark:text-gray-500">({@error["at"]})</span></span>
629+
<span>
630+
{Timing.datetime_to_words(@error["at"])}
631+
<span class="text-gray-400 dark:text-gray-500">({@error["at"]})</span>
632+
</span>
623633
</div>
624634
625635
<div class="font-mono text-base font-medium text-gray-800 dark:text-gray-200 mb-4">

lib/oban/web/live/jobs/history_chart_component.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ defmodule Oban.Web.Jobs.HistoryChartComponent do
7676
end
7777

7878
defp timestamp_for(job) do
79-
datetime = job.completed_at || job.cancelled_at || job.discarded_at || job.attempted_at || job.scheduled_at
79+
datetime =
80+
job.completed_at || job.cancelled_at || job.discarded_at || job.attempted_at ||
81+
job.scheduled_at
8082

8183
datetime
8284
|> DateTime.from_naive!("Etc/UTC")
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
defmodule Oban.Web.Jobs.NewComponent do
2+
use Oban.Web, :live_component
3+
4+
import Oban.Web.FormComponents
5+
6+
@fields ~w(args max_attempts priority queue scheduled_at tags worker)a
7+
8+
@impl Phoenix.LiveComponent
9+
def render(assigns) do
10+
~H"""
11+
<div
12+
id="new-job"
13+
class="relative z-50 hidden"
14+
phx-mounted={show_drawer()}
15+
phx-remove={hide_drawer()}
16+
phx-window-keydown="keydown"
17+
phx-target={@myself}
18+
>
19+
<div
20+
id="new-job-bg"
21+
class="bg-zinc-50/80 dark:bg-zinc-950/80 fixed inset-0 hidden transition-opacity"
22+
aria-hidden="true"
23+
phx-click="close"
24+
phx-target={@myself}
25+
/>
26+
27+
<div class="fixed inset-0 overflow-hidden">
28+
<div class="absolute inset-0 overflow-hidden">
29+
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
30+
<div
31+
id="new-job-panel"
32+
class="pointer-events-auto w-screen max-w-md hidden transition-transform translate-x-full"
33+
>
34+
<div class="flex h-full flex-col overflow-y-scroll bg-white dark:bg-gray-900 shadow-xl">
35+
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
36+
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
37+
New Job
38+
</h2>
39+
<button
40+
type="button"
41+
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer"
42+
phx-click="close"
43+
phx-target={@myself}
44+
aria-label="Close"
45+
>
46+
<Icons.x_mark class="h-6 w-6" />
47+
</button>
48+
</div>
49+
50+
<form
51+
id="new-job-form"
52+
class="flex-1 px-4 py-6 space-y-4"
53+
phx-change="form-change"
54+
phx-submit="create-job"
55+
phx-target={@myself}
56+
>
57+
<.form_field
58+
label="Worker"
59+
name="worker"
60+
value={@form.worker}
61+
placeholder="MyApp.Workers.SomeWorker"
62+
required={true}
63+
/>
64+
65+
<.form_field
66+
label="Args"
67+
name="args"
68+
value={@form.args}
69+
type="textarea"
70+
placeholder="{}"
71+
required={true}
72+
rows={3}
73+
/>
74+
75+
<.select_field
76+
label="Queue"
77+
name="queue"
78+
value={@form.queue}
79+
options={queue_options(@queues)}
80+
/>
81+
82+
<div class="grid grid-cols-2 gap-4">
83+
<.form_field
84+
label="Priority"
85+
name="priority"
86+
value={@form.priority}
87+
type="number"
88+
placeholder="0"
89+
hint="0 (highest) to 9 (lowest)"
90+
/>
91+
92+
<.form_field
93+
label="Max Attempts"
94+
name="max_attempts"
95+
value={@form.max_attempts}
96+
type="number"
97+
placeholder="20"
98+
/>
99+
</div>
100+
101+
<.form_field
102+
label="Scheduled At"
103+
name="scheduled_at"
104+
value={@form.scheduled_at}
105+
type="datetime-local"
106+
hint="Leave empty to run immediately"
107+
/>
108+
109+
<.form_field label="Tags" name="tags" value={@form.tags} placeholder="tag1, tag2" />
110+
111+
<div class="pt-4">
112+
<button
113+
type="submit"
114+
class="w-full px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded-md hover:bg-blue-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 cursor-pointer"
115+
>
116+
Create Job
117+
</button>
118+
</div>
119+
</form>
120+
</div>
121+
</div>
122+
</div>
123+
</div>
124+
</div>
125+
</div>
126+
"""
127+
end
128+
129+
@impl Phoenix.LiveComponent
130+
def update(assigns, socket) do
131+
socket =
132+
socket
133+
|> assign(assigns)
134+
|> assign_new(:form, fn -> default_form() end)
135+
136+
{:ok, socket}
137+
end
138+
139+
@impl Phoenix.LiveComponent
140+
def handle_event("form-change", params, socket) do
141+
form = Map.new(@fields, fn key -> {key, params[to_string(key)]} end)
142+
143+
{:noreply, assign(socket, form: form)}
144+
end
145+
146+
def handle_event("create-job", params, socket) do
147+
enforce_access!(:insert_jobs, socket.assigns.access)
148+
149+
%{conf: conf} = socket.assigns
150+
151+
with {:ok, worker} <- parse_worker(params["worker"]),
152+
{:ok, args} <- parse_args(params["args"]),
153+
{:ok, opts} <- build_opts(params) do
154+
changeset = Oban.Job.new(args, [{:worker, worker} | opts])
155+
156+
case Oban.insert(conf.name, changeset) do
157+
{:ok, job} ->
158+
send(self(), {:flash, :info, "Job created successfully"})
159+
{:noreply, push_patch(socket, to: oban_path([:jobs, job.id]))}
160+
161+
{:error, changeset} ->
162+
message = format_changeset_error(changeset)
163+
send(self(), {:flash, :error, "Failed to create job: #{message}"})
164+
{:noreply, socket}
165+
end
166+
else
167+
{:error, message} ->
168+
send(self(), {:flash, :error, message})
169+
{:noreply, socket}
170+
end
171+
end
172+
173+
def handle_event("close", _params, socket) do
174+
{:noreply, push_patch(socket, to: oban_path(:jobs))}
175+
end
176+
177+
def handle_event("keydown", %{"key" => "Escape"}, socket) do
178+
{:noreply, push_patch(socket, to: oban_path(:jobs))}
179+
end
180+
181+
def handle_event("keydown", _params, socket) do
182+
{:noreply, socket}
183+
end
184+
185+
# JS Commands
186+
187+
defp show_drawer do
188+
%JS{}
189+
|> JS.show(to: "#new-job")
190+
|> JS.show(
191+
to: "#new-job-bg",
192+
transition: {"ease-out duration-300", "opacity-0", "opacity-100"}
193+
)
194+
|> JS.show(
195+
to: "#new-job-panel",
196+
transition: {"ease-out duration-300", "translate-x-full", "translate-x-0"}
197+
)
198+
|> JS.add_class("overflow-hidden", to: "body")
199+
end
200+
201+
defp hide_drawer do
202+
%JS{}
203+
|> JS.hide(
204+
to: "#new-job-bg",
205+
transition: {"ease-in duration-200", "opacity-100", "opacity-0"}
206+
)
207+
|> JS.hide(
208+
to: "#new-job-panel",
209+
time: 200,
210+
transition: {"ease-in duration-200", "translate-x-0", "translate-x-full"}
211+
)
212+
|> JS.hide(to: "#new-job", transition: {"block", "block", "hidden"})
213+
|> JS.remove_class("overflow-hidden", to: "body")
214+
end
215+
216+
# Helpers
217+
218+
defp default_form do
219+
@fields
220+
|> Map.new(&{&1, ""})
221+
|> Map.put(:args, "{}")
222+
end
223+
224+
defp parse_worker(worker) when is_binary(worker) and worker != "" do
225+
{:ok, String.trim(worker)}
226+
end
227+
228+
defp parse_worker(_), do: {:error, "Worker is required"}
229+
230+
defp parse_args(args) when is_binary(args) do
231+
case parse_json(args) do
232+
nil -> {:error, "Args must be a valid JSON object"}
233+
map -> {:ok, map}
234+
end
235+
end
236+
237+
defp parse_args(_), do: {:error, "Args must be a valid JSON object"}
238+
239+
defp build_opts(params) do
240+
queue = if params["queue"] in [nil, ""], do: "default", else: params["queue"]
241+
opts = [queue: queue]
242+
243+
opts =
244+
case parse_int(params["priority"]) do
245+
nil -> opts
246+
priority when priority >= 0 and priority <= 9 -> Keyword.put(opts, :priority, priority)
247+
_ -> opts
248+
end
249+
250+
opts =
251+
case parse_int(params["max_attempts"]) do
252+
nil -> opts
253+
max_attempts -> Keyword.put(opts, :max_attempts, max_attempts)
254+
end
255+
256+
opts =
257+
case parse_scheduled_at(params["scheduled_at"]) do
258+
nil -> opts
259+
scheduled_at -> Keyword.put(opts, :scheduled_at, scheduled_at)
260+
end
261+
262+
opts =
263+
case parse_tags(params["tags"]) do
264+
nil -> opts
265+
tags -> Keyword.put(opts, :tags, tags)
266+
end
267+
268+
{:ok, opts}
269+
end
270+
271+
defp parse_scheduled_at(nil), do: nil
272+
defp parse_scheduled_at(""), do: nil
273+
274+
defp parse_scheduled_at(str) when is_binary(str) do
275+
case NaiveDateTime.from_iso8601(str <> ":00") do
276+
{:ok, naive} -> DateTime.from_naive!(naive, "Etc/UTC")
277+
_ -> nil
278+
end
279+
end
280+
281+
defp format_changeset_error(changeset) do
282+
changeset
283+
|> Ecto.Changeset.traverse_errors(fn {msg, opts} ->
284+
Enum.reduce(opts, msg, fn {key, value}, acc ->
285+
String.replace(acc, "%{#{key}}", to_string(value))
286+
end)
287+
end)
288+
|> Enum.map_join(", ", fn {key, errors} -> "#{key} #{Enum.join(errors, ", ")}" end)
289+
end
290+
end

0 commit comments

Comments
 (0)