refactor(frontend): RepositoryDashboardGrid wraps Dashboard.DataGrid
Reduced from 734 to ~300 lines. All git-specific logic preserved: - status fetching via gitService (loadRepositoryStatuses) - bulk git actions (sync, commit, pull, push, delete) - GitManager modal integration - repositoriesOnly pre-filtering - status badge rendering via raw render Grid logic (filter, sort, paginate, select, table rendering, pagination UI) delegated to Dashboard.DataGrid. Removed 5 duplicated functions: handleSort, handleSelectionChange, handleSelectAll, goToPage, and the inline table template (300+ lines).
This commit is contained in:
@@ -2,18 +2,20 @@
|
||||
<!-- @ingroup Dashboard -->
|
||||
<!-- @BRIEF Dashboard grid with git repository integration — status fetching, bulk git actions, GitManager modal. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RATIONALE Kept separate from Dashboard.DataGrid due to deep git-specific integration (async status fetching via gitService, bulk git operations, GitManager modal coupling). Future consolidation candidate: extract git-logic into a GitDashboardModel and render via Dashboard.DataGrid with raw HTML column support. ~400 of 722 lines are git-specific; remaining ~320 lines duplicate DashboardGrid sort/filter/paginate/select logic. -->
|
||||
<!-- @RATIONALE Now delegates grid rendering to Dashboard.DataGrid. Keeps ~200 lines of git-specific logic (status fetching via gitService, bulk git operations, GitManager modal). Filter/sort/paginate/select/selection tracking handled by Dashboard.DataGrid. -->
|
||||
<!-- @RELATION USED_BY -> [frontend/src/routes/git/+page.svelte] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [$lib/components/git/GitManager] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [gitService] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [Dashboard.DataGrid] -->
|
||||
<!-- @INVARIANT Selected IDs must be a subset of available dashboards. -->
|
||||
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
import { untrack } from "svelte";
|
||||
import type { DashboardMetadata } from "../../types/dashboard";
|
||||
import type { Column } from "./DashboardDataGrid.svelte";
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { Button, Input, ConfirmDialog } from "$lib/ui";
|
||||
import { Button, ConfirmDialog } from "$lib/ui";
|
||||
import DashboardDataGrid from "./DashboardDataGrid.svelte";
|
||||
import GitManager from "$lib/components/git/GitManager.svelte";
|
||||
import { gitService } from "../../../services/gitService";
|
||||
import { addToast } from "$lib/toasts.svelte.js";
|
||||
@@ -21,24 +23,19 @@
|
||||
|
||||
// [SECTION: PROPS]
|
||||
let {
|
||||
dashboards = [],
|
||||
selectedIds = [],
|
||||
statusMode = "dashboard",
|
||||
envId = null,
|
||||
dashboards = [] as DashboardMetadata[],
|
||||
selectedIds = [] as number[],
|
||||
statusMode = "dashboard" as "dashboard" | "repository",
|
||||
envId = null as string | null,
|
||||
repositoriesOnly = false,
|
||||
} = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
let filterText = $state("");
|
||||
let currentPage = $state(0);
|
||||
let pageSize = $state(20);
|
||||
let sortColumn: keyof DashboardMetadata = $state("title");
|
||||
let sortColumn = $state("title");
|
||||
let sortDirection: "asc" | "desc" = $state("asc");
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: UI STATE]
|
||||
let showGitManager = $state(false);
|
||||
let gitDashboardId: string | null = $state(null);
|
||||
let gitDashboardTitle = $state("");
|
||||
@@ -48,132 +45,39 @@
|
||||
let showBulkDeleteConfirm = $state(false);
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: DERIVED]
|
||||
let filteredDashboards = $derived(
|
||||
dashboards.filter((d) => {
|
||||
const matchesTitle = d.title.toLowerCase().includes(filterText.toLowerCase());
|
||||
if (!matchesTitle) return false;
|
||||
if (!repositoriesOnly || statusMode !== "repository") return true;
|
||||
return getRepositoryStatusToken(d.id) !== "no_repo";
|
||||
}),
|
||||
// ── Column definitions ─────────────────────────────────────────
|
||||
let columns: Column[] = $derived([
|
||||
{ key: "title", label: $t.dashboard?.title || "Title", sortable: true },
|
||||
{ key: "last_modified", label: $t.dashboard?.last_modified || "Last Modified", sortable: true },
|
||||
{ key: "status", label: $t.dashboard?.status || "Status", sortable: true, raw: true, render: (item: DashboardMetadata) => renderStatusBadge(item) },
|
||||
]);
|
||||
|
||||
// ── Actions per row ────────────────────────────────────────────
|
||||
let rowActions = $derived([
|
||||
{
|
||||
label: $t.git?.manage || "Git",
|
||||
variant: "ghost" as const,
|
||||
handler: (item: DashboardMetadata) => openGitManagerForDashboard(item),
|
||||
condition: (item: DashboardMetadata) => statusMode === "repository",
|
||||
},
|
||||
]);
|
||||
|
||||
// ── Pre-filtered data (repositoriesOnly) ──────────────────────
|
||||
let preFilteredData = $derived<DashboardMetadata[]>(
|
||||
repositoriesOnly && statusMode === "repository"
|
||||
? dashboards.filter(d => getRepositoryStatusToken(d.id) !== "no_repo")
|
||||
: dashboards,
|
||||
);
|
||||
|
||||
let sortedDashboards = $derived(
|
||||
[...filteredDashboards].sort((a, b) => {
|
||||
let aVal =
|
||||
sortColumn === "status"
|
||||
? getSortStatusValue(a)
|
||||
: a[sortColumn];
|
||||
let bVal =
|
||||
sortColumn === "status"
|
||||
? getSortStatusValue(b)
|
||||
: b[sortColumn];
|
||||
if (sortColumn === "id") {
|
||||
aVal = Number(aVal);
|
||||
bVal = Number(bVal);
|
||||
}
|
||||
if (aVal < bVal) return sortDirection === "asc" ? -1 : 1;
|
||||
if (aVal > bVal) return sortDirection === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
}),
|
||||
);
|
||||
|
||||
let paginatedDashboards = $derived(
|
||||
sortedDashboards.slice(
|
||||
currentPage * pageSize,
|
||||
(currentPage + 1) * pageSize,
|
||||
),
|
||||
);
|
||||
|
||||
let totalPages = $derived(Math.ceil(sortedDashboards.length / pageSize));
|
||||
|
||||
let allSelected = $derived(
|
||||
paginatedDashboards.length > 0 &&
|
||||
paginatedDashboards.every((d) => selectedIds.includes(d.id)),
|
||||
);
|
||||
let someSelected = $derived(
|
||||
paginatedDashboards.some((d) => selectedIds.includes(d.id)),
|
||||
);
|
||||
// [/SECTION]
|
||||
|
||||
// #region handleSort:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
// @PURPOSE: Toggles sort direction or changes sort column.
|
||||
// @PRE: column name is provided.
|
||||
// @POST: sortColumn and sortDirection state updated.
|
||||
function handleSort(column: keyof DashboardMetadata) {
|
||||
if (sortColumn === column) {
|
||||
sortDirection = sortDirection === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
sortColumn = column;
|
||||
sortDirection = "asc";
|
||||
}
|
||||
}
|
||||
// #endregion handleSort:Function
|
||||
|
||||
// #region handleSelectionChange:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
// @PURPOSE: Handles individual checkbox changes.
|
||||
// @PRE: dashboard ID and checked status provided.
|
||||
// @POST: selectedIds array updated.
|
||||
function handleSelectionChange(id: number, checked: boolean) {
|
||||
let newSelected = [...selectedIds];
|
||||
if (checked) {
|
||||
if (!newSelected.includes(id)) newSelected.push(id);
|
||||
} else {
|
||||
newSelected = newSelected.filter((sid) => sid !== id);
|
||||
}
|
||||
selectedIds = newSelected;
|
||||
}
|
||||
// #endregion handleSelectionChange:Function
|
||||
|
||||
// #region handleSelectAll:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
// @PURPOSE: Handles select all checkbox.
|
||||
// @PRE: checked status provided.
|
||||
// @POST: selectedIds array updated for all paginated items.
|
||||
function handleSelectAll(checked: boolean) {
|
||||
let newSelected = [...selectedIds];
|
||||
if (checked) {
|
||||
paginatedDashboards.forEach((d) => {
|
||||
if (!newSelected.includes(d.id)) newSelected.push(d.id);
|
||||
});
|
||||
} else {
|
||||
paginatedDashboards.forEach((d) => {
|
||||
newSelected = newSelected.filter((sid) => sid !== d.id);
|
||||
});
|
||||
}
|
||||
selectedIds = newSelected;
|
||||
}
|
||||
// #endregion handleSelectAll:Function
|
||||
|
||||
// #region goToPage:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
// @PURPOSE: Changes current page.
|
||||
// @PRE: page index is provided.
|
||||
// @POST: currentPage state updated if within valid range.
|
||||
function goToPage(page: number) {
|
||||
if (page >= 0 && page < totalPages) {
|
||||
currentPage = page;
|
||||
}
|
||||
}
|
||||
// #endregion goToPage:Function
|
||||
// [SECTION: GIT-SPECIFIC FUNCTIONS]
|
||||
|
||||
// #region getRepositoryStatusToken:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
/**
|
||||
* @purpose Returns normalized repository status token for a dashboard.
|
||||
* @pre Dashboard exists.
|
||||
* @post Returns one of loading|no_repo|synced|changes|behind_remote|ahead_remote|diverged|error.
|
||||
*/
|
||||
function getRepositoryStatusToken(dashboardId: number): string {
|
||||
return repositoryStatusByDashboardId[dashboardId] || "loading";
|
||||
}
|
||||
// #endregion getRepositoryStatusToken:Function
|
||||
|
||||
// #region isRepositoryReady:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
// @PURPOSE: Determines whether git actions can run for a dashboard.
|
||||
function isRepositoryReady(dashboardId: number): boolean {
|
||||
const token = getRepositoryStatusToken(dashboardId);
|
||||
return token !== "loading" && token !== "no_repo" && token !== "error";
|
||||
@@ -181,23 +85,15 @@
|
||||
// #endregion isRepositoryReady:Function
|
||||
|
||||
// #region invalidateRepositoryStatuses:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
// @PURPOSE: Marks dashboard statuses as loading so they are refetched.
|
||||
function invalidateRepositoryStatuses(dashboardIds: number[]): void {
|
||||
if (dashboardIds.length === 0) return;
|
||||
const nextStatuses = { ...repositoryStatusByDashboardId };
|
||||
dashboardIds.forEach((dashboardId) => {
|
||||
nextStatuses[dashboardId] = "loading";
|
||||
});
|
||||
dashboardIds.forEach((did) => { nextStatuses[did] = "loading"; });
|
||||
repositoryStatusByDashboardId = nextStatuses;
|
||||
}
|
||||
// #endregion invalidateRepositoryStatuses:Function
|
||||
|
||||
// #region resolveRepositoryStatusToken:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
/**
|
||||
* @purpose Converts git status payload into a stable UI status token.
|
||||
*/
|
||||
function resolveRepositoryStatusToken(status: any): string {
|
||||
const syncState = String(status?.sync_state || "").toUpperCase();
|
||||
if (syncState === "DIVERGED") return "diverged";
|
||||
@@ -205,81 +101,53 @@
|
||||
if (syncState === "AHEAD_REMOTE") return "ahead_remote";
|
||||
if (syncState === "CHANGES") return "changes";
|
||||
if (syncState === "SYNCED") return "synced";
|
||||
|
||||
const syncStatus = String(status?.sync_status || "").toUpperCase();
|
||||
if (syncStatus === "NO_REPO") return "no_repo";
|
||||
if (syncStatus === "ERROR") return "error";
|
||||
if (syncStatus === "DIFF") return "changes";
|
||||
if (syncStatus === "OK") return "synced";
|
||||
|
||||
const aheadCount = Number(status?.ahead_count || 0);
|
||||
const behindCount = Number(status?.behind_count || 0);
|
||||
if (aheadCount > 0 && behindCount > 0) return "diverged";
|
||||
if (behindCount > 0) return "behind_remote";
|
||||
if (aheadCount > 0) return "ahead_remote";
|
||||
|
||||
const hasChanges =
|
||||
Boolean(status?.is_dirty) ||
|
||||
(status?.untracked_files?.length || 0) > 0 ||
|
||||
(status?.modified_files?.length || 0) > 0 ||
|
||||
(status?.staged_files?.length || 0) > 0;
|
||||
|
||||
return hasChanges ? "changes" : "synced";
|
||||
}
|
||||
// #endregion resolveRepositoryStatusToken:Function
|
||||
|
||||
// #region loadRepositoryStatuses:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
/**
|
||||
* @purpose Hydrates repository status map for dashboards in repository mode.
|
||||
*/
|
||||
async function loadRepositoryStatuses() {
|
||||
if (statusMode !== "repository") {
|
||||
repositoryStatusByDashboardId = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = repositoryStatusRequestId + 1;
|
||||
repositoryStatusRequestId = requestId;
|
||||
|
||||
const visibleDashboards = paginatedDashboards;
|
||||
const visibleIds = visibleDashboards.map((dashboard) => dashboard.id);
|
||||
|
||||
if (visibleDashboards.length === 0) {
|
||||
repositoryStatusByDashboardId = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const missingIds = visibleIds.filter((dashboardId) => {
|
||||
const token = repositoryStatusByDashboardId[dashboardId];
|
||||
return token === undefined || token === "loading";
|
||||
const requestId = ++repositoryStatusRequestId;
|
||||
// Load statuses for all pre-filtered data (not just current page — DashboardDataGrid owns pagination)
|
||||
const allIds = dashboards.map(d => d.id);
|
||||
const missingIds = allIds.filter(id => {
|
||||
const tkn = repositoryStatusByDashboardId[id];
|
||||
return tkn === undefined || tkn === "loading";
|
||||
});
|
||||
|
||||
if (missingIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (missingIds.length === 0) return;
|
||||
const loadingStatuses = { ...repositoryStatusByDashboardId };
|
||||
missingIds.forEach((dashboardId) => {
|
||||
loadingStatuses[dashboardId] = "loading";
|
||||
});
|
||||
missingIds.forEach(id => { loadingStatuses[id] = "loading"; });
|
||||
repositoryStatusByDashboardId = loadingStatuses;
|
||||
|
||||
let entries: Array<readonly [number, string]> = [];
|
||||
try {
|
||||
const batchResult = await gitService.getStatusesBatch(missingIds);
|
||||
const statusesByDashboardId = batchResult?.statuses || {};
|
||||
entries = missingIds.map((dashboardId) => {
|
||||
const status =
|
||||
statusesByDashboardId[dashboardId] ??
|
||||
statusesByDashboardId[String(dashboardId)];
|
||||
if (!status) return [dashboardId, "error"] as const;
|
||||
return [dashboardId, resolveRepositoryStatusToken(status)] as const;
|
||||
entries = missingIds.map((id) => {
|
||||
const s = statusesByDashboardId[id] ?? statusesByDashboardId[String(id)];
|
||||
return [id, s ? resolveRepositoryStatusToken(s) : "error"] as const;
|
||||
});
|
||||
} catch {
|
||||
entries = missingIds.map((dashboardId) => [dashboardId, "error"] as const);
|
||||
entries = missingIds.map((id) => [id, "error"] as const);
|
||||
}
|
||||
|
||||
if (requestId !== repositoryStatusRequestId) return;
|
||||
repositoryStatusByDashboardId = {
|
||||
...repositoryStatusByDashboardId,
|
||||
@@ -289,424 +157,191 @@
|
||||
// #endregion loadRepositoryStatuses:Function
|
||||
|
||||
// #region runBulkGitAction:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
// @PURPOSE: Executes git action for selected dashboards with limited parallelism.
|
||||
async function runBulkGitAction(
|
||||
actionToken: string,
|
||||
action: (_dashboardId: number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
async function runBulkGitAction(actionToken: string, action: (_id: number) => Promise<void>): Promise<void> {
|
||||
if (bulkActionRunning) return;
|
||||
const selectedDashboardIds = selectedIds.filter((dashboardId) =>
|
||||
isRepositoryReady(dashboardId),
|
||||
);
|
||||
|
||||
if (selectedDashboardIds.length === 0) {
|
||||
addToast($t.git?.no_repositories_selected, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const readyIds = selectedIds.filter((id) => isRepositoryReady(id));
|
||||
if (readyIds.length === 0) { addToast($t.git?.no_repositories_selected, "error"); return; }
|
||||
bulkActionRunning = true;
|
||||
const concurrency = 3;
|
||||
const idsQueue = [...selectedDashboardIds];
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
const queue = [...readyIds];
|
||||
let successCount = 0, failedCount = 0;
|
||||
const worker = async () => {
|
||||
while (idsQueue.length > 0) {
|
||||
const dashboardId = idsQueue.shift();
|
||||
if (dashboardId === undefined) break;
|
||||
try {
|
||||
await action(dashboardId);
|
||||
successCount += 1;
|
||||
} catch {
|
||||
failedCount += 1;
|
||||
}
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift();
|
||||
if (id === undefined) break;
|
||||
try { await action(id); successCount++; }
|
||||
catch { failedCount++; }
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, selectedDashboardIds.length) }, () => worker()),
|
||||
);
|
||||
invalidateRepositoryStatuses(selectedDashboardIds);
|
||||
const actionLabel = $t.git?.[`bulk_action_${actionToken}`] || actionToken;
|
||||
addToast(
|
||||
$t.git?.bulk_result
|
||||
.replace("{action}", actionLabel)
|
||||
.replace("{success}", String(successCount))
|
||||
.replace("{failed}", String(failedCount)),
|
||||
failedCount > 0 ? "warning" : "success",
|
||||
);
|
||||
} finally {
|
||||
bulkActionRunning = false;
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, readyIds.length) }, () => worker()));
|
||||
invalidateRepositoryStatuses(readyIds);
|
||||
const al = $t.git?.[`bulk_action_${actionToken}`] || actionToken;
|
||||
addToast($t.git?.bulk_result.replace("{action}", al).replace("{success}", String(successCount)).replace("{failed}", String(failedCount)),
|
||||
failedCount > 0 ? "warning" : "success");
|
||||
} finally { bulkActionRunning = false; }
|
||||
}
|
||||
// #endregion runBulkGitAction:Function
|
||||
|
||||
// #region handleBulkSync:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
async function handleBulkSync(): Promise<void> {
|
||||
await runBulkGitAction("sync", (dashboardId) => gitService.sync(dashboardId, null, envId));
|
||||
// #region handleBulkAction:Function [TYPE Function]
|
||||
async function handleBulkSync() { await runBulkGitAction("sync", (id) => gitService.sync(id, null, envId)); }
|
||||
async function handleBulkCommit() {
|
||||
const msg = prompt($t.git?.commit_message);
|
||||
if (!msg?.trim()) return;
|
||||
await runBulkGitAction("commit", (id) => gitService.commit(id, msg.trim(), [], envId));
|
||||
}
|
||||
// #endregion handleBulkSync:Function
|
||||
|
||||
// #region handleBulkCommit:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
async function handleBulkCommit(): Promise<void> {
|
||||
const message = prompt($t.git?.commit_message);
|
||||
if (!message?.trim()) return;
|
||||
await runBulkGitAction("commit", (dashboardId) =>
|
||||
gitService.commit(dashboardId, message.trim(), [], envId),
|
||||
);
|
||||
}
|
||||
// #endregion handleBulkCommit:Function
|
||||
|
||||
// #region handleBulkPull:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
async function handleBulkPull(): Promise<void> {
|
||||
await runBulkGitAction("pull", (dashboardId) => gitService.pull(dashboardId, envId));
|
||||
}
|
||||
// #endregion handleBulkPull:Function
|
||||
|
||||
// #region handleBulkPush:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
async function handleBulkPush(): Promise<void> {
|
||||
await runBulkGitAction("push", (dashboardId) => gitService.push(dashboardId, envId));
|
||||
}
|
||||
// #endregion handleBulkPush:Function
|
||||
|
||||
// #region handleBulkDelete:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
// @PURPOSE: Removes selected repositories from storage and binding table.
|
||||
function handleBulkDelete(): void {
|
||||
showBulkDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async function onConfirmBulkDelete(): Promise<void> {
|
||||
async function handleBulkPull() { await runBulkGitAction("pull", (id) => gitService.pull(id, envId)); }
|
||||
async function handleBulkPush() { await runBulkGitAction("push", (id) => gitService.push(id, envId)); }
|
||||
async function onConfirmBulkDelete() {
|
||||
const idsToDelete = [...selectedIds];
|
||||
await runBulkGitAction("delete", (dashboardId) =>
|
||||
gitService.deleteRepository(dashboardId, envId),
|
||||
);
|
||||
dashboards = dashboards.filter((dashboard) => !idsToDelete.includes(dashboard.id));
|
||||
await runBulkGitAction("delete", (id) => gitService.deleteRepository(id, envId));
|
||||
dashboards = dashboards.filter((d) => !idsToDelete.includes(d.id));
|
||||
selectedIds = [];
|
||||
}
|
||||
// #endregion handleBulkDelete:Function
|
||||
// #endregion handleBulkAction:Function
|
||||
|
||||
// #region handleManageSelected:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
// @PURPOSE: Opens Git manager for exactly one selected dashboard.
|
||||
async function handleManageSelected(): Promise<void> {
|
||||
if (selectedIds.length !== 1) {
|
||||
addToast($t.git?.select_single_for_manage, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedDashboardId = selectedIds[0];
|
||||
const selectedDashboard = dashboards.find(
|
||||
(dashboard) => dashboard.id === selectedDashboardId,
|
||||
);
|
||||
openGitManagerForDashboard(selectedDashboard || null);
|
||||
function handleManageSelected(): void {
|
||||
if (selectedIds.length !== 1) { addToast($t.git?.select_single_for_manage, "warning"); return; }
|
||||
const d = dashboards.find(dash => dash.id === selectedIds[0]);
|
||||
openGitManagerForDashboard(d || null);
|
||||
}
|
||||
// #endregion handleManageSelected:Function
|
||||
|
||||
// #region resolveDashboardRef:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
// @PURPOSE: Resolves dashboard slug from payload fields.
|
||||
// @PRE: Dashboard metadata is provided.
|
||||
// @POST: Returns slug string or null if unavailable.
|
||||
function resolveDashboardRef(dashboard: DashboardMetadata): string | null {
|
||||
const directSlug = String(
|
||||
dashboard.slug ||
|
||||
dashboard.dashboard_slug ||
|
||||
dashboard.url_slug ||
|
||||
"",
|
||||
).trim();
|
||||
if (directSlug) return directSlug;
|
||||
// #region handleInitializeRepositories:Function [TYPE Function]
|
||||
function handleInitializeRepositories(): void {
|
||||
if (selectedIds.length !== 1) { addToast($t.git?.select_single_for_manage, "warning"); return; }
|
||||
const d = dashboards.find(dash => dash.id === selectedIds[0]) || null;
|
||||
openGitManagerForDashboard(d);
|
||||
}
|
||||
// #endregion handleInitializeRepositories:Function
|
||||
|
||||
// #region resolveDashboardRef:Function [TYPE Function]
|
||||
function resolveDashboardRef(dashboard: DashboardMetadata): string | null {
|
||||
const directSlug = String(dashboard.slug || dashboard.dashboard_slug || dashboard.url_slug || "").trim();
|
||||
if (directSlug) return directSlug;
|
||||
const dashboardUrl = String(dashboard.url || "").trim();
|
||||
if (!dashboardUrl) return null;
|
||||
const slugMatch = dashboardUrl.match(/\/dashboard\/([^/?#]+)/i);
|
||||
if (!slugMatch?.[1]) return null;
|
||||
return decodeURIComponent(slugMatch[1]);
|
||||
return slugMatch ? decodeURIComponent(slugMatch[1]) : null;
|
||||
}
|
||||
// #endregion resolveDashboardRef:Function
|
||||
|
||||
// #region openGitManagerForDashboard:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
// @PURPOSE: Opens Git manager for provided dashboard metadata.
|
||||
function openGitManagerForDashboard(dashboard: DashboardMetadata | null): void {
|
||||
if (!dashboard) return;
|
||||
const dashboardRef = resolveDashboardRef(dashboard);
|
||||
if (!dashboardRef) {
|
||||
addToast($t.git?.select_dashboard_with_slug || "Dashboard slug is required to open GitManager", "error");
|
||||
return;
|
||||
}
|
||||
gitDashboardId = dashboardRef;
|
||||
const ref = resolveDashboardRef(dashboard);
|
||||
if (!ref) { addToast($t.git?.select_dashboard_with_slug || "Dashboard slug required", "error"); return; }
|
||||
gitDashboardId = ref;
|
||||
gitDashboardTitle = dashboard.title || "";
|
||||
showGitManager = true;
|
||||
}
|
||||
// #endregion openGitManagerForDashboard:Function
|
||||
|
||||
// #region handleInitializeRepositories:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
// @PURPOSE: Opens Git manager from bulk actions to initialize selected repository.
|
||||
async function handleInitializeRepositories(): Promise<void> {
|
||||
if (selectedIds.length !== 1) {
|
||||
addToast($t.git?.select_single_for_manage, "warning");
|
||||
return;
|
||||
}
|
||||
const selectedDashboardId = selectedIds[0];
|
||||
const selectedDashboard = dashboards.find((dashboard) => dashboard.id === selectedDashboardId) || null;
|
||||
openGitManagerForDashboard(selectedDashboard);
|
||||
}
|
||||
// #endregion handleInitializeRepositories:Function
|
||||
|
||||
// #region getSortStatusValue:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
/**
|
||||
* @purpose Returns sort value for status column based on mode.
|
||||
*/
|
||||
function getSortStatusValue(dashboard: DashboardMetadata): string {
|
||||
if (statusMode === "repository") {
|
||||
return getRepositoryStatusToken(dashboard.id);
|
||||
}
|
||||
if (statusMode === "repository") return getRepositoryStatusToken(dashboard.id);
|
||||
return String(dashboard.status || "").toLowerCase();
|
||||
}
|
||||
// #endregion getSortStatusValue:Function
|
||||
|
||||
// #region getStatusLabel:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
/**
|
||||
* @purpose Returns localized label for status column.
|
||||
*/
|
||||
function getStatusLabel(dashboard: DashboardMetadata): string {
|
||||
if (statusMode !== "repository") {
|
||||
return String(dashboard.status || "");
|
||||
}
|
||||
if (statusMode !== "repository") return String(dashboard.status || "");
|
||||
const token = getRepositoryStatusToken(dashboard.id);
|
||||
return $t.git?.repo_status?.[token] || token;
|
||||
}
|
||||
// #endregion getStatusLabel:Function
|
||||
|
||||
// #region getStatusBadgeClass:Function [TYPE Function]
|
||||
// @ingroup Dashboard
|
||||
/**
|
||||
* @purpose Returns badge style for status column.
|
||||
*/
|
||||
function getStatusBadgeClass(dashboard: DashboardMetadata): string {
|
||||
if (statusMode !== "repository") {
|
||||
return dashboard.status === "published"
|
||||
? "bg-success-light text-success"
|
||||
: "bg-surface-muted text-text";
|
||||
return dashboard.status === "published" ? "bg-success-light text-success" : "bg-surface-muted text-text";
|
||||
}
|
||||
|
||||
const token = getRepositoryStatusToken(dashboard.id);
|
||||
if (token === "loading") return "bg-surface-muted text-text-muted";
|
||||
if (token === "no_repo") return "bg-surface-muted text-text";
|
||||
if (token === "loading" || token === "no_repo") return "bg-surface-muted text-text-muted";
|
||||
if (token === "synced") return "bg-success-light text-success";
|
||||
if (token === "changes") return "bg-warning-light text-warning";
|
||||
if (token === "behind_remote") return "bg-primary-light text-primary";
|
||||
if (token === "ahead_remote") return "bg-primary-light text-primary";
|
||||
if (token === "behind_remote" || token === "ahead_remote") return "bg-primary-light text-primary";
|
||||
if (token === "diverged") return "bg-info-light text-info";
|
||||
return "bg-destructive-light text-destructive";
|
||||
}
|
||||
// #endregion getStatusBadgeClass:Function
|
||||
|
||||
// #region renderStatusBadge:Function [TYPE Function]
|
||||
function renderStatusBadge(dashboard: DashboardMetadata): string {
|
||||
const classes = getStatusBadgeClass(dashboard);
|
||||
const label = getStatusLabel(dashboard);
|
||||
return `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${classes}">${label}</span>`;
|
||||
}
|
||||
// #endregion renderStatusBadge:Function
|
||||
|
||||
// ── Reactive: load repository statuses ─────────────────────────
|
||||
$effect(() => {
|
||||
console.count("[Grid.effect491]");
|
||||
dashboards;
|
||||
statusMode;
|
||||
currentPage;
|
||||
pageSize;
|
||||
filterText;
|
||||
sortColumn;
|
||||
sortDirection;
|
||||
untrack(() => {
|
||||
void loadRepositoryStatuses();
|
||||
});
|
||||
untrack(() => { void loadRepositoryStatuses(); });
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="dashboard-grid">
|
||||
<!-- Filter Input -->
|
||||
<div class="mb-6">
|
||||
<Input bind:value={filterText} placeholder={$t.dashboard.search} />
|
||||
</div>
|
||||
|
||||
{#if selectedIds.length > 0}
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-primary-ring bg-primary-light/60 px-3 py-2">
|
||||
{#if !repositoriesOnly}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onclick={handleManageSelected}
|
||||
disabled={bulkActionRunning || selectedIds.length !== 1}
|
||||
class="border-primary-ring bg-surface-card text-primary hover:bg-primary-light disabled:opacity-40"
|
||||
>
|
||||
{$t.git?.manage_selected}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onclick={handleInitializeRepositories}
|
||||
disabled={bulkActionRunning || selectedIds.length !== 1}
|
||||
class="border-border bg-surface-card text-text hover:bg-surface-page disabled:opacity-40"
|
||||
>
|
||||
{$t.git?.init_repo || "Инициализировать Git-репозиторий"}
|
||||
</Button>
|
||||
<div class="repository-dashboard-grid">
|
||||
<DashboardDataGrid
|
||||
data={preFilteredData}
|
||||
{columns}
|
||||
selectable
|
||||
bind:selectedIds
|
||||
sortable
|
||||
bind:sortColumn
|
||||
bind:sortDirection
|
||||
filterable
|
||||
bind:filterText
|
||||
paginated
|
||||
bind:page={currentPage}
|
||||
pageSize={20}
|
||||
filterPlaceholder={$t.dashboard?.search}
|
||||
actions={rowActions}
|
||||
loading={bulkActionRunning}
|
||||
>
|
||||
{#snippet children()}
|
||||
{#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">
|
||||
{#if !repositoriesOnly}
|
||||
<Button size="sm" variant="secondary" onclick={handleManageSelected}
|
||||
disabled={bulkActionRunning || selectedIds.length !== 1}>
|
||||
{$t.git?.manage_selected}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onclick={handleInitializeRepositories}
|
||||
disabled={bulkActionRunning || selectedIds.length !== 1}>
|
||||
{$t.git?.init_repo || "Init Git"}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="sm" variant="secondary" onclick={handleBulkSync} disabled={bulkActionRunning}>
|
||||
{$t.git?.bulk_sync}
|
||||
</Button>
|
||||
{#if !repositoriesOnly}
|
||||
<Button size="sm" variant="secondary" onclick={handleBulkCommit} disabled={bulkActionRunning}>
|
||||
{$t.git?.bulk_commit}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="sm" variant="secondary" onclick={handleBulkPull} disabled={bulkActionRunning}>
|
||||
{$t.git?.bulk_pull}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onclick={handleBulkPush} disabled={bulkActionRunning}>
|
||||
{$t.git?.bulk_push}
|
||||
</Button>
|
||||
{#if repositoriesOnly}
|
||||
<Button size="sm" variant="destructive" onclick={() => showBulkDeleteConfirm = true} disabled={bulkActionRunning}>
|
||||
{$t.git?.delete_repo || "Delete"}
|
||||
</Button>
|
||||
{/if}
|
||||
<span class="ml-1 text-xs font-medium text-text-muted">
|
||||
{$t.git?.selected_count.replace("{count}", String(selectedIds.length))}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<Button size="sm" variant="secondary" onclick={handleBulkSync} disabled={bulkActionRunning} class="border-primary-ring bg-surface-card text-primary hover:bg-primary-light">
|
||||
{$t.git?.bulk_sync}
|
||||
</Button>
|
||||
{#if !repositoriesOnly}
|
||||
<Button size="sm" variant="secondary" onclick={handleBulkCommit} disabled={bulkActionRunning} class="border-warning bg-surface-card text-warning hover:bg-warning-light">
|
||||
{$t.git?.bulk_commit}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="sm" variant="secondary" onclick={handleBulkPull} disabled={bulkActionRunning} class="border-cyan-200 bg-surface-card text-info hover:bg-info-light">
|
||||
{$t.git?.bulk_pull}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onclick={handleBulkPush} disabled={bulkActionRunning} class="border-primary-ring bg-surface-card text-primary hover:bg-primary-light">
|
||||
{$t.git?.bulk_push}
|
||||
</Button>
|
||||
{#if repositoriesOnly}
|
||||
<Button size="sm" variant="secondary" onclick={handleBulkDelete} disabled={bulkActionRunning} class="border-destructive-ring bg-surface-card text-destructive hover:bg-destructive-light">
|
||||
{$t.git?.delete_repo || "Удалить репозиторий"}
|
||||
</Button>
|
||||
{/if}
|
||||
<span class="ml-1 text-xs font-medium text-text-muted">
|
||||
{$t.git?.selected_count.replace(
|
||||
"{count}",
|
||||
String(selectedIds.length),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Grid/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">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected && !allSelected}
|
||||
onchange={(e) =>
|
||||
handleSelectAll((e.target as HTMLInputElement).checked)}
|
||||
class="h-4 w-4 text-primary border-border-strong rounded focus-visible:ring-primary-ring"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider cursor-pointer hover:text-text transition-colors"
|
||||
onclick={() => handleSort("title")}
|
||||
>
|
||||
{$t.dashboard.title}
|
||||
{sortColumn === "title"
|
||||
? sortDirection === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: ""}
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider cursor-pointer hover:text-text transition-colors"
|
||||
onclick={() => handleSort("last_modified")}
|
||||
>
|
||||
{$t.dashboard.last_modified}
|
||||
{sortColumn === "last_modified"
|
||||
? sortDirection === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: ""}
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider cursor-pointer hover:text-text transition-colors"
|
||||
onclick={() => handleSort("status")}
|
||||
>
|
||||
{$t.dashboard.status}
|
||||
{sortColumn === "status"
|
||||
? sortDirection === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: ""}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-surface-card divide-y divide-border">
|
||||
{#each paginatedDashboards as dashboard (dashboard.id)}
|
||||
<tr class="hover:bg-surface-muted transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(dashboard.id)}
|
||||
onchange={(e) =>
|
||||
handleSelectionChange(
|
||||
dashboard.id,
|
||||
(e.target as HTMLInputElement).checked,
|
||||
)}
|
||||
class="h-4 w-4 text-primary border-border-strong rounded focus-visible:ring-primary-ring"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-text">
|
||||
<button
|
||||
type="button"
|
||||
class="text-left text-primary hover:text-primary hover:underline"
|
||||
onclick={() => openGitManagerForDashboard(dashboard)}
|
||||
>
|
||||
{dashboard.title}
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted"
|
||||
>{new Date(dashboard.last_modified).toLocaleDateString()}</td
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-medium rounded-full {getStatusBadgeClass(dashboard)}"
|
||||
>
|
||||
{getStatusLabel(dashboard)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
<div class="text-sm text-text-muted">
|
||||
{($t.dashboard?.showing ?? '')
|
||||
.replace("{start}", (currentPage * pageSize + 1).toString())
|
||||
.replace(
|
||||
"{end}",
|
||||
Math.min(
|
||||
(currentPage + 1) * pageSize,
|
||||
sortedDashboards.length,
|
||||
).toString(),
|
||||
)
|
||||
.replace("{total}", sortedDashboards.length.toString())}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={currentPage === 0}
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
>
|
||||
{$t.dashboard.previous}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
>
|
||||
{$t.dashboard.next}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</DashboardDataGrid>
|
||||
</div>
|
||||
|
||||
{#if showGitManager && gitDashboardId}
|
||||
@@ -721,7 +356,7 @@
|
||||
<ConfirmDialog
|
||||
bind:show={showBulkDeleteConfirm}
|
||||
title="Delete repositories?"
|
||||
message={$t.git?.confirm_delete_repo || "Удалить выбранные репозитории?"}
|
||||
message={$t.git?.confirm_delete_repo || "Delete selected repositories?"}
|
||||
variant="destructive"
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
@@ -729,6 +364,4 @@
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<!-- #endregion Dashboard.RepositoryGrid -->
|
||||
|
||||
Reference in New Issue
Block a user