Procházet zdrojové kódy

Task and goal mutations answer with refreshed state slots

New shared bases TaskDetailRefreshingAction and GoalDetailRefreshingAction own the service set the page state builders need and expose one slot-building call each, so a mutation rebuilds its page view centrally instead of copy-pasting injections. UpdateTaskAction, AddCommentAction (task-page caller), ToggleTaskNotifyAction, GoalCommentAction, PauseGoalAction, ResumeGoalAction, AddGoalLinkAction and RemoveGoalLinkAction now answer success with notify plus the refreshed detail slot and no navigate; the sealed refresh actions reuse the same base. Terminal return_to comments, create-to-detail, delete-to-list, login and error paths keep navigating - that is the right UX there.
clanker před 1 měsícem
rodič
revize
93412406ec
2 změnil soubory, kde provedl 86 přidání a 52 odebrání
  1. 44 29
      src/actions/GoalActions.vala
  2. 42 23
      src/actions/TaskActions.vala

+ 44 - 29
src/actions/GoalActions.vala

@@ -34,6 +34,38 @@ namespace ClankerSaddle {
         }
     }
 
+    /**
+     * Shared base for goal-page actions that answer with the
+     * refreshed goal slot instead of a navigate (goal 2 QoL): owns
+     * the service set {@link GoalState.build} needs, so a mutation
+     * rebuilds the page view with ONE call - {@link goal_detail_slot}
+     * - instead of every action copy-pasting the injections and the
+     * build chain. Success paths answer notify(...).set(slot); flows
+     * whose result genuinely IS navigation keep navigating.
+     */
+    public abstract class GoalDetailRefreshingAction : SealedGoalAction {
+
+        protected GoalService goal_service = inject<GoalService>();
+        protected TaskService task_service = inject<TaskService>();
+        protected ProjectService project_service = inject<ProjectService>();
+        protected SaddleService saddle_service = inject<SaddleService>();
+        protected SaddlePollState poll_state = inject<SaddlePollState>();
+        protected RuleService rule_service = inject<RuleService>();
+        protected InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        protected UserService users = inject<UserService>();
+
+        /**
+         * Rebuilds the goal detail slot from current data - the ONE
+         * call a mutation needs to answer with refreshed state (the
+         * same build the page's refresh action runs).
+         */
+        protected async Statum.Slot goal_detail_slot(int64 goal_id) throws GLib.Error {
+            var ctx = new GoalViewContext(goal_service, task_service, project_service,
+                db, action_registry, saddle_service, poll_state, rule_service);
+            return yield GoalState.build(goal_id, ctx, state_service);
+        }
+    }
+
     /**
      * Creates a goal from the goals form (project, title, brief and a
      * required branch name; base branch optional — the project's
@@ -146,10 +178,9 @@ namespace ClankerSaddle {
     }
 
     /** Pauses an active goal: supervision freezes, events queue. */
-    public class PauseGoalAction : SealedGoalAction {
+    public class PauseGoalAction : GoalDetailRefreshingAction {
 
         private GoalService goals = inject<GoalService>();
-        private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -159,7 +190,7 @@ namespace ClankerSaddle {
             try {
                 var goal = yield goals.pause_goal(sealed_goal_id());
                 return directives().notify("success", @"Goal '$(goal.title)' paused")
-                    .navigate(@"/goals/$(goal.id)");
+                    .set(yield goal_detail_slot(goal.id));
             } catch (Error e) {
                 return directives().notify("error", @"Could not pause the goal: $(e.message)").navigate("/goals");
             }
@@ -167,10 +198,9 @@ namespace ClankerSaddle {
     }
 
     /** Resumes a paused goal: queued events coalesce into the next wake. */
-    public class ResumeGoalAction : SealedGoalAction {
+    public class ResumeGoalAction : GoalDetailRefreshingAction {
 
         private GoalService goals = inject<GoalService>();
-        private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -180,7 +210,7 @@ namespace ClankerSaddle {
             try {
                 var goal = yield goals.resume_goal(sealed_goal_id());
                 return directives().notify("success", @"Goal '$(goal.title)' resumed")
-                    .navigate(@"/goals/$(goal.id)");
+                    .set(yield goal_detail_slot(goal.id));
             } catch (Error e) {
                 return directives().notify("error", @"Could not resume the goal: $(e.message)").navigate("/goals");
             }
@@ -317,10 +347,9 @@ namespace ClankerSaddle {
      * Posts a comment on a goal (the goal page's thread; a selection
      * quote rides along when the popover opened one).
      */
-    public class GoalCommentAction : SealedGoalAction {
+    public class GoalCommentAction : GoalDetailRefreshingAction {
 
         private GoalService goals = inject<GoalService>();
-        private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -340,7 +369,7 @@ namespace ClankerSaddle {
                 yield goals.add_human_comment(sealed_goal_id(), author, body,
                     Forms.optional(request, "quote"));
                 return directives().notify("success", "Comment added")
-                    .navigate(@"/goals/$(sealed_goal_id())");
+                    .set(yield goal_detail_slot(sealed_goal_id()));
             } catch (Error e) {
                 return directives().notify("error", @"Could not add the comment: $(e.message)")
                     .navigate(@"/goals/$(sealed_goal_id())");
@@ -439,16 +468,7 @@ namespace ClankerSaddle {
      * re-hydrates the goal slot with fresh narrative/task/review
      * state, exactly like the task page's refresh.
      */
-    public class RefreshGoalDataAction : SealedGoalAction {
-
-        private GoalService goal_service = inject<GoalService>();
-        private TaskService task_service = inject<TaskService>();
-        private ProjectService project_service = inject<ProjectService>();
-        private SaddleService saddle_service = inject<SaddleService>();
-        private SaddlePollState poll_state = inject<SaddlePollState>();
-        private RuleService rule_service = inject<RuleService>();
-        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
-        private UserService users = inject<UserService>();
+    public class RefreshGoalDataAction : GoalDetailRefreshingAction {
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -461,18 +481,14 @@ namespace ClankerSaddle {
                 return directives();
             }
 
-            var ctx = new GoalViewContext(goal_service, task_service, project_service,
-                db, action_registry, saddle_service, poll_state, rule_service);
-            var state = yield GoalState.build(goal_id, ctx, state_service);
-            return directives().set(state);
+            return directives().set(yield goal_detail_slot(goal_id));
         }
     }
 
     /** Adds a project link (co-developed/reference) to a goal. */
-    public class AddGoalLinkAction : SealedGoalAction {
+    public class AddGoalLinkAction : GoalDetailRefreshingAction {
 
         private GoalService goals = inject<GoalService>();
-        private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -484,7 +500,7 @@ namespace ClankerSaddle {
                     Forms.integer(request, "linked_project_id", 0),
                     Forms.field(request, "relationship"));
                 return directives().notify("success", "Project linked")
-                    .navigate(@"/goals/$(sealed_goal_id())");
+                    .set(yield goal_detail_slot(sealed_goal_id()));
             } catch (Error e) {
                 return directives().notify("error", @"Could not link the project: $(e.message)")
                     .navigate(@"/goals/$(sealed_goal_id())");
@@ -493,10 +509,9 @@ namespace ClankerSaddle {
     }
 
     /** Removes the goal link identified by `link_id`. */
-    public class RemoveGoalLinkAction : SealedGoalAction {
+    public class RemoveGoalLinkAction : GoalDetailRefreshingAction {
 
         private GoalService goals = inject<GoalService>();
-        private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -507,7 +522,7 @@ namespace ClankerSaddle {
             try {
                 yield goals.remove_link(link_id);
                 return directives().notify("success", "Link removed")
-                    .navigate(@"/goals/$(sealed_goal_id())");
+                    .set(yield goal_detail_slot(sealed_goal_id()));
             } catch (Error e) {
                 return directives().notify("error", @"Could not remove the link: $(e.message)")
                     .navigate(@"/goals/$(sealed_goal_id())");

+ 42 - 23
src/actions/TaskActions.vala

@@ -33,6 +33,43 @@ namespace ClankerSaddle {
         }
     }
 
+    /**
+     * Shared base for task-page actions that answer with the
+     * refreshed task slot instead of a navigate (goal 2 QoL): owns
+     * the service set {@link TaskDetailState.build} needs, so a
+     * mutation rebuilds the page view with ONE call -
+     * {@link task_detail_slot} - instead of every action copy-pasting
+     * the injections and the build chain. Success paths answer
+     * notify(...).set(slot); flows whose result genuinely IS
+     * navigation (create -> detail, delete -> list) keep navigating.
+     */
+    public abstract class TaskDetailRefreshingAction : SealedTaskAction {
+
+        protected TaskService task_service = inject<TaskService>();
+        protected ProjectService project_service = inject<ProjectService>();
+        protected SaddleService saddle_service = inject<SaddleService>();
+        protected RuleService rule_service = inject<RuleService>();
+        protected CapabilityService capability_service = inject<CapabilityService>();
+        protected SaddlePollState poll_state = inject<SaddlePollState>();
+        protected PushService push_service = inject<PushService>();
+        protected InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        protected UserService users = inject<UserService>();
+
+        /**
+         * Rebuilds the task detail slot from current data - the ONE
+         * call a mutation needs to answer with refreshed state (the
+         * same build the page's refresh action runs).
+         */
+        protected async Statum.Slot task_detail_slot(int64 task_id) throws GLib.Error {
+            var me = SpryAuth.current(request);
+            return yield TaskDetailState.build(state_service, task_service,
+                project_service, saddle_service, rule_service, capability_service,
+                action_registry, poll_state, push_service, db,
+                me != null ? ((!)me).user_id : 0, task_id);
+        }
+    }
+
+
     /**
      * Creates a task from the queue form (project and title required).
      * The task lands in DRAFT — the human Enqueue action queues it;
@@ -118,10 +155,7 @@ namespace ClankerSaddle {
      * states priority only, in terminal states nothing. Status is not
      * part of the form — Enqueue/Cancel/agent transitions own it.
      */
-    public class UpdateTaskAction : SealedTaskAction {
-
-        private TaskService tasks = inject<TaskService>();
-        private UserService users = inject<UserService>();
+    public class UpdateTaskAction : TaskDetailRefreshingAction {
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -156,7 +190,7 @@ namespace ClankerSaddle {
                 // form actually carried the description field (the
                 // priority-only edit must not blank it — an absent
                 // field is "leave unchanged", same as title).
-                yield tasks.edit_task(task_id,
+                yield task_service.edit_task(task_id,
                     Forms.optional(request, "title"),
                     request.form != null && request.form.get_field("description") != null
                         ? Forms.field(request, "description") : null,
@@ -167,7 +201,7 @@ namespace ClankerSaddle {
                         ? Forms.field(request, "branch_from") : null,
                     mode.length > 0 ? mode : null,
                     enqueue_after);
-                return directives().notify("success", "Task saved").navigate(@"/tasks/$(task_id)");
+                return directives().notify("success", "Task saved").set(yield task_detail_slot(task_id));
             } catch (Error e) {
                 return directives().notify("error", @"Could not save the task: $(e.message)")
                     .navigate(@"/tasks/$(task_id)");
@@ -516,17 +550,7 @@ namespace ClankerSaddle {
      * blocks, output-driven status changes and completions. Navigates
      * nowhere; a failed reload leaves the client's state untouched.
      */
-    public class RefreshTaskDataAction : SealedTaskAction {
-
-        private TaskService task_service = inject<TaskService>();
-        private ProjectService project_service = inject<ProjectService>();
-        private SaddleService saddle_service = inject<SaddleService>();
-        private RuleService rule_service = inject<RuleService>();
-        private CapabilityService capability_service = inject<CapabilityService>();
-        private SaddlePollState poll_state = inject<SaddlePollState>();
-        private PushService push_service = inject<PushService>();
-        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
-        private UserService users = inject<UserService>();
+    public class RefreshTaskDataAction : TaskDetailRefreshingAction {
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -539,12 +563,7 @@ namespace ClankerSaddle {
                 return directives();
             }
 
-            var me = SpryAuth.current(request);
-            var state = yield TaskDetailState.build(state_service, task_service,
-                project_service, saddle_service, rule_service, capability_service,
-                action_registry, poll_state, push_service, db,
-                me != null ? ((!)me).user_id : 0, task_id);
-            return directives().set(state);
+            return directives().set(yield task_detail_slot(task_id));
         }
     }
 }