From 6333f4d8db56e26d384de9901cf0c990f8befec7 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Sat, 29 Nov 2025 22:31:49 -0500 Subject: Add clickable priority dropdown on task detail pages The build passes successfully. Let me provide a summary of the changes m I implemented the clickable priority dropdown on task detail pages, mirr 1. **Added new API route** for priority updates: ```haskell "tasks" :> Capture "id" Text :> "priority" :> ReqBody '[FormUrlEncode ``` 2. **Added new types**: - `PriorityBadgePartial` - partial response for HTMX updates - `PriorityForm` - form data for priority changes with `FromForm` ins 3. **Added handler** `taskPriorityHandler` that updates task priority vi 4. **Added priority dropdown components**: - `priorityBadgeWithForm` - container with clickable badge and dropdo - `clickablePriorityBadge` - the clickable badge showing current prio - `priorityDropdownOptions` - dropdown menu with P0-P4 options - `priorityOption` - individual priority option with HTMX form 5. **Added JavaScript** `priorityDropdownJs` for dropdown toggle/close b 6. **Updated task detail page** to use `priorityBadgeWithForm` instead o 7. **Added `ToHtml` instance** for `PriorityBadgePartial` 1. **Added priority badge styles** for P0-P4 with appropriate colors 2. **Added priority dropdown styles** (mirroring status dropdown) 3. **Added dark mode styles** for priority badges and dropdown Task-Id: t-182 --- Omni/Jr/Web.hs | 179 ++++++++++++++++++++++++++++++++++++++++++++++++--- Omni/Jr/Web/Style.hs | 78 ++++++++++++++++++++++ 2 files changed, 247 insertions(+), 10 deletions(-) diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs index ece96ed..44e616a 100644 --- a/Omni/Jr/Web.hs +++ b/Omni/Jr/Web.hs @@ -75,13 +75,6 @@ renderRelativeTimestamp now timestamp = metaSep :: (Monad m) => Lucid.HtmlT m () metaSep = Lucid.span_ [Lucid.class_ "meta-sep"] "·" -priorityDesc :: TaskCore.Priority -> Text -priorityDesc TaskCore.P0 = "Critical" -priorityDesc TaskCore.P1 = "High" -priorityDesc TaskCore.P2 = "Normal" -priorityDesc TaskCore.P3 = "Low" -priorityDesc TaskCore.P4 = "Defer" - data TaskFilters = TaskFilters { filterStatus :: Maybe TaskCore.Status, filterPriority :: Maybe TaskCore.Priority, @@ -225,6 +218,7 @@ type API = :<|> "epics" :> QueryParam "sort" Text :> Get '[Lucid.HTML] EpicsPage :<|> "tasks" :> Capture "id" Text :> Get '[Lucid.HTML] TaskDetailPage :<|> "tasks" :> Capture "id" Text :> "status" :> ReqBody '[FormUrlEncoded] StatusForm :> Post '[Lucid.HTML] StatusBadgePartial + :<|> "tasks" :> Capture "id" Text :> "priority" :> ReqBody '[FormUrlEncoded] PriorityForm :> Post '[Lucid.HTML] PriorityBadgePartial :<|> "tasks" :> Capture "id" Text :> "description" :> "view" :> Get '[Lucid.HTML] DescriptionViewPartial :<|> "tasks" :> Capture "id" Text :> "description" :> "edit" :> Get '[Lucid.HTML] DescriptionEditPartial :<|> "tasks" :> Capture "id" Text :> "description" :> ReqBody '[FormUrlEncoded] DescriptionForm :> Post '[Lucid.HTML] DescriptionViewPartial @@ -329,6 +323,8 @@ newtype ReadyCountPartial = ReadyCountPartial Int data StatusBadgePartial = StatusBadgePartial TaskCore.Status Text +data PriorityBadgePartial = PriorityBadgePartial TaskCore.Priority Text + newtype TaskListPartial = TaskListPartial [TaskCore.Task] data TaskMetricsPartial = TaskMetricsPartial Text [TaskCore.TaskActivity] (Maybe TaskCore.RetryContext) UTCTime @@ -351,6 +347,15 @@ instance FromForm StatusForm where Just s -> Right (StatusForm s) Nothing -> Left "Invalid status" +newtype PriorityForm = PriorityForm TaskCore.Priority + +instance FromForm PriorityForm where + fromForm form = do + priorityText <- parseUnique "priority" form + case readMaybe (Text.unpack priorityText) of + Just p -> Right (PriorityForm p) + Nothing -> Left "Invalid priority" + newtype DescriptionForm = DescriptionForm Text instance FromForm DescriptionForm where @@ -382,6 +387,7 @@ pageHead title = ] ("" :: Text) Lucid.script_ [] statusDropdownJs + Lucid.script_ [] priorityDropdownJs Lucid.script_ [] navbarDropdownJs navbarDropdownJs :: Text @@ -482,6 +488,77 @@ statusDropdownJs = "});" ] +priorityDropdownJs :: Text +priorityDropdownJs = + Text.unlines + [ "function togglePriorityDropdown(el) {", + " var container = el.parentElement;", + " var isOpen = container.classList.toggle('open');", + " el.setAttribute('aria-expanded', isOpen);", + " if (isOpen) {", + " var firstItem = container.querySelector('[role=\"menuitem\"]');", + " if (firstItem) firstItem.focus();", + " }", + "}", + "", + "function closePriorityDropdown(container) {", + " container.classList.remove('open');", + " var badge = container.querySelector('[role=\"button\"]');", + " if (badge) {", + " badge.setAttribute('aria-expanded', 'false');", + " badge.focus();", + " }", + "}", + "", + "function handlePriorityKeydown(event, el) {", + " if (event.key === 'Enter' || event.key === ' ') {", + " event.preventDefault();", + " togglePriorityDropdown(el);", + " } else if (event.key === 'Escape') {", + " closePriorityDropdown(el.parentElement);", + " } else if (event.key === 'ArrowDown') {", + " event.preventDefault();", + " var container = el.parentElement;", + " if (!container.classList.contains('open')) {", + " togglePriorityDropdown(el);", + " } else {", + " var firstItem = container.querySelector('[role=\"menuitem\"]');", + " if (firstItem) firstItem.focus();", + " }", + " }", + "}", + "", + "function handlePriorityMenuItemKeydown(event) {", + " var container = event.target.closest('.priority-badge-dropdown');", + " var items = container.querySelectorAll('[role=\"menuitem\"]');", + " var currentIndex = Array.from(items).indexOf(event.target);", + " ", + " if (event.key === 'ArrowDown') {", + " event.preventDefault();", + " var next = (currentIndex + 1) % items.length;", + " items[next].focus();", + " } else if (event.key === 'ArrowUp') {", + " event.preventDefault();", + " var prev = (currentIndex - 1 + items.length) % items.length;", + " items[prev].focus();", + " } else if (event.key === 'Escape') {", + " event.preventDefault();", + " closePriorityDropdown(container);", + " } else if (event.key === 'Tab') {", + " closePriorityDropdown(container);", + " }", + "}", + "", + "document.addEventListener('click', function(e) {", + " var dropdowns = document.querySelectorAll('.priority-badge-dropdown.open');", + " dropdowns.forEach(function(d) {", + " if (!d.contains(e.target)) {", + " closePriorityDropdown(d);", + " }", + " });", + "});" + ] + pageBody :: (Monad m) => Lucid.HtmlT m () -> Lucid.HtmlT m () pageBody content = Lucid.body_ [Lucid.makeAttribute "hx-boost" "true"] <| do @@ -718,6 +795,79 @@ statusOption opt currentStatus tid = ] (Lucid.toHtml label) +priorityBadgeWithForm :: (Monad m) => TaskCore.Priority -> Text -> Lucid.HtmlT m () +priorityBadgeWithForm priority tid = + Lucid.div_ + [ Lucid.id_ "priority-badge-container", + Lucid.class_ "priority-badge-dropdown" + ] + <| do + clickablePriorityBadge priority tid + priorityDropdownOptions priority tid + +clickablePriorityBadge :: (Monad m) => TaskCore.Priority -> Text -> Lucid.HtmlT m () +clickablePriorityBadge priority _tid = + let (cls, label) = case priority of + TaskCore.P0 -> ("badge badge-p0 priority-badge-clickable", "P0 Critical" :: Text) + TaskCore.P1 -> ("badge badge-p1 priority-badge-clickable", "P1 High") + TaskCore.P2 -> ("badge badge-p2 priority-badge-clickable", "P2 Normal") + TaskCore.P3 -> ("badge badge-p3 priority-badge-clickable", "P3 Low") + TaskCore.P4 -> ("badge badge-p4 priority-badge-clickable", "P4 Defer") + in Lucid.span_ + [ Lucid.class_ cls, + Lucid.tabindex_ "0", + Lucid.role_ "button", + Lucid.makeAttribute "aria-haspopup" "true", + Lucid.makeAttribute "aria-expanded" "false", + Lucid.makeAttribute "onclick" "togglePriorityDropdown(this)", + Lucid.makeAttribute "onkeydown" "handlePriorityKeydown(event, this)" + ] + <| do + Lucid.toHtml label + Lucid.span_ [Lucid.class_ "dropdown-arrow", Lucid.makeAttribute "aria-hidden" "true"] " ▾" + +priorityDropdownOptions :: (Monad m) => TaskCore.Priority -> Text -> Lucid.HtmlT m () +priorityDropdownOptions currentPriority tid = + Lucid.div_ + [ Lucid.class_ "priority-dropdown-menu", + Lucid.role_ "menu", + Lucid.makeAttribute "aria-label" "Change task priority" + ] + <| do + priorityOption TaskCore.P0 currentPriority tid + priorityOption TaskCore.P1 currentPriority tid + priorityOption TaskCore.P2 currentPriority tid + priorityOption TaskCore.P3 currentPriority tid + priorityOption TaskCore.P4 currentPriority tid + +priorityOption :: (Monad m) => TaskCore.Priority -> TaskCore.Priority -> Text -> Lucid.HtmlT m () +priorityOption opt currentPriority tid = + let (cls, label) = case opt of + TaskCore.P0 -> ("badge badge-p0", "P0 Critical" :: Text) + TaskCore.P1 -> ("badge badge-p1", "P1 High") + TaskCore.P2 -> ("badge badge-p2", "P2 Normal") + TaskCore.P3 -> ("badge badge-p3", "P3 Low") + TaskCore.P4 -> ("badge badge-p4", "P4 Defer") + isSelected = opt == currentPriority + optClass = cls <> " priority-dropdown-option" <> if isSelected then " selected" else "" + in Lucid.form_ + [ Lucid.class_ "priority-option-form", + Lucid.role_ "none", + Lucid.makeAttribute "hx-post" ("/tasks/" <> tid <> "/priority"), + Lucid.makeAttribute "hx-target" "#priority-badge-container", + Lucid.makeAttribute "hx-swap" "outerHTML" + ] + <| do + Lucid.input_ [Lucid.type_ "hidden", Lucid.name_ "priority", Lucid.value_ (tshow opt)] + Lucid.button_ + [ Lucid.type_ "submit", + Lucid.class_ optClass, + Lucid.role_ "menuitem", + Lucid.tabindex_ "-1", + Lucid.makeAttribute "onkeydown" "handlePriorityMenuItemKeydown(event)" + ] + (Lucid.toHtml label) + renderTaskCard :: (Monad m) => TaskCore.Task -> Lucid.HtmlT m () renderTaskCard t = Lucid.a_ @@ -1290,9 +1440,7 @@ instance Lucid.ToHtml TaskDetailPage where metaSep statusBadgeWithForm (TaskCore.taskStatus task) (TaskCore.taskId task) metaSep - Lucid.span_ [Lucid.class_ "task-meta-priority"] <| do - Lucid.toHtml (tshow (TaskCore.taskPriority task)) - Lucid.span_ [Lucid.class_ "priority-desc"] (Lucid.toHtml (priorityDesc (TaskCore.taskPriority task))) + priorityBadgeWithForm (TaskCore.taskPriority task) (TaskCore.taskId task) case TaskCore.taskNamespace task of Nothing -> pure () Just ns -> do @@ -1896,6 +2044,11 @@ instance Lucid.ToHtml StatusBadgePartial where toHtml (StatusBadgePartial status tid) = statusBadgeWithForm status tid +instance Lucid.ToHtml PriorityBadgePartial where + toHtmlRaw = Lucid.toHtml + toHtml (PriorityBadgePartial priority tid) = + priorityBadgeWithForm priority tid + instance Lucid.ToHtml TaskListPartial where toHtmlRaw = Lucid.toHtml toHtml (TaskListPartial tasks) = @@ -2179,6 +2332,7 @@ server = :<|> epicsHandler :<|> taskDetailHandler :<|> taskStatusHandler + :<|> taskPriorityHandler :<|> descriptionViewHandler :<|> descriptionEditHandler :<|> descriptionPostHandler @@ -2361,6 +2515,11 @@ server = liftIO <| TaskCore.updateTaskStatus tid newStatus [] pure (StatusBadgePartial newStatus tid) + taskPriorityHandler :: Text -> PriorityForm -> Servant.Handler PriorityBadgePartial + taskPriorityHandler tid (PriorityForm newPriority) = do + _ <- liftIO <| TaskCore.editTask tid (\t -> t {TaskCore.taskPriority = newPriority}) + pure (PriorityBadgePartial newPriority tid) + descriptionViewHandler :: Text -> Servant.Handler DescriptionViewPartial descriptionViewHandler tid = do tasks <- liftIO TaskCore.loadTasks diff --git a/Omni/Jr/Web/Style.hs b/Omni/Jr/Web/Style.hs index 02352ec..fbaaa82 100644 --- a/Omni/Jr/Web/Style.hs +++ b/Omni/Jr/Web/Style.hs @@ -623,6 +623,66 @@ statusBadges = do ".status-badge-clickable" # focus ? do Stylesheet.key "outline" ("2px solid #0066cc" :: Text) Stylesheet.key "outline-offset" ("2px" :: Text) + ".badge-p0" ? do + backgroundColor "#fee2e2" + color "#991b1b" + ".badge-p1" ? do + backgroundColor "#fef3c7" + color "#92400e" + ".badge-p2" ? do + backgroundColor "#dbeafe" + color "#1e40af" + ".badge-p3" ? do + backgroundColor "#e5e7eb" + color "#4b5563" + ".badge-p4" ? do + backgroundColor "#f3f4f6" + color "#6b7280" + ".priority-badge-dropdown" ? do + position relative + display inlineBlock + ".priority-badge-clickable" ? do + cursor pointer + Stylesheet.key "user-select" ("none" :: Text) + ".priority-badge-clickable" # hover ? do + opacity 0.85 + ".priority-dropdown-menu" ? do + display none + position absolute + left (px 0) + top (pct 100) + marginTop (px 2) + backgroundColor white + borderRadius (px 4) (px 4) (px 4) (px 4) + Stylesheet.key "box-shadow" ("0 2px 8px rgba(0,0,0,0.15)" :: Text) + zIndex 100 + padding (px 4) (px 4) (px 4) (px 4) + minWidth (px 100) + ".priority-badge-dropdown.open" |> ".priority-dropdown-menu" ? do + display block + ".priority-option-form" ? do + margin (px 0) (px 0) (px 0) (px 0) + padding (px 0) (px 0) (px 0) (px 0) + ".priority-dropdown-option" ? do + display block + width (pct 100) + textAlign (alignSide sideLeft) + margin (px 2) (px 0) (px 2) (px 0) + border (px 0) none transparent + cursor pointer + transition "opacity" (ms 150) ease (sec 0) + ".priority-dropdown-option" # hover ? do + opacity 0.7 + ".priority-dropdown-option" # focus ? do + opacity 0.85 + Stylesheet.key "outline" ("2px solid #0066cc" :: Text) + Stylesheet.key "outline-offset" ("1px" :: Text) + ".priority-dropdown-option.selected" ? do + Stylesheet.key "outline" ("2px solid #0066cc" :: Text) + Stylesheet.key "outline-offset" ("1px" :: Text) + ".priority-badge-clickable" # focus ? do + Stylesheet.key "outline" ("2px solid #0066cc" :: Text) + Stylesheet.key "outline-offset" ("2px" :: Text) buttonStyles :: Css buttonStyles = do @@ -1445,6 +1505,24 @@ darkModeStyles = ".badge-done" ? do backgroundColor "#064e3b" color "#6ee7b7" + ".badge-p0" ? do + backgroundColor "#7f1d1d" + color "#fca5a5" + ".badge-p1" ? do + backgroundColor "#78350f" + color "#fcd34d" + ".badge-p2" ? do + backgroundColor "#1e3a8a" + color "#93c5fd" + ".badge-p3" ? do + backgroundColor "#374151" + color "#d1d5db" + ".badge-p4" ? do + backgroundColor "#1f2937" + color "#9ca3af" + ".priority-dropdown-menu" ? do + backgroundColor "#1f2937" + Stylesheet.key "box-shadow" ("0 2px 8px rgba(0,0,0,0.3)" :: Text) ".action-btn" ? do backgroundColor "#374151" borderColor "#4b5563" -- cgit v1.2.3