feat(issues): S4b editor controls — Kind selector + Systems multi-select
In the full task editor (TaskEditorView) sidebar: - Kind selector (Work / Plan / Issue), mirroring the Status/Priority selects. - Systems multi-select (checkboxes of the project's systems, fetched via the systems store), shown when a project is set. Both wired through load (prefill from task.task_kind / task.systems), dirty tracking, and save (kind + system_ids via the store's IssueFields). No new colors — existing sb-field/sb-select tokens. Deferred: the arose-from (provenance) picker — least-critical control and the riskiest (task-search UI); the field is already supported by API/store/route for a later add. NEEDS operator browser verification (CI typechecks only). Refs plan 825 (S4b editor). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,9 @@ import { useTagSuggestions } from "@/composables/useTagSuggestions";
|
|||||||
import { useFloatingAssist } from "@/composables/useFloatingAssist";
|
import { useFloatingAssist } from "@/composables/useFloatingAssist";
|
||||||
import { apiPost, apiGet, apiPatch } from "@/api/client";
|
import { apiPost, apiGet, apiPatch } from "@/api/client";
|
||||||
import type { TaskStatus, TaskPriority } from "@/types/task";
|
import type { TaskStatus, TaskPriority } from "@/types/task";
|
||||||
|
import type { TaskKind } from "@/types/note";
|
||||||
|
import { useSystemsStore } from "@/stores/systems";
|
||||||
|
import type { System } from "@/api/systems";
|
||||||
import type { Note } from "@/types/note";
|
import type { Note } from "@/types/note";
|
||||||
import type { Editor } from "@tiptap/vue-3";
|
import type { Editor } from "@tiptap/vue-3";
|
||||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||||
@@ -31,6 +34,7 @@ import { Trash2 } from "lucide-vue-next";
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useTasksStore();
|
const store = useTasksStore();
|
||||||
|
const systemsStore = useSystemsStore();
|
||||||
const notesStore = useNotesStore();
|
const notesStore = useNotesStore();
|
||||||
const toast = useToastStore();
|
const toast = useToastStore();
|
||||||
|
|
||||||
@@ -41,6 +45,8 @@ const consolidatedAt = ref<string | null>(null);
|
|||||||
const tags = ref<string[]>([]);
|
const tags = ref<string[]>([]);
|
||||||
const status = ref<TaskStatus>("todo");
|
const status = ref<TaskStatus>("todo");
|
||||||
const priority = ref<TaskPriority>("none");
|
const priority = ref<TaskPriority>("none");
|
||||||
|
const kind = ref<TaskKind>("work");
|
||||||
|
const systemIds = ref<number[]>([]);
|
||||||
const dueDate = ref("");
|
const dueDate = ref("");
|
||||||
const projectId = ref<number | null>(null);
|
const projectId = ref<number | null>(null);
|
||||||
const milestoneId = ref<number | null>(null);
|
const milestoneId = ref<number | null>(null);
|
||||||
@@ -201,6 +207,8 @@ let savedDueDate = "";
|
|||||||
let savedProjectId: number | null = null;
|
let savedProjectId: number | null = null;
|
||||||
let savedMilestoneId: number | null = null;
|
let savedMilestoneId: number | null = null;
|
||||||
let savedParentId: number | null = null;
|
let savedParentId: number | null = null;
|
||||||
|
let savedKind: TaskKind = "work";
|
||||||
|
let savedSystemIds: number[] = [];
|
||||||
|
|
||||||
function markDirty() {
|
function markDirty() {
|
||||||
dirty.value =
|
dirty.value =
|
||||||
@@ -213,7 +221,22 @@ function markDirty() {
|
|||||||
dueDate.value !== savedDueDate ||
|
dueDate.value !== savedDueDate ||
|
||||||
projectId.value !== savedProjectId ||
|
projectId.value !== savedProjectId ||
|
||||||
milestoneId.value !== savedMilestoneId ||
|
milestoneId.value !== savedMilestoneId ||
|
||||||
parentId.value !== savedParentId;
|
parentId.value !== savedParentId ||
|
||||||
|
kind.value !== savedKind ||
|
||||||
|
JSON.stringify(systemIds.value) !== JSON.stringify(savedSystemIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectSystems = computed<System[]>(() =>
|
||||||
|
projectId.value ? (systemsStore.systemsByProject[projectId.value] ?? []) : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadSystems() {
|
||||||
|
if (!projectId.value) return;
|
||||||
|
try {
|
||||||
|
await systemsStore.fetchSystems(projectId.value);
|
||||||
|
} catch {
|
||||||
|
/* non-fatal — the systems picker just won't populate */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBodyUpdate(newVal: string) {
|
function onBodyUpdate(newVal: string) {
|
||||||
@@ -288,6 +311,8 @@ onMounted(async () => {
|
|||||||
const taskRec = store.currentTask as Record<string, unknown>;
|
const taskRec = store.currentTask as Record<string, unknown>;
|
||||||
projectId.value = (taskRec.project_id as number | null) ?? null;
|
projectId.value = (taskRec.project_id as number | null) ?? null;
|
||||||
milestoneId.value = (taskRec.milestone_id as number | null) ?? null;
|
milestoneId.value = (taskRec.milestone_id as number | null) ?? null;
|
||||||
|
kind.value = (taskRec.task_kind as TaskKind) ?? "work";
|
||||||
|
systemIds.value = ((taskRec.systems as Array<{ id: number }> | undefined) ?? []).map((s) => s.id);
|
||||||
parentId.value = (taskRec.parent_id as number | null) ?? null;
|
parentId.value = (taskRec.parent_id as number | null) ?? null;
|
||||||
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
|
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
|
||||||
parentSearchQuery.value = parentTitle.value;
|
parentSearchQuery.value = parentTitle.value;
|
||||||
@@ -305,6 +330,8 @@ onMounted(async () => {
|
|||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
savedParentId = parentId.value;
|
savedParentId = parentId.value;
|
||||||
|
savedKind = kind.value;
|
||||||
|
savedSystemIds = [...systemIds.value];
|
||||||
// Start in preview mode only if the task already has body content
|
// Start in preview mode only if the task already has body content
|
||||||
showPreview.value = body.value.trim().length > 0;
|
showPreview.value = body.value.trim().length > 0;
|
||||||
}
|
}
|
||||||
@@ -315,6 +342,7 @@ onMounted(async () => {
|
|||||||
if (route.query.milestoneId) milestoneId.value = Number(route.query.milestoneId);
|
if (route.query.milestoneId) milestoneId.value = Number(route.query.milestoneId);
|
||||||
if (route.query.parentId) parentId.value = Number(route.query.parentId);
|
if (route.query.parentId) parentId.value = Number(route.query.parentId);
|
||||||
}
|
}
|
||||||
|
loadSystems();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -333,6 +361,8 @@ async function save() {
|
|||||||
milestone_id: milestoneId.value,
|
milestone_id: milestoneId.value,
|
||||||
parent_id: parentId.value,
|
parent_id: parentId.value,
|
||||||
recurrence_rule: recurrenceRule.value,
|
recurrence_rule: recurrenceRule.value,
|
||||||
|
kind: kind.value,
|
||||||
|
system_ids: systemIds.value,
|
||||||
};
|
};
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
await store.updateTask(taskId.value!, data);
|
await store.updateTask(taskId.value!, data);
|
||||||
@@ -346,6 +376,8 @@ async function save() {
|
|||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
savedParentId = parentId.value;
|
savedParentId = parentId.value;
|
||||||
|
savedKind = kind.value;
|
||||||
|
savedSystemIds = [...systemIds.value];
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Task saved");
|
toast.show("Task saved");
|
||||||
router.push(`/tasks/${taskId.value}`);
|
router.push(`/tasks/${taskId.value}`);
|
||||||
@@ -543,6 +575,14 @@ useEditorGuards(dirty, save);
|
|||||||
<option value="cancelled">Cancelled</option>
|
<option value="cancelled">Cancelled</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sb-field">
|
||||||
|
<label class="sb-label">Kind</label>
|
||||||
|
<select v-model="kind" @change="markDirty" class="sb-select">
|
||||||
|
<option value="work">Work</option>
|
||||||
|
<option value="plan">Plan</option>
|
||||||
|
<option value="issue">Issue</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div v-if="startedAt || completedAt" class="sb-timestamps">
|
<div v-if="startedAt || completedAt" class="sb-timestamps">
|
||||||
<div v-if="startedAt" class="sb-timestamp">
|
<div v-if="startedAt" class="sb-timestamp">
|
||||||
<span class="sb-ts-label">Started</span>
|
<span class="sb-ts-label">Started</span>
|
||||||
@@ -578,6 +618,16 @@ useEditorGuards(dirty, save);
|
|||||||
<label class="sb-label">Milestone</label>
|
<label class="sb-label">Milestone</label>
|
||||||
<MilestoneSelector :projectId="projectId" v-model="milestoneId" @update:modelValue="markDirty" />
|
<MilestoneSelector :projectId="projectId" v-model="milestoneId" @update:modelValue="markDirty" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="projectId" class="sb-field">
|
||||||
|
<label class="sb-label">Systems</label>
|
||||||
|
<div v-if="projectSystems.length" class="sb-systems">
|
||||||
|
<label v-for="s in projectSystems" :key="s.id" class="sb-system-opt">
|
||||||
|
<input type="checkbox" :value="s.id" v-model="systemIds" @change="markDirty" />
|
||||||
|
<span>{{ s.name }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-else class="sb-systems-empty">No systems in this project yet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Parent task -->
|
<!-- Parent task -->
|
||||||
<div class="sb-field">
|
<div class="sb-field">
|
||||||
@@ -955,6 +1005,12 @@ useEditorGuards(dirty, save);
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Systems multi-select (in sidebar) */
|
||||||
|
.sb-systems { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; }
|
||||||
|
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--color-text); cursor: pointer; }
|
||||||
|
.sb-system-opt input { accent-color: var(--color-primary); cursor: pointer; }
|
||||||
|
.sb-systems-empty { margin: 0; font-size: 0.8rem; color: var(--color-text-muted); }
|
||||||
|
|
||||||
/* Writing Assistant section (in sidebar) */
|
/* Writing Assistant section (in sidebar) */
|
||||||
.assist-section {
|
.assist-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user