Fix: settings master/detail, widget kind filter, reorder, media worker
Four fixes: 1. Settings > Services tab: master/detail layout. Replaced the vertical stack of ServiceConfigEditor cards with a SelectionRailCard (list on left) + SectionCard (details on right) — same pattern as Machines and SSH Keys tabs. Click a service in the rail to edit it. 2. Service Overview widget restriction. When adding widgets on a service's Overview, the dialog now only shows built-in widgets + widgets for THAT service type (not all services). Dashboard/named dashboards (no serviceId) still see all. 3. Reorder buttons fixed. The swap-sort_order approach was a no-op when both items had sort_order=0 (the default). Now moveInstance renumbers ALL items by their new index position (i * 10) after the swap, guaranteeing values change. References use updateRef, owned widgets use saveWidget, both sequential. 4. Media index worker resolution. The subprocess worker called get_jellyfin_client/get_user_id (FastAPI request dependencies) which don't work outside request context. Now accepts a service_id parameter (passed from post_build_index) and resolves the Jellyfin instance directly from the settings store via _resolve_jellyfin. Raises RuntimeError (not HTTPException) on failure. 283 backend tests pass; 128 frontend tests pass; ruff/eslint clean.
This commit is contained in:
@@ -98,7 +98,7 @@ def _serialize_status(status: Any) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
|
def _worker_command(final_db_path: Path, staging_db_path: Path, service_id: str = "") -> list[str]:
|
||||||
return [
|
return [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-m",
|
"-m",
|
||||||
@@ -107,14 +107,16 @@ def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
|
|||||||
str(final_db_path),
|
str(final_db_path),
|
||||||
"--staging-path",
|
"--staging-path",
|
||||||
str(staging_db_path),
|
str(staging_db_path),
|
||||||
|
"--service-id",
|
||||||
|
service_id,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _start_worker(index: MediaIndex) -> subprocess.Popen[bytes]:
|
def _start_worker(index: MediaIndex, service_id: str = "") -> subprocess.Popen[bytes]:
|
||||||
staging_path = _staging_db_path(index)
|
staging_path = _staging_db_path(index)
|
||||||
staging_path.unlink(missing_ok=True)
|
staging_path.unlink(missing_ok=True)
|
||||||
return subprocess.Popen(
|
return subprocess.Popen(
|
||||||
_worker_command(index.db_path, staging_path),
|
_worker_command(index.db_path, staging_path, service_id),
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
env=os.environ.copy(),
|
env=os.environ.copy(),
|
||||||
)
|
)
|
||||||
@@ -133,6 +135,7 @@ def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str,
|
|||||||
def post_build_index(
|
def post_build_index(
|
||||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||||
user_id: str = Depends(get_user_id),
|
user_id: str = Depends(get_user_id),
|
||||||
|
jellyfin_service_id: str | None = None,
|
||||||
index: MediaIndex = Depends(get_media_index),
|
index: MediaIndex = Depends(get_media_index),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Start a media index build in a subprocess worker."""
|
"""Start a media index build in a subprocess worker."""
|
||||||
@@ -144,7 +147,7 @@ def post_build_index(
|
|||||||
|
|
||||||
libraries = client.libraries(user_id)
|
libraries = client.libraries(user_id)
|
||||||
logger.info("Starting media index build user_id=%s libraries=%s", user_id, len(libraries))
|
logger.info("Starting media index build user_id=%s libraries=%s", user_id, len(libraries))
|
||||||
process = _start_worker(index)
|
process = _start_worker(index, jellyfin_service_id or "")
|
||||||
_set_build_metadata(
|
_set_build_metadata(
|
||||||
index,
|
index,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from media_library_viewer_api.config import get_settings
|
from media_library_viewer_api.config import get_settings
|
||||||
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
|
|
||||||
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
|
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
|
||||||
from media_library_viewer_api.services.media_index import (
|
from media_library_viewer_api.services.media_index import (
|
||||||
MediaIndex,
|
MediaIndex,
|
||||||
@@ -88,13 +87,41 @@ def _progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) -> No
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> int:
|
def _resolve_jellyfin(service_id: str) -> tuple[Any, str]:
|
||||||
|
"""Resolve the Jellyfin client + user_id from the settings store.
|
||||||
|
|
||||||
|
In a subprocess we cannot use the FastAPI dependency layer (no request),
|
||||||
|
so we query the settings store directly. When ``service_id`` is given,
|
||||||
|
resolve that specific instance; otherwise fall back to first-enabled.
|
||||||
|
"""
|
||||||
|
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||||
|
from media_library_viewer_api.dependencies import _service_record, get_settings_store
|
||||||
|
|
||||||
|
store = get_settings_store()
|
||||||
|
service = _service_record(store, "jellyfin", service_id or None)
|
||||||
|
if service is None:
|
||||||
|
raise RuntimeError("No Jellyfin service is configured. Add a Jellyfin service on the Services page.")
|
||||||
|
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||||
|
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||||
|
if not base_url or not api_key:
|
||||||
|
raise RuntimeError("Jellyfin service is missing base_url or api_key. Edit it on the Services page.")
|
||||||
|
timeout = int(service.get("config", {}).get("timeout_seconds", 10))
|
||||||
|
client = JellyfinClient(base_url, api_key, timeout)
|
||||||
|
user_id = str(service.get("config", {}).get("user_id") or "")
|
||||||
|
if not user_id:
|
||||||
|
users = client.users()
|
||||||
|
if not users:
|
||||||
|
raise RuntimeError("No Jellyfin users found and no user_id configured on the service")
|
||||||
|
user_id = users[0]["Id"]
|
||||||
|
return client, user_id
|
||||||
|
|
||||||
|
|
||||||
|
def run_build(final_index_path: str | Path, staging_index_path: str | Path, service_id: str = "") -> int:
|
||||||
"""Run the media index build in a subprocess."""
|
"""Run the media index build in a subprocess."""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
configure_logging(settings.log_level)
|
configure_logging(settings.log_level)
|
||||||
logger.info("Media index worker starting: %s", describe_settings(settings))
|
logger.info("Media index worker starting: %s", describe_settings(settings))
|
||||||
client = get_jellyfin_client()
|
client, user_id = _resolve_jellyfin(service_id)
|
||||||
user_id = get_user_id()
|
|
||||||
libraries = client.libraries(user_id)
|
libraries = client.libraries(user_id)
|
||||||
|
|
||||||
final_index = MediaIndex(final_index_path)
|
final_index = MediaIndex(final_index_path)
|
||||||
@@ -192,8 +219,9 @@ def main() -> int:
|
|||||||
parser = argparse.ArgumentParser(description="Build the media index in a worker process")
|
parser = argparse.ArgumentParser(description="Build the media index in a worker process")
|
||||||
parser.add_argument("--index-path", required=True)
|
parser.add_argument("--index-path", required=True)
|
||||||
parser.add_argument("--staging-path", required=True)
|
parser.add_argument("--staging-path", required=True)
|
||||||
|
parser.add_argument("--service-id", default="", help="Jellyfin service instance id")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
return run_build(args.index_path, args.staging_path)
|
return run_build(args.index_path, args.staging_path, args.service_id)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
|||||||
@@ -232,7 +232,6 @@ export function WidgetConfigDialog({
|
|||||||
startEdit(target);
|
startEdit(target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}, [open, editWidgetId, instances]);
|
}, [open, editWidgetId, instances]);
|
||||||
|
|
||||||
function startAddBuiltIn(kind: string) {
|
function startAddBuiltIn(kind: string) {
|
||||||
@@ -307,27 +306,26 @@ export function WidgetConfigDialog({
|
|||||||
async function moveInstance(index: number, direction: -1 | 1) {
|
async function moveInstance(index: number, direction: -1 | 1) {
|
||||||
const targetIndex = index + direction;
|
const targetIndex = index + direction;
|
||||||
if (targetIndex < 0 || targetIndex >= combinedWidgets.length) return;
|
if (targetIndex < 0 || targetIndex >= combinedWidgets.length) return;
|
||||||
const a = combinedWidgets[index];
|
// Swap the two items in a copy, then renumber ALL items by their new
|
||||||
const b = combinedWidgets[targetIndex];
|
// index position (index * 10). This guarantees the sort_order values
|
||||||
const aRefId = (a as { _ref_id?: string })._ref_id;
|
// change even when both items previously shared the same value (e.g. 0).
|
||||||
const bRefId = (b as { _ref_id?: string })._ref_id;
|
const reordered = [...combinedWidgets];
|
||||||
// References use their own sort_order on the widget_references row;
|
const tmp = reordered[index];
|
||||||
// owned widgets use the widget instance's sort_order.
|
reordered[index] = reordered[targetIndex];
|
||||||
if (aRefId) {
|
reordered[targetIndex] = tmp;
|
||||||
await updateRef.mutateAsync({
|
// Sequential (not Promise.all) to avoid cache-invalidation race.
|
||||||
referenceId: aRefId,
|
for (let i = 0; i < reordered.length; i++) {
|
||||||
sortOrder: b.sort_order,
|
const item = reordered[i];
|
||||||
});
|
const newSortOrder = i * 10;
|
||||||
} else {
|
const refId = (item as { _ref_id?: string })._ref_id;
|
||||||
await saveWidget.mutateAsync({ ...a, sort_order: b.sort_order });
|
if (refId) {
|
||||||
}
|
await updateRef.mutateAsync({
|
||||||
if (bRefId) {
|
referenceId: refId,
|
||||||
await updateRef.mutateAsync({
|
sortOrder: newSortOrder,
|
||||||
referenceId: bRefId,
|
});
|
||||||
sortOrder: a.sort_order,
|
} else {
|
||||||
});
|
await saveWidget.mutateAsync({ ...item, sort_order: newSortOrder });
|
||||||
} else {
|
}
|
||||||
await saveWidget.mutateAsync({ ...b, sort_order: a.sort_order });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,6 +653,9 @@ export function WidgetConfigDialog({
|
|||||||
))}
|
))}
|
||||||
{services
|
{services
|
||||||
.filter((s) => s.enabled)
|
.filter((s) => s.enabled)
|
||||||
|
// When scoped to a service Overview, only show widgets for THAT
|
||||||
|
// service instance's type (not all services' widgets).
|
||||||
|
.filter((s) => !serviceId || s.id === serviceId)
|
||||||
.flatMap((s) =>
|
.flatMap((s) =>
|
||||||
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => (
|
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
+168
-124
@@ -1338,46 +1338,90 @@ export function Settings() {
|
|||||||
function ServicesAdminCard() {
|
function ServicesAdminCard() {
|
||||||
const { data: services = [] } = useServiceInstances();
|
const { data: services = [] } = useServiceInstances();
|
||||||
const { data: types = [] } = useServiceTypes();
|
const { data: types = [] } = useServiceTypes();
|
||||||
|
const [selectedServiceId, setSelectedServiceId] = useState("");
|
||||||
|
|
||||||
// Group by service_type, alphabetical.
|
const sortedServices = useMemo(
|
||||||
const grouped = useMemo(() => {
|
() =>
|
||||||
const map = new Map<string, ServiceInstance[]>();
|
[...services].sort((a, b) =>
|
||||||
for (const svc of services) {
|
`${a.service_type}:${a.name}`.localeCompare(
|
||||||
const list = map.get(svc.service_type) ?? [];
|
`${b.service_type}:${b.name}`,
|
||||||
list.push(svc);
|
),
|
||||||
map.set(svc.service_type, list);
|
),
|
||||||
}
|
[services],
|
||||||
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
);
|
||||||
}, [services]);
|
|
||||||
|
const selectedService = useMemo(
|
||||||
|
() =>
|
||||||
|
sortedServices.find((s) => s.id === selectedServiceId) ??
|
||||||
|
sortedServices[0] ??
|
||||||
|
null,
|
||||||
|
[sortedServices, selectedServiceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedTypeInfo = selectedService
|
||||||
|
? types.find((t) => t.service_type === selectedService.service_type)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (sortedServices.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No service instances configured. Create one from the Services page.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
{grouped.length === 0 ? (
|
<SelectionRailCard
|
||||||
<p className="text-sm text-muted-foreground">
|
title="Services"
|
||||||
No service instances configured. Create one from the Services page.
|
description="Select a service to edit its configuration."
|
||||||
</p>
|
minHeight={420}
|
||||||
) : (
|
>
|
||||||
grouped.map(([serviceType, instances]) => {
|
{sortedServices.map((svc) => {
|
||||||
const typeInfo = types.find((t) => t.service_type === serviceType);
|
const active = svc.id === (selectedService?.id ?? "");
|
||||||
|
const typeName =
|
||||||
|
types.find((t) => t.service_type === svc.service_type)?.name ??
|
||||||
|
svc.service_type;
|
||||||
return (
|
return (
|
||||||
<SectionCard
|
<div
|
||||||
key={serviceType}
|
key={svc.id}
|
||||||
title={typeInfo?.name ?? serviceType}
|
onClick={() => setSelectedServiceId(svc.id)}
|
||||||
description={typeInfo?.description ?? ""}
|
className={cn(
|
||||||
|
"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5",
|
||||||
|
active ? "bg-muted" : "bg-card hover:bg-muted/50",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="min-w-0">
|
||||||
{instances.map((svc) => (
|
<p className="truncate font-semibold">{svc.name}</p>
|
||||||
<ServiceConfigEditor
|
<p className="text-xs text-muted-foreground">
|
||||||
key={svc.id}
|
{typeName} · {svc.enabled ? "Enabled" : "Disabled"}
|
||||||
instance={svc}
|
</p>
|
||||||
typeInfo={typeInfo}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
<Badge variant={svc.enabled ? "default" : "secondary"}>
|
||||||
|
{svc.enabled ? "on" : "off"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
)}
|
</SelectionRailCard>
|
||||||
|
<SectionCard
|
||||||
|
title={selectedService?.name ?? "No service selected"}
|
||||||
|
description={
|
||||||
|
selectedTypeInfo?.description ??
|
||||||
|
"Select a service on the left to edit its configuration."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedService ? (
|
||||||
|
<ServiceConfigEditor
|
||||||
|
instance={selectedService}
|
||||||
|
typeInfo={selectedTypeInfo}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Select a service on the left.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1439,113 +1483,113 @@ function ServiceConfigEditor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<span className="font-medium">{instance.name}</span>
|
<span className="font-medium">{instance.name}</span>
|
||||||
<Badge variant={instance.enabled ? "default" : "secondary"}>
|
<Badge variant={instance.enabled ? "default" : "secondary"}>
|
||||||
{instance.enabled ? "enabled" : "disabled"}
|
{instance.enabled ? "enabled" : "disabled"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<FormField label="Name" htmlFor={`svc-name-${instance.id}`}>
|
|
||||||
<Input
|
|
||||||
id={`svc-name-${instance.id}`}
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id={`svc-enabled-${instance.id}`}
|
|
||||||
checked={enabled}
|
|
||||||
onCheckedChange={setEnabled}
|
|
||||||
/>
|
|
||||||
<Label htmlFor={`svc-enabled-${instance.id}`}>Enabled</Label>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<FormField label="Name" htmlFor={`svc-name-${instance.id}`}>
|
||||||
|
<Input
|
||||||
|
id={`svc-name-${instance.id}`}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id={`svc-enabled-${instance.id}`}
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={setEnabled}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`svc-enabled-${instance.id}`}>Enabled</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{configEntries.map(([key, schema]) => {
|
{configEntries.map(([key, schema]) => {
|
||||||
const isNumber =
|
const isNumber =
|
||||||
schema.type === "integer" || schema.type === "number";
|
schema.type === "integer" || schema.type === "number";
|
||||||
return (
|
return (
|
||||||
<FormField
|
|
||||||
key={key}
|
|
||||||
label={key}
|
|
||||||
htmlFor={`svc-cfg-${instance.id}-${key}`}
|
|
||||||
helperText={schema.description}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id={`svc-cfg-${instance.id}-${key}`}
|
|
||||||
type={isNumber ? "number" : "text"}
|
|
||||||
value={String(draftConfig[key] ?? "")}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraftConfig({
|
|
||||||
...draftConfig,
|
|
||||||
[key]: isNumber
|
|
||||||
? e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value)
|
|
||||||
: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{Object.keys(instance.secrets_set).length === 0
|
|
||||||
? null
|
|
||||||
: Object.entries(instance.secrets_set).map(([key, isSet]) => (
|
|
||||||
<FormField
|
<FormField
|
||||||
key={key}
|
key={key}
|
||||||
label={key}
|
label={key}
|
||||||
htmlFor={`svc-secret-${instance.id}-${key}`}
|
htmlFor={`svc-cfg-${instance.id}-${key}`}
|
||||||
helperText="Leave blank to keep the current value."
|
helperText={schema.description}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id={`svc-secret-${instance.id}-${key}`}
|
id={`svc-cfg-${instance.id}-${key}`}
|
||||||
type="password"
|
type={isNumber ? "number" : "text"}
|
||||||
placeholder={isSet ? "•••••• (set)" : "Not set"}
|
value={String(draftConfig[key] ?? "")}
|
||||||
value={draftSecrets[key] ?? ""}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraftSecrets({
|
setDraftConfig({
|
||||||
...draftSecrets,
|
...draftConfig,
|
||||||
[key]: e.target.value,
|
[key]: isNumber
|
||||||
|
? e.target.value === ""
|
||||||
|
? undefined
|
||||||
|
: Number(e.target.value)
|
||||||
|
: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<div className="flex justify-between">
|
{Object.keys(instance.secrets_set).length === 0
|
||||||
<Button
|
? null
|
||||||
onClick={handleSave}
|
: Object.entries(instance.secrets_set).map(([key, isSet]) => (
|
||||||
disabled={saveService.isPending}
|
<FormField
|
||||||
className="mobile-touch-target"
|
key={key}
|
||||||
>
|
label={key}
|
||||||
Save
|
htmlFor={`svc-secret-${instance.id}-${key}`}
|
||||||
</Button>
|
helperText="Leave blank to keep the current value."
|
||||||
<Button
|
>
|
||||||
variant="destructive"
|
<Input
|
||||||
onClick={() => setDeleteOpen(true)}
|
id={`svc-secret-${instance.id}-${key}`}
|
||||||
className="mobile-touch-target"
|
type="password"
|
||||||
>
|
placeholder={isSet ? "•••••• (set)" : "Not set"}
|
||||||
Delete
|
value={draftSecrets[key] ?? ""}
|
||||||
</Button>
|
onChange={(e) =>
|
||||||
|
setDraftSecrets({
|
||||||
|
...draftSecrets,
|
||||||
|
[key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saveService.isPending}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteOpen}
|
open={deleteOpen}
|
||||||
title="Delete service?"
|
title="Delete service?"
|
||||||
message="This removes the service and any widgets that reference it. This cannot be undone."
|
message="This removes the service and any widgets that reference it. This cannot be undone."
|
||||||
confirmLabel="Delete"
|
confirmLabel="Delete"
|
||||||
onCancel={() => setDeleteOpen(false)}
|
onCancel={() => setDeleteOpen(false)}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
deleteService.mutate(instance.id);
|
deleteService.mutate(instance.id);
|
||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user