Compare commits
6 Commits
b6e36fdf00
...
9bb9041580
Author | SHA1 | Date |
---|---|---|
Brieuc Dubois | 9bb9041580 | |
Brieuc Dubois | 2092125fc5 | |
Brieuc Dubois | ca3811a48b | |
Brieuc Dubois | c725d5154e | |
Brieuc Dubois | bb479c45ef | |
Brieuc Dubois | 7404aed139 |
|
@ -0,0 +1,76 @@
|
|||
package db
|
||||
|
||||
import "git.bhasher.com/bhasher/focus/backend/types"
|
||||
|
||||
func CreateFilter(filter types.Filter) (int, error) {
|
||||
res, err := db.Exec("INSERT INTO filters (view_id, tag_id, filter_type, option_id) VALUES (?, ?, ?, ?)", filter.ViewID, filter.TagID, filter.FilterType, filter.OptionID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return int(id), nil
|
||||
}
|
||||
|
||||
func GetViewFilters(viewID int) ([]types.Filter, error) {
|
||||
rows, err := db.Query("SELECT * FROM filters WHERE view_id = ?", viewID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var filters []types.Filter
|
||||
for rows.Next() {
|
||||
var f types.Filter
|
||||
if err := rows.Scan(&f.ID, &f.ViewID, &f.TagID, &f.FilterType, &f.OptionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filters = append(filters, f)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
func GetFilter(id int) (*types.Filter, error) {
|
||||
rows, err := db.Query("SELECT * FROM filters WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var f types.Filter
|
||||
rows.Scan(&f.ID, &f.ViewID, &f.TagID, &f.FilterType, &f.OptionID)
|
||||
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func UpdateFilter(f types.Filter) (int64, error) {
|
||||
res, err := db.Exec("UPDATE filters SET view_id = ?, tag_id = ?, filter_type = ?, option_id = ? WHERE id = ?", f.ViewID, f.TagID, f.FilterType, f.OptionID, f.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func DeleteFilter(id int) (int64, error) {
|
||||
res, err := db.Exec("DELETE FROM filters WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return res.RowsAffected()
|
||||
}
|
|
@ -67,6 +67,17 @@ func InitDB(driver string, connStr string) error {
|
|||
FOREIGN KEY(secondary_tag_id) REFERENCES tags(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS filters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
view_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
filter_type INTEGER NOT NULL,
|
||||
option_id INTEGER,
|
||||
FOREIGN KEY(view_id) REFERENCES views(id),
|
||||
FOREIGN KEY(tag_id) REFERENCES tags(id),
|
||||
FOREIGN KEY(option_id) REFERENCES tagsoptions(id)
|
||||
);
|
||||
|
||||
INSERT INTO schema_version (version)
|
||||
SELECT ? WHERE NOT EXISTS (SELECT 1 FROM schema_version);
|
||||
`, DB_VERSION)
|
||||
|
|
|
@ -16,20 +16,26 @@ func CreateView(v types.View) (int, error) {
|
|||
return int(id), nil
|
||||
}
|
||||
|
||||
func GetProjectViews(projectID int) ([]types.View, error) {
|
||||
func GetProjectViews(projectID int) ([]types.FullView, error) {
|
||||
rows, err := db.Query("SELECT * FROM views WHERE project_id = ?", projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var views []types.View
|
||||
var views []types.FullView
|
||||
for rows.Next() {
|
||||
var v types.View
|
||||
var v types.FullView
|
||||
if err := rows.Scan(&v.ID, &v.ProjectID, &v.PrimaryTagID, &v.SecondaryTagID, &v.Title, &v.SortTagID, &v.SortDirection); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filters, err := GetViewFilters(v.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Filters = filters
|
||||
|
||||
views = append(views, v)
|
||||
}
|
||||
|
||||
|
@ -40,7 +46,7 @@ func GetProjectViews(projectID int) ([]types.View, error) {
|
|||
return views, nil
|
||||
}
|
||||
|
||||
func GetView(id int) (*types.View, error) {
|
||||
func GetView(id int) (*types.FullView, error) {
|
||||
rows, err := db.Query("SELECT * FROM views WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -51,9 +57,15 @@ func GetView(id int) (*types.View, error) {
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
var v types.View
|
||||
var v types.FullView
|
||||
rows.Scan(&v.ID, &v.ProjectID, v.PrimaryTagID, v.SecondaryTagID, v.Title, v.SortTagID, v.SortDirection)
|
||||
|
||||
filters, err := GetViewFilters(id)
|
||||
if(err != nil) {
|
||||
return nil, err
|
||||
}
|
||||
v.Filters = filters
|
||||
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
|
@ -74,3 +86,13 @@ func DeleteView(id int) (int64, error) {
|
|||
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func ExistView(id int) (bool, error) {
|
||||
var count int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM views WHERE id = ?", id).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,131 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"git.bhasher.com/bhasher/focus/backend/db"
|
||||
"git.bhasher.com/bhasher/focus/backend/types"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func filtersRouter(router fiber.Router) error {
|
||||
router.Post("/", CreateFilter)
|
||||
router.Get("/:id", GetFilter)
|
||||
router.Put("/:id", UpdateFilter)
|
||||
router.Delete("/:id", DeleteFilter)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateFilter(c *fiber.Ctx) error {
|
||||
filter := types.Filter{}
|
||||
if err := c.BodyParser(&filter); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Cannot parse request",
|
||||
"trace": fmt.Sprint(err),
|
||||
})
|
||||
}
|
||||
|
||||
exist, err := db.ExistView(filter.ViewID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Error finding view",
|
||||
"trace": fmt.Sprint(err),
|
||||
})
|
||||
}
|
||||
if !exist {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "View not found"})
|
||||
}
|
||||
|
||||
id, err := db.CreateFilter(filter)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Cannot create filter",
|
||||
"trace": fmt.Sprint(err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"id": id,
|
||||
})
|
||||
}
|
||||
|
||||
func GetFilter(c *fiber.Ctx) error {
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid filter ID"})
|
||||
}
|
||||
|
||||
filter, err := db.GetFilter(id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Cannot retrieve filter",
|
||||
"trace": fmt.Sprint(err),
|
||||
})
|
||||
}
|
||||
if filter == nil {
|
||||
return c.SendStatus(fiber.StatusNotFound)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(filter)
|
||||
}
|
||||
|
||||
func UpdateFilter(c *fiber.Ctx) error {
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid filter ID"})
|
||||
}
|
||||
|
||||
filter := types.Filter{ID: id}
|
||||
if err := c.BodyParser(&filter); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Cannot parse request",
|
||||
"trace": fmt.Sprint(err),
|
||||
})
|
||||
}
|
||||
exist, err := db.ExistView(filter.ViewID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Error finding view",
|
||||
"trace": fmt.Sprint(err),
|
||||
})
|
||||
}
|
||||
if !exist {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "View not found"})
|
||||
}
|
||||
|
||||
count, err := db.UpdateFilter(filter)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Cannot update filter",
|
||||
"trace": fmt.Sprint(err),
|
||||
})
|
||||
}
|
||||
if count == 0 {
|
||||
return c.SendStatus(fiber.StatusNotFound)
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
func DeleteFilter(c *fiber.Ctx) error {
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid filter ID"})
|
||||
}
|
||||
|
||||
count, err := db.DeleteFilter(id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Cannot delete filter",
|
||||
"trace": fmt.Sprint(err),
|
||||
})
|
||||
}
|
||||
if count == 0 {
|
||||
return c.SendStatus(fiber.StatusNotFound)
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
|
@ -11,6 +11,7 @@ func v1Router(router fiber.Router) error {
|
|||
cardsRouter(router.Group("/cards"))
|
||||
tagsRouter(router.Group("/tags"))
|
||||
viewsRouter(router.Group("/views"))
|
||||
filtersRouter(router.Group("/filters"))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ func viewsRouter(router fiber.Router) error {
|
|||
router.Get("/:id", GetView)
|
||||
router.Put("/:id", UpdateView)
|
||||
router.Delete("/:id", DeleteView)
|
||||
|
||||
router.Get("/:id/filters", GetView)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -108,3 +108,20 @@ func DeleteView(c *fiber.Ctx) error {
|
|||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
func GetViewFilters(c *fiber.Ctx) error {
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid view ID"})
|
||||
}
|
||||
|
||||
filters, err := db.GetViewFilters(id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Cannot retrieve view filters",
|
||||
"trace": fmt.Sprint(err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(filters)
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package types
|
||||
|
||||
type Filter struct {
|
||||
ID int `json:"id"`
|
||||
ViewID int `json:"view_id"`
|
||||
TagID int `json:"tag_id"`
|
||||
FilterType int `json:"filter_type"`
|
||||
OptionID int `json:"option_id"`
|
||||
}
|
|
@ -9,3 +9,14 @@ type View struct {
|
|||
SortTagID *int `json:"sort_tag_id"`
|
||||
SortDirection *int `json:"sort_direction"`
|
||||
}
|
||||
|
||||
type FullView struct {
|
||||
ID int `json:"id"`
|
||||
ProjectID int `json:"project_id"`
|
||||
Title string `json:"title"`
|
||||
PrimaryTagID *int `json:"primary_tag_id"`
|
||||
SecondaryTagID *int `json:"secondary_tag_id"`
|
||||
SortTagID *int `json:"sort_tag_id"`
|
||||
SortDirection *int `json:"sort_direction"`
|
||||
Filters []Filter `json:"filters"`
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import api, { processError } from '$lib/utils/api';
|
||||
import status from '$lib/utils/status';
|
||||
|
||||
async function create(
|
||||
viewId: number,
|
||||
projectTagId: number,
|
||||
filterType: number,
|
||||
tagOptionId: number | null
|
||||
): Promise<number | null> {
|
||||
const response = await api.post(`/v1/filters`, {
|
||||
view_id: viewId,
|
||||
tag_id: projectTagId,
|
||||
filter_type: filterType,
|
||||
option_id: tagOptionId
|
||||
});
|
||||
|
||||
if (response.status !== status.Created) {
|
||||
processError(response, 'Failed to create filter');
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.data.id;
|
||||
}
|
||||
|
||||
async function update(
|
||||
filterId: number,
|
||||
viewId: number,
|
||||
projectTagId: number,
|
||||
filterType: number,
|
||||
tagOptionId: number | null
|
||||
): Promise<boolean> {
|
||||
const response = await api.put(`/v1/filters/${filterId}`, {
|
||||
view_id: viewId,
|
||||
tag_id: projectTagId,
|
||||
filter_type: filterType,
|
||||
option_id: tagOptionId
|
||||
});
|
||||
|
||||
if (response.status !== status.NoContent) {
|
||||
processError(response, 'Failed to update filter');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function delete_(filterId: number): Promise<boolean> {
|
||||
const response = await api.delete(`/v1/filters/${filterId}`);
|
||||
|
||||
if (response.status !== status.NoContent) {
|
||||
processError(response, 'Failed to delete filter');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default {
|
||||
create,
|
||||
update,
|
||||
delete: delete_
|
||||
};
|
|
@ -1,5 +1,5 @@
|
|||
import Filter from '$lib/types/Filter';
|
||||
import type Project from '$lib/types/Project';
|
||||
import type View from '$lib/types/View';
|
||||
import api, { processError } from '$lib/utils/api';
|
||||
import status from '$lib/utils/status';
|
||||
|
||||
|
@ -53,8 +53,20 @@ async function delete_(viewId: number): Promise<boolean> {
|
|||
return true;
|
||||
}
|
||||
|
||||
async function getFilters(viewId: number): Promise<Filter[]> {
|
||||
const response = await api.get(`/v1/views/${viewId}/filters`);
|
||||
|
||||
if (response.status !== status.OK) {
|
||||
processError(response, 'Failed to get view filters');
|
||||
return [];
|
||||
}
|
||||
|
||||
return Filter.parseAll(response.data);
|
||||
}
|
||||
|
||||
export default {
|
||||
create,
|
||||
update,
|
||||
delete: delete_
|
||||
delete: delete_,
|
||||
getFilters
|
||||
};
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import currentView from '$lib/stores/currentView';
|
||||
import type Filter from '$lib/types/Filter';
|
||||
import FilterMenuItem from './FilterMenuItem.svelte';
|
||||
import Menu from './Menu.svelte';
|
||||
|
||||
export let isOpen = false;
|
||||
</script>
|
||||
|
||||
{#if $currentView}
|
||||
<Menu bind:isOpen>
|
||||
{#each $currentView.filters as filter}
|
||||
<FilterMenuItem {filter} />
|
||||
{/each}
|
||||
<FilterMenuItem />
|
||||
</Menu>
|
||||
{/if}
|
||||
|
||||
<style lang="less">
|
||||
</style>
|
|
@ -0,0 +1,190 @@
|
|||
<script lang="ts">
|
||||
import currentView from '$lib/stores/currentView';
|
||||
import Filter from '$lib/types/Filter';
|
||||
import ProjectTag, { projectTags } from '$lib/types/ProjectTag';
|
||||
import type TagOption from '$lib/types/TagOption';
|
||||
import TrashIcon from '../icons/TrashIcon.svelte';
|
||||
import Menu from './Menu.svelte';
|
||||
|
||||
export let filter: Filter | null = null;
|
||||
|
||||
let isProjectTagOpen = false;
|
||||
let isFilterTypeOpen = false;
|
||||
let isOptionOpen = false;
|
||||
|
||||
async function selectProjectTag(projectTag: ProjectTag) {
|
||||
if (!$currentView) return;
|
||||
if (!filter) {
|
||||
await $currentView?.addFilter(projectTag, 0, null);
|
||||
currentView.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
if (filter.projectTag.id !== projectTag.id) {
|
||||
const res = await filter.setProjectTag(projectTag);
|
||||
if (!res) return;
|
||||
currentView.reload();
|
||||
}
|
||||
isProjectTagOpen = false;
|
||||
}
|
||||
|
||||
async function selectFilterType(filterType: number) {
|
||||
if (!filter) return;
|
||||
|
||||
if (filter.filterType !== filterType) {
|
||||
const res = await filter.setFilterType(filterType);
|
||||
if (!res) return;
|
||||
currentView.reload();
|
||||
}
|
||||
isFilterTypeOpen = false;
|
||||
}
|
||||
|
||||
async function selectOption(option: TagOption) {
|
||||
if (!filter) return;
|
||||
|
||||
if (filter.tagOption !== option) {
|
||||
const res = await filter.setTagOption(option);
|
||||
if (!res) return;
|
||||
currentView.reload();
|
||||
}
|
||||
isOptionOpen = false;
|
||||
}
|
||||
|
||||
async function deleteFilter() {
|
||||
if (!filter) return;
|
||||
|
||||
const res = await $currentView?.removeFilter(filter);
|
||||
if (!res) return;
|
||||
currentView.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="item">
|
||||
<div>
|
||||
<div
|
||||
class="part"
|
||||
on:click={() => (isProjectTagOpen = !isProjectTagOpen)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') isProjectTagOpen = !isProjectTagOpen;
|
||||
}}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
{#if filter}
|
||||
{filter.projectTag.title}
|
||||
{/if}
|
||||
</div>
|
||||
<Menu bind:isOpen={isProjectTagOpen}>
|
||||
{#each $projectTags as projectTag}
|
||||
<button on:click={() => selectProjectTag(projectTag)}>
|
||||
{projectTag.title}
|
||||
</button>
|
||||
{/each}
|
||||
</Menu>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="part"
|
||||
on:click={() => (isFilterTypeOpen = !isFilterTypeOpen)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') isFilterTypeOpen = !isFilterTypeOpen;
|
||||
}}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
{#if filter}
|
||||
{#if filter.filterType === 0}
|
||||
is
|
||||
{:else if filter.filterType === 1}
|
||||
is not
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if filter}
|
||||
<Menu bind:isOpen={isFilterTypeOpen}>
|
||||
<button on:click={() => selectFilterType(0)}> is </button>
|
||||
<button
|
||||
on:click={() => {
|
||||
selectFilterType(1);
|
||||
isFilterTypeOpen = false;
|
||||
}}
|
||||
>
|
||||
is not
|
||||
</button>
|
||||
</Menu>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="part"
|
||||
on:click={() => (isOptionOpen = !isOptionOpen)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') isOptionOpen = !isOptionOpen;
|
||||
}}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
{#if filter && filter.tagOption}
|
||||
{filter.tagOption.value}
|
||||
{/if}
|
||||
</div>
|
||||
{#if filter && filter.projectTag}
|
||||
<Menu bind:isOpen={isOptionOpen}>
|
||||
{#each filter.projectTag.options as option}
|
||||
<button on:click={() => selectOption(option)}>
|
||||
{option.value}
|
||||
</button>
|
||||
{/each}
|
||||
</Menu>
|
||||
{/if}
|
||||
</div>
|
||||
{#if filter}
|
||||
<div
|
||||
class="delete"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
on:click={() => deleteFilter()}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') deleteFilter();
|
||||
}}
|
||||
>
|
||||
<TrashIcon size={20} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="less">
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.part {
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.part,
|
||||
.delete {
|
||||
height: 30px;
|
||||
margin: 5px;
|
||||
text-align: center;
|
||||
line-height: 30px;
|
||||
padding: 0 2px;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff2;
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.delete {
|
||||
line-height: 35px;
|
||||
}
|
||||
|
||||
button {
|
||||
min-width: 100px;
|
||||
text-align: left;
|
||||
padding: 5px;
|
||||
margin: 2px 5px;
|
||||
}
|
||||
</style>
|
|
@ -3,9 +3,10 @@
|
|||
import CardComponent from '../card/Card.svelte';
|
||||
import AddIcon from '../icons/AddIcon.svelte';
|
||||
import type TagOption from '$lib/types/TagOption';
|
||||
import type ProjectTag from '$lib/types/ProjectTag';
|
||||
import ProjectTag, { projectTags } from '$lib/types/ProjectTag';
|
||||
import type Project from '$lib/types/Project';
|
||||
import currentDraggedCard from '$lib/stores/currentDraggedCard';
|
||||
import currentView from '$lib/stores/currentView';
|
||||
|
||||
export let project: Project;
|
||||
export let option: TagOption | null = null;
|
||||
|
@ -43,10 +44,22 @@
|
|||
const card = await Card.create(project);
|
||||
|
||||
if (!card) return;
|
||||
if (!primaryTag) return;
|
||||
if (!option) return;
|
||||
|
||||
await card.addTag(primaryTag, option, null);
|
||||
if ($currentView?.filters && $currentView.filters.length > 0) {
|
||||
for (const projectTag of $projectTags) {
|
||||
for (const filter of $currentView.filters) {
|
||||
if (projectTag !== filter.projectTag) continue;
|
||||
if (!filter.tagOption) continue;
|
||||
if (filter.filterType !== 0) continue;
|
||||
|
||||
if (await card.addTag(projectTag, filter.tagOption, null)) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (primaryTag && option) {
|
||||
await card.addTag(primaryTag, option, null);
|
||||
}
|
||||
|
||||
cards.reload();
|
||||
}
|
||||
|
|
|
@ -2,15 +2,17 @@
|
|||
import GroupMenu from '$lib/components/menu/GroupMenu.svelte';
|
||||
import SortMenu from '$lib/components/menu/SortMenu.svelte';
|
||||
import currentView from '$lib/stores/currentView';
|
||||
import Card from '$lib/types/Card';
|
||||
import Card, { cards } from '$lib/types/Card';
|
||||
import type Project from '$lib/types/Project';
|
||||
import type ProjectTag from '$lib/types/ProjectTag';
|
||||
import { projectTags } from '$lib/types/ProjectTag';
|
||||
import { get } from 'svelte/store';
|
||||
import FilterMenu from '../menu/FilterMenu.svelte';
|
||||
|
||||
export let project: Project;
|
||||
let groupMenuOpen = false;
|
||||
let sortMenuOpen = false;
|
||||
let filterMenuOpen = false;
|
||||
|
||||
async function setGroup(projectTag: ProjectTag): Promise<boolean> {
|
||||
const view = get(currentView);
|
||||
|
@ -33,6 +35,26 @@
|
|||
|
||||
return res;
|
||||
}
|
||||
|
||||
async function addCard() {
|
||||
const card = await Card.create(project);
|
||||
|
||||
if (!card) return;
|
||||
|
||||
if ($currentView?.filters && $currentView.filters.length > 0) {
|
||||
for (const projectTag of $projectTags) {
|
||||
for (const filter of $currentView.filters) {
|
||||
if (projectTag !== filter.projectTag) continue;
|
||||
if (!filter.tagOption) continue;
|
||||
if (filter.filterType !== 0) continue;
|
||||
|
||||
if (await card.addTag(projectTag, filter.tagOption, null)) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cards.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<header>
|
||||
|
@ -54,7 +76,15 @@
|
|||
/>
|
||||
</div>
|
||||
<button class:disabled={true}>Sub-group</button>
|
||||
<button class:disabled={true}>Filter</button>
|
||||
<div>
|
||||
<button
|
||||
on:click={() => (filterMenuOpen = !filterMenuOpen)}
|
||||
class:defined={$currentView?.filters && $currentView?.filters.length > 0}
|
||||
>
|
||||
Filter
|
||||
</button>
|
||||
<FilterMenu bind:isOpen={filterMenuOpen} />
|
||||
</div>
|
||||
<div>
|
||||
<button on:click={() => (sortMenuOpen = !sortMenuOpen)} class:defined={$currentView?.sortTag}>
|
||||
Sort
|
||||
|
@ -70,7 +100,7 @@
|
|||
currentDirection={$currentView?.sortDirection || null}
|
||||
/>
|
||||
</div>
|
||||
<button id="newButton" on:click={async () => Card.create(project)}>New</button>
|
||||
<button id="newButton" on:click={addCard}>New</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
|
|
@ -1,57 +1,103 @@
|
|||
<script lang="ts">
|
||||
import currentView from '$lib/stores/currentView';
|
||||
import { cards } from '$lib/types/Card';
|
||||
import Card, { cards } from '$lib/types/Card';
|
||||
import type Filter from '$lib/types/Filter';
|
||||
import type Project from '$lib/types/Project';
|
||||
import type ProjectTag from '$lib/types/ProjectTag';
|
||||
import { projectTags } from '$lib/types/ProjectTag';
|
||||
import type TagOption from '$lib/types/TagOption';
|
||||
import type View from '$lib/types/View';
|
||||
import Column from './Column.svelte';
|
||||
import Header from './Header.svelte';
|
||||
|
||||
export let project: Project;
|
||||
|
||||
$: allCards = $cards;
|
||||
function cardComparator(a: Card, b: Card, sortTag: ProjectTag | null, sortDirection: number) {
|
||||
if (!sortTag) return 0;
|
||||
|
||||
const aTag = a.cardTags.find((t) => t.projectTag === sortTag);
|
||||
const bTag = b.cardTags.find((t) => t.projectTag === sortTag);
|
||||
|
||||
if (!aTag) return -sortDirection;
|
||||
if (!bTag) return sortDirection;
|
||||
|
||||
const aValue = aTag.value || aTag.option?.value || '';
|
||||
const bValue = bTag.value || bTag.option?.value || '';
|
||||
|
||||
return aValue < bValue ? sortDirection : -sortDirection;
|
||||
}
|
||||
|
||||
function passFilters(card: Card, filters: Filter[]): boolean {
|
||||
for (const projectTag of $projectTags) {
|
||||
let is: TagOption[] = [];
|
||||
|
||||
const cardTag = card.cardTags.find((t) => t.projectTag === projectTag);
|
||||
|
||||
for (const filter of filters) {
|
||||
if (projectTag !== filter.projectTag) continue;
|
||||
if (!filter.tagOption) continue;
|
||||
|
||||
if (filter.filterType === 0) {
|
||||
is.push(filter.tagOption);
|
||||
} else if (filter.filterType === 1) {
|
||||
if (cardTag?.option === filter.tagOption) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (is.length > 0) {
|
||||
if (!cardTag) return false;
|
||||
if (!is.some((o) => o === cardTag?.option)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function extractColumnCards(view: View | null, cards: Card[], tagOption: TagOption | null) {
|
||||
if (!view) return cards;
|
||||
|
||||
const filteredCards = cards.filter((c) => passFilters(c, view.filters));
|
||||
|
||||
const primaryTag = view.primaryTag;
|
||||
|
||||
if (!primaryTag) return filteredCards;
|
||||
|
||||
if (!tagOption) {
|
||||
return filteredCards.filter((c) => !c.cardTags.map((t) => t.projectTag).includes(primaryTag));
|
||||
}
|
||||
|
||||
const rightOptionCards = filteredCards.filter((c) => {
|
||||
const tag = c.cardTags.find((t) => t.projectTag === primaryTag);
|
||||
if (!tag) return false;
|
||||
return tag.option?.id === tagOption.id;
|
||||
});
|
||||
|
||||
const sortedCards = rightOptionCards.sort((a, b) =>
|
||||
cardComparator(a, b, view.sortTag, $currentView?.sortDirection || 1)
|
||||
);
|
||||
|
||||
return sortedCards;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if project}
|
||||
<section>
|
||||
{#if $currentView}
|
||||
<Header {project} />
|
||||
{#if $cards && allCards}
|
||||
{#if $cards}
|
||||
<div class="grid">
|
||||
{#if $currentView.primaryTag}
|
||||
{#each $currentView.primaryTag.options as option}
|
||||
<Column
|
||||
{option}
|
||||
primaryTag={$currentView.primaryTag}
|
||||
columnCards={allCards
|
||||
.filter((c) => c.cardTags.map((t) => t.option).includes(option))
|
||||
.sort((a, b) => {
|
||||
if (!$currentView?.sortTag) return 0;
|
||||
const aTag = a.cardTags.find((t) => t.projectTag === $currentView?.sortTag);
|
||||
const bTag = b.cardTags.find((t) => t.projectTag === $currentView?.sortTag);
|
||||
|
||||
if (!aTag) return -($currentView?.sortDirection || 1);
|
||||
if (!bTag) return $currentView?.sortDirection || 1;
|
||||
|
||||
const aValue = aTag.value || aTag.option?.value || '';
|
||||
const bValue = bTag.value || bTag.option?.value || '';
|
||||
|
||||
return aValue < bValue
|
||||
? $currentView?.sortDirection || 1
|
||||
: -($currentView?.sortDirection || 1);
|
||||
})}
|
||||
columnCards={extractColumnCards($currentView, $cards, option)}
|
||||
{project}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
<Column
|
||||
primaryTag={$currentView.primaryTag}
|
||||
columnCards={$currentView.primaryTag != null
|
||||
? (() => {
|
||||
const primaryTag = $currentView.primaryTag;
|
||||
return allCards.filter(
|
||||
(c) => !c.cardTags.map((t) => t.projectTag).includes(primaryTag)
|
||||
);
|
||||
})()
|
||||
: allCards}
|
||||
columnCards={extractColumnCards($currentView, $cards, null)}
|
||||
{project}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,154 @@
|
|||
import { writable } from 'svelte/store';
|
||||
import View from './View';
|
||||
import ProjectTag from './ProjectTag';
|
||||
import TagOption from './TagOption';
|
||||
import { toastAlert } from '$lib/utils/toasts';
|
||||
import filtersApi from '$lib/api/filtersApi';
|
||||
|
||||
export default class Filter {
|
||||
private _id: number;
|
||||
private _view: View;
|
||||
private _projectTag: ProjectTag;
|
||||
private _filterType: number;
|
||||
private _tagOption: TagOption | null;
|
||||
|
||||
private constructor(
|
||||
id: number,
|
||||
view: View,
|
||||
projectTag: ProjectTag,
|
||||
filterType: number,
|
||||
tagOption: TagOption | null
|
||||
) {
|
||||
this._id = id;
|
||||
this._view = view;
|
||||
this._projectTag = projectTag;
|
||||
this._filterType = filterType;
|
||||
this._tagOption = tagOption;
|
||||
}
|
||||
|
||||
get id(): number {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get view(): View {
|
||||
return this._view;
|
||||
}
|
||||
|
||||
get projectTag(): ProjectTag {
|
||||
return this._projectTag;
|
||||
}
|
||||
|
||||
get filterType(): number {
|
||||
return this._filterType;
|
||||
}
|
||||
|
||||
get tagOption(): TagOption | null {
|
||||
return this._tagOption;
|
||||
}
|
||||
|
||||
static async create(
|
||||
view: View,
|
||||
projectTag: ProjectTag,
|
||||
filterType: number,
|
||||
tagOption: TagOption | null
|
||||
): Promise<Filter | null> {
|
||||
const id = await filtersApi.create(view.id, projectTag.id, filterType, tagOption?.id || null);
|
||||
if (!id) return null;
|
||||
|
||||
return new Filter(id, view, projectTag, filterType, tagOption);
|
||||
}
|
||||
|
||||
async delete(): Promise<boolean> {
|
||||
return await filtersApi.delete(this.id);
|
||||
}
|
||||
|
||||
async setProjectTag(projectTag: ProjectTag): Promise<boolean> {
|
||||
const res = await filtersApi.update(
|
||||
this.id,
|
||||
this.view.id,
|
||||
projectTag.id,
|
||||
this.filterType,
|
||||
this.tagOption?.id || null
|
||||
);
|
||||
|
||||
if (!res) return false;
|
||||
|
||||
this._projectTag = projectTag;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async setFilterType(filterType: number): Promise<boolean> {
|
||||
const res = await filtersApi.update(
|
||||
this.id,
|
||||
this.view.id,
|
||||
this.projectTag.id,
|
||||
filterType,
|
||||
this.tagOption?.id || null
|
||||
);
|
||||
|
||||
if (!res) return false;
|
||||
|
||||
this._filterType = filterType;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async setTagOption(tagOption: TagOption | null): Promise<boolean> {
|
||||
const res = await filtersApi.update(
|
||||
this.id,
|
||||
this.view.id,
|
||||
this.projectTag.id,
|
||||
this.filterType,
|
||||
tagOption?.id || null
|
||||
);
|
||||
|
||||
if (!res) return false;
|
||||
|
||||
this._tagOption = tagOption;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static parseAll(json: any): Filter[];
|
||||
static parseAll(json: any, view: View | null): Filter[];
|
||||
|
||||
static parseAll(json: any[], view?: View | null): Filter[] {
|
||||
if (!json) return [];
|
||||
|
||||
const filters: Filter[] = [];
|
||||
|
||||
for (const filter of json) {
|
||||
const parsed = Filter.parse(filter, view);
|
||||
if (parsed) filters.push(parsed);
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
static parse(json: any): Filter | null;
|
||||
static parse(json: any, view: View | null | undefined): Filter | null;
|
||||
|
||||
static parse(json: any, view?: View | null | undefined): Filter | null {
|
||||
if (!json) {
|
||||
toastAlert('Failed to parse filter: json is null');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!view) view = View.fromId(json.view_id);
|
||||
if (!view) {
|
||||
toastAlert('Failed to parse filter: view not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectTag = ProjectTag.fromId(json.tag_id);
|
||||
if (!projectTag) {
|
||||
toastAlert('Failed to parse filter: projectTag not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const tagOption = projectTag.options.find((option) => option.id === json.option_id);
|
||||
|
||||
return new Filter(json.id, view, projectTag, json.filter_type, tagOption || null);
|
||||
}
|
||||
}
|
|
@ -1,8 +1,10 @@
|
|||
import { writable } from 'svelte/store';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import Project from './Project';
|
||||
import ProjectTag from './ProjectTag';
|
||||
import viewsApi from '$lib/api/viewsApi';
|
||||
import { toastAlert } from '$lib/utils/toasts';
|
||||
import Filter from './Filter';
|
||||
import type TagOption from './TagOption';
|
||||
|
||||
export const views = writable([] as View[]);
|
||||
|
||||
|
@ -14,6 +16,7 @@ export default class View {
|
|||
private _title: string;
|
||||
private _sortTag: ProjectTag | null;
|
||||
private _sortDirection: number | null;
|
||||
private _filters: Filter[];
|
||||
|
||||
private constructor(
|
||||
id: number,
|
||||
|
@ -22,7 +25,8 @@ export default class View {
|
|||
secondaryTag: ProjectTag | null,
|
||||
title: string,
|
||||
sortTag: ProjectTag | null,
|
||||
sortDirection: number | null
|
||||
sortDirection: number | null,
|
||||
filters: Filter[]
|
||||
) {
|
||||
this._id = id;
|
||||
this._project = project;
|
||||
|
@ -31,6 +35,7 @@ export default class View {
|
|||
this._title = title;
|
||||
this._sortTag = sortTag;
|
||||
this._sortDirection = sortDirection;
|
||||
this._filters = filters;
|
||||
}
|
||||
|
||||
get id(): number {
|
||||
|
@ -61,6 +66,20 @@ export default class View {
|
|||
return this._sortDirection;
|
||||
}
|
||||
|
||||
get filters(): Filter[] {
|
||||
return this._filters;
|
||||
}
|
||||
|
||||
static fromId(id: number): View | null {
|
||||
for (const view of get(views)) {
|
||||
if (view.id === id) {
|
||||
return view;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async setPrimaryTag(projectTag: ProjectTag): Promise<boolean> {
|
||||
const response = await viewsApi.update(
|
||||
this.id,
|
||||
|
@ -103,7 +122,7 @@ export default class View {
|
|||
|
||||
if (!id) return null;
|
||||
|
||||
const view = new View(id, project, null, null, 'New view', null, null);
|
||||
const view = new View(id, project, null, null, 'New view', null, null, []);
|
||||
|
||||
views.update((views) => [...views, view]);
|
||||
|
||||
|
@ -137,6 +156,27 @@ export default class View {
|
|||
return true;
|
||||
}
|
||||
|
||||
async addFilter(
|
||||
projectTag: ProjectTag,
|
||||
filterType: number,
|
||||
option: TagOption | null
|
||||
): Promise<Filter | null> {
|
||||
const filter = await Filter.create(this, projectTag, filterType, option);
|
||||
if (!filter) return null;
|
||||
|
||||
this._filters = [...this._filters, filter];
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
async removeFilter(filter: Filter): Promise<boolean> {
|
||||
if (!(await filter.delete())) return false;
|
||||
|
||||
this._filters = this._filters.filter((f) => f.id !== filter.id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static parse(json: any): View | null;
|
||||
static parse(json: any, project: Project | null | undefined): View | null;
|
||||
|
||||
|
@ -163,9 +203,12 @@ export default class View {
|
|||
secondaryTag,
|
||||
json.title,
|
||||
sortTag,
|
||||
json.sort_direction
|
||||
json.sort_direction,
|
||||
[]
|
||||
);
|
||||
|
||||
view._filters = Filter.parseAll(json.filters, view);
|
||||
|
||||
views.update((views) => {
|
||||
if (!views.find((view) => view.id === json.id)) {
|
||||
return [...views, view];
|
||||
|
|
Loading…
Reference in New Issue