Compare commits

...

6 Commits

Author SHA1 Message Date
Brieuc Dubois 9bb9041580 New card respect filters
Backend docker image / release-image (push) Successful in 10m4s Details
Frontend and backend docker image / release-image (push) Successful in 14m44s Details
Frontend docker image / release-image (push) Successful in 3m48s Details
2024-01-10 19:12:47 +01:00
Brieuc Dubois 2092125fc5 Filter cards 2024-01-10 19:01:53 +01:00
Brieuc Dubois ca3811a48b Frontend filter menu 2024-01-10 17:56:31 +01:00
Brieuc Dubois c725d5154e Frontend logic for filters 2024-01-10 16:39:28 +01:00
Brieuc Dubois bb479c45ef Add filters to views requests 2024-01-10 16:21:07 +01:00
Brieuc Dubois 7404aed139 Backend for filters 2024-01-10 15:56:31 +01:00
17 changed files with 895 additions and 47 deletions

76
backend/db/filters.go Normal file
View File

@ -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()
}

View File

@ -67,6 +67,17 @@ func InitDB(driver string, connStr string) error {
FOREIGN KEY(secondary_tag_id) REFERENCES tags(id) 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) INSERT INTO schema_version (version)
SELECT ? WHERE NOT EXISTS (SELECT 1 FROM schema_version); SELECT ? WHERE NOT EXISTS (SELECT 1 FROM schema_version);
`, DB_VERSION) `, DB_VERSION)

View File

@ -16,20 +16,26 @@ func CreateView(v types.View) (int, error) {
return int(id), nil 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) rows, err := db.Query("SELECT * FROM views WHERE project_id = ?", projectID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var views []types.View var views []types.FullView
for rows.Next() { 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 { if err := rows.Scan(&v.ID, &v.ProjectID, &v.PrimaryTagID, &v.SecondaryTagID, &v.Title, &v.SortTagID, &v.SortDirection); err != nil {
return nil, err return nil, err
} }
filters, err := GetViewFilters(v.ID)
if err != nil {
return nil, err
}
v.Filters = filters
views = append(views, v) views = append(views, v)
} }
@ -40,7 +46,7 @@ func GetProjectViews(projectID int) ([]types.View, error) {
return views, nil 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) rows, err := db.Query("SELECT * FROM views WHERE id = ?", id)
if err != nil { if err != nil {
return nil, err return nil, err
@ -51,9 +57,15 @@ func GetView(id int) (*types.View, error) {
return nil, nil 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) 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 return &v, nil
} }
@ -74,3 +86,13 @@ func DeleteView(id int) (int64, error) {
return res.RowsAffected() 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
}

131
backend/handlers/filters.go Normal file
View File

@ -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)
}

View File

@ -11,6 +11,7 @@ func v1Router(router fiber.Router) error {
cardsRouter(router.Group("/cards")) cardsRouter(router.Group("/cards"))
tagsRouter(router.Group("/tags")) tagsRouter(router.Group("/tags"))
viewsRouter(router.Group("/views")) viewsRouter(router.Group("/views"))
filtersRouter(router.Group("/filters"))
return nil return nil
} }

View File

@ -14,7 +14,7 @@ func viewsRouter(router fiber.Router) error {
router.Get("/:id", GetView) router.Get("/:id", GetView)
router.Put("/:id", UpdateView) router.Put("/:id", UpdateView)
router.Delete("/:id", DeleteView) router.Delete("/:id", DeleteView)
router.Get("/:id/filters", GetView)
return nil return nil
} }
@ -108,3 +108,20 @@ func DeleteView(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent) 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)
}

9
backend/types/filter.go Normal file
View File

@ -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"`
}

View File

@ -9,3 +9,14 @@ type View struct {
SortTagID *int `json:"sort_tag_id"` SortTagID *int `json:"sort_tag_id"`
SortDirection *int `json:"sort_direction"` 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"`
}

View File

@ -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_
};

View File

@ -1,5 +1,5 @@
import Filter from '$lib/types/Filter';
import type Project from '$lib/types/Project'; import type Project from '$lib/types/Project';
import type View from '$lib/types/View';
import api, { processError } from '$lib/utils/api'; import api, { processError } from '$lib/utils/api';
import status from '$lib/utils/status'; import status from '$lib/utils/status';
@ -53,8 +53,20 @@ async function delete_(viewId: number): Promise<boolean> {
return true; 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 { export default {
create, create,
update, update,
delete: delete_ delete: delete_,
getFilters
}; };

View File

@ -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>

View File

@ -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>

View File

@ -3,9 +3,10 @@
import CardComponent from '../card/Card.svelte'; import CardComponent from '../card/Card.svelte';
import AddIcon from '../icons/AddIcon.svelte'; import AddIcon from '../icons/AddIcon.svelte';
import type TagOption from '$lib/types/TagOption'; 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 type Project from '$lib/types/Project';
import currentDraggedCard from '$lib/stores/currentDraggedCard'; import currentDraggedCard from '$lib/stores/currentDraggedCard';
import currentView from '$lib/stores/currentView';
export let project: Project; export let project: Project;
export let option: TagOption | null = null; export let option: TagOption | null = null;
@ -43,10 +44,22 @@
const card = await Card.create(project); const card = await Card.create(project);
if (!card) return; if (!card) return;
if (!primaryTag) return;
if (!option) 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;
}
}
}
if (primaryTag && option) {
await card.addTag(primaryTag, option, null); await card.addTag(primaryTag, option, null);
}
cards.reload(); cards.reload();
} }

View File

@ -2,15 +2,17 @@
import GroupMenu from '$lib/components/menu/GroupMenu.svelte'; import GroupMenu from '$lib/components/menu/GroupMenu.svelte';
import SortMenu from '$lib/components/menu/SortMenu.svelte'; import SortMenu from '$lib/components/menu/SortMenu.svelte';
import currentView from '$lib/stores/currentView'; 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 Project from '$lib/types/Project';
import type ProjectTag from '$lib/types/ProjectTag'; import type ProjectTag from '$lib/types/ProjectTag';
import { projectTags } from '$lib/types/ProjectTag'; import { projectTags } from '$lib/types/ProjectTag';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import FilterMenu from '../menu/FilterMenu.svelte';
export let project: Project; export let project: Project;
let groupMenuOpen = false; let groupMenuOpen = false;
let sortMenuOpen = false; let sortMenuOpen = false;
let filterMenuOpen = false;
async function setGroup(projectTag: ProjectTag): Promise<boolean> { async function setGroup(projectTag: ProjectTag): Promise<boolean> {
const view = get(currentView); const view = get(currentView);
@ -33,6 +35,26 @@
return res; 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> </script>
<header> <header>
@ -54,7 +76,15 @@
/> />
</div> </div>
<button class:disabled={true}>Sub-group</button> <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> <div>
<button on:click={() => (sortMenuOpen = !sortMenuOpen)} class:defined={$currentView?.sortTag}> <button on:click={() => (sortMenuOpen = !sortMenuOpen)} class:defined={$currentView?.sortTag}>
Sort Sort
@ -70,7 +100,7 @@
currentDirection={$currentView?.sortDirection || null} currentDirection={$currentView?.sortDirection || null}
/> />
</div> </div>
<button id="newButton" on:click={async () => Card.create(project)}>New</button> <button id="newButton" on:click={addCard}>New</button>
</nav> </nav>
</header> </header>

View File

@ -1,57 +1,103 @@
<script lang="ts"> <script lang="ts">
import currentView from '$lib/stores/currentView'; 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 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 Column from './Column.svelte';
import Header from './Header.svelte'; import Header from './Header.svelte';
export let project: Project; 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> </script>
{#if project} {#if project}
<section> <section>
{#if $currentView} {#if $currentView}
<Header {project} /> <Header {project} />
{#if $cards && allCards} {#if $cards}
<div class="grid"> <div class="grid">
{#if $currentView.primaryTag} {#if $currentView.primaryTag}
{#each $currentView.primaryTag.options as option} {#each $currentView.primaryTag.options as option}
<Column <Column
{option} {option}
primaryTag={$currentView.primaryTag} primaryTag={$currentView.primaryTag}
columnCards={allCards columnCards={extractColumnCards($currentView, $cards, option)}
.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);
})}
{project} {project}
/> />
{/each} {/each}
{/if} {/if}
<Column <Column
primaryTag={$currentView.primaryTag} primaryTag={$currentView.primaryTag}
columnCards={$currentView.primaryTag != null columnCards={extractColumnCards($currentView, $cards, null)}
? (() => {
const primaryTag = $currentView.primaryTag;
return allCards.filter(
(c) => !c.cardTags.map((t) => t.projectTag).includes(primaryTag)
);
})()
: allCards}
{project} {project}
/> />
</div> </div>

View File

@ -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);
}
}

View File

@ -1,8 +1,10 @@
import { writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
import Project from './Project'; import Project from './Project';
import ProjectTag from './ProjectTag'; import ProjectTag from './ProjectTag';
import viewsApi from '$lib/api/viewsApi'; import viewsApi from '$lib/api/viewsApi';
import { toastAlert } from '$lib/utils/toasts'; import { toastAlert } from '$lib/utils/toasts';
import Filter from './Filter';
import type TagOption from './TagOption';
export const views = writable([] as View[]); export const views = writable([] as View[]);
@ -14,6 +16,7 @@ export default class View {
private _title: string; private _title: string;
private _sortTag: ProjectTag | null; private _sortTag: ProjectTag | null;
private _sortDirection: number | null; private _sortDirection: number | null;
private _filters: Filter[];
private constructor( private constructor(
id: number, id: number,
@ -22,7 +25,8 @@ export default class View {
secondaryTag: ProjectTag | null, secondaryTag: ProjectTag | null,
title: string, title: string,
sortTag: ProjectTag | null, sortTag: ProjectTag | null,
sortDirection: number | null sortDirection: number | null,
filters: Filter[]
) { ) {
this._id = id; this._id = id;
this._project = project; this._project = project;
@ -31,6 +35,7 @@ export default class View {
this._title = title; this._title = title;
this._sortTag = sortTag; this._sortTag = sortTag;
this._sortDirection = sortDirection; this._sortDirection = sortDirection;
this._filters = filters;
} }
get id(): number { get id(): number {
@ -61,6 +66,20 @@ export default class View {
return this._sortDirection; 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> { async setPrimaryTag(projectTag: ProjectTag): Promise<boolean> {
const response = await viewsApi.update( const response = await viewsApi.update(
this.id, this.id,
@ -103,7 +122,7 @@ export default class View {
if (!id) return null; 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]); views.update((views) => [...views, view]);
@ -137,6 +156,27 @@ export default class View {
return true; 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): View | null;
static parse(json: any, project: Project | null | undefined): View | null; static parse(json: any, project: Project | null | undefined): View | null;
@ -163,9 +203,12 @@ export default class View {
secondaryTag, secondaryTag,
json.title, json.title,
sortTag, sortTag,
json.sort_direction json.sort_direction,
[]
); );
view._filters = Filter.parseAll(json.filters, view);
views.update((views) => { views.update((views) => {
if (!views.find((view) => view.id === json.id)) { if (!views.find((view) => view.id === json.id)) {
return [...views, view]; return [...views, view];