feat: DashboardDataGrid migration + FileList 7-feature rewrite + validation API
DashboardDataGrid:
- Add server-side pagination (serverTotal, serverTotalPages)
- Add hideFilter, bulkActions (renamed from children)
- Add header/rowCell snippet slots
- Support {#key} for forced re-render
/dashboards migration:
- Replace inline CSS Grid (1460px) with DashboardDataGrid
- Header snippet for sort buttons + ColumnFilterPopover
- Render functions with raw:true for complex cells
- Selection bridge (array <-> Set) for checkboxes
- Server-side pagination via model bridge
- Maintenance badge mounting via $effect + tick
- Validation dots via {#key localValidationVersion}
Fixes:
- getFilterOptions: pass column parameter (was hardcoded 'title')
- pageSize bridge: pass Event-like object (was number)
- getValidationStatusBatch: stub -> real fetchApi endpoint
- Breadcrumbs: nav.dashboard -> nav.dashboards
- Uncaught (in promise): add .catch() to async calls
Backend:
- New GET /status/batch endpoint for validation batch query
FileList rewrite (7 features):
1. Headers: text-sm font-semibold (was text-xs uppercase)
2. Column sorting (Name, Category, Size, Date)
3. Breadcrumbs with navigate-up button
4. Loading skeleton (animated rows)
5. Client-side pagination (20 per page)
6. Multi-select + bulk delete/download
7. Search by name/category
QA: build passes, 131 dashboard tests pass, 0 console errors
Pre-existing: 3 test failures unrelated (provider_config, api stub)
This commit is contained in:
@@ -121,7 +121,7 @@ async def create_task(
|
|||||||
extra={"src": "validation_tasks_routes"},
|
extra={"src": "validation_tasks_routes"},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = service.create_task(payload)
|
result = await service.create_task(payload)
|
||||||
scheduler.reload_validation_policy(result.id)
|
scheduler.reload_validation_policy(result.id)
|
||||||
return result
|
return result
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -203,7 +203,7 @@ async def update_task(
|
|||||||
extra={"src": "validation_tasks_routes"},
|
extra={"src": "validation_tasks_routes"},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = service.update_task(policy_id, payload)
|
result = await service.update_task(policy_id, payload)
|
||||||
scheduler.reload_validation_policy(result.id)
|
scheduler.reload_validation_policy(result.id)
|
||||||
return result
|
return result
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -259,7 +259,7 @@ async def toggle_task_status(
|
|||||||
extra={"src": "validation_tasks_routes"},
|
extra={"src": "validation_tasks_routes"},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = service.update_task(policy_id, ValidationTaskUpdate(is_active=is_active))
|
result = await service.update_task(policy_id, ValidationTaskUpdate(is_active=is_active))
|
||||||
if not is_active:
|
if not is_active:
|
||||||
scheduler.remove_validation_job(policy_id)
|
scheduler.remove_validation_job(policy_id)
|
||||||
else:
|
else:
|
||||||
@@ -327,7 +327,7 @@ async def list_all_runs(
|
|||||||
extra={"src": "validation_tasks_routes"},
|
extra={"src": "validation_tasks_routes"},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
total, raw_items = service.list_all_runs(
|
total, raw_items = await service.list_all_runs(
|
||||||
status=status_filter, environment_id=environment_id, dashboard_id=dashboard_id,
|
status=status_filter, environment_id=environment_id, dashboard_id=dashboard_id,
|
||||||
page=page, page_size=page_size,
|
page=page, page_size=page_size,
|
||||||
)
|
)
|
||||||
@@ -382,7 +382,7 @@ async def get_run_detail(
|
|||||||
extra={"src": "validation_tasks_routes"},
|
extra={"src": "validation_tasks_routes"},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = service.get_run_detail(run_id)
|
result = await service.get_run_detail(run_id)
|
||||||
return RunDetailResponse(
|
return RunDetailResponse(
|
||||||
run=_dict_to_run_response(result["run"]),
|
run=_dict_to_run_response(result["run"]),
|
||||||
records=result.get("records", []),
|
records=result.get("records", []),
|
||||||
@@ -490,4 +490,70 @@ async def legacy_llm_report_redirect(
|
|||||||
# #endregion legacy_llm_report_redirect
|
# #endregion legacy_llm_report_redirect
|
||||||
|
|
||||||
|
|
||||||
|
# #region get_validation_status_batch [C:3] [TYPE Function] [SEMANTICS validation,api,status,batch]
|
||||||
|
# @BRIEF Batch-fetch latest validation status for multiple dashboard IDs.
|
||||||
|
# @LAYER API
|
||||||
|
# @RELATION DEPENDS_ON -> [ValidationRecord]
|
||||||
|
# @DATA_CONTRACT params -> env_id: str, dashboard_ids: str (comma-separated)
|
||||||
|
# @DATA_CONTRACT response -> { [dashboard_id]: { status: str, run_id: str | None, task_name: str | None, last_run_at: str | None, history: list } }
|
||||||
|
@router.get("/status/batch")
|
||||||
|
async def get_validation_status_batch(
|
||||||
|
env_id: str = Query(...),
|
||||||
|
dashboard_ids: str = Query(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return latest validation status for each requested dashboard ID."""
|
||||||
|
from ...models.llm import ValidationRecord, ValidationRun
|
||||||
|
|
||||||
|
ids = [id.strip() for id in dashboard_ids.split(",") if id.strip()]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Get latest record per dashboard for this environment
|
||||||
|
from sqlalchemy import and_, desc
|
||||||
|
records = (
|
||||||
|
db.query(ValidationRecord)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
ValidationRecord.dashboard_id.in_(ids),
|
||||||
|
ValidationRecord.environment_id == env_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(desc(ValidationRecord.timestamp))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get run info for task names
|
||||||
|
run_ids = list({r.run_id for r in records if r.run_id})
|
||||||
|
runs = {}
|
||||||
|
if run_ids:
|
||||||
|
run_objs = db.query(ValidationRun).filter(ValidationRun.id.in_(run_ids)).all()
|
||||||
|
runs = {r.id: r for r in run_objs}
|
||||||
|
|
||||||
|
result: dict[str, dict] = {}
|
||||||
|
for rec in records:
|
||||||
|
if rec.dashboard_id in result:
|
||||||
|
# Append to history if already have latest
|
||||||
|
result[rec.dashboard_id]["history"].append({
|
||||||
|
"status": rec.status,
|
||||||
|
"last_run_at": rec.timestamp.isoformat() if rec.timestamp else None,
|
||||||
|
"task_name": runs.get(rec.run_id, {}).task_id if rec.run_id else None,
|
||||||
|
"run_id": rec.run_id,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# First (latest) record sets the primary status
|
||||||
|
run = runs.get(rec.run_id)
|
||||||
|
result[rec.dashboard_id] = {
|
||||||
|
"status": rec.status,
|
||||||
|
"last_run_at": rec.timestamp.isoformat() if rec.timestamp else None,
|
||||||
|
"task_name": rec.task_id or (run.task_id if run else None),
|
||||||
|
"run_id": rec.run_id,
|
||||||
|
"history": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
# #endregion get_validation_status_batch
|
||||||
|
|
||||||
|
|
||||||
# #endregion ValidationTasksRoutes
|
# #endregion ValidationTasksRoutes
|
||||||
|
|||||||
@@ -1034,7 +1034,8 @@ export const api = {
|
|||||||
// @RELATION DEPENDS_ON -> [fetchApi]
|
// @RELATION DEPENDS_ON -> [fetchApi]
|
||||||
// @DATA_CONTRACT params -> { env_id, dashboard_ids: comma-separated string }
|
// @DATA_CONTRACT params -> { env_id, dashboard_ids: comma-separated string }
|
||||||
// @DATA_CONTRACT response -> { [dashboard_id]: { status: PASS|FAIL|WARN, last_run_at?, task_name?, run_id?, history?: { status, last_run_at, task_name }[] } }
|
// @DATA_CONTRACT response -> { [dashboard_id]: { status: PASS|FAIL|WARN, last_run_at?, task_name?, run_id?, history?: { status, last_run_at, task_name }[] } }
|
||||||
getValidationStatusBatch: async <T = unknown>(): Promise<T> => ({ results: [] } as T),
|
getValidationStatusBatch: <T = unknown>(envId: string, dashboardIds: string) =>
|
||||||
|
fetchApi<T>(`/validation-tasks/status/batch?env_id=${encodeURIComponent(envId)}&dashboard_ids=${encodeURIComponent(dashboardIds)}`),
|
||||||
// #endregion getValidationStatusBatch
|
// #endregion getValidationStatusBatch
|
||||||
|
|
||||||
// ═══ API Keys ═════════════════════════════════════════════════
|
// ═══ API Keys ═════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<!-- #region Dashboard.DataGrid [C:3] [TYPE Component] [SEMANTICS dashboard,grid,selection,sort,filter,pagination] -->
|
<!-- #region Dashboard.DataGrid [C:3] [TYPE Component] [SEMANTICS dashboard,grid,selection,sort,filter,pagination,server-side] -->
|
||||||
<!-- @ingroup Dashboard -->
|
<!-- @ingroup Dashboard -->
|
||||||
<!-- @BRIEF Configurable dashboard data grid with optional selection, sorting, filtering, and pagination. -->
|
<!-- @BRIEF Configurable dashboard data grid with optional selection, sorting, filtering, pagination, custom header/cell snippets, and server-side pagination support. -->
|
||||||
<!-- @LAYER UI -->
|
<!-- @LAYER UI -->
|
||||||
<!-- @RATIONALE Replaces DashboardGrid — consolidation of 3 dashboard grids (migration, git, dashboards) into one configurable component. Reduces ~1080 lines of duplicated sort/filter/paginate/select logic to a single component with opt-in features. -->
|
<!-- @RATIONALE Replaces DashboardGrid — consolidation of 3 dashboard grids (migration, git, dashboards) into one configurable component. Reduces ~1080 lines of duplicated sort/filter/paginate/select logic to a single component with opt-in features. Added server-side pagination and snippet slots for dashboards inline grid migration. -->
|
||||||
<!-- @RELATION DEPENDS_ON -> [$lib/ui/Input] -->
|
<!-- @RELATION DEPENDS_ON -> [$lib/ui/Input] -->
|
||||||
<!-- @RELATION DEPENDS_ON -> [$lib/ui/Button] -->
|
<!-- @RELATION DEPENDS_ON -> [$lib/ui/Button] -->
|
||||||
<!-- @RELATION USED_BY -> [frontend/src/routes/migration/+page.svelte] -->
|
<!-- @RELATION USED_BY -> [frontend/src/routes/migration/+page.svelte] -->
|
||||||
|
<!-- @RELATION USED_BY -> [frontend/src/routes/dashboards/+page.svelte] -->
|
||||||
<!-- @UX_STATE Loading -> Animated skeleton rows -->
|
<!-- @UX_STATE Loading -> Animated skeleton rows -->
|
||||||
<!-- @UX_STATE Empty -> Centered dashed-border message with muted text -->
|
<!-- @UX_STATE Empty -> Centered dashed-border message with muted text -->
|
||||||
<!-- @UX_STATE Table -> Data rows with optional selection, sort headers, filter, pagination -->
|
<!-- @UX_STATE Table -> Data rows with optional selection, sort headers, filter, pagination -->
|
||||||
@@ -21,15 +22,19 @@
|
|||||||
<!-- @INVARIANT State via $bindable() — parent MAY bind props for external state ownership -->
|
<!-- @INVARIANT State via $bindable() — parent MAY bind props for external state ownership -->
|
||||||
<!-- @INVARIANT Filter is case-insensitive across all visible columns (using render() if defined, else raw value) -->
|
<!-- @INVARIANT Filter is case-insensitive across all visible columns (using render() if defined, else raw value) -->
|
||||||
<!-- @INVARIANT Sorting operates on item[key] raw value, independent of display rendering -->
|
<!-- @INVARIANT Sorting operates on item[key] raw value, independent of display rendering -->
|
||||||
<!-- @INVARIANT Page resets to 0 on filter change -->
|
<!-- @INVARIANT Page resets to 0 on filter change (client-side mode only) -->
|
||||||
|
<!-- @INVARIANT In server-side mode (serverTotal provided), data is treated as current page, no client-side slicing -->
|
||||||
|
<!-- @INVARIANT Custom header/cell snippets receive Column and item — parent owns per-column UI -->
|
||||||
<!-- @PRE data is array of items, columns is array of Column definitions -->
|
<!-- @PRE data is array of items, columns is array of Column definitions -->
|
||||||
<!-- @POST Renders empty state when data is empty, loading skeleton when loading is true -->
|
<!-- @POST Renders empty state when data is empty, loading skeleton when loading is true -->
|
||||||
<!-- @POST Renders checkboxes when selectable, filter input when filterable, pagination when paginated -->
|
<!-- @POST Renders checkboxes when selectable, filter input when filterable, pagination when paginated -->
|
||||||
<!-- @POST Selected items tracked via bind:selectedIds regardless of which page they are on -->
|
<!-- @POST Selected items tracked via bind:selectedIds regardless of which page they are on -->
|
||||||
|
<!-- @POST With serverTotal, pagination uses server counts; displayData = data directly -->
|
||||||
<!-- @SIDE_EFFECT Modifies selectedIds array via bind: when user checks/unchecks -->
|
<!-- @SIDE_EFFECT Modifies selectedIds array via bind: when user checks/unchecks -->
|
||||||
<!-- @SIDE_EFFECT Modifies page/filterText/sortColumn/sortDirection via bind: when user interacts -->
|
<!-- @SIDE_EFFECT Modifies page/filterText/sortColumn/sortDirection via bind: when user interacts -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// [SECTION: IMPORTS]
|
// [SECTION: IMPORTS]
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte.js';
|
import { t } from '$lib/i18n/index.svelte.js';
|
||||||
import { Button, EmptyState, Input } from "$lib/ui";
|
import { Button, EmptyState, Input } from "$lib/ui";
|
||||||
|
|
||||||
@@ -68,11 +73,17 @@
|
|||||||
filterable = false,
|
filterable = false,
|
||||||
filterText = $bindable("" as string),
|
filterText = $bindable("" as string),
|
||||||
filterPlaceholder = "",
|
filterPlaceholder = "",
|
||||||
|
/** Hide built-in filter input (parent provides custom search UI) */
|
||||||
|
hideFilter = false,
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
paginated = false,
|
paginated = false,
|
||||||
page = $bindable(0),
|
page = $bindable(0),
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
|
/** Server-side total count — when provided, disables client-side slicing */
|
||||||
|
serverTotal = undefined as number | undefined,
|
||||||
|
/** Server-side total pages — when provided, used instead of Math.ceil(totalCount/pageSize) */
|
||||||
|
serverTotalPages = undefined as number | undefined,
|
||||||
|
|
||||||
// UX
|
// UX
|
||||||
loading = false,
|
loading = false,
|
||||||
@@ -80,7 +91,7 @@
|
|||||||
emptyText = "",
|
emptyText = "",
|
||||||
|
|
||||||
// Snippet for bulk actions bar (shown above table when items are selected)
|
// Snippet for bulk actions bar (shown above table when items are selected)
|
||||||
children = undefined,
|
bulkActions = undefined as Snippet | undefined,
|
||||||
|
|
||||||
// Per-row action buttons (rendered as a final column)
|
// Per-row action buttons (rendered as a final column)
|
||||||
actions = [] as Array<{
|
actions = [] as Array<{
|
||||||
@@ -90,23 +101,17 @@
|
|||||||
condition?: (item: any) => boolean;
|
condition?: (item: any) => boolean;
|
||||||
disabled?: (item: any) => boolean;
|
disabled?: (item: any) => boolean;
|
||||||
}>,
|
}>,
|
||||||
|
|
||||||
|
// Custom header snippet — receives Column, parent renders sort button + filters
|
||||||
|
header = undefined as Snippet | undefined,
|
||||||
|
|
||||||
|
// Custom cell snippet — receives item, parent renders complex cells
|
||||||
|
rowCell = undefined as Snippet | undefined,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// [SECTION: HELPERS]
|
|
||||||
function getCellText(item: any, column: Column): string {
|
|
||||||
if (column.render) return column.render(item);
|
|
||||||
return String(item[column.key] ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemMatchesFilter(item: any): boolean {
|
|
||||||
if (!filterable || !filterText) return true;
|
|
||||||
const q = filterText.toLowerCase();
|
|
||||||
return columns.some(col => {
|
|
||||||
return getCellText(item, col).toLowerCase().includes(q);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// [SECTION: DERIVED]
|
// [SECTION: DERIVED]
|
||||||
|
let isServerMode = $derived(serverTotal !== undefined);
|
||||||
|
|
||||||
let filteredData = $derived(
|
let filteredData = $derived(
|
||||||
filterable && filterText ? data.filter(itemMatchesFilter) : data,
|
filterable && filterText ? data.filter(itemMatchesFilter) : data,
|
||||||
);
|
);
|
||||||
@@ -125,13 +130,21 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let displayData = $derived(
|
let displayData = $derived(
|
||||||
paginated
|
isServerMode
|
||||||
|
? sortedData // Server already paginated — use as-is
|
||||||
|
: paginated
|
||||||
? sortedData.slice(page * pageSize, (page + 1) * pageSize)
|
? sortedData.slice(page * pageSize, (page + 1) * pageSize)
|
||||||
: sortedData,
|
: sortedData,
|
||||||
);
|
);
|
||||||
|
|
||||||
let totalCount = $derived(sortedData.length);
|
let totalCount = $derived(isServerMode ? serverTotal! : sortedData.length);
|
||||||
let totalPages = $derived(paginated ? Math.ceil(totalCount / pageSize) : 1);
|
let totalPages = $derived(
|
||||||
|
isServerMode
|
||||||
|
? (serverTotalPages ?? Math.ceil(totalCount / pageSize))
|
||||||
|
: paginated
|
||||||
|
? Math.ceil(totalCount / pageSize)
|
||||||
|
: 1,
|
||||||
|
);
|
||||||
|
|
||||||
let allSelected = $derived(
|
let allSelected = $derived(
|
||||||
displayData.length > 0 && displayData.every((d: any) => selectedIds.includes(d[keyField])),
|
displayData.length > 0 && displayData.every((d: any) => selectedIds.includes(d[keyField])),
|
||||||
@@ -140,6 +153,20 @@
|
|||||||
displayData.some((d: any) => selectedIds.includes(d[keyField])),
|
displayData.some((d: any) => selectedIds.includes(d[keyField])),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// [SECTION: HELPERS]
|
||||||
|
function getCellText(item: any, column: Column): string {
|
||||||
|
if (column.render) return column.render(item);
|
||||||
|
return String(item[column.key] ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemMatchesFilter(item: any): boolean {
|
||||||
|
if (!filterable || !filterText) return true;
|
||||||
|
const q = filterText.toLowerCase();
|
||||||
|
return columns.some(col => {
|
||||||
|
return getCellText(item, col).toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// [SECTION: HANDLERS]
|
// [SECTION: HANDLERS]
|
||||||
function handleSort(key: string) {
|
function handleSort(key: string) {
|
||||||
if (sortColumn === key) {
|
if (sortColumn === key) {
|
||||||
@@ -176,17 +203,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// [SECTION: REACTIVE]
|
// [SECTION: REACTIVE]
|
||||||
// Reset page to 0 when filter text changes
|
// Reset page to 0 when filter text changes (client-side mode only)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
filterText; // track
|
filterText; // track
|
||||||
if (paginated) page = 0;
|
if (paginated && !isServerMode) page = 0;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- [SECTION: TEMPLATE] -->
|
<!-- [SECTION: TEMPLATE] -->
|
||||||
<div class="dashboard-data-grid">
|
<div class="dashboard-data-grid">
|
||||||
<!-- ── Filter Input ── -->
|
<!-- ── Filter Input ── -->
|
||||||
{#if filterable}
|
{#if filterable && !hideFilter}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<Input
|
<Input
|
||||||
bind:value={filterText}
|
bind:value={filterText}
|
||||||
@@ -196,16 +223,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ── Bulk Actions Bar ── -->
|
<!-- ── Bulk Actions Bar ── -->
|
||||||
{#if selectable && selectedIds.length > 0 && children}
|
{#if selectable && selectedIds.length > 0 && bulkActions}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
{@render children()}
|
{@render bulkActions()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ── Loading Skeleton ── -->
|
<!-- ── Loading Skeleton ── -->
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="animate-pulse rounded-lg border border-border overflow-hidden">
|
<div class="animate-pulse rounded-lg border border-border overflow-hidden">
|
||||||
<div class="bg-surface-muted h-10 border-b border-border"></div>
|
<div class="bg-surface-muted h-12 border-b border-border"></div>
|
||||||
{#each Array(loadingRows) as _}
|
{#each Array(loadingRows) as _}
|
||||||
<div class="flex items-center gap-4 px-6 py-4 border-b border-border last:border-b-0">
|
<div class="flex items-center gap-4 px-6 py-4 border-b border-border last:border-b-0">
|
||||||
{#if selectable}
|
{#if selectable}
|
||||||
@@ -235,7 +262,7 @@
|
|||||||
<thead class="bg-surface-muted">
|
<thead class="bg-surface-muted">
|
||||||
<tr>
|
<tr>
|
||||||
{#if selectable}
|
{#if selectable}
|
||||||
<th class="px-6 py-3 text-left w-12" scope="col">
|
<th class="px-6 py-4 text-left w-12" scope="col">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={allSelected}
|
checked={allSelected}
|
||||||
@@ -249,13 +276,16 @@
|
|||||||
{#each columns as col (col.key)}
|
{#each columns as col (col.key)}
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider
|
class="px-6 py-4 text-left text-sm font-semibold text-text-muted
|
||||||
{col.sortable ? 'cursor-pointer hover:text-text transition-colors select-none' : ''}
|
{col.sortable && !header ? 'cursor-pointer hover:text-text transition-colors select-none' : ''}
|
||||||
{col.class || ''}"
|
{col.class || ''}"
|
||||||
style={col.width ? `width:${col.width}` : ""}
|
style={col.width ? `width:${col.width}` : ""}
|
||||||
onclick={col.sortable ? () => handleSort(col.key) : undefined}
|
onclick={col.sortable && !header ? () => handleSort(col.key) : undefined}
|
||||||
aria-sort={col.sortable && sortColumn === col.key ? (sortDirection === 'asc' ? 'ascending' : 'descending') : undefined}
|
aria-sort={col.sortable && sortColumn === col.key ? (sortDirection === 'asc' ? 'ascending' : 'descending') : undefined}
|
||||||
>
|
>
|
||||||
|
{#if header}
|
||||||
|
{@render header(col)}
|
||||||
|
{:else}
|
||||||
{col.label}
|
{col.label}
|
||||||
{#if col.sortable}
|
{#if col.sortable}
|
||||||
{#if sortColumn === col.key}
|
{#if sortColumn === col.key}
|
||||||
@@ -264,10 +294,11 @@
|
|||||||
<span class="ml-0.5 text-text-subtle opacity-40">⇅</span>
|
<span class="ml-0.5 text-text-subtle opacity-40">⇅</span>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</th>
|
</th>
|
||||||
{/each}
|
{/each}
|
||||||
{#if actions.length > 0}
|
{#if actions.length > 0}
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
|
<th scope="col" class="px-6 py-4 text-left text-sm font-semibold text-text-muted">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -291,7 +322,9 @@
|
|||||||
class="px-6 py-4 whitespace-nowrap text-sm {col.class || ''}"
|
class="px-6 py-4 whitespace-nowrap text-sm {col.class || ''}"
|
||||||
style={col.width ? `width:${col.width}` : ""}
|
style={col.width ? `width:${col.width}` : ""}
|
||||||
>
|
>
|
||||||
{#if col.render}
|
{#if rowCell}
|
||||||
|
{@render rowCell(item)}
|
||||||
|
{:else if col.render}
|
||||||
{#if col.raw}
|
{#if col.raw}
|
||||||
{@html col.render(item)}
|
{@html col.render(item)}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -326,7 +359,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Pagination Controls ── -->
|
<!-- ── Pagination Controls ── -->
|
||||||
{#if paginated && sortedData.length > 0}
|
{#if paginated && totalCount > 0}
|
||||||
<div class="flex items-center justify-between mt-4">
|
<div class="flex items-center justify-between mt-4">
|
||||||
<div class="text-sm text-text-muted">
|
<div class="text-sm text-text-muted">
|
||||||
{($t.dashboard?.showing ?? "")
|
{($t.dashboard?.showing ?? "")
|
||||||
|
|||||||
@@ -303,7 +303,7 @@
|
|||||||
actions={rowActions}
|
actions={rowActions}
|
||||||
loading={bulkActionRunning}
|
loading={bulkActionRunning}
|
||||||
>
|
>
|
||||||
{#snippet children()}
|
{#snippet bulkActions()}
|
||||||
{#if selectedIds.length > 0}
|
{#if selectedIds.length > 0}
|
||||||
<div class="flex flex-wrap items-center gap-2 rounded-lg border border-primary-ring bg-primary-light/60 px-3 py-2">
|
<div class="flex flex-wrap items-center gap-2 rounded-lg border border-primary-ring bg-primary-light/60 px-3 py-2">
|
||||||
{#if !repositoriesOnly}
|
{#if !repositoriesOnly}
|
||||||
|
|||||||
@@ -252,5 +252,80 @@ describe('DashboardDataGrid', () => {
|
|||||||
expect(screen.queryByText('Prev')).toBeNull();
|
expect(screen.queryByText('Prev')).toBeNull();
|
||||||
expect(screen.queryByText('Next')).toBeNull();
|
expect(screen.queryByText('Next')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Server-side pagination ────────────────────────────────────
|
||||||
|
it('uses serverTotal for pagination count when provided', () => {
|
||||||
|
render(DashboardDataGrid, {
|
||||||
|
data: sampleData.slice(0, 2), // Only 2 items visible
|
||||||
|
columns: sampleColumns,
|
||||||
|
paginated: true,
|
||||||
|
pageSize: 2,
|
||||||
|
serverTotal: 100, // Server says 100 total
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should show "Showing 1-2 of 100" not "Showing 1-2 of 3"
|
||||||
|
expect(screen.getByText(/Showing 1-2 of 100/)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses serverTotalPages for page count when provided', () => {
|
||||||
|
render(DashboardDataGrid, {
|
||||||
|
data: sampleData.slice(0, 2),
|
||||||
|
columns: sampleColumns,
|
||||||
|
paginated: true,
|
||||||
|
pageSize: 2,
|
||||||
|
serverTotal: 100,
|
||||||
|
serverTotalPages: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Next should be enabled (page 0 < 49)
|
||||||
|
const nextBtn = screen.getByText('Next');
|
||||||
|
expect((nextBtn as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables next button on last server-side page', () => {
|
||||||
|
render(DashboardDataGrid, {
|
||||||
|
data: [{ id: 99, title: 'Last', last_modified: '2025-06-01' }],
|
||||||
|
columns: sampleColumns,
|
||||||
|
paginated: true,
|
||||||
|
pageSize: 1,
|
||||||
|
page: 2,
|
||||||
|
serverTotal: 3,
|
||||||
|
serverTotalPages: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextBtn = screen.getByText('Next');
|
||||||
|
expect((nextBtn as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── hideFilter ────────────────────────────────────────────────
|
||||||
|
it('hides filter input when hideFilter is true', () => {
|
||||||
|
render(DashboardDataGrid, {
|
||||||
|
data: sampleData,
|
||||||
|
columns: sampleColumns,
|
||||||
|
filterable: true,
|
||||||
|
hideFilter: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByPlaceholderText('Search...')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Custom cell snippet (via raw render, tested as HTML injection) ──
|
||||||
|
it('renders raw HTML when column has raw:true and render function', () => {
|
||||||
|
const badgeColumns = [
|
||||||
|
{ key: 'status', label: 'Status', render: (item: any) => `<span class="badge">${item.status}</span>`, raw: true },
|
||||||
|
];
|
||||||
|
const badgeData = [
|
||||||
|
{ id: 1, status: 'published' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container } = render(DashboardDataGrid, {
|
||||||
|
data: badgeData,
|
||||||
|
columns: badgeColumns,
|
||||||
|
});
|
||||||
|
|
||||||
|
const badge = container.querySelector('.badge');
|
||||||
|
expect(badge).toBeDefined();
|
||||||
|
expect(badge?.textContent).toBe('published');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
// #endregion Test.Dashboard.DataGrid
|
// #endregion Test.Dashboard.DataGrid
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
*/
|
*/
|
||||||
function formatBreadcrumbLabel(segment) {
|
function formatBreadcrumbLabel(segment) {
|
||||||
const specialCases = {
|
const specialCases = {
|
||||||
dashboards: "nav.dashboard",
|
dashboards: "nav.dashboards",
|
||||||
datasets: "nav.datasets",
|
datasets: "nav.datasets",
|
||||||
storage: "nav.storage",
|
storage: "nav.storage",
|
||||||
admin: "nav.admin",
|
admin: "nav.admin",
|
||||||
|
|||||||
@@ -1,153 +1,361 @@
|
|||||||
<!-- #region FileList [C:3] [TYPE Component] [SEMANTICS storage, file, table, navigation, explorer] -->
|
<!-- #region FileList [C:3] [TYPE Component] [SEMANTICS storage,file,table,navigation,explorer,pagination,sort,search,selection] -->
|
||||||
<!-- @ingroup Components -->
|
<!-- @ingroup Components -->
|
||||||
<!-- @BRIEF Displays a table of files with metadata and actions. -->
|
<!-- @BRIEF File explorer table with breadcrumbs, search, sort, pagination, multi-select, and bulk actions. -->
|
||||||
<!-- @LAYER UI -->
|
<!-- @LAYER UI -->
|
||||||
<!--
|
<!-- @RELATION DEPENDS_ON -> [storageService] -->
|
||||||
@UX_STATE: Loading -> Default
|
<!-- @RELATION DEPENDS_ON -> [$lib/ui/Button] -->
|
||||||
|
<!-- @RELATION DEPENDS_ON -> [$lib/ui/Input] -->
|
||||||
@SEMANTICS: storage, files, list, table
|
<!-- @RELATION DEPENDS_ON -> [$lib/utils/dateFormat] -->
|
||||||
@PURPOSE: Displays a table of files with metadata and actions.
|
<!-- @UX_STATE Loading -> Skeleton rows -->
|
||||||
@LAYER UI
|
<!-- @UX_STATE Empty -> "No files" message -->
|
||||||
@RELATION DEPENDS_ON -> [storageService]
|
<!-- @UX_STATE Table -> Data rows with sort, search, pagination -->
|
||||||
|
<!-- @UX_STATE Selected -> Checkboxes + bulk action bar -->
|
||||||
@PROPS: files (Array) - List of StoredFile objects.
|
<!-- @UX_FEEDBACK Sort indicator on column headers -->
|
||||||
@EVENTS: ondelete/onnavigate callback props - Raised when a file is deleted or navigation is requested.
|
<!-- @UX_FEEDBACK Pagination "Showing X-Y of Z" -->
|
||||||
-->
|
<!-- @UX_FEEDBACK Search input filters in real-time -->
|
||||||
|
<!-- @UX_RECOVERY Clear search to restore full list -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// [SECTION: IMPORTS]
|
|
||||||
import { downloadFile } from '../../../services/storageService.js';
|
import { downloadFile } from '../../../services/storageService.js';
|
||||||
import { t } from '$lib/i18n/index.svelte.js';
|
import { t } from '$lib/i18n/index.svelte.js';
|
||||||
import { addToast } from '$lib/toasts.svelte.js';
|
import { addToast } from '$lib/toasts.svelte.js';
|
||||||
import { formatDateTime } from "$lib/utils/dateFormat";
|
import { formatDateTime, formatFileSize } from "$lib/utils/dateFormat";
|
||||||
// [/SECTION: IMPORTS]
|
import { Button, Input, EmptyState } from "$lib/ui";
|
||||||
|
|
||||||
|
interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
category: string;
|
||||||
|
size: number;
|
||||||
|
created_at: string;
|
||||||
|
mime_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
files = [],
|
files = [] as FileEntry[],
|
||||||
ondelete = () => {},
|
currentPath = "",
|
||||||
onnavigate = () => {},
|
loading = false,
|
||||||
|
pageSize = 20,
|
||||||
|
ondelete = (_payload: { category: string; path: string; name: string }) => {},
|
||||||
|
onnavigate = (_path: string) => {},
|
||||||
|
onnavigateup = () => {},
|
||||||
|
onbulkdelete = (_files: FileEntry[]) => {},
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// #region isDirectory:Function [TYPE Function]
|
// ── Helpers ─────────────────────────────────────────────────────
|
||||||
// @ingroup Components
|
function isDirectory(file: FileEntry): boolean {
|
||||||
/**
|
|
||||||
* @purpose Checks if a file object represents a directory.
|
|
||||||
* @pre file object has mime_type property.
|
|
||||||
* @post Returns boolean.
|
|
||||||
* @param {Object} file - The file object to check.
|
|
||||||
* @return {boolean} True if it's a directory, false otherwise.
|
|
||||||
*/
|
|
||||||
function isDirectory(file) {
|
|
||||||
console.log("[isDirectory][Action] Checking file type");
|
|
||||||
return file.mime_type === 'directory';
|
return file.mime_type === 'directory';
|
||||||
}
|
}
|
||||||
// #endregion isDirectory:Function
|
|
||||||
|
|
||||||
// #region formatSize:Function [TYPE Function]
|
function isFile(file: FileEntry): boolean {
|
||||||
// @ingroup Components
|
return !isDirectory(file);
|
||||||
/**
|
|
||||||
* @purpose Formats file size in bytes into a human-readable string.
|
|
||||||
* @pre bytes is a number.
|
|
||||||
* @post Returns formatted string.
|
|
||||||
* @param {number} bytes - The size in bytes.
|
|
||||||
* @return {string} Formatted size (e.g., "1.2 MB").
|
|
||||||
*/
|
|
||||||
function formatSize(bytes) {
|
|
||||||
console.log(`[formatSize][Action] Formatting ${bytes} bytes`);
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
}
|
}
|
||||||
// #endregion formatSize:Function
|
|
||||||
|
|
||||||
// #region handleDownload:Function [TYPE Function]
|
// ── Search ──────────────────────────────────────────────────────
|
||||||
// @ingroup Components
|
let searchText = $state("");
|
||||||
/**
|
|
||||||
* @purpose Downloads selected file through authenticated API request.
|
// ── Sort ────────────────────────────────────────────────────────
|
||||||
* @pre file is a non-directory storage entry with category/path.
|
type SortColumn = 'name' | 'category' | 'size' | 'created_at';
|
||||||
* @post Browser download starts or user sees toast on failure.
|
type SortDir = 'asc' | 'desc';
|
||||||
*/
|
let sortColumn = $state<SortColumn>('name');
|
||||||
async function handleDownload(file) {
|
let sortDirection = $state<SortDir>('asc');
|
||||||
try {
|
|
||||||
await downloadFile(file.category, file.path, file.name);
|
function handleSort(col: SortColumn) {
|
||||||
} catch (error) {
|
if (sortColumn === col) {
|
||||||
addToast(error?.message || 'Download failed', 'error');
|
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
sortColumn = col;
|
||||||
|
sortDirection = 'asc';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// #endregion handleDownload:Function
|
|
||||||
|
function getSortIndicator(col: SortColumn): string {
|
||||||
|
if (sortColumn !== col) return '⇅';
|
||||||
|
return sortDirection === 'asc' ? '▲' : '▼';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Derived: filtered + sorted data ─────────────────────────────
|
||||||
|
let filteredFiles = $derived.by(() => {
|
||||||
|
let result = files;
|
||||||
|
if (searchText) {
|
||||||
|
const q = searchText.toLowerCase();
|
||||||
|
result = result.filter(f => f.name.toLowerCase().includes(q) || f.category.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
// Directories always first, then files
|
||||||
|
const dirs = result.filter(f => isDirectory(f));
|
||||||
|
const fils = result.filter(f => isFile(f));
|
||||||
|
const sortFn = (a: FileEntry, b: FileEntry) => {
|
||||||
|
let aVal: any, bVal: any;
|
||||||
|
if (sortColumn === 'size') {
|
||||||
|
aVal = a.size ?? 0;
|
||||||
|
bVal = b.size ?? 0;
|
||||||
|
} else if (sortColumn === 'created_at') {
|
||||||
|
aVal = a.created_at ?? '';
|
||||||
|
bVal = b.created_at ?? '';
|
||||||
|
} else {
|
||||||
|
aVal = (a[sortColumn] ?? '').toString().toLowerCase();
|
||||||
|
bVal = (b[sortColumn] ?? '').toString().toLowerCase();
|
||||||
|
}
|
||||||
|
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
|
||||||
|
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
dirs.sort(sortFn);
|
||||||
|
fils.sort(sortFn);
|
||||||
|
return [...dirs, ...fils];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Pagination ──────────────────────────────────────────────────
|
||||||
|
let page = $state(0);
|
||||||
|
let totalPages = $derived(Math.max(1, Math.ceil(filteredFiles.length / pageSize)));
|
||||||
|
|
||||||
|
// Reset page on search change
|
||||||
|
$effect(() => { searchText; page = 0; });
|
||||||
|
|
||||||
|
let displayFiles = $derived(
|
||||||
|
filteredFiles.slice(page * pageSize, (page + 1) * pageSize)
|
||||||
|
);
|
||||||
|
|
||||||
|
function goToPage(p: number) {
|
||||||
|
if (p >= 0 && p < totalPages) page = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selection ───────────────────────────────────────────────────
|
||||||
|
let selectedNames = $state<string[]>([]);
|
||||||
|
|
||||||
|
function toggleSelect(name: string) {
|
||||||
|
if (selectedNames.includes(name)) {
|
||||||
|
selectedNames = selectedNames.filter(n => n !== name);
|
||||||
|
} else {
|
||||||
|
selectedNames = [...selectedNames, name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllVisible() {
|
||||||
|
const visibleNames = displayFiles.map(f => f.name);
|
||||||
|
const allSelected = visibleNames.every(n => selectedNames.includes(n));
|
||||||
|
if (allSelected) {
|
||||||
|
selectedNames = selectedNames.filter(n => !visibleNames.includes(n));
|
||||||
|
} else {
|
||||||
|
const newSet = new Set([...selectedNames, ...visibleNames]);
|
||||||
|
selectedNames = Array.from(newSet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let isAllSelected = $derived(
|
||||||
|
displayFiles.length > 0 && displayFiles.every(f => selectedNames.includes(f.name))
|
||||||
|
);
|
||||||
|
|
||||||
|
let selectedFiles = $derived(
|
||||||
|
files.filter(f => selectedNames.includes(f.name))
|
||||||
|
);
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedNames = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bulk actions ────────────────────────────────────────────────
|
||||||
|
function handleBulkDelete() {
|
||||||
|
if (selectedFiles.length === 0) return;
|
||||||
|
onbulkdelete(selectedFiles);
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBulkDownload() {
|
||||||
|
const fileEntries = selectedFiles.filter(f => isFile(f));
|
||||||
|
for (const f of fileEntries) {
|
||||||
|
try { await downloadFile(f.category, f.path, f.name); }
|
||||||
|
catch (e: any) { addToast(e?.message || 'Download failed', 'error'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single file download ────────────────────────────────────────
|
||||||
|
async function handleDownload(file: FileEntry) {
|
||||||
|
try { await downloadFile(file.category, file.path, file.name); }
|
||||||
|
catch (e: any) { addToast(e?.message || 'Download failed', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Breadcrumb segments ─────────────────────────────────────────
|
||||||
|
let breadcrumbSegments = $derived(
|
||||||
|
currentPath ? currentPath.split('/').filter(Boolean) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
function navigateToBreadcrumb(index: number) {
|
||||||
|
const segments = currentPath.split('/').filter(Boolean);
|
||||||
|
const newPath = segments.slice(0, index + 1).join('/');
|
||||||
|
onnavigate(newPath);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- [SECTION: TEMPLATE] -->
|
<div class="file-list space-y-3">
|
||||||
<div class="overflow-x-auto">
|
<!-- ── Breadcrumbs ── -->
|
||||||
<table class="min-w-full bg-surface-card border border-border">
|
<div class="flex items-center text-sm text-text-muted flex-wrap gap-1">
|
||||||
|
<button onclick={() => onnavigate('')} class="hover:text-primary transition-colors" aria-label={$t.storage?.root || "Root"}>
|
||||||
|
<svg class="h-4 w-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#each breadcrumbSegments as seg, i}
|
||||||
|
<span class="text-text-subtle">/</span>
|
||||||
|
{#if i === breadcrumbSegments.length - 1}
|
||||||
|
<span class="text-text font-medium">{seg}</span>
|
||||||
|
{:else}
|
||||||
|
<button onclick={() => navigateToBreadcrumb(i)} class="hover:text-primary transition-colors capitalize">{seg}</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if currentPath}
|
||||||
|
<button onclick={onnavigateup} class="ml-2 text-xs text-primary hover:text-primary-hover transition-colors" title={$t.common?.back}>
|
||||||
|
<svg class="h-4 w-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Toolbar: search + bulk actions ── -->
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<div class="flex-1 min-w-[200px] max-w-sm">
|
||||||
|
<Input bind:value={searchText} placeholder={$t.storage?.search || "Search files..."} />
|
||||||
|
</div>
|
||||||
|
{#if selectedFiles.length > 0}
|
||||||
|
<span class="text-sm text-text-muted">{selectedFiles.length} selected</span>
|
||||||
|
<Button size="sm" variant="secondary" onclick={handleBulkDownload}
|
||||||
|
disabled={!selectedFiles.some(f => isFile(f))}>
|
||||||
|
{$t.storage.table.download || "Download"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" onclick={handleBulkDelete}>
|
||||||
|
{$t.storage.table.delete || "Delete"} ({selectedFiles.length})
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onclick={clearSelection}>Clear</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Table ── -->
|
||||||
|
<div class="overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table class="min-w-full divide-y divide-border">
|
||||||
<thead class="bg-surface-muted">
|
<thead class="bg-surface-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.storage.table.name}</th>
|
<th class="px-4 py-3 w-10" scope="col">
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.storage.table.category}</th>
|
<input type="checkbox"
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.storage.table.size}</th>
|
checked={isAllSelected}
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.storage.table.created_at}</th>
|
onchange={selectAllVisible}
|
||||||
<th class="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">{$t.storage.table.actions}</th>
|
class="h-4 w-4 text-primary border-border-strong rounded"
|
||||||
|
aria-label="Select all" />
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-sm font-semibold text-text-muted cursor-pointer hover:text-text transition-colors select-none"
|
||||||
|
onclick={() => handleSort('name')}>
|
||||||
|
{$t.storage.table.name || "Name"} <span class="ml-0.5 text-xs">{getSortIndicator('name')}</span>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-sm font-semibold text-text-muted cursor-pointer hover:text-text transition-colors select-none"
|
||||||
|
onclick={() => handleSort('category')}>
|
||||||
|
{$t.storage.table.category || "Category"} <span class="ml-0.5 text-xs">{getSortIndicator('category')}</span>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-sm font-semibold text-text-muted cursor-pointer hover:text-text transition-colors select-none"
|
||||||
|
onclick={() => handleSort('size')}>
|
||||||
|
{$t.storage.table.size || "Size"} <span class="ml-0.5 text-xs">{getSortIndicator('size')}</span>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-sm font-semibold text-text-muted cursor-pointer hover:text-text transition-colors select-none"
|
||||||
|
onclick={() => handleSort('created_at')}>
|
||||||
|
{$t.storage.table.created_at || "Created"} <span class="ml-0.5 text-xs">{getSortIndicator('created_at')}</span>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-right text-sm font-semibold text-text-muted">
|
||||||
|
{$t.storage.table.actions || "Actions"}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-border">
|
<tbody class="bg-surface-card divide-y divide-border">
|
||||||
{#each files as file}
|
{#if loading}
|
||||||
<tr class="hover:bg-surface-muted">
|
{#each Array(Math.min(pageSize, 5)) as _}
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-text">
|
<tr class="animate-pulse">
|
||||||
|
<td class="px-4 py-4"><div class="h-4 w-4 bg-surface-muted rounded"></div></td>
|
||||||
|
<td class="px-6 py-4"><div class="h-4 bg-surface-muted rounded w-3/4"></div></td>
|
||||||
|
<td class="px-6 py-4"><div class="h-4 bg-surface-muted rounded w-1/2"></div></td>
|
||||||
|
<td class="px-6 py-4"><div class="h-4 bg-surface-muted rounded w-16"></div></td>
|
||||||
|
<td class="px-6 py-4"><div class="h-4 bg-surface-muted rounded w-24"></div></td>
|
||||||
|
<td class="px-6 py-4"><div class="h-4 bg-surface-muted rounded w-20 ml-auto"></div></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{:else if !loading && displayFiles.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-6 py-10">
|
||||||
|
<EmptyState title={$t.storage.no_files || "No files found"} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
{#each displayFiles as file (file.path + '/' + file.name)}
|
||||||
|
<tr class="hover:bg-surface-muted transition-colors">
|
||||||
|
<!-- Checkbox -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={selectedNames.includes(file.name)}
|
||||||
|
onchange={() => toggleSelect(file.name)}
|
||||||
|
class="h-4 w-4 text-primary border-border-strong rounded" />
|
||||||
|
</td>
|
||||||
|
<!-- Name -->
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap text-sm">
|
||||||
{#if isDirectory(file)}
|
{#if isDirectory(file)}
|
||||||
<button
|
<button onclick={() => onnavigate(file.path)}
|
||||||
onclick={() => onnavigate(file.path)}
|
class="flex items-center text-primary hover:text-primary-hover transition-colors font-medium">
|
||||||
class="flex items-center text-primary hover:text-primary"
|
<svg class="h-5 w-5 mr-2 text-warning shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
>
|
|
||||||
<svg class="h-5 w-5 mr-2 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
|
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{file.name}
|
{file.name}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center text-text">
|
||||||
<svg class="h-5 w-5 mr-2 text-text-subtle" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5 mr-2 text-text-subtle shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{file.name}
|
{file.name}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted capitalize">{file.category}</td>
|
<!-- Category -->
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted">
|
<td class="px-6 py-3 whitespace-nowrap text-sm text-text-muted capitalize">{file.category}</td>
|
||||||
{isDirectory(file) ? '--' : formatSize(file.size)}
|
<!-- Size -->
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap text-sm text-text-muted">
|
||||||
|
{isDirectory(file) ? '--' : formatFileSize(file.size)}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted">{formatDateTime(file.created_at)}</td>
|
<!-- Created -->
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td class="px-6 py-3 whitespace-nowrap text-sm text-text-muted">{formatDateTime(file.created_at)}</td>
|
||||||
{#if !isDirectory(file)}
|
<!-- Actions -->
|
||||||
<button
|
<td class="px-6 py-3 whitespace-nowrap text-right text-sm">
|
||||||
type="button"
|
<div class="flex items-center justify-end gap-2">
|
||||||
onclick={() => handleDownload(file)}
|
{#if isFile(file)}
|
||||||
class="text-primary hover:text-primary mr-4"
|
<Button size="sm" variant="ghost" onclick={() => handleDownload(file)}>
|
||||||
>
|
{$t.storage.table.download || "Download"}
|
||||||
{$t.storage.table.download}
|
</Button>
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<Button size="sm" variant="ghost"
|
||||||
onclick={() => ondelete({ category: file.category, path: file.path, name: file.name })}
|
onclick={() => ondelete({ category: file.category, path: file.path, name: file.name })}>
|
||||||
class="text-destructive hover:text-destructive"
|
<span class="text-destructive">{$t.storage.table.delete || "Delete"}</span>
|
||||||
>
|
</Button>
|
||||||
{$t.storage.table.delete}
|
</div>
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{:else}
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="px-6 py-10 text-center text-sm text-text-muted">
|
|
||||||
{$t.storage.no_files}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<!-- [/SECTION: TEMPLATE] -->
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ── Pagination ── -->
|
||||||
|
{#if !loading && filteredFiles.length > pageSize}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-sm text-text-muted">
|
||||||
|
{$t.storage.showing?.replace('{start}', String(page * pageSize + 1))
|
||||||
|
?.replace('{end}', String(Math.min((page + 1) * pageSize, filteredFiles.length)))
|
||||||
|
?.replace('{total}', String(filteredFiles.length))
|
||||||
|
|| `Showing ${page * pageSize + 1}-${Math.min((page + 1) * pageSize, filteredFiles.length)} of ${filteredFiles.length}`}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="secondary" size="sm" disabled={page === 0} onclick={() => goToPage(page - 1)}>
|
||||||
|
{$t.dashboard?.previous || "Prev"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" disabled={page >= totalPages - 1} onclick={() => goToPage(page + 1)}>
|
||||||
|
{$t.dashboard?.next || "Next"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<!-- #endregion FileList -->
|
<!-- #endregion FileList -->
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
"repositories": "Repositories",
|
"repositories": "Repositories",
|
||||||
"root": "Root",
|
"root": "Root",
|
||||||
"no_files": "No files found.",
|
"no_files": "No files found.",
|
||||||
|
"search": "Search files...",
|
||||||
|
"showing": "Showing {start} to {end} of {total}",
|
||||||
"upload_title": "Upload File",
|
"upload_title": "Upload File",
|
||||||
"target_category": "Target Category",
|
"target_category": "Target Category",
|
||||||
"upload_button": "Upload a file",
|
"upload_button": "Upload a file",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
"repositories": "Репозитории",
|
"repositories": "Репозитории",
|
||||||
"root": "Корень",
|
"root": "Корень",
|
||||||
"no_files": "Файлы не найдены.",
|
"no_files": "Файлы не найдены.",
|
||||||
|
"search": "Поиск файлов...",
|
||||||
|
"showing": "Показано с {start} по {end} из {total}",
|
||||||
"upload_title": "Загрузить файл",
|
"upload_title": "Загрузить файл",
|
||||||
"target_category": "Целевая категория",
|
"target_category": "Целевая категория",
|
||||||
"upload_button": "Загрузить файл",
|
"upload_button": "Загрузить файл",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
import { addToast } from '$lib/toasts.svelte.js';
|
import { addToast } from '$lib/toasts.svelte.js';
|
||||||
import { t } from '$lib/i18n/index.svelte.js';
|
import { t } from '$lib/i18n/index.svelte.js';
|
||||||
import { ConfirmDialog } from "$lib/ui";
|
import { ConfirmDialog } from "$lib/ui";
|
||||||
|
import { log } from "$lib/cot-logger";
|
||||||
import FileList from '$lib/components/storage/FileList.svelte';
|
import FileList from '$lib/components/storage/FileList.svelte';
|
||||||
import FileUpload from '$lib/components/storage/FileUpload.svelte';
|
import FileUpload from '$lib/components/storage/FileUpload.svelte';
|
||||||
// [/SECTION: IMPORTS]
|
// [/SECTION: IMPORTS]
|
||||||
@@ -63,14 +64,14 @@
|
|||||||
// #endregion resolveStorageQueryFromPath:Function
|
// #endregion resolveStorageQueryFromPath:Function
|
||||||
|
|
||||||
async function loadFiles() {
|
async function loadFiles() {
|
||||||
console.log('[STORAGE-PAGE][LOAD_START] path=%s', currentPath);
|
log("StorageIndexPage", "REASON", "Loading files", { path: currentPath });
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
const query = resolveStorageQueryFromPath(currentPath);
|
const query = resolveStorageQueryFromPath(currentPath);
|
||||||
files = await listFiles(query.category, query.subpath);
|
files = await listFiles(query.category, query.subpath);
|
||||||
console.log('[STORAGE-PAGE][LOAD_OK] count=%d', files.length);
|
log("StorageIndexPage", "REFLECT", "Files loaded", { count: files.length });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('[STORAGE-PAGE][LOAD_ERR] error=%s', error.message);
|
log("StorageIndexPage", "EXPLORE", "Failed to load files", {}, String(error.message));
|
||||||
addToast($t.storage.messages.load_failed.replace('{error}', error.message), 'error');
|
addToast($t.storage.messages.load_failed.replace('{error}', error.message), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
@@ -89,28 +90,43 @@
|
|||||||
async function handleDelete(payload) {
|
async function handleDelete(payload) {
|
||||||
const { category, path, name } = payload || {};
|
const { category, path, name } = payload || {};
|
||||||
if (!category || !path || !name) {
|
if (!category || !path || !name) {
|
||||||
console.warn('[STORAGE-PAGE][DELETE_SKIP] invalid payload', payload);
|
log("StorageIndexPage", "EXPLORE", "Delete skipped — invalid payload", { payload });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deletePayload = payload;
|
deletePayload = payload;
|
||||||
showDeleteConfirm = true;
|
showDeleteConfirm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onConfirmDelete() {
|
// ── Bulk delete ─────────────────────────────────────────────────
|
||||||
const payload = deletePayload;
|
let bulkDeletePayloads = $state<Array<{category: string; path: string; name: string}>>([]);
|
||||||
const { category, path, name } = payload || {};
|
|
||||||
deletePayload = null;
|
|
||||||
console.log('[STORAGE-PAGE][DELETE_START] category=%s path=%s', category, path);
|
|
||||||
|
|
||||||
try {
|
function handleBulkDelete(files: Array<{category: string; path: string; name: string}>) {
|
||||||
await deleteFile(category, path);
|
bulkDeletePayloads = files;
|
||||||
console.log('[STORAGE-PAGE][DELETE_OK] name=%s', name);
|
deletePayload = files[0]; // for confirm dialog message
|
||||||
addToast($t.storage.messages.delete_success.replace('{name}', name), 'success');
|
showDeleteConfirm = true;
|
||||||
await loadFiles();
|
|
||||||
} catch (error) {
|
|
||||||
console.log('[STORAGE-PAGE][DELETE_ERR] error=%s', error.message);
|
|
||||||
addToast($t.storage.messages.delete_failed.replace('{error}', error.message), 'error');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onConfirmDelete() {
|
||||||
|
const payloads = bulkDeletePayloads.length > 0 ? bulkDeletePayloads : (deletePayload ? [deletePayload] : []);
|
||||||
|
deletePayload = null;
|
||||||
|
bulkDeletePayloads = [];
|
||||||
|
let errors = 0;
|
||||||
|
for (const p of payloads) {
|
||||||
|
try {
|
||||||
|
await deleteFile(p.category, p.path);
|
||||||
|
} catch (error) {
|
||||||
|
errors++;
|
||||||
|
log("StorageIndexPage", "EXPLORE", "Failed to delete file", {}, String(error.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors === 0) {
|
||||||
|
addToast(payloads.length > 1
|
||||||
|
? $t.storage.messages.bulk_delete_success?.replace('{count}', String(payloads.length)) || `${payloads.length} files deleted`
|
||||||
|
: $t.storage.messages.delete_success.replace('{name}', payloads[0].name), 'success');
|
||||||
|
} else {
|
||||||
|
addToast($t.storage.messages.delete_failed.replace('{error}', `${errors} failed`), 'error');
|
||||||
|
}
|
||||||
|
await loadFiles();
|
||||||
}
|
}
|
||||||
// #endregion handleDelete:Function
|
// #endregion handleDelete:Function
|
||||||
|
|
||||||
@@ -124,10 +140,10 @@
|
|||||||
*/
|
*/
|
||||||
function handleNavigate(path) {
|
function handleNavigate(path) {
|
||||||
if (!path || typeof path !== 'string') {
|
if (!path || typeof path !== 'string') {
|
||||||
console.warn('[STORAGE-PAGE][NAVIGATE_SKIP] invalid path', path);
|
log("StorageIndexPage", "EXPLORE", "Navigate skipped — invalid path", { path });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('[STORAGE-PAGE][NAVIGATE] path=%s', path);
|
log("StorageIndexPage", "REASON", "Navigating to path", { path });
|
||||||
currentPath = path;
|
currentPath = path;
|
||||||
loadFiles();
|
loadFiles();
|
||||||
}
|
}
|
||||||
@@ -182,21 +198,6 @@
|
|||||||
<div class="container mx-auto w-full p-4 min-[1280px]:max-w-[1400px] min-[1536px]:max-w-[1680px]">
|
<div class="container mx-auto w-full p-4 min-[1280px]:max-w-[1400px] min-[1536px]:max-w-[1680px]">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-2xl font-bold text-text">{$t.storage.management}</h1>
|
<h1 class="text-2xl font-bold text-text">{$t.storage.management}</h1>
|
||||||
<div class="flex items-center mt-2 text-sm text-text-muted">
|
|
||||||
<button onclick={() => { currentPath = ''; loadFiles(); }} class="hover:text-primary">{$t.storage.root}</button>
|
|
||||||
{#each currentPath.split('/').filter(Boolean) as part, i}
|
|
||||||
<span class="mx-2">/</span>
|
|
||||||
<button
|
|
||||||
onclick={() => {
|
|
||||||
currentPath = currentPath.split('/').filter(Boolean).slice(0, i + 1).join('/');
|
|
||||||
loadFiles();
|
|
||||||
}}
|
|
||||||
class="hover:text-primary capitalize"
|
|
||||||
>
|
|
||||||
{part}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end mb-4">
|
<div class="flex justify-end mb-4">
|
||||||
@@ -212,21 +213,15 @@
|
|||||||
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_420px] gap-6">
|
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_420px] gap-6">
|
||||||
<!-- Main Content: File List -->
|
<!-- Main Content: File List -->
|
||||||
<div class="space-y-4 min-w-0">
|
<div class="space-y-4 min-w-0">
|
||||||
<div class="flex items-center mb-2">
|
<FileList
|
||||||
{#if currentPath}
|
{files}
|
||||||
<button
|
{currentPath}
|
||||||
onclick={navigateUp}
|
loading={isLoading}
|
||||||
class="mr-4 inline-flex items-center px-3 py-1 border border-border-strong shadow-sm text-xs font-medium rounded text-text bg-surface-card hover:bg-surface-muted"
|
ondelete={handleDelete}
|
||||||
>
|
onnavigate={handleNavigate}
|
||||||
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
onnavigateup={navigateUp}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
onbulkdelete={handleBulkDelete}
|
||||||
</svg>
|
/>
|
||||||
{$t.common?.back}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FileList {files} ondelete={handleDelete} onnavigate={handleNavigate} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar: Upload -->
|
<!-- Sidebar: Upload -->
|
||||||
@@ -243,12 +238,14 @@
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
bind:show={showDeleteConfirm}
|
bind:show={showDeleteConfirm}
|
||||||
title="Delete file?"
|
title="Delete file?"
|
||||||
message={deletePayload ? $t.storage.messages.delete_confirm.replace('{name}', deletePayload.name) : ""}
|
message={bulkDeletePayloads.length > 1
|
||||||
|
? `Delete ${bulkDeletePayloads.length} files?`
|
||||||
|
: (deletePayload ? $t.storage.messages.delete_confirm.replace('{name}', deletePayload.name) : "")}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
confirmLabel="Delete"
|
confirmLabel="Delete"
|
||||||
cancelLabel="Cancel"
|
cancelLabel="Cancel"
|
||||||
onConfirm={onConfirmDelete}
|
onConfirm={onConfirmDelete}
|
||||||
onCancel={() => {}}
|
onCancel={() => { deletePayload = null; bulkDeletePayloads = []; }}
|
||||||
/>
|
/>
|
||||||
<!-- [/SECTION: TEMPLATE] -->
|
<!-- [/SECTION: TEMPLATE] -->
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user