Tag selection

This commit is contained in:
Brieuc Dubois 2024-01-02 21:13:44 +01:00
parent b182c831a3
commit 29ab752170
16 changed files with 533 additions and 151 deletions

View File

@ -18,10 +18,15 @@ func GetCardTags(cardID int, projectID int) ([]types.FullCardTag, error) {
projectID = card.ProjectID projectID = card.ProjectID
} }
rows, err := db.Query(`SELECT t.id, t.title, t.type, COALESCE(ct.option_id, -1), COALESCE(ct.value, '') // rows, err := db.Query(`SELECT t.id, t.title, t.type, COALESCE(ct.option_id, -1), COALESCE(ct.value, '')
FROM tags t // FROM tags t
LEFT JOIN cardtags ct ON ct.tag_id = t.id AND ct.card_id = ? // LEFT JOIN cardtags ct ON ct.tag_id = t.id AND ct.card_id = ?
WHERE t.project_id = ? // WHERE t.project_id = ?
// `, cardID, projectID)
rows, err := db.Query(`SELECT t.id, t.title, t.type, ct.option_id, ct.value
FROM cardtags ct
INNER JOIN tags t ON t.id = ct.tag_id
WHERE ct.card_id = ? AND t.project_id = ?
`, cardID, projectID) `, cardID, projectID)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -20,7 +20,7 @@ export async function newCardApi(projectId: number, tags: TagValue[]): Promise<C
return { return {
id: id, id: id,
projectId: projectId, project_id: projectId,
title: 'Untitled', title: 'Untitled',
content: '', content: '',
tags: tags tags: tags

View File

@ -1,7 +1,11 @@
<script lang="ts">
export let size: number = 24;
</script>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width={size}
height="24" height={size}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="white" stroke="white"

Before

Width:  |  Height:  |  Size: 417 B

After

Width:  |  Height:  |  Size: 482 B

View File

@ -13,12 +13,12 @@
async function save(closeModal: boolean = true) { async function save(closeModal: boolean = true) {
if ( if (
card.projectId != tempCard.projectId || card.project_id != tempCard.project_id ||
card.title !== tempCard.title || card.title !== tempCard.title ||
card.content !== tempCard.content card.content !== tempCard.content
) { ) {
const response = await api.put(`/v1/cards/${card.id}`, { const response = await api.put(`/v1/cards/${card.id}`, {
project_id: tempCard.projectId, project_id: tempCard.project_id,
title: tempCard.title, title: tempCard.title,
content: tempCard.content content: tempCard.content
}); });
@ -131,10 +131,6 @@
} }
.modal .buttons button:first-child:hover { .modal .buttons button:first-child:hover {
background-color: #343;
}
.modal .buttons button:last-child:hover {
background-color: #433; background-color: #433;
} }
@ -149,18 +145,6 @@
font-family: inherit; font-family: inherit;
} }
.modal td {
margin-right: 40px;
}
.modal td:first-child {
font-weight: bold;
}
.modal td:last-child {
margin-right: 0;
}
.modal td button { .modal td button {
width: 30px; width: 30px;
height: 30px; height: 30px;

View File

@ -1,79 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { TagValue } from '../../../stores/interfaces'; import type { Card, MeTag, TagValue } from '../../../stores/interfaces';
import projectTags from '../../../stores/projectTags'; import ModalTagTitle from './modal_tag/modal_tag_title.svelte';
import { cards } from '../../../stores/smallStore'; import ModalTagValue from './modal_tag/modal_tag_value.svelte';
import api, { processError } from '../../../utils/api';
import status from '../../../utils/status';
import ModalTagTitle from './modal_tag_title.svelte';
export let tag: TagValue; export let projectTag: MeTag;
// export let removeTag: (id: number) => void; export let tagValue: TagValue | undefined;
let newValue: string = tag.value; export let card: Card;
let newOption: number = tag.option_id;
async function saveTag() {
if (tag.value === newValue && tag.option_id == newOption) return;
// DELETE
if ((tag.value !== '' && newValue === '') || (tag.option_id !== -1 && newOption === -1)) {
const response = await api.delete(`/v1/cards/${tag.card_id}/tags/${tag.tag_id}`);
if (response.status !== status.NoContent) {
processError(response, 'Failed to delete tag');
return;
}
}
// CREATE
else if ((tag.value === '' && newValue !== '') || (tag.option_id == -1 && newOption !== -1)) {
const response = await api.post(`/v1/cards/${tag.card_id}/tags/${tag.tag_id}`, {
value: newValue,
option_id: newOption
});
if (response.status !== status.Created) {
processError(response, 'Failed to create tag');
return;
}
}
// UPDATE
else {
const response = await api.put(`/v1/cards/${tag.card_id}/tags/${tag.tag_id}`, {
value: newValue,
option_id: newOption
});
if (response.status !== status.NoContent) {
processError(response, 'Failed to update tag');
return;
}
}
tag.value = newValue;
tag.option_id = newOption;
cards.reload();
}
async function newTagOption() {
const value = prompt('New option value');
if (value) projectTags.addOption(tag.tag_id, value);
}
const projectTag = $projectTags[tag.tag_id];
</script> </script>
{#if projectTag} {#if projectTag}
<tr> <tr>
<ModalTagTitle projectTag={$projectTags[tag.tag_id]} /> <ModalTagTitle {projectTag} />
<td> <ModalTagValue {projectTag} bind:tagValue {card} />
{#if projectTag.type == 0}
<select bind:value={newOption} on:change={saveTag}>
<option value={-1}></option>
{#each projectTag.options as option}
<option value={option.id}>{option.value}</option>
{/each}
</select>
<button style="color: white; font-size: 20px;" on:click={newTagOption}>+</button>
{:else}
<input bind:value={newValue} on:blur={saveTag} />
{/if}
</td>
</tr> </tr>
{/if} {/if}

View File

@ -0,0 +1,95 @@
<script lang="ts">
import { tick } from 'svelte';
import AddIcon from '../../../icons/addIcon.svelte';
import Menu from '../../../tuils/menu.svelte';
import ModalTagTypes from './modal_tag_types.svelte';
import projectTags from '../../../../stores/projectTags';
import { toastAlert } from '../../../../utils/toasts';
export let projectId: number;
let isOpen = false;
let titleInput: HTMLInputElement;
let title: string = '';
let typeId: number = 0;
async function openMenu() {
isOpen = !isOpen;
await tick();
if (isOpen && titleInput) titleInput.focus();
}
async function createTag() {
if (title == '') return;
if (!projectId) {
toastAlert('Failed to create tag', `ProjectId is ${projectId}`);
return;
}
await projectTags.add(projectId, title, typeId);
isOpen = false;
}
</script>
<tr>
<td>
<button
class="add-button"
on:click={openMenu}
on:keydown={(e) => {
if (e.key === 'Enter') {
openMenu();
}
}}
>
<AddIcon />
</button>
<Menu
{isOpen}
onLeave={() => {
isOpen = false;
}}
>
<div class="menu-items">
<input bind:this={titleInput} bind:value={title} />
<ModalTagTypes type={typeId} onChoice={(id) => (typeId = id)} />
<button on:click={createTag}>Create</button>
</div>
</Menu>
</td>
</tr>
<style lang="less">
.add-button {
margin-top: 10px;
padding: 3px 5px;
border-radius: 5px;
width: 100%;
height: 100%;
}
.menu-items {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
input {
margin-bottom: 5px;
width: 70%;
background-color: inherit;
color: inherit;
border: 1px solid #666;
padding: 3px 5px;
font-size: inherit;
border-radius: 5px;
}
button {
padding: 3px 5px;
text-align: left;
width: 80%;
}
}
</style>

View File

@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte'; import { tick } from 'svelte';
import type { MeTag } from '../../../stores/interfaces'; import type { MeTag } from '../../../../stores/interfaces';
import Menu from '../../tuils/menu.svelte'; import Menu from '../../../tuils/menu.svelte';
import projectTags from '../../../stores/projectTags'; import projectTags from '../../../../stores/projectTags';
import { toastAlert } from '../../../utils/toasts'; import { toastAlert } from '../../../../utils/toasts';
import ModalTagTypes from './modal_tag_types.svelte';
export let projectTag: MeTag; export let projectTag: MeTag;
@ -65,12 +66,24 @@
} }
}} }}
/> />
<button>Type: </button> <ModalTagTypes
type={projectTag.type}
onChoice={async (id) => {
projectTag.type = id;
await projectTags.update(projectTag);
}}
/>
{#if askConfirm} {#if askConfirm}
<div class="askconfirm"> <div class="askconfirm">
<span>Confirm?</span> <span>Confirm?</span>
<div> <div>
<button on:click={() => {}}>✓</button> <button
on:click={() => {
projectTags.delete(projectTag.id);
isMenuOpen = false;
}}>✓</button
>
<button on:click={() => (askConfirm = false)}>✗</button> <button on:click={() => (askConfirm = false)}>✗</button>
</div> </div>
</div> </div>
@ -82,10 +95,15 @@
</td> </td>
<style lang="less"> <style lang="less">
td {
min-width: 120px;
}
.title { .title {
padding: 3px 5px; padding: 3px 5px;
margin: 2px; margin: 2px;
border-radius: 5px; border-radius: 5px;
height: 28px;
&:hover { &:hover {
background-color: #fff2; background-color: #fff2;

View File

@ -0,0 +1,78 @@
<script lang="ts">
import projectTags from '../../../../stores/projectTags';
import { getTagTypeFromId, tagTypes } from '../../../../utils/tagTypes';
import Menu from '../../../tuils/menu.svelte';
export let type: number;
export let isTagMenuOpen: boolean = false;
export let onChoice: (id: number) => void;
async function onLocalChoice(id: number) {
onChoice(id);
isTagMenuOpen = false;
}
</script>
<button on:click={() => (isTagMenuOpen = !isTagMenuOpen)}>
Type: {getTagTypeFromId(type)?.name}
</button>
<div class="menu">
<Menu
isOpen={isTagMenuOpen}
onLeave={() => {
isTagMenuOpen = false;
}}
>
{#each Object.values(tagTypes) as tagType}
<div
class="menu-item"
role="button"
on:click={() => onLocalChoice(tagType.id)}
tabindex="0"
on:keypress={(e) => {
if (e.key === 'Enter') {
onLocalChoice(tagType.id);
}
}}
>
<span>{tagType.name}</span>
{#if type === tagType.id}
<span class="mark"></span>
{:else}
<span class="mark"> </span>
{/if}
</div>
{/each}
</Menu>
</div>
<style lang="less">
button {
padding: 3px 5px;
text-align: left;
width: 80%;
}
.menu {
position: relative;
bottom: 30px;
left: 105px;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 20px;
&:hover {
cursor: pointer;
background-color: #333;
}
}
.mark {
margin-left: 20px;
}
</style>

View File

@ -0,0 +1,19 @@
<script lang="ts">
import type { Card, MeTag, TagValue } from '../../../../stores/interfaces';
import { getTagTypeFromId } from '../../../../utils/tagTypes';
import SelectTags from './select_tags.svelte';
export let projectTag: MeTag;
export let tagValue: TagValue | undefined;
export let card: Card;
let tagType = getTagTypeFromId(projectTag.type);
</script>
<td>
{#if tagType?.hasOptions}
<SelectTags multiple={false} {projectTag} {card} bind:tagValue />
{:else if !tagType?.hasOptions}
<input />
{/if}
</td>

View File

@ -0,0 +1,232 @@
<script lang="ts">
import type { Card, MeTag, TagOption, TagValue } from '../../../../stores/interfaces';
import projectTags from '../../../../stores/projectTags';
import { cards } from '../../../../stores/smallStore';
import api, { processError } from '../../../../utils/api';
import status from '../../../../utils/status';
import TrashIcon from '../../../icons/trashIcon.svelte';
import Menu from '../../../tuils/menu.svelte';
export let multiple: boolean = false;
export let card: Card;
export let projectTag: MeTag;
export let tagValue: TagValue | undefined;
let lastTagValue = { ...tagValue };
let newOption: string = '';
$: tagOption = projectTag.options.find((o) => o.id === tagValue?.option_id);
let isOpen = false;
async function selectOption(option_id: number) {
if (lastTagValue.option_id === option_id) {
isOpen = false;
return;
}
if (tagValue) {
const response = await api.put(`/v1/cards/${card.id}/tags/${projectTag.id}`, {
option_id,
value: ''
});
if (response.status !== status.NoContent) {
processError(response, 'Failed to update tag');
return;
}
card.tags = card.tags.map((t) => {
if (t.tag_id === projectTag.id) {
t.option_id = option_id;
}
return t;
});
tagValue = { ...tagValue, option_id };
} else {
const response = await api.post(`/v1/cards/${card.id}/tags/${projectTag.id}`, {
option_id,
value: ''
});
if (response.status !== status.Created) {
processError(response, 'Failed to create tag');
return;
}
tagValue = {
card_id: card.id,
tag_id: projectTag.id,
option_id,
value: ''
};
card.tags.push(tagValue);
}
lastTagValue = { ...tagValue };
isOpen = false;
cards.reload();
}
async function deleteOption(_: number | undefined) {
const response = await api.delete(`/v1/cards/${card.id}/tags/${projectTag.id}`);
if (response.status !== status.NoContent) {
processError(response, 'Failed to delete tag');
return;
}
tagValue = undefined;
}
function createOption() {
if (!newOption) return;
projectTags.addOption(projectTag.id, newOption);
newOption = '';
}
</script>
<div
class="select"
on:click={() => (isOpen = !isOpen)}
tabindex="0"
role="button"
on:keydown={(e) => {
if (e.key === 'Enter') {
isOpen = !isOpen;
}
}}
>
<div class="tags">
{#if tagValue}
<span class="tag">
{tagOption?.value}
<button class="real" on:click={() => deleteOption(tagValue?.option_id)}>✗</button>
</span>
{/if}
</div>
</div>
<Menu
{isOpen}
onLeave={() => {
isOpen = false;
}}
>
{#each projectTag.options as option}
<div
class="option"
on:click={() => selectOption(option.id)}
tabindex="0"
role="button"
on:keydown={(e) => {
if (e.key === 'Enter') {
selectOption(option.id);
}
}}
>
<span class="value">{option.value}</span>
<button
on:click|stopPropagation={() => {
projectTags.deleteOption(projectTag.id, option.id);
}}><TrashIcon size={16} /></button
>
</div>
{/each}
<div class="new">
<input
type="text"
placeholder="New option"
on:keydown={(e) => {
if (e.key === 'Enter') {
createOption();
}
}}
bind:value={newOption}
/>
<button on:click={createOption}>✓</button>
</div>
</Menu>
<style lang="less">
.select {
display: flex;
align-items: center;
min-width: 150px;
border-radius: 5px;
padding: 3px 20px 3px 10px;
height: 30px;
cursor: pointer;
}
.select:hover {
background-color: #fff2;
}
.tag {
background-color: #444;
border-radius: 3px;
padding: 1px 2px;
cursor: default;
button {
padding: 2px 4px;
background-color: transparent;
border: none;
color: inherit;
cursor: pointer;
}
}
.option {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 150px;
cursor: pointer;
&:hover {
background-color: #fff2;
}
.value {
margin: 5px 10px;
border-radius: 5px;
padding: 5px 8px;
background-color: #fff2;
}
button {
display: flex;
align-items: center;
margin-left: 15px;
padding: 10px;
background-color: transparent;
&:hover {
background-color: #fff2;
}
}
}
.new {
display: flex;
align-items: center;
justify-content: space-between;
input {
width: 50%;
margin: 5px 10px;
padding: 5px;
border-radius: 5px;
border: 1px solid #666;
background-color: transparent;
color: inherit;
}
button {
margin-left: 15px;
padding: 10px;
}
}
</style>

View File

@ -1,58 +1,21 @@
<script lang="ts"> <script lang="ts">
import type { Card } from '../../../stores/interfaces'; import type { Card } from '../../../stores/interfaces';
import api, { processError } from '../../../utils/api'; import projectTags from '../../../stores/projectTags';
import status from '../../../utils/status';
import AddIcon from '../../icons/addIcon.svelte';
import ModalTag from './modal_tag.svelte'; import ModalTag from './modal_tag.svelte';
import ModalNewTag from './modal_tag/modal_new_tag.svelte';
export let card: Card; export let card: Card;
let newTagName = '';
async function addTag() {
if (newTagName === '') return;
const response = await api.post(`/v1/tags`, {
project_id: card.projectId,
title: newTagName,
type: 0
});
if (response.status !== status.Created) {
processError(response, 'Failed to create tag');
return;
}
const id = response.data.id;
card.tags = [...card.tags, { card_id: card.id, tag_id: id, option_id: -1, value: '' }];
newTagName = '';
}
function addTagHandler(event: KeyboardEvent) {
if (event.key === 'Enter') {
addTag();
}
}
function removeTag(id: number) {
card.tags = card.tags.filter((tag) => tag.tag_id !== id);
}
</script> </script>
<table> <table>
{#if card.tags} {#if card.tags}
{#each card.tags as tag} {#each Object.values($projectTags) as projectTag}
<ModalTag bind:tag /> <ModalTag
tagValue={card.tags.find((t) => t.tag_id === projectTag.id)}
bind:projectTag
{card}
/>
{/each} {/each}
{/if} {/if}
<tr class="tag"> <ModalNewTag projectId={card.project_id} />
<td class="tag-title"
><input placeholder="Add a new tag" on:keydown={addTagHandler} bind:value={newTagName} /></td
>
<td
><button on:click={addTag}>
<AddIcon />
</button>
</td>
</tr>
</table> </table>

View File

@ -41,7 +41,9 @@
tag_id: view.primary_tag_id, tag_id: view.primary_tag_id,
value: `No ${$projectTags[view.primary_tag_id].title}` value: `No ${$projectTags[view.primary_tag_id].title}`
}} }}
columnCards={$cards.filter((c) => c.tags.find((t) => t.tag_id)?.option_id == -1 || false)} columnCards={$cards.filter(
(c) => !c.tags.map((t) => t.tag_id).includes(view?.primary_tag_id || -2)
)}
projectId={project.id} projectId={project.id}
editable={false} editable={false}
/> />

View File

@ -13,7 +13,6 @@
let viewEditValue: string; let viewEditValue: string;
onMount(async () => { onMount(async () => {
console.log('aaa');
await views.init(project.id); await views.init(project.id);
if ($views.length > 0) currentView.set($views[0]); if ($views.length > 0) currentView.set($views[0]);

View File

@ -5,7 +5,7 @@ export interface Project {
export interface Card { export interface Card {
id: number; id: number;
projectId: number; project_id: number;
title: string; title: string;
content: string; content: string;
tags: TagValue[]; tags: TagValue[];

View File

@ -2,6 +2,7 @@ import { get, writable } from 'svelte/store';
import type { MeTag, TagOption } from './interfaces'; import type { MeTag, TagOption } from './interfaces';
import api, { processError } from '../utils/api'; import api, { processError } from '../utils/api';
import status from '../utils/status'; import status from '../utils/status';
import { cards } from './smallStore';
const { subscribe, set, update } = writable({} as { [key: number]: MeTag }); const { subscribe, set, update } = writable({} as { [key: number]: MeTag });
@ -49,6 +50,25 @@ export default {
return tags; return tags;
}); });
}, },
deleteOption: async (tag_id: number, option_id: number) => {
const response = await api.delete(`/v1/tags/${tag_id}/options/${option_id}`);
if (response.status !== status.NoContent) {
processError(response, 'Failed to delete tag option');
return;
}
update((tags) => {
tags[tag_id].options = tags[tag_id].options.filter((option) => option.id !== option_id);
return tags;
});
for (const card of get(cards)) {
//TODO: same in db
card.tags.filter((tag) => tag.tag_id !== tag_id || tag.option_id !== option_id);
}
cards.reload();
},
delete: async (tag_id: number) => { delete: async (tag_id: number) => {
const response = await api.delete(`/v1/tags/${tag_id}`); const response = await api.delete(`/v1/tags/${tag_id}`);

View File

@ -0,0 +1,26 @@
export const tagTypes: { [name: string]: { id: number; name: string; hasOptions: boolean } } = {
SELECT: {
id: 0,
name: 'Select',
hasOptions: true
},
MULTISELECT: {
id: 1,
name: 'Multiselect',
hasOptions: true
},
TEXT: {
id: 2,
name: 'Text',
hasOptions: false
}
};
export const getTagTypeFromId = (id: number) => {
for (let type in tagTypes) {
if (tagTypes[type].id === id) {
return tagTypes[type];
}
}
return null;
};