diff options
| author | Ben Sima <ben@bensima.com> | 2025-11-26 09:34:11 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-11-26 09:34:11 -0500 |
| commit | e709ef77c4158a66ba3e572e52ad866d4855fc21 (patch) | |
| tree | 918b4d36b61ffb63912cc70c05ab8ea09524620e /Omni/Jr | |
| parent | 3611e15f055d3cf39cbd206e2d6d46a01b4df6ef (diff) | |
Add task list filters (status, priority, namespace)
The build passes. Let me also verify that the filter functionality
is co
1. **API endpoint with query params** (lines 46-49): ✅ Already
has `Quer 2. **Handler** (lines 776-781): ✅ Already receives
and applies filters 3. **Filter form in HTML** (lines 295-330):
✅ Already has form with drop 4. **Filter logic** (lines 787-807):
✅ Already applies AND-combined filt
The implementation is complete and the hlint suggestions have been
addre
Task-Id: t-1o2g8gugkr1.8
Diffstat (limited to 'Omni/Jr')
| -rw-r--r-- | Omni/Jr/Web.hs | 131 |
1 files changed, 119 insertions, 12 deletions
diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs index 4fd77b6..c32c716 100644 --- a/Omni/Jr/Web.hs +++ b/Omni/Jr/Web.hs @@ -32,10 +32,21 @@ type PostRedirect = Verb 'POST 303 '[Lucid.HTML] (Headers '[Header "Location" Te defaultPort :: Warp.Port defaultPort = 8080 +data TaskFilters = TaskFilters + { filterStatus :: Maybe TaskCore.Status, + filterPriority :: Maybe TaskCore.Priority, + filterNamespace :: Maybe Text + } + deriving (Show, Eq) + type API = Get '[Lucid.HTML] HomePage :<|> "ready" :> Get '[Lucid.HTML] ReadyQueuePage - :<|> "tasks" :> Get '[Lucid.HTML] TaskListPage + :<|> "tasks" + :> QueryParam "status" TaskCore.Status + :> QueryParam "priority" TaskCore.Priority + :> QueryParam "namespace" Text + :> Get '[Lucid.HTML] TaskListPage :<|> "tasks" :> Capture "id" Text :> Get '[Lucid.HTML] TaskDetailPage :<|> "tasks" :> Capture "id" Text :> "status" :> ReqBody '[FormUrlEncoded] StatusForm :> PostRedirect :<|> "tasks" :> Capture "id" Text :> "review" :> Get '[Lucid.HTML] TaskReviewPage @@ -46,7 +57,7 @@ data HomePage = HomePage TaskCore.TaskStats [TaskCore.Task] [TaskCore.Task] newtype ReadyQueuePage = ReadyQueuePage [TaskCore.Task] -newtype TaskListPage = TaskListPage [TaskCore.Task] +data TaskListPage = TaskListPage [TaskCore.Task] TaskFilters data TaskDetailPage = TaskDetailFound TaskCore.Task [TaskCore.Task] @@ -267,7 +278,7 @@ instance Lucid.ToHtml ReadyQueuePage where instance Lucid.ToHtml TaskListPage where toHtmlRaw = Lucid.toHtml - toHtml (TaskListPage tasks) = + toHtml (TaskListPage tasks filters) = Lucid.doctypehtml_ <| do Lucid.head_ <| do Lucid.title_ "Tasks - Jr Web UI" @@ -278,16 +289,84 @@ instance Lucid.ToHtml TaskListPage where ] Lucid.style_ styles Lucid.body_ <| do - Lucid.h1_ "Tasks" - Lucid.div_ [Lucid.class_ "task-list"] <| do - traverse_ renderTask tasks + Lucid.p_ <| Lucid.a_ [Lucid.href_ "/"] "← Back to Dashboard" + Lucid.h1_ <| Lucid.toHtml ("Tasks (" <> tshow (length tasks) <> ")") + + Lucid.div_ [Lucid.class_ "filter-form"] <| do + Lucid.form_ [Lucid.method_ "GET", Lucid.action_ "/tasks"] <| do + Lucid.div_ [Lucid.class_ "filter-row"] <| do + Lucid.div_ [Lucid.class_ "filter-group"] <| do + Lucid.label_ [Lucid.for_ "status"] "Status:" + Lucid.select_ [Lucid.name_ "status", Lucid.id_ "status", Lucid.class_ "filter-select"] <| do + Lucid.option_ ([Lucid.value_ ""] <> maybeSelected Nothing (filterStatus filters)) "All" + statusFilterOption TaskCore.Open (filterStatus filters) + statusFilterOption TaskCore.InProgress (filterStatus filters) + statusFilterOption TaskCore.Review (filterStatus filters) + statusFilterOption TaskCore.Approved (filterStatus filters) + statusFilterOption TaskCore.Done (filterStatus filters) + + Lucid.div_ [Lucid.class_ "filter-group"] <| do + Lucid.label_ [Lucid.for_ "priority"] "Priority:" + Lucid.select_ [Lucid.name_ "priority", Lucid.id_ "priority", Lucid.class_ "filter-select"] <| do + Lucid.option_ ([Lucid.value_ ""] <> maybeSelected Nothing (filterPriority filters)) "All" + priorityFilterOption TaskCore.P0 (filterPriority filters) + priorityFilterOption TaskCore.P1 (filterPriority filters) + priorityFilterOption TaskCore.P2 (filterPriority filters) + priorityFilterOption TaskCore.P3 (filterPriority filters) + priorityFilterOption TaskCore.P4 (filterPriority filters) + + Lucid.div_ [Lucid.class_ "filter-group"] <| do + Lucid.label_ [Lucid.for_ "namespace"] "Namespace:" + Lucid.input_ + [ Lucid.type_ "text", + Lucid.name_ "namespace", + Lucid.id_ "namespace", + Lucid.class_ "filter-input", + Lucid.placeholder_ "e.g. Omni/Jr", + Lucid.value_ (fromMaybe "" (filterNamespace filters)) + ] + + Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "filter-btn"] "Filter" + Lucid.a_ [Lucid.href_ "/tasks", Lucid.class_ "clear-btn"] "Clear" + + if null tasks + then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks match the current filters." + else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTask tasks where + maybeSelected :: (Eq a) => Maybe a -> Maybe a -> [Lucid.Attribute] + maybeSelected opt current = [Lucid.selected_ "selected" | opt == current] + + statusFilterOption :: (Monad m) => TaskCore.Status -> Maybe TaskCore.Status -> Lucid.HtmlT m () + statusFilterOption s current = + let attrs = [Lucid.value_ (tshow s)] <> [Lucid.selected_ "selected" | Just s == current] + in Lucid.option_ attrs (Lucid.toHtml (tshow s)) + + priorityFilterOption :: (Monad m) => TaskCore.Priority -> Maybe TaskCore.Priority -> Lucid.HtmlT m () + priorityFilterOption p current = + let attrs = [Lucid.value_ (tshow p)] <> [Lucid.selected_ "selected" | Just p == current] + in Lucid.option_ attrs (Lucid.toHtml (tshow p)) + styles :: Text styles = "* { box-sizing: border-box; } \ \body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; \ - \ margin: 0; padding: 16px; background: #f5f5f5; } \ - \h1 { margin: 0 0 16px 0; } \ + \ margin: 0; padding: 16px; background: #f5f5f5; max-width: 900px; } \ + \h1 { margin: 16px 0; } \ + \.filter-form { background: white; border-radius: 8px; padding: 16px; \ + \ box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 16px; } \ + \.filter-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; } \ + \.filter-group { display: flex; flex-direction: column; gap: 4px; } \ + \.filter-group label { font-size: 12px; color: #6b7280; font-weight: 500; } \ + \.filter-select, .filter-input { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 4px; \ + \ font-size: 14px; min-width: 120px; } \ + \.filter-input { min-width: 150px; } \ + \.filter-btn { padding: 8px 16px; background: #0066cc; color: white; border: none; \ + \ border-radius: 4px; font-size: 14px; cursor: pointer; } \ + \.filter-btn:hover { background: #0052a3; } \ + \.clear-btn { padding: 8px 16px; background: #6b7280; color: white; border: none; \ + \ border-radius: 4px; font-size: 14px; cursor: pointer; text-decoration: none; } \ + \.clear-btn:hover { background: #4b5563; } \ + \.empty-msg { color: #6b7280; font-style: italic; } \ \.task-list { display: flex; flex-direction: column; gap: 8px; } \ \.task-card { background: white; border-radius: 8px; padding: 16px; \ \ box-shadow: 0 1px 3px rgba(0,0,0,0.1); } \ @@ -694,10 +773,38 @@ server = let sortedTasks = List.sortBy (compare `on` TaskCore.taskPriority) readyTasks pure (ReadyQueuePage sortedTasks) - taskListHandler :: Servant.Handler TaskListPage - taskListHandler = do - tasks <- liftIO TaskCore.loadTasks - pure (TaskListPage tasks) + taskListHandler :: Maybe TaskCore.Status -> Maybe TaskCore.Priority -> Maybe Text -> Servant.Handler TaskListPage + taskListHandler maybeStatus maybePriority maybeNamespace = do + allTasks <- liftIO TaskCore.loadTasks + let filters = TaskFilters maybeStatus maybePriority (emptyToNothing maybeNamespace) + filteredTasks = applyFilters filters allTasks + pure (TaskListPage filteredTasks filters) + + emptyToNothing :: Maybe Text -> Maybe Text + emptyToNothing (Just t) | Text.null (Text.strip t) = Nothing + emptyToNothing x = x + + applyFilters :: TaskFilters -> [TaskCore.Task] -> [TaskCore.Task] + applyFilters filters = filter matchesAllFilters + where + matchesAllFilters task = + matchesStatus task + && matchesPriority task + && matchesNamespace task + + matchesStatus task = case filterStatus filters of + Nothing -> True + Just s -> TaskCore.taskStatus task == s + + matchesPriority task = case filterPriority filters of + Nothing -> True + Just p -> TaskCore.taskPriority task == p + + matchesNamespace task = case filterNamespace filters of + Nothing -> True + Just ns -> case TaskCore.taskNamespace task of + Nothing -> False + Just taskNs -> ns `Text.isPrefixOf` taskNs taskDetailHandler :: Text -> Servant.Handler TaskDetailPage taskDetailHandler tid = do |
