feat(#221): change TagInput binding to Tag[], add color dots and hierarchy grouping
Backend:
- TagRepository: add findDescendantIdsByName() recursive CTE query
- TagService: add expandTagNamesToDescendantIdSets() for document search
Frontend:
- TagInput: accept Tag[] (id, name, color, parentId) instead of string[]
- Chips show color dot via var(--c-tag-{color}) when tag has color
- Suggestions grouped hierarchically: children indented under their parents
- Update DescriptionSection, edit/new pages, SearchFilterBar, +page.svelte
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,4 +34,21 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
|
|||||||
SELECT parent_id FROM ancestors
|
SELECT parent_id FROM ancestors
|
||||||
""", nativeQuery = true)
|
""", nativeQuery = true)
|
||||||
List<UUID> findAncestorIds(@Param("tagId") UUID tagId);
|
List<UUID> findAncestorIds(@Param("tagId") UUID tagId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the IDs of the tag with the given name AND all of its descendants
|
||||||
|
* via a recursive CTE. Used to expand a selected tag to inclusive hierarchy results.
|
||||||
|
* Includes a depth guard of 50 levels to prevent runaway queries.
|
||||||
|
*/
|
||||||
|
@Query(value = """
|
||||||
|
WITH RECURSIVE descendants AS (
|
||||||
|
SELECT id, 0 AS depth FROM tag WHERE LOWER(name) = LOWER(:name)
|
||||||
|
UNION ALL
|
||||||
|
SELECT t.id, d.depth + 1 FROM tag t
|
||||||
|
JOIN descendants d ON t.parent_id = d.id
|
||||||
|
WHERE d.depth < 50
|
||||||
|
)
|
||||||
|
SELECT id FROM descendants
|
||||||
|
""", nativeQuery = true)
|
||||||
|
List<UUID> findDescendantIdsByName(@Param("name") String name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -16,6 +17,7 @@ import org.raddatz.familienarchiv.repository.TagRepository;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -71,6 +73,18 @@ public class TagService {
|
|||||||
tagRepository.delete(getById(id));
|
tagRepository.delete(getById(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For each tag name, returns the set of that tag's ID plus all descendant IDs.
|
||||||
|
* Used by DocumentService to expand selected filter tags before applying AND/OR logic.
|
||||||
|
*/
|
||||||
|
public List<Set<UUID>> expandTagNamesToDescendantIdSets(List<String> tagNames) {
|
||||||
|
if (tagNames == null || tagNames.isEmpty()) return List.of();
|
||||||
|
return tagNames.stream()
|
||||||
|
.filter(StringUtils::hasText)
|
||||||
|
.map(name -> (Set<UUID>) new HashSet<>(tagRepository.findDescendantIdsByName(name.trim())))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all tags assembled into a tree. Document counts are not included here
|
* Returns all tags assembled into a tree. Document counts are not included here
|
||||||
* (they are populated by the controller layer if needed, or set to 0).
|
* (they are populated by the controller layer if needed, or set to 0).
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
|
|
||||||
|
export type Tag = { id?: string; name: string; color?: string; parentId?: string };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tags?: string[];
|
tags?: Tag[];
|
||||||
allowCreation?: boolean;
|
allowCreation?: boolean;
|
||||||
onTextInput?: (text: string) => void;
|
onTextInput?: (text: string) => void;
|
||||||
}
|
}
|
||||||
@@ -12,10 +15,41 @@ interface Props {
|
|||||||
let { tags = $bindable([]), allowCreation = true, onTextInput }: Props = $props();
|
let { tags = $bindable([]), allowCreation = true, onTextInput }: Props = $props();
|
||||||
|
|
||||||
let inputVal = $state('');
|
let inputVal = $state('');
|
||||||
let suggestions: string[] = $state([]);
|
let suggestions: Tag[] = $state([]);
|
||||||
let activeIndex = $state(-1);
|
let activeIndex = $state(-1);
|
||||||
let showSuggestions = $state(false);
|
let showSuggestions = $state(false);
|
||||||
|
|
||||||
|
const suggestionsById = $derived(
|
||||||
|
new SvelteMap(suggestions.filter((s) => s.id).map((s) => [s.id!, s]))
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderedSuggestions = $derived.by(() => {
|
||||||
|
const roots: Tag[] = [];
|
||||||
|
const childrenMap = new SvelteMap<string, Tag[]>();
|
||||||
|
const orphans: Tag[] = [];
|
||||||
|
|
||||||
|
for (const s of suggestions) {
|
||||||
|
if (!s.parentId) {
|
||||||
|
roots.push(s);
|
||||||
|
} else if (suggestionsById.has(s.parentId)) {
|
||||||
|
const children = childrenMap.get(s.parentId) ?? [];
|
||||||
|
children.push(s);
|
||||||
|
childrenMap.set(s.parentId, children);
|
||||||
|
} else {
|
||||||
|
orphans.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Tag[] = [];
|
||||||
|
for (const root of roots) {
|
||||||
|
result.push(root);
|
||||||
|
const children = childrenMap.get(root.id!) ?? [];
|
||||||
|
result.push(...children);
|
||||||
|
}
|
||||||
|
result.push(...orphans);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
async function fetchSuggestions(query: string) {
|
async function fetchSuggestions(query: string) {
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
suggestions = [];
|
suggestions = [];
|
||||||
@@ -24,10 +58,10 @@ async function fetchSuggestions(query: string) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
|
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data: Tag[] = await res.json();
|
||||||
const names: string[] = data.map((t: { name: string }) => t.name);
|
|
||||||
const currentTags = untrack(() => tags);
|
const currentTags = untrack(() => tags);
|
||||||
suggestions = names.filter((t) => !currentTags.includes(t));
|
const currentNames = new Set(currentTags.map((t) => t.name));
|
||||||
|
suggestions = data.filter((t) => !currentNames.has(t.name));
|
||||||
showSuggestions = true;
|
showSuggestions = true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -35,11 +69,19 @@ async function fetchSuggestions(query: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTag(tag: string) {
|
function addTag(tag: Tag | string) {
|
||||||
const trimmed = tag.trim();
|
const newTag: Tag = typeof tag === 'string' ? { name: tag.trim() } : tag;
|
||||||
if (trimmed && !tags.includes(trimmed)) {
|
if (!newTag.name) return;
|
||||||
tags = [...tags, trimmed];
|
const currentTags = untrack(() => tags);
|
||||||
|
if (currentTags.some((t) => t.name === newTag.name)) {
|
||||||
|
inputVal = '';
|
||||||
|
suggestions = [];
|
||||||
|
showSuggestions = false;
|
||||||
|
activeIndex = -1;
|
||||||
|
onTextInput?.('');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
tags = [...tags, newTag];
|
||||||
inputVal = '';
|
inputVal = '';
|
||||||
suggestions = [];
|
suggestions = [];
|
||||||
showSuggestions = false;
|
showSuggestions = false;
|
||||||
@@ -54,8 +96,8 @@ function removeTag(index: number) {
|
|||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (activeIndex >= 0 && suggestions[activeIndex]) {
|
if (activeIndex >= 0 && orderedSuggestions[activeIndex]) {
|
||||||
addTag(suggestions[activeIndex]);
|
addTag(orderedSuggestions[activeIndex]);
|
||||||
} else if (allowCreation) {
|
} else if (allowCreation) {
|
||||||
addTag(inputVal);
|
addTag(inputVal);
|
||||||
}
|
}
|
||||||
@@ -63,10 +105,10 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
removeTag(tags.length - 1);
|
removeTag(tags.length - 1);
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
activeIndex = (activeIndex + 1) % suggestions.length;
|
activeIndex = (activeIndex + 1) % orderedSuggestions.length;
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
activeIndex = (activeIndex - 1 + orderedSuggestions.length) % orderedSuggestions.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -79,7 +121,15 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<!-- Render Selected Tags -->
|
<!-- Render Selected Tags -->
|
||||||
{#each tags as tag, i (i)}
|
{#each tags as tag, i (i)}
|
||||||
<span class="flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
|
<span class="flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
|
||||||
{tag}
|
{#if tag.color}
|
||||||
|
<span
|
||||||
|
data-testid="tag-color-dot"
|
||||||
|
data-color={tag.color}
|
||||||
|
style="background-color: var(--c-tag-{tag.color})"
|
||||||
|
class="inline-block h-2 w-2 flex-shrink-0 rounded-full"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
{tag.name}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => removeTag(i)}
|
onclick={() => removeTag(i)}
|
||||||
@@ -115,22 +165,22 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Typeahead Dropdown -->
|
<!-- Typeahead Dropdown -->
|
||||||
{#if showSuggestions && suggestions.length > 0}
|
{#if showSuggestions && orderedSuggestions.length > 0}
|
||||||
<ul
|
<ul
|
||||||
class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-line bg-surface shadow-lg"
|
class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-line bg-surface shadow-lg"
|
||||||
>
|
>
|
||||||
{#each suggestions as suggestion, i (i)}
|
{#each orderedSuggestions as suggestion, i (i)}
|
||||||
<li
|
<li
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === activeIndex}
|
aria-selected={i === activeIndex}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-muted {i === activeIndex
|
class="cursor-pointer px-3 py-2 text-sm hover:bg-muted {i === activeIndex
|
||||||
? 'bg-muted font-bold text-ink'
|
? 'bg-muted font-bold text-ink'
|
||||||
: 'text-ink-2'}"
|
: 'text-ink-2'} {suggestion.parentId && suggestionsById.has(suggestion.parentId) ? 'pl-6' : ''}"
|
||||||
onclick={() => addTag(suggestion)}
|
onclick={() => addTag(suggestion)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
||||||
>
|
>
|
||||||
{suggestion}
|
{suggestion.name}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ describe('TagInput – rendering', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders existing tags as chips', async () => {
|
it('renders existing tags as chips', async () => {
|
||||||
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
|
render(TagInput, { tags: [{ name: 'Familie' }, { name: 'Krieg' }], allowCreation: true });
|
||||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||||
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
|
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
|
||||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-with-chips.png' });
|
await page.screenshot({ path: 'test-results/screenshots/tag-input-with-chips.png' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides input placeholder once tags exist', async () => {
|
it('hides input placeholder once tags exist', async () => {
|
||||||
render(TagInput, { tags: ['Familie'], allowCreation: true });
|
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await expect.element(input).toHaveAttribute('placeholder', '');
|
await expect.element(input).toHaveAttribute('placeholder', '');
|
||||||
});
|
});
|
||||||
@@ -64,6 +64,18 @@ describe('TagInput – rendering', () => {
|
|||||||
render(TagInput, { tags: [], allowCreation: false });
|
render(TagInput, { tags: [], allowCreation: false });
|
||||||
await expect.element(page.getByText(/Enter drücken/i)).not.toBeInTheDocument();
|
await expect.element(page.getByText(/Enter drücken/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders a color dot on chips that have a color', async () => {
|
||||||
|
render(TagInput, { tags: [{ name: 'Familie', color: 'sage' }], allowCreation: true });
|
||||||
|
const dot = page.getByTestId('tag-color-dot');
|
||||||
|
await expect.element(dot).toBeInTheDocument();
|
||||||
|
await expect.element(dot).toHaveAttribute('data-color', 'sage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render a color dot on chips without a color', async () => {
|
||||||
|
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
|
||||||
|
await expect.element(page.getByTestId('tag-color-dot')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Adding tags ──────────────────────────────────────────────────────────────
|
// ─── Adding tags ──────────────────────────────────────────────────────────────
|
||||||
@@ -90,7 +102,7 @@ describe('TagInput – adding tags', () => {
|
|||||||
|
|
||||||
it('does not add a duplicate tag', async () => {
|
it('does not add a duplicate tag', async () => {
|
||||||
mockFetchEmpty();
|
mockFetchEmpty();
|
||||||
render(TagInput, { tags: ['Familie'], allowCreation: true });
|
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('Familie');
|
await input.fill('Familie');
|
||||||
await userEvent.keyboard('{Enter}');
|
await userEvent.keyboard('{Enter}');
|
||||||
@@ -112,7 +124,7 @@ describe('TagInput – adding tags', () => {
|
|||||||
|
|
||||||
describe('TagInput – removing tags', () => {
|
describe('TagInput – removing tags', () => {
|
||||||
it('removes a chip when its × button is clicked', async () => {
|
it('removes a chip when its × button is clicked', async () => {
|
||||||
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
|
render(TagInput, { tags: [{ name: 'Familie' }, { name: 'Krieg' }], allowCreation: true });
|
||||||
// The × buttons have aria-label="Schlagwort entfernen"
|
// The × buttons have aria-label="Schlagwort entfernen"
|
||||||
document.querySelector<HTMLElement>('button[aria-label="Schlagwort entfernen"]')!.click();
|
document.querySelector<HTMLElement>('button[aria-label="Schlagwort entfernen"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
@@ -122,7 +134,7 @@ describe('TagInput – removing tags', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('removes the last tag on Backspace when the input is empty', async () => {
|
it('removes the last tag on Backspace when the input is empty', async () => {
|
||||||
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
|
render(TagInput, { tags: [{ name: 'Familie' }, { name: 'Krieg' }], allowCreation: true });
|
||||||
(document.querySelector('input[type="text"]') as HTMLInputElement).focus();
|
(document.querySelector('input[type="text"]') as HTMLInputElement).focus();
|
||||||
await userEvent.keyboard('{Backspace}');
|
await userEvent.keyboard('{Backspace}');
|
||||||
await expect.element(page.getByText('Krieg')).not.toBeInTheDocument();
|
await expect.element(page.getByText('Krieg')).not.toBeInTheDocument();
|
||||||
@@ -130,7 +142,7 @@ describe('TagInput – removing tags', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not remove a tag on Backspace when the input has text', async () => {
|
it('does not remove a tag on Backspace when the input has text', async () => {
|
||||||
render(TagInput, { tags: ['Familie'], allowCreation: true });
|
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('x');
|
await input.fill('x');
|
||||||
await userEvent.keyboard('{Backspace}');
|
await userEvent.keyboard('{Backspace}');
|
||||||
@@ -163,7 +175,7 @@ describe('TagInput – autocomplete', () => {
|
|||||||
|
|
||||||
it('filters already-selected tags out of suggestions', async () => {
|
it('filters already-selected tags out of suggestions', async () => {
|
||||||
mockFetchWithTags(['Familie', 'Freunde']);
|
mockFetchWithTags(['Familie', 'Freunde']);
|
||||||
render(TagInput, { tags: ['Familie'], allowCreation: true });
|
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('Fr');
|
await input.fill('Fr');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
@@ -207,6 +219,30 @@ describe('TagInput – autocomplete', () => {
|
|||||||
await tick();
|
await tick();
|
||||||
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
|
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows child suggestion after its parent when both are in results', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'p1', name: 'Eltern' },
|
||||||
|
{ id: 'c1', name: 'Kind', parentId: 'p1' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(TagInput, { tags: [], allowCreation: true });
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await input.fill('El');
|
||||||
|
await waitForDebounce();
|
||||||
|
const options = document.querySelectorAll('[role="option"]');
|
||||||
|
const names = Array.from(options).map((el) => el.textContent?.trim());
|
||||||
|
const elternIdx = names.indexOf('Eltern');
|
||||||
|
const kindIdx = names.indexOf('Kind');
|
||||||
|
expect(elternIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(kindIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(elternIdx).toBeLessThan(kindIdx);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── onTextInput callback ──────────────────────────────────────────────────────
|
// ─── onTextInput callback ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import TagInput from '$lib/components/TagInput.svelte';
|
import TagInput, { type Tag } from '$lib/components/TagInput.svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tags = $bindable<string[]>([]),
|
tags = $bindable<Tag[]>([]),
|
||||||
initialTitle = '',
|
initialTitle = '',
|
||||||
initialDocumentLocation = '',
|
initialDocumentLocation = '',
|
||||||
initialSummary = '',
|
initialSummary = '',
|
||||||
@@ -12,7 +12,7 @@ let {
|
|||||||
suggestedTitle = '',
|
suggestedTitle = '',
|
||||||
hideTitle = false
|
hideTitle = false
|
||||||
}: {
|
}: {
|
||||||
tags?: string[];
|
tags?: Tag[];
|
||||||
initialTitle?: string;
|
initialTitle?: string;
|
||||||
initialDocumentLocation?: string;
|
initialDocumentLocation?: string;
|
||||||
initialSummary?: string;
|
initialSummary?: string;
|
||||||
@@ -74,7 +74,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
|
|||||||
<div>
|
<div>
|
||||||
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_tags()}</p>
|
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_tags()}</p>
|
||||||
<TagInput bind:tags={tags} />
|
<TagInput bind:tags={tags} />
|
||||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
<input type="hidden" name="tags" value={tags.map((t) => t.name).join(',')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Inhalt -->
|
<!-- Inhalt -->
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ let from = $state(untrack(() => data.filters?.from || ''));
|
|||||||
let to = $state(untrack(() => data.filters?.to || ''));
|
let to = $state(untrack(() => data.filters?.to || ''));
|
||||||
let senderId = $state(untrack(() => data.filters?.senderId || ''));
|
let senderId = $state(untrack(() => data.filters?.senderId || ''));
|
||||||
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
||||||
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
|
let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>(
|
||||||
|
untrack(() => (data.filters?.tags || []).map((name: string) => ({ name })))
|
||||||
|
);
|
||||||
let sort = $state(untrack(() => data.filters?.sort || 'DATE'));
|
let sort = $state(untrack(() => data.filters?.sort || 'DATE'));
|
||||||
let dir = $state(untrack(() => data.filters?.dir || 'desc'));
|
let dir = $state(untrack(() => data.filters?.dir || 'desc'));
|
||||||
let tagQ = $state(untrack(() => data.filters?.tagQ || ''));
|
let tagQ = $state(untrack(() => data.filters?.tagQ || ''));
|
||||||
@@ -43,7 +45,7 @@ function triggerSearch() {
|
|||||||
if (to) params.set('to', to);
|
if (to) params.set('to', to);
|
||||||
if (senderId) params.set('senderId', senderId);
|
if (senderId) params.set('senderId', senderId);
|
||||||
if (receiverId) params.set('receiverId', receiverId);
|
if (receiverId) params.set('receiverId', receiverId);
|
||||||
if (tagNames) tagNames.forEach((tag) => params.append('tag', tag));
|
if (tagNames) tagNames.forEach((tag) => params.append('tag', tag.name));
|
||||||
if (sort) params.set('sort', sort);
|
if (sort) params.set('sort', sort);
|
||||||
if (dir) params.set('dir', dir);
|
if (dir) params.set('dir', dir);
|
||||||
if (tagQ) params.set('tagQ', tagQ);
|
if (tagQ) params.set('tagQ', tagQ);
|
||||||
@@ -56,9 +58,9 @@ function handleTextSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Trigger search when tags change
|
// Trigger search when tags change
|
||||||
let prevTagStr = untrack(() => tagNames.join(','));
|
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const cur = tagNames.join(',');
|
const cur = tagNames.map((t) => t.name).join(',');
|
||||||
if (cur !== prevTagStr) {
|
if (cur !== prevTagStr) {
|
||||||
prevTagStr = cur;
|
prevTagStr = cur;
|
||||||
triggerSearch();
|
triggerSearch();
|
||||||
@@ -73,7 +75,7 @@ $effect(() => {
|
|||||||
to = data.filters?.to || '';
|
to = data.filters?.to || '';
|
||||||
senderId = data.filters?.senderId || '';
|
senderId = data.filters?.senderId || '';
|
||||||
receiverId = data.filters?.receiverId || '';
|
receiverId = data.filters?.receiverId || '';
|
||||||
tagNames = data.filters?.tags || [];
|
tagNames = (data.filters?.tags || []).map((name: string) => ({ name }));
|
||||||
sort = data.filters?.sort || 'DATE';
|
sort = data.filters?.sort || 'DATE';
|
||||||
dir = data.filters?.dir || 'desc';
|
dir = data.filters?.dir || 'desc';
|
||||||
tagQ = data.filters?.tagQ || '';
|
tagQ = data.filters?.tagQ || '';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ let {
|
|||||||
to = $bindable(''),
|
to = $bindable(''),
|
||||||
senderId = $bindable(''),
|
senderId = $bindable(''),
|
||||||
receiverId = $bindable(''),
|
receiverId = $bindable(''),
|
||||||
tagNames = $bindable<string[]>([]),
|
tagNames = $bindable<{ name: string; id?: string; color?: string; parentId?: string }[]>([]),
|
||||||
tagQ = $bindable(''),
|
tagQ = $bindable(''),
|
||||||
sort = $bindable('DATE'),
|
sort = $bindable('DATE'),
|
||||||
dir = $bindable('desc'),
|
dir = $bindable('desc'),
|
||||||
@@ -29,7 +29,7 @@ let {
|
|||||||
to?: string;
|
to?: string;
|
||||||
senderId?: string;
|
senderId?: string;
|
||||||
receiverId?: string;
|
receiverId?: string;
|
||||||
tagNames?: string[];
|
tagNames?: { name: string; id?: string; color?: string; parentId?: string }[];
|
||||||
tagQ?: string;
|
tagQ?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
dir?: string;
|
dir?: string;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import SaveBar from './SaveBar.svelte';
|
|||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
let { document: doc } = untrack(() => data);
|
let { document: doc } = untrack(() => data);
|
||||||
let tags = $state(doc.tags ? doc.tags.map((t: { name: string }) => t.name) : []);
|
let tags = $state(doc.tags ?? []);
|
||||||
let senderId = $state(doc.sender?.id ?? '');
|
let senderId = $state(doc.sender?.id ?? '');
|
||||||
let selectedReceivers = $state(doc.receivers ?? []);
|
let selectedReceivers = $state(doc.receivers ?? []);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { type FilenameParseResult } from '$lib/utils/filename';
|
|||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
let tags: string[] = $state([]);
|
let tags: { name: string; id?: string; color?: string; parentId?: string }[] = $state([]);
|
||||||
let senderId = $state(untrack(() => data.initialSenderId));
|
let senderId = $state(untrack(() => data.initialSenderId));
|
||||||
let selectedReceivers: { id: string; firstName?: string; lastName: string; displayName: string }[] =
|
let selectedReceivers: { id: string; firstName?: string; lastName: string; displayName: string }[] =
|
||||||
$state(untrack(() => data.initialReceivers));
|
$state(untrack(() => data.initialReceivers));
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ $effect(() => {
|
|||||||
onDestroy(() => fileLoader.destroy());
|
onDestroy(() => fileLoader.destroy());
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? []));
|
let tags = $state(untrack(() => doc.tags ?? []));
|
||||||
let senderId = $state(untrack(() => doc.sender?.id ?? ''));
|
let senderId = $state(untrack(() => doc.sender?.id ?? ''));
|
||||||
let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
|
let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user