refactor admin crud popups
All checks were successful
deploy / build-and-deploy (push) Successful in 23s
All checks were successful
deploy / build-and-deploy (push) Successful in 23s
This commit is contained in:
255
src/components/admin/popup/CrudPopup.svelte
Normal file
255
src/components/admin/popup/CrudPopup.svelte
Normal file
@ -0,0 +1,255 @@
|
||||
<script lang="ts">
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
import Textarea from '@components/input/Textarea.svelte';
|
||||
import Password from '@components/input/Password.svelte';
|
||||
import BitBadge from '@components/input/BitBadge.svelte';
|
||||
import UserSearch from '@components/admin/search/UserSearch.svelte';
|
||||
import Checkbox from '@components/input/Checkbox.svelte';
|
||||
import TeamSearch from '@components/admin/search/TeamSearch.svelte';
|
||||
|
||||
// html bindings
|
||||
let modal: HTMLDialogElement;
|
||||
let modalForm: HTMLFormElement;
|
||||
|
||||
// types
|
||||
interface Props<T> {
|
||||
texts: {
|
||||
title: string;
|
||||
submitButtonTitle: string;
|
||||
confirmPopupTitle: string;
|
||||
confirmPopupMessage: string;
|
||||
};
|
||||
|
||||
target: T | null;
|
||||
keys: Key<any>[][];
|
||||
|
||||
onSubmit: (target: T) => void;
|
||||
onClose?: () => void;
|
||||
|
||||
open: boolean;
|
||||
}
|
||||
type Key<T extends KeyInputType> = {
|
||||
key: string;
|
||||
type: T | null;
|
||||
label: string;
|
||||
default?: any;
|
||||
options?: KeyInputTypeOptions<T>;
|
||||
};
|
||||
type KeyInputType =
|
||||
| 'bit-badge'
|
||||
| 'checkbox'
|
||||
| 'color'
|
||||
| 'date'
|
||||
| 'datetime-local'
|
||||
| 'password'
|
||||
| 'tel'
|
||||
| 'team-search'
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'user-search';
|
||||
type KeyInputTypeOptions<T extends KeyInputType> = {
|
||||
['bit-badge']: {
|
||||
available: Record<number, string>;
|
||||
};
|
||||
['checkbox']: {};
|
||||
['color']: {};
|
||||
['date']: {};
|
||||
['datetime-local']: {};
|
||||
['password']: {};
|
||||
['tel']: {};
|
||||
['team-search']: {};
|
||||
['text']: {};
|
||||
['textarea']: {
|
||||
rows?: boolean;
|
||||
};
|
||||
['user-search']: {
|
||||
mustMatch?: boolean;
|
||||
};
|
||||
}[T] & {
|
||||
convert?: (value: any) => any;
|
||||
validate?: (value: any) => boolean;
|
||||
required?: boolean;
|
||||
dynamicWidth?: boolean;
|
||||
};
|
||||
|
||||
// input
|
||||
let { texts, target, keys, onSubmit, onClose, open = $bindable() }: Props<any> = $props();
|
||||
|
||||
onInit();
|
||||
|
||||
// state
|
||||
let submitEnabled = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
onInit();
|
||||
modal.show();
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
if (target == null) onInit();
|
||||
updateSubmitEnabled();
|
||||
});
|
||||
|
||||
// function
|
||||
function onInit() {
|
||||
if (target == null) target = {};
|
||||
|
||||
for (const key of keys) {
|
||||
for (const k of key) {
|
||||
if (k.default !== undefined && target[k.key] === undefined)
|
||||
target[k.key] = k.options?.convert ? k.options.convert(k.default) : k.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateSubmitEnabled() {
|
||||
submitEnabled = false;
|
||||
for (const key of keys) {
|
||||
for (const k of key) {
|
||||
if (k.options?.required) {
|
||||
if (k.options?.validate) {
|
||||
if (!k.options.validate(target[k.key])) return;
|
||||
} else {
|
||||
if (!target[k.key]) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
submitEnabled = true;
|
||||
}
|
||||
|
||||
// callbacks
|
||||
function onBindChange(key: string, value: any, options?: KeyInputTypeOptions<any>) {
|
||||
target[key] = options?.convert ? options.convert(value) : value;
|
||||
|
||||
updateSubmitEnabled();
|
||||
}
|
||||
|
||||
async function onSaveButtonClick(e: Event) {
|
||||
e.preventDefault();
|
||||
$confirmPopupState = {
|
||||
title: texts.confirmPopupTitle,
|
||||
message: texts.confirmPopupMessage,
|
||||
onConfirm: () => {
|
||||
modalForm.submit();
|
||||
onSubmit(target);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function onCancelButtonClick(e: Event) {
|
||||
e.preventDefault();
|
||||
modalForm.submit();
|
||||
}
|
||||
|
||||
function onModalClose() {
|
||||
setTimeout(() => {
|
||||
open = false;
|
||||
target = null;
|
||||
modalForm.reset();
|
||||
onClose?.();
|
||||
}, 300);
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog class="modal" bind:this={modal} onclose={onModalClose}>
|
||||
<form method="dialog" class="modal-box overflow-visible" bind:this={modalForm}>
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onCancelButtonClick}>✕</button>
|
||||
<div class="space-y-5">
|
||||
<h3 class="text-xl font-geist font-bold">{texts.title}</h3>
|
||||
<div class="w-full flex flex-col">
|
||||
{#each keys as key (key)}
|
||||
<div
|
||||
class="grid grid-flow-col gap-4"
|
||||
class:grid-cols-1={key.length === 1}
|
||||
class:grid-cols-2={key.length === 2}
|
||||
>
|
||||
{#each key as k (k)}
|
||||
{#if k.type === 'color' || k.type === 'date' || k.type === 'datetime-local' || k.type === 'tel' || k.type === 'text'}
|
||||
<Input
|
||||
type={k.type}
|
||||
bind:value={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
dynamicWidth={k.options?.dynamicWidth}
|
||||
/>
|
||||
{:else if k.type === 'textarea'}
|
||||
<Textarea
|
||||
bind:value={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
rows={k.options?.rows}
|
||||
dynamicWidth={k.options?.dynamicWidth}
|
||||
/>
|
||||
{:else if k.type === 'checkbox'}
|
||||
<Checkbox
|
||||
bind:checked={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
/>
|
||||
{:else if k.type === 'user-search'}
|
||||
<UserSearch
|
||||
bind:value={
|
||||
() =>
|
||||
target[k.key] ? (target[k.key]['username'] ?? null) : k.default ? k.default['username'] : null,
|
||||
(v) =>
|
||||
k.options.mustMatch
|
||||
? onBindChange(k.key, { id: null, username: null }, k.options)
|
||||
: onBindChange(k.key, { id: null, username: v }, k.options)
|
||||
}
|
||||
onSubmit={(user) => onBindChange(k.key, user, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
mustMatch={k.options?.mustMatch}
|
||||
/>
|
||||
{:else if k.type === 'team-search'}
|
||||
<TeamSearch
|
||||
bind:value={
|
||||
() => (target[k.key] ? (target[k.key]['name'] ?? null) : k.default ? k.default['name'] : null),
|
||||
(v) =>
|
||||
k.options.mustMatch
|
||||
? onBindChange(k.key, { id: null, name: null, color: null }, k.options)
|
||||
: onBindChange(k.key, { id: null, name: v, color: null }, k.options)
|
||||
}
|
||||
onSubmit={(team) => onBindChange(k.key, team, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
mustMatch={k.options?.mustMatch}
|
||||
/>
|
||||
{:else if k.type === 'password'}
|
||||
<Password
|
||||
bind:value={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
/>
|
||||
{:else if k.type === 'bit-badge'}
|
||||
<BitBadge
|
||||
available={k.options?.available}
|
||||
bind:value={
|
||||
() => (target[k.key] ? (target[k.key]['name'] ?? target[k.key]) : k.default),
|
||||
(v) => onBindChange(k.key, v, k.options)
|
||||
}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-success"
|
||||
class:disabled={!submitEnabled}
|
||||
disabled={!submitEnabled}
|
||||
onclick={onSaveButtonClick}>{texts.submitButtonTitle}</button
|
||||
>
|
||||
<button class="btn btn-error" type="button" onclick={onCancelButtonClick}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||
<button class="!cursor-default">close</button>
|
||||
</form>
|
||||
</dialog>
|
@ -20,7 +20,7 @@
|
||||
let { id, value = $bindable(), label, readonly, required, mustMatch, requestSuggestions, onSubmit }: Props = $props();
|
||||
|
||||
// states
|
||||
let inputValue = $state(value);
|
||||
let inputValue = $derived(value);
|
||||
let suggestions = $state<string[]>([]);
|
||||
let matched = $state(false);
|
||||
|
||||
@ -38,7 +38,7 @@
|
||||
if (suggestion != null) {
|
||||
inputValue = value = suggestion;
|
||||
matched = true;
|
||||
onSubmit?.(value);
|
||||
onSubmit?.(suggestion);
|
||||
} else if (!mustMatch) {
|
||||
value = inputValue;
|
||||
matched = false;
|
||||
@ -52,7 +52,7 @@
|
||||
function onSuggestionClick(suggestion: string) {
|
||||
inputValue = value = suggestion;
|
||||
suggestions = [];
|
||||
onSubmit?.(value);
|
||||
onSubmit?.(suggestion);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
73
src/components/admin/table/DataTable.svelte
Normal file
73
src/components/admin/table/DataTable.svelte
Normal file
@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from 'svelte/store';
|
||||
import SortableTr from '@components/admin/table/SortableTr.svelte';
|
||||
import SortableTh from '@components/admin/table/SortableTh.svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getObjectEntryByKey } from '@util/objects.ts';
|
||||
|
||||
// types
|
||||
interface Props<T> {
|
||||
data: Writable<T[]>;
|
||||
|
||||
count?: boolean;
|
||||
keys: {
|
||||
key: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
sortable?: boolean;
|
||||
transform?: Snippet<[T]>;
|
||||
}[];
|
||||
|
||||
onClick?: (t: T) => void;
|
||||
onEdit?: (t: T) => void;
|
||||
}
|
||||
|
||||
// input
|
||||
let { data, count, keys, onClick, onEdit }: Props<any> = $props();
|
||||
</script>
|
||||
|
||||
<div class="h-screen overflow-x-auto">
|
||||
<table class="table table-pin-rows">
|
||||
<thead>
|
||||
<SortableTr {data}>
|
||||
{#if count}
|
||||
<SortableTh style="width: 5%">#</SortableTh>
|
||||
{/if}
|
||||
{#each keys as key (key.key)}
|
||||
<SortableTh style={key.width ? `width: ${key.width}%` : undefined} key={key.sortable ? key.key : undefined}
|
||||
>{key.label}</SortableTh
|
||||
>
|
||||
{/each}
|
||||
{#if onEdit}
|
||||
<SortableTh style="width: 5%"></SortableTh>
|
||||
{/if}
|
||||
</SortableTr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $data as d, i (d)}
|
||||
<tr class="hover:bg-base-200" onclick={() => onClick?.(d)}>
|
||||
{#if count}
|
||||
<td>{i + 1}</td>
|
||||
{/if}
|
||||
{#each keys as key (key.key)}
|
||||
<td>
|
||||
{#if key.transform}
|
||||
{@render key.transform(getObjectEntryByKey(key.key, d))}
|
||||
{:else}
|
||||
{getObjectEntryByKey(key.key, d)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
{#if onEdit}
|
||||
<td>
|
||||
<button class="cursor-pointer" onclick={() => onEdit(d)}>
|
||||
<Icon icon="heroicons:pencil-square" />
|
||||
</button>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { setContext, type Snippet } from 'svelte';
|
||||
import { type Writable, writable } from 'svelte/store';
|
||||
import { getObjectEntryByKey } from '@util/objects.ts';
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
@ -21,8 +22,8 @@
|
||||
function onSort(key: string, order: 'asc' | 'desc') {
|
||||
data.update((old) => {
|
||||
old.sort((a, b) => {
|
||||
let entryA = getDataEntryByKey(key, a);
|
||||
let entryB = getDataEntryByKey(key, b);
|
||||
let entryA = getObjectEntryByKey(key, a);
|
||||
let entryB = getObjectEntryByKey(key, b);
|
||||
|
||||
if (entryA === undefined || entryB === undefined) return 0;
|
||||
|
||||
@ -40,16 +41,6 @@
|
||||
return old;
|
||||
});
|
||||
}
|
||||
|
||||
function getDataEntryByKey(key: string, data: { [key: string]: any }): any | undefined {
|
||||
let entry = data;
|
||||
for (const part of key.split('.')) {
|
||||
if ((entry = entry[part]) === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
</script>
|
||||
|
||||
<tr {...restProps}>
|
||||
|
50
src/components/input/BitBadge.svelte
Normal file
50
src/components/input/BitBadge.svelte
Normal file
@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
available: Record<number, string>;
|
||||
value: number;
|
||||
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
// inputs
|
||||
let { available, value = $bindable(), readonly }: Props = $props();
|
||||
|
||||
// idk why, but this is needed to trigger loop reactivity
|
||||
let reactiveValue = $derived(value);
|
||||
|
||||
// callbacks
|
||||
function onOptionSelect(e: Event) {
|
||||
const selected = Number((e.target as HTMLSelectElement).value);
|
||||
|
||||
reactiveValue |= selected;
|
||||
|
||||
(e.target as HTMLSelectElement).value = '-';
|
||||
}
|
||||
|
||||
function onBadgeRemove(flag: number) {
|
||||
reactiveValue &= ~flag;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if !readonly}
|
||||
<select class="select select-xs w-min" onchange={onOptionSelect}>
|
||||
<option selected hidden>-</option>
|
||||
{#each Object.entries(available) as [flag, badge] (flag)}
|
||||
<option value={flag} hidden={(reactiveValue & Number(flag)) !== 0}>{badge}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<div class="flex flow flex-wrap gap-2">
|
||||
{#each Object.entries(available) as [flag, badge] (flag)}
|
||||
{#if (reactiveValue & Number(flag)) !== 0}
|
||||
<div class="badge badge-outline gap-1">
|
||||
{#if !readonly}
|
||||
<button class="cursor-pointer" type="button" onclick={() => onBadgeRemove(Number(flag))}>✕</button>
|
||||
{/if}
|
||||
<span>{badge}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
Reference in New Issue
Block a user