feat: Created task lists that work in categories
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m20s
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m20s
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user