feat: Created task lists that work in categories
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m20s

This commit is contained in:
domrichardson
2026-03-29 16:14:23 +01:00
parent a1dd2f2c00
commit b9ca845b9c
22 changed files with 1000 additions and 249 deletions

View File

@@ -44,7 +44,7 @@
<!-- Search -->
<div class="search-box nav-search" v-if="!isAdminRoute">
<input type="text" class="form-control" placeholder="Search notes..." v-model="searchQuery" @keyup.enter="performSearch" />
<input type="text" class="form-control" placeholder="Search notes & task lists..." v-model="searchQuery" @keyup.enter="performSearch" />
</div>
<!-- Theme Toggle -->
@@ -98,6 +98,7 @@
:on-add-subcategory="openCreateSubcategoryModal"
:on-edit-category="openEditCategoryModal"
:on-delete-category="removeCategory"
:on-select-task-list="selectTaskList"
:can-create-categories="canCreateCategories"
:can-edit-categories="canEditCategories"
:can-delete-categories="canDeleteCategories"
@@ -128,9 +129,36 @@
</h5>
</div>
<div class="col-auto d-flex align-items-center">
<div class="btn-group me-2" role="group" aria-label="Workspace mode">
<button type="button" class="btn action-button" :class="activeView === 'notes' ? 'btn-secondary' : 'btn-outline-secondary'" @click="activeView = 'notes'">Notes</button>
<button type="button" class="btn action-button" :class="activeView === 'tasks' ? 'btn-secondary' : 'btn-outline-secondary'" @click="activeView = 'tasks'">Tasks</button>
<div
v-if="(activeView === 'notes' || activeView === 'tasks') && (canCreateNotes || canCreateTasks)"
ref="createDropdownRef"
class="dropdown me-2"
@mouseleave="showCreateMenu = false"
>
<button class="btn btn-primary dropdown-toggle action-button" type="button" aria-label="Create" title="Create" @click="toggleCreateMenu">
<i class="mdi mdi-plus-circle-outline me-1" aria-hidden="true"></i>
<span class="action-label">Create</span>
</button>
<ul class="dropdown-menu dropdown-menu-end" :class="{ show: showCreateMenu }">
<li v-if="activeView === 'notes' && canCreateNotes">
<button class="dropdown-item" type="button" @click="openCreateNoteModalFromMenu">
<i class="mdi mdi-note-plus-outline me-1" aria-hidden="true"></i>
New Note
</button>
</li>
<li v-if="activeView === 'notes' && canCreateTasks">
<button class="dropdown-item" type="button" @click="openCreateTaskListModalFromMenu">
<i class="mdi mdi-format-list-checkbox me-1" aria-hidden="true"></i>
New Task List
</button>
</li>
<li v-if="activeView === 'tasks' && canCreateTasks">
<button class="dropdown-item" type="button" @click="openTaskCreateModal">
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
New Task
</button>
</li>
</ul>
</div>
<div v-if="activeView === 'notes' && (!selectedNote || isSearchRoute)" class="btn-group me-2 d-none d-md-flex" role="group" aria-label="View mode">
<button
@@ -174,14 +202,6 @@
<i class="mdi mdi-share-variant-outline me-1" aria-hidden="true"></i>
<span class="action-label">{{ shareCopied ? "Copied" : "Share" }}</span>
</button>
<button v-if="activeView === 'notes' && canCreateNotes" class="btn btn-primary action-button" aria-label="New note" title="New note" @click="showCreateNoteModal = true">
<i class="mdi mdi-note-plus-outline me-1" aria-hidden="true"></i>
<span class="action-label">New Note</span>
</button>
<button v-if="activeView === 'tasks' && canCreateTasks" class="btn btn-primary action-button" aria-label="New task" title="New task" @click="openTaskCreateModal">
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
<span class="action-label">New Task</span>
</button>
</div>
</div>
</div>
@@ -192,8 +212,6 @@
v-if="activeView === 'tasks'"
:tasks="tasks"
:statuses="taskStatuses"
:category-options="categoryOptions"
@create-task="openTaskCreateModal"
@select-task="openTaskDetail"
@filter-change="applyTaskFilters"
@reorder-status="reorderTaskStatuses"
@@ -204,12 +222,13 @@
/>
<SearchResultsPage
v-else-if="isSearchRoute"
:notes="searchResults"
:items="searchItems"
:query="searchQuery"
:current-page="searchPage"
:page-size="searchPageSize"
:view-mode="noteViewMode"
@select-note="selectSearchResultNote"
@select-task-list="selectTaskList"
@page-change="setSearchPage"
/>
<NoteEditor
@@ -231,13 +250,14 @@
:linked-tasks="linkedTasksForSelectedNote"
@open-linked-task="openLinkedTaskFromNote"
/>
<NoteList
<WorkspaceList
v-else
:notes="displayedNotes"
:items="displayedItems"
:can-load-more="canLoadMoreMainNotes"
:is-loading-more="spaceStore.notesLoading"
:view-mode="noteViewMode"
@select-note="selectNote"
@select-task-list="selectTaskList"
@load-more="loadMoreMainNotes"
/>
</div>
@@ -279,6 +299,13 @@
@submit="submitCategory"
/>
<CreateNoteModal v-if="showCreateNoteModal" :category-options="categoryOptions" :default-category-id="selectedCategory?.id || null" @close="showCreateNoteModal = false" @create="createNote" />
<CreateTaskListModal
v-if="showCreateTaskListModal"
:category-options="categoryOptions"
:default-category-id="selectedCategory?.id || null"
@close="showCreateTaskListModal = false"
@create="createTaskList"
/>
<SpaceSettingsModal
v-if="showSpaceSettingsModal && currentSpace && canManageSpaceSettings"
:space="currentSpace"
@@ -290,7 +317,6 @@
v-if="showTaskModal"
:task="taskModalDraft || {}"
:statuses="taskStatuses"
:category-options="categoryOptions"
:parent-task-options="taskParentOptions"
:subtasks="taskDetail?.subtasks || []"
@close="showTaskModal = false"
@@ -342,11 +368,12 @@ import { useSpaceStore } from "./stores/spaceStore";
import CategoryTree from "./components/CategoryTree.vue";
import NoteEditor from "./components/NoteEditor.vue";
import NoteViewer from "./components/NoteViewer.vue";
import NoteList from "./components/NoteList.vue";
import WorkspaceList from "./components/WorkspaceList.vue";
import SearchResultsPage from "./components/SearchResultsPage.vue";
import CreateSpaceModal from "./components/CreateSpaceModal.vue";
import CreateCategoryModal from "./components/CreateCategoryModal.vue";
import CreateNoteModal from "./components/CreateNoteModal.vue";
import CreateTaskListModal from "./components/CreateTaskListModal.vue";
import SpaceSettingsModal from "./components/SpaceSettingsModal.vue";
import TaskBoard from "./components/TaskBoard.vue";
import TaskDetailModal from "./components/TaskDetailModal.vue";
@@ -363,12 +390,14 @@ const showUserMenu = ref(false);
const showCreateSpaceModal = ref(false);
const showCreateCategoryModal = ref(false);
const showCreateNoteModal = ref(false);
const showCreateTaskListModal = ref(false);
const showSpaceSettingsModal = ref(false);
const showSidebar = ref(false);
const navbarRef = ref(null);
const navbarHeight = ref(56);
const spaceDropdownRef = ref(null);
const userDropdownRef = ref(null);
const createDropdownRef = ref(null);
const searchQuery = ref("");
const selectedNote = ref(null);
const selectedCategory = ref(null);
@@ -393,14 +422,16 @@ const unlockError = ref("");
const unlockingNote = ref(false);
const activeView = ref("notes");
const taskFilters = ref({
categoryId: null,
taskListId: null,
statusId: null,
parentTaskId: null,
});
const selectedTaskList = ref(null);
const showTaskModal = ref(false);
const taskDetail = ref(null);
const taskModalDraft = ref(null);
const linkedTasksForSelectedNote = ref([]);
const showCreateMenu = ref(false);
const currentUser = computed(() => authStore.user);
const isAdminRoute = computed(() => route.path === "/admin");
@@ -410,6 +441,19 @@ const isAuthRoute = computed(() => route.path === "/login" || route.path === "/r
const spaces = computed(() => spaceStore.spaces);
const currentSpace = computed(() => spaceStore.currentSpace);
const searchResults = computed(() => sortNotesByPriority(spaceStore.searchResults));
const searchItems = computed(() => {
const query = searchQuery.value.toLowerCase();
const noteItems = searchResults.value.map((note) => ({
...note,
kind: "note",
}));
const matchingTaskLists = (spaceStore.taskLists || []).filter((tl) => tl.name.toLowerCase().includes(query));
const taskListItems = matchingTaskLists.map((tl) => ({
...tl,
kind: "task-list",
}));
return [...taskListItems, ...noteItems];
});
const searchPageSize = 12;
const searchPage = computed(() => {
const pageValue = Number.parseInt(route.query.page || "1", 10);
@@ -469,13 +513,46 @@ const collectNotesFromCategory = (category, bucket = []) => {
return bucket;
};
const collectTaskListsFromCategory = (category, bucket = []) => {
if (!category) {
return bucket;
}
if (Array.isArray(category.task_lists) && category.task_lists.length) {
bucket.push(...category.task_lists);
}
for (const subcategory of category.subcategories || []) {
collectTaskListsFromCategory(subcategory, bucket);
}
return bucket;
};
const displayedNotes = computed(() => {
if (!selectedCategory.value) {
return sortNotesByPriority(spaceStore.notes);
}
return sortNotesByPriority(collectNotesFromCategory(selectedCategory.value, []));
});
const displayedTaskLists = computed(() => {
if (!selectedCategory.value) {
return [...(spaceStore.taskLists || [])].sort((left, right) => left.name.localeCompare(right.name));
}
return [...collectTaskListsFromCategory(selectedCategory.value, [])].sort((left, right) => left.name.localeCompare(right.name));
});
const displayedItems = computed(() => {
const taskListItems = displayedTaskLists.value.map((taskList) => ({
...taskList,
kind: "task-list",
}));
const noteItems = displayedNotes.value.map((note) => ({
...note,
kind: "note",
}));
return [...taskListItems, ...noteItems];
});
const tasks = computed(() => spaceStore.tasks || []);
const taskLists = computed(() => spaceStore.taskLists || []);
const taskStatuses = computed(() => spaceStore.taskStatuses || []);
const initialTaskStatusId = computed(() => {
if (!taskStatuses.value.length) {
@@ -518,6 +595,13 @@ const openSpaceHome = () => {
unlockPassword.value = "";
unlockError.value = "";
searchQuery.value = "";
selectedTaskList.value = null;
activeView.value = "notes";
taskFilters.value = {
taskListId: null,
statusId: null,
parentTaskId: null,
};
spaceStore.clearSearchResults();
if (route.path !== "/") {
router.push("/");
@@ -580,6 +664,27 @@ const breadcrumbItems = computed(() => {
return items;
}
if (activeView.value === "tasks" && selectedTaskList.value) {
const taskListCategoryId = selectedTaskList.value.category_id;
if (taskListCategoryId) {
const categoryTrail = findCategoryPath(categoryTree.value, taskListCategoryId) || [];
for (const category of categoryTrail) {
items.push({
label: category.name,
clickable: true,
onClick: () => openCategoryFromBreadcrumb(category),
});
}
}
items.push({
label: selectedTaskList.value.name,
clickable: false,
onClick: null,
});
return items;
}
if (selectedCategory.value) {
const categoryTrail = findCategoryPath(categoryTree.value, selectedCategory.value.id) || [selectedCategory.value];
for (let i = 0; i < categoryTrail.length; i++) {
@@ -710,6 +815,14 @@ const toggleUserMenu = () => {
}
};
const toggleCreateMenu = () => {
showCreateMenu.value = !showCreateMenu.value;
if (showCreateMenu.value) {
showSpaceDropdown.value = false;
showUserMenu.value = false;
}
};
const handleDocumentClick = (event) => {
const target = event.target;
@@ -720,6 +833,10 @@ const handleDocumentClick = (event) => {
if (showUserMenu.value && userDropdownRef.value && !userDropdownRef.value.contains(target)) {
showUserMenu.value = false;
}
if (showCreateMenu.value && createDropdownRef.value && !createDropdownRef.value.contains(target)) {
showCreateMenu.value = false;
}
};
const handleEscapeKey = (event) => {
@@ -728,6 +845,7 @@ const handleEscapeKey = (event) => {
}
showSpaceDropdown.value = false;
showUserMenu.value = false;
showCreateMenu.value = false;
showSidebar.value = false;
};
@@ -737,7 +855,9 @@ const selectSpace = async (space) => {
localStorage.setItem("currentSpaceId", space.id);
selectedNote.value = null;
selectedCategory.value = null;
selectedTaskList.value = null;
isEditingNote.value = false;
activeView.value = "notes";
linkedTasksForSelectedNote.value = [];
await applyTaskFilters(taskFilters.value);
};
@@ -782,8 +902,13 @@ const selectNote = async (note) => {
showUnlockModal.value = true;
selectedNote.value = null;
selectedCategory.value = null;
selectedTaskList.value = null;
isEditingNote.value = false;
activeView.value = "notes";
showSidebar.value = false;
if (route.path === "/search") {
router.push("/");
}
return;
}
@@ -795,8 +920,13 @@ const selectNote = async (note) => {
const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/${note.id}`);
selectedNote.value = response.data;
selectedCategory.value = null;
selectedTaskList.value = null;
isEditingNote.value = false;
activeView.value = "notes";
showSidebar.value = false;
if (route.path === "/search") {
router.push("/");
}
} catch {
alert("Unable to load note content.");
}
@@ -828,7 +958,9 @@ const unlockProtectedNote = async () => {
});
selectedNote.value = response.data;
selectedCategory.value = null;
selectedTaskList.value = null;
isEditingNote.value = false;
activeView.value = "notes";
showSidebar.value = false;
closeUnlockModal();
} catch (error) {
@@ -840,11 +972,31 @@ const unlockProtectedNote = async () => {
const selectCategory = (category) => {
selectedCategory.value = category;
selectedTaskList.value = null;
selectedNote.value = null;
isEditingNote.value = false;
activeView.value = "notes";
showSidebar.value = false;
};
const selectTaskList = async (taskList) => {
selectedTaskList.value = taskList;
selectedCategory.value = null;
selectedNote.value = null;
isEditingNote.value = false;
activeView.value = "tasks";
showSidebar.value = false;
if (route.path === "/search") {
router.push("/");
}
await applyTaskFilters({
...taskFilters.value,
taskListId: taskList?.id || null,
});
};
const performSearch = async () => {
const q = searchQuery.value.trim();
if (!q) {
@@ -903,22 +1055,30 @@ const loadMoreMainNotes = async () => {
};
const applyTaskFilters = async (filters) => {
taskFilters.value = filters;
taskFilters.value = {
...taskFilters.value,
...filters,
taskListId: selectedTaskList.value?.id || taskFilters.value.taskListId || null,
};
if (!currentSpace.value?.id) {
return;
}
await spaceStore.fetchTasks(currentSpace.value.id, filters);
await spaceStore.fetchTasks(currentSpace.value.id, taskFilters.value);
};
const openTaskCreateModal = () => {
if (!canCreateTasks.value) {
return;
}
if (!selectedTaskList.value?.id) {
alert("Select a task list first.");
return;
}
taskDetail.value = null;
taskModalDraft.value = {
title: "",
description: "",
category_id: selectedCategory.value?.id || null,
task_list_id: selectedTaskList.value?.id || null,
status_id: initialTaskStatusId.value,
parent_task_id: null,
note_links: selectedNote.value?.id ? [selectedNote.value.id] : [],
@@ -1010,7 +1170,7 @@ const createSubtask = (parentTask) => {
taskModalDraft.value = {
title: "",
description: "",
category_id: parentTask.category_id || null,
task_list_id: parentTask.task_list_id || selectedTaskList.value?.id || null,
status_id: initialTaskStatusId.value,
parent_task_id: parentTask.id,
note_links: selectedNote.value?.id ? [selectedNote.value.id] : [],
@@ -1102,10 +1262,38 @@ const updateTaskStatusFromBoard = async ({ taskId, currentStatusId, targetStatus
};
const openLinkedTaskFromNote = async (task) => {
selectedTaskList.value = taskLists.value.find((list) => list.id === task.task_list_id) || null;
selectedNote.value = null;
await applyTaskFilters({ taskListId: selectedTaskList.value?.id || null });
activeView.value = "tasks";
await openTaskDetail(task);
};
const openCreateNoteModalFromMenu = () => {
showCreateMenu.value = false;
showCreateNoteModal.value = true;
};
const openCreateTaskListModalFromMenu = () => {
showCreateMenu.value = false;
showCreateTaskListModal.value = true;
};
const createTaskList = async (taskListData) => {
if (!currentSpace.value?.id || !canCreateTasks.value) {
showCreateTaskListModal.value = false;
return;
}
try {
const created = await spaceStore.createTaskList(currentSpace.value.id, taskListData);
showCreateTaskListModal.value = false;
await selectTaskList(created);
} catch (error) {
alert(error?.response?.data || "Unable to create task list.");
}
};
const createSpace = async (spaceData) => {
showCreateSpaceModal.value = false;
await spaceStore.createSpace(spaceData);
@@ -1259,6 +1447,3 @@ const logout = () => {
</script>
<style scoped src="./assets/styles/scoped/App.css"></style>