feat(s3-storage): standardize validation and improve action menu UI
This commit is contained in:
1 parent
a2cd6b7099
commit
7b97032370
2 files changed
+86
-71
No files matched your search
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { type MouseEvent, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Cloud,
|
Cloud,
|
||||||
Database,
|
Database,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Key,
|
Key,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
MoreVertical,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Power,
|
Power,
|
||||||
@@ -213,6 +214,12 @@ export default function S3StorageSettingsPanel({
|
|||||||
}: S3StorageSettingsPanelProps) {
|
}: S3StorageSettingsPanelProps) {
|
||||||
const [draft, setDraft] = useState<DestinationDraft | null>(null);
|
const [draft, setDraft] = useState<DestinationDraft | null>(null);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||||
|
const [menuPosition, setMenuPosition] = useState<{
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
} | null>(null);
|
||||||
|
const actionMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
@@ -262,6 +269,58 @@ export default function S3StorageSettingsPanel({
|
|||||||
setDraft(null);
|
setDraft(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleActionMenu = (
|
||||||
|
event: MouseEvent<HTMLButtonElement>,
|
||||||
|
destinationId: string,
|
||||||
|
) => {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const menuWidth = 176;
|
||||||
|
const menuHeight = 132;
|
||||||
|
const left = Math.min(
|
||||||
|
window.innerWidth - menuWidth - 12,
|
||||||
|
Math.max(12, rect.right - menuWidth + rect.width),
|
||||||
|
);
|
||||||
|
const top = Math.min(window.innerHeight - menuHeight - 12, rect.bottom + 6);
|
||||||
|
|
||||||
|
if (openMenuId === destinationId) {
|
||||||
|
setOpenMenuId(null);
|
||||||
|
setMenuPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMenuPosition({ top: Math.max(12, top), left });
|
||||||
|
setOpenMenuId(destinationId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeActionMenu = () => {
|
||||||
|
setOpenMenuId(null);
|
||||||
|
setMenuPosition(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openMenuId) return;
|
||||||
|
|
||||||
|
const closeMenuOnOutsideClick = (event: globalThis.MouseEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Node) || actionMenuRef.current?.contains(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trigger =
|
||||||
|
target instanceof Element
|
||||||
|
? target.closest("[data-action-menu-trigger]")
|
||||||
|
: null;
|
||||||
|
if (trigger?.getAttribute("data-action-menu-trigger") === openMenuId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeActionMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", closeMenuOnOutsideClick);
|
||||||
|
return () => document.removeEventListener("mousedown", closeMenuOnOutsideClick);
|
||||||
|
}, [openMenuId]);
|
||||||
|
|
||||||
const saveDraft = async () => {
|
const saveDraft = async () => {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
|
|
||||||
@@ -514,81 +573,36 @@ export default function S3StorageSettingsPanel({
|
|||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
gap: 8,
|
gap: 6,
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button type="button" className="btn btn-ghost" onClick={() => void onVerifyDestination({ id: destination.id, name: destination.name, provider: destination.provider, enabled: destination.enabled, accessKeyId: destination.accessKeyId, secretAccessKey: "", hasSecretAccessKey: destination.hasSecretAccessKey, region: destination.region, bucket: destination.bucket, endpoint: destination.endpoint, additionalFlags: [...destination.additionalFlags], serverId: destination.serverId })} disabled={isVerifying} title="Verify connection" style={{ padding: "4px 8px", fontSize: 11 }}>
|
||||||
type="button"
|
{isVerifying ? <Loader2 size={12} className="animate-spin" /> : <CloudCheck size={12} />}
|
||||||
className="btn btn-ghost"
|
|
||||||
onClick={() =>
|
|
||||||
void onToggleDestination(destination)
|
|
||||||
}
|
|
||||||
style={{ padding: "7px 12px", fontSize: 12 }}
|
|
||||||
>
|
|
||||||
{destination.enabled ? (
|
|
||||||
<Power color="#10b981" size={13} />
|
|
||||||
) : (
|
|
||||||
<Power color="#ef4444" size={13} />
|
|
||||||
)}
|
|
||||||
{/* {destination.enabled ? "Disable" : "Enable"} */}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={() => openEditModal(destination)}
|
onClick={() => { openEditModal(destination); closeActionMenu(); }}
|
||||||
style={{ padding: "7px 12px", fontSize: 12 }}
|
title="Edit destination"
|
||||||
|
style={{ padding: "4px 8px", fontSize: 11 }}
|
||||||
>
|
>
|
||||||
<Pencil size={13} />
|
<Pencil size={12} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={() =>
|
onClick={(event) => toggleActionMenu(event, destination.id)}
|
||||||
void onVerifyDestination({
|
data-action-menu-trigger={destination.id}
|
||||||
id: destination.id,
|
title="More actions"
|
||||||
name: destination.name,
|
style={{ padding: "4px 8px", fontSize: 11 }}
|
||||||
provider: destination.provider,
|
|
||||||
enabled: destination.enabled,
|
|
||||||
accessKeyId: destination.accessKeyId,
|
|
||||||
secretAccessKey: "",
|
|
||||||
hasSecretAccessKey:
|
|
||||||
destination.hasSecretAccessKey,
|
|
||||||
region: destination.region,
|
|
||||||
bucket: destination.bucket,
|
|
||||||
endpoint: destination.endpoint,
|
|
||||||
additionalFlags: [
|
|
||||||
...destination.additionalFlags,
|
|
||||||
],
|
|
||||||
serverId: destination.serverId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={isVerifying}
|
|
||||||
style={{ padding: "7px 12px", fontSize: 12 }}
|
|
||||||
>
|
>
|
||||||
{isVerifying ? (
|
<MoreVertical size={12} />
|
||||||
<Loader2 size={13} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<CloudCheck size={13} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
onClick={() => onDeleteDestination(destination)}
|
|
||||||
disabled={isDeleting}
|
|
||||||
style={{
|
|
||||||
padding: "7px 12px",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#ef4444",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isDeleting ? (
|
|
||||||
<Loader2 size={13} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 size={13} />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
{openMenuId === destination.id && menuPosition ? <div className="card" ref={actionMenuRef} onClick={(event) => event.stopPropagation()} style={{ position: "fixed", top: menuPosition.top, left: menuPosition.left, minWidth: 176, padding: 6, display: "flex", flexDirection: "column", gap: 4, zIndex: 160, boxShadow: "0 18px 40px rgba(2, 6, 23, 0.42)" }}>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => { void onToggleDestination(destination); closeActionMenu(); }} style={{ fontSize: 12, justifyContent: "flex-start" }}><Power color={destination.enabled ? "#10b981" : "#ef4444"} size={12} />{destination.enabled ? "Disable destination" : "Enable destination"}</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => { onDeleteDestination(destination); closeActionMenu(); }} disabled={isDeleting} style={{ fontSize: 12, justifyContent: "flex-start", color: "#ef4444" }}>{isDeleting ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}Delete destination</button>
|
||||||
|
</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -842,7 +856,10 @@ export default function S3StorageSettingsPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ gridColumn: "1 / -1" }}>
|
<div style={{ gridColumn: "1 / -1" }}>
|
||||||
<FieldLabel>Object prefix / folder</FieldLabel>
|
<FieldLabel>
|
||||||
|
Object prefix / folder
|
||||||
|
{(draft.retentionDays || draft.maxBackupCount) && " *"}
|
||||||
|
</FieldLabel>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={draft.objectPrefix ?? ""}
|
value={draft.objectPrefix ?? ""}
|
||||||
@@ -919,7 +936,7 @@ export default function S3StorageSettingsPanel({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Destination name</FieldLabel>
|
<FieldLabel>Destination name *</FieldLabel>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
@@ -980,7 +997,7 @@ export default function S3StorageSettingsPanel({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Access Key ID</FieldLabel>
|
<FieldLabel>Access Key ID *</FieldLabel>
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -1012,7 +1029,7 @@ export default function S3StorageSettingsPanel({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>
|
<FieldLabel>
|
||||||
Secret Access Key
|
Secret Access Key{!draft.hasSecretAccessKey && " *"}
|
||||||
{draft.hasSecretAccessKey
|
{draft.hasSecretAccessKey
|
||||||
? " (leave blank to keep existing key)"
|
? " (leave blank to keep existing key)"
|
||||||
: ""}
|
: ""}
|
||||||
@@ -1039,7 +1056,7 @@ export default function S3StorageSettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Bucket</FieldLabel>
|
<FieldLabel>Bucket *</FieldLabel>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={draft.bucket}
|
value={draft.bucket}
|
||||||
@@ -1055,7 +1072,7 @@ export default function S3StorageSettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Region</FieldLabel>
|
<FieldLabel>Region *</FieldLabel>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={draft.region}
|
value={draft.region}
|
||||||
@@ -1072,7 +1089,7 @@ export default function S3StorageSettingsPanel({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>
|
<FieldLabel>
|
||||||
Endpoint
|
Endpoint{draft.provider !== "awsS3" && " *"}
|
||||||
{draft.provider === "awsS3"
|
{draft.provider === "awsS3"
|
||||||
? " (optional for AWS S3)"
|
? " (optional for AWS S3)"
|
||||||
: " (required for S3-compatible providers)"}
|
: " (required for S3-compatible providers)"}
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ export default function S3StoragePage() {
|
|||||||
const upsertDestination = async (draft: DestinationDraft) => {
|
const upsertDestination = async (draft: DestinationDraft) => {
|
||||||
const missing = [
|
const missing = [
|
||||||
!draft.accessKeyId && "Access Key ID",
|
!draft.accessKeyId && "Access Key ID",
|
||||||
!draft.secretAccessKey && "Secret Access Key",
|
|
||||||
!draft.region && "Region",
|
!draft.region && "Region",
|
||||||
!draft.bucket && "Bucket",
|
!draft.bucket && "Bucket",
|
||||||
!draft.name && "Destination Name",
|
!draft.name && "Destination Name",
|
||||||
@@ -179,7 +178,6 @@ export default function S3StoragePage() {
|
|||||||
const verifyDestination = async (draft: DestinationDraft) => {
|
const verifyDestination = async (draft: DestinationDraft) => {
|
||||||
const missing = [
|
const missing = [
|
||||||
!draft.accessKeyId && "Access Key ID",
|
!draft.accessKeyId && "Access Key ID",
|
||||||
!draft.secretAccessKey && "Secret Access Key",
|
|
||||||
!draft.region && "Region",
|
!draft.region && "Region",
|
||||||
!draft.bucket && "Bucket",
|
!draft.bucket && "Bucket",
|
||||||
!draft.name && "Destination Name",
|
!draft.name && "Destination Name",
|
||||||
|
|||||||
Reference in new issue
Block a user