Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/routes/projects/[project]/family/[family]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
<p class="text-sm text-neutral-500">Family not found.</p>
{:else}
<div class="space-y-8">
<FamilyMetadata {family} />
<FamilyMetadata {family} versionCount={versions.length} />

<section class="space-y-4">
<div class="flex items-center gap-2">
Expand Down
304 changes: 294 additions & 10 deletions src/routes/projects/[project]/family/[family]/FamilyMetadata.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/state";
import { AUTH_CTX } from "$lib/auth.svelte";
import FlagsDisplay from "$lib/components/FlagsDisplay.svelte";
import * as AlertDialog from "$lib/components/ui/alert-dialog";
import { Button } from "$lib/components/ui/button";
import * as Input from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import type { Java } from "$lib/gql/graphql";
import { splitFlags } from "$lib/utils";
import { getContextClient, mutationStore } from "@urql/svelte";
import { watch } from "runed";

const auth = AUTH_CTX.get();

type Family = {
key: string;
Expand All @@ -9,15 +22,186 @@

interface Props {
family: Family;
versionCount: number;
editMode?: boolean;
}

interface EditState {
minimum?: number;
flags?: string[];
}

let { family, versionCount, editMode = $bindable(false) }: Props = $props();

function createEditState(): EditState {
return {
minimum: family.java.version.minimum,
flags: family.java.flags.recommended,
};
}

let editState: EditState = $state(createEditState());

function resetEditState() {
editState = createEditState();
}

const client = getContextClient();
let saving = $state(false);
let deleting = $state(false);
let deleteDialogOpen = $state(false);
let deleteConfirmation = $state("");
let canDelete = $derived(versionCount === 0);

watch(
() => deleteDialogOpen,
(open) => {
if (!open) {
deleteConfirmation = "";
}
},
);

function saveChanges() {
if (saving || deleting) return;
if (editState.minimum == null) {
alert("Please set a minimum Java version.");
return;
}

saving = true;

const result = mutationStore({
client,
query: `
mutation UpdateFamily($input: UpdateFamilyInput!) {
updateFamily(input: $input) {
family {
id
key
java {
version {
minimum
}
flags {
recommended
}
}
}
}
}
`,
variables: {
Comment on lines +74 to +94
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These mutations are passed as raw query strings. Elsewhere in the repo, mutationStore typically uses the generated graphql(...) helper (typed documents / codegen), e.g. src/routes/projects/[project]/family/new/+page.svelte:38-58. Using raw strings here forces manual casting of data and makes it easier to drift from the schema.

Suggestion: import graphql from $lib/gql and wrap both UpdateFamily and DeleteFamily operations with it, then rely on the generated types instead of (data as ...) casts.

Copilot uses AI. Check for mistakes.
input: {
project: page.params.project!,
key: family.key,
java: {
version: {
minimum: editState.minimum,
},
flags: {
recommended: editState.flags || [],
},
},
},
},
});

result.subscribe(({ data, error, fetching }) => {
if (!fetching) {
saving = false;
if (error) {
alert(`Error updating family: ${error.message}`);
return;
}

const updated = (data as { updateFamily?: { family?: { key?: string } } } | undefined)?.updateFamily?.family;
if (!updated?.key) {
alert("Error updating family: The server did not confirm the update.");
return;
}

editMode = false;
goto(
resolve("/projects/[project]/family/[family]", {
project: page.params.project!,
family: updated.key,
}),
{
invalidateAll: true,
keepFocus: true,
noScroll: true,
replaceState: true,
},
);
}
});
}

let { family }: Props = $props();
function deleteFamily() {
if (deleting || saving || !canDelete || deleteConfirmation !== "delete") return;

deleting = true;

const result = mutationStore({
client,
query: `
mutation DeleteFamily($input: DeleteFamilyInput!) {
deleteFamily(input: $input) {
ok
}
}
`,
variables: {
input: {
project: page.params.project!,
key: family.key,
},
},
});

result.subscribe(({ data, error, fetching }) => {
if (!fetching) {
deleting = false;
if (error) {
alert(`Error deleting family: ${error.message}`);
return;
}

const deleted = (data as { deleteFamily?: { ok?: boolean | null } } | undefined)?.deleteFamily?.ok;
if (!deleted) {
alert("Error deleting family: The server did not confirm the deletion.");
return;
}

deleteDialogOpen = false;
editMode = false;
goto(resolve("/projects/[project]", { project: page.params.project! }));
}
Comment on lines +163 to +180
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to version deletion, deleteFamily only returns { ok } and doesn’t invalidate the cached Project families list. If the user navigated here from the project page, that list is likely already cached (graphcache + default cache-first), so after goto("/projects/[project]") the deleted family may still appear until refresh.

Suggestion: update/invalidate the urql cache on successful deletion (e.g., return the deleted family id/key and add a graphcache update that removes it from project.families), or ensure the project families query re-fetches after navigation (e.g., cache-and-network).

Copilot uses AI. Check for mistakes.
});
}
</script>

<section class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h2 class="flex items-center text-lg font-medium">Metadata</h2>
{#if auth.getUsername()}
<Button
size="icon"
variant="ghost"
disabled={editMode}
class="size-6"
onclick={() => {
editMode = true;
resetEditState();
}}
title={editMode ? "Editing metadata" : "Edit metadata"}
>
<span class="iconify size-4 lucide--pencil"></span>
</Button>
{/if}
</div>

<div class="space-y-2 rounded-lg border p-4">
<h3 class="text-lg font-medium">General</h3>
<div class="text-sm">
Expand All @@ -26,14 +210,114 @@
</div>

<h3 class="mt-4 text-lg font-medium">Java</h3>
<div class="text-sm">
<div class="flex items-center gap-2 font-medium">Minimum Version</div>
<div class="mt-0.5">{family.java.version.minimum}</div>
</div>
{#if editMode}
<div class="space-y-2 text-sm">
<div class="space-y-1">
<Label for="family-min-java">Minimum Version</Label>
<Input.Root id="family-min-java" type="number" min="1" step="1" bind:value={editState.minimum} />
</div>

<div class="space-y-1 text-sm">
<div class="flex items-center gap-2 font-medium">Recommended Flags</div>
<FlagsDisplay flags={family.java.flags.recommended.length > 0 ? family.java.flags.recommended : ["None"]} />
</div>
<div class="space-y-1">
<Label for="family-flags">Recommended Flags</Label>
<Input.Root
id="family-flags"
bind:value={() => editState.flags?.join(" ") || "", (value) => (editState.flags = value ? splitFlags(value) : [])}
placeholder="-Xms4G -Xmx4G"
/>
</div>
</div>
{:else}
<div class="text-sm">
<div class="flex items-center gap-2 font-medium">Minimum Version</div>
<div class="mt-0.5">{family.java.version.minimum}</div>
</div>

<div class="space-y-1 text-sm">
<div class="flex items-center gap-2 font-medium">Recommended Flags</div>
<FlagsDisplay flags={family.java.flags.recommended.length > 0 ? family.java.flags.recommended : ["None"]} />
</div>
{/if}
</div>

{#if editMode}
<div class="flex flex-wrap items-center gap-2">
<Button
variant="secondary"
onclick={() => {
editMode = false;
deleteDialogOpen = false;
resetEditState();
}}
disabled={saving || deleting}
>
<span class="iconify lucide--x"></span>
Cancel
</Button>

<Button disabled={saving || deleting} onclick={saveChanges}>
{#if saving}
<span class="iconify animate-spin lucide--loader-2"></span>
Saving Changes...
{:else}
<span class="iconify lucide--check"></span>
Save Changes
{/if}
</Button>

<AlertDialog.Root bind:open={deleteDialogOpen}>
<AlertDialog.Trigger>
{#snippet child({ props })}
<Button variant="destructive" disabled={saving || deleting || !canDelete} title={!canDelete ? "Families with versions cannot be deleted." : undefined} {...props}>
{#if deleting}
<span class="iconify animate-spin lucide--loader-2"></span>
Deleting...
{:else}
<span class="iconify lucide--trash-2"></span>
Delete
{/if}
</Button>
{/snippet}
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Family</AlertDialog.Title>
<AlertDialog.Description>
Type <code>delete</code> to confirm deleting family <code>{family.key}</code>. This cannot be undone.
</AlertDialog.Description>
</AlertDialog.Header>

<div class="space-y-2">
<Label for="delete-family-confirmation">Confirmation</Label>
<Input.Root
id="delete-family-confirmation"
bind:value={deleteConfirmation}
placeholder="type 'delete'"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck={false}
disabled={deleting}
/>
</div>

<AlertDialog.Footer>
<AlertDialog.Cancel disabled={deleting}>Cancel</AlertDialog.Cancel>
<Button variant="destructive" disabled={deleting || !canDelete || deleteConfirmation !== "delete"} onclick={deleteFamily}>
{#if deleting}
<span class="iconify animate-spin lucide--loader-2"></span>
Deleting...
{:else}
<span class="iconify lucide--trash-2"></span>
Delete Family
{/if}
</Button>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>

{#if !canDelete}
<p class="text-sm text-neutral-500">Delete is only available for empty families. Remove or move all versions first.</p>
{/if}
{/if}
</section>
Loading