Skip to content

Commit 89f5266

Browse files
[Feat] Dialog Refactor (#1140)
* wip * refactor * fixes based on manual testing * Claude Review fixes * copilot fixes * fixes * coderabbit feedback * low fixes * feedback
1 parent 25141ca commit 89f5266

102 files changed

Lines changed: 2846 additions & 4124 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/instructions/frontend.instructions.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,85 @@ description: "React, Inertia, Tailwind v4, and TypeScript frontend standards"
1212
- Use the `useForm` helper for forms — follow existing patterns in the codebase.
1313
- Use `<Link>` or `router.visit()` for navigation — never raw `<a>` tags for internal routes.
1414

15+
## Dialogs (centralized registry)
16+
17+
App-level dialogs are **not** mounted inline next to their trigger. They live in a central registry and are opened imperatively. This is the required pattern for any new modal/sheet — do not hand-roll local `open` state with a `<DialogTrigger>`.
18+
19+
**The pieces:**
20+
- `resources/js/components/dialogs/registry.ts` — maps a typed key to a dialog component.
21+
- `resources/js/hooks/use-dialog.ts``useDialog()` returns `dialog.<key>.open(props)` / `.close()` with full prop typing.
22+
- `resources/js/stores/dialog-store.ts` — Zustand store holding the single active dialog.
23+
- `resources/js/components/dialogs/dialog-host.tsx` — renders the active dialog once, app-wide.
24+
25+
**Opening a dialog:**
26+
```tsx
27+
const dialog = useDialog();
28+
// inside a handler / DropdownMenuItem onSelect:
29+
dialog.firewallForm.open({ serverId: server.id, firewallRule });
30+
```
31+
32+
**Simple confirm / destructive actions — don't create a component, use `dialog.confirm`:**
33+
```tsx
34+
dialog.confirm.open({
35+
title: `Delete rule [${rule.name}]`,
36+
description: 'Are you sure? This cannot be undone.',
37+
variant: 'destructive',
38+
confirmLabel: 'Delete',
39+
method: 'delete',
40+
url: route('firewall.destroy', { server: rule.server_id, firewallRule: rule }),
41+
});
42+
```
43+
44+
**Opening from a dropdown — this is the whole point of the pattern:** use a plain `DropdownMenuItem` with the default `onSelect` so the menu closes, then open the dialog. **Never** wrap a `<Dialog>`/`<DialogTrigger>` inside a `DropdownMenuItem` with `onSelect={(e) => e.preventDefault()}` — that leaves the dropdown stuck open behind the dialog.
45+
```tsx
46+
<DropdownMenuItem onSelect={() => dialog.editHostedDomain.open({ hostedDomain })}>Edit</DropdownMenuItem>
47+
```
48+
49+
**Authoring a registered dialog component** — it takes control props, renders `<Dialog>` directly (NO `DialogTrigger`, NO local `open` state), and suppresses Radix close-autofocus (the store restores focus):
50+
```tsx
51+
export default function FirewallRuleForm({
52+
open,
53+
onOpenChange,
54+
serverId,
55+
firewallRule,
56+
}: {
57+
open: boolean;
58+
onOpenChange: (open: boolean) => void;
59+
serverId: number;
60+
firewallRule?: FirewallRule;
61+
}) {
62+
const form = useForm({ /* seed from props */ });
63+
const submit = (e: FormEvent) => {
64+
e.preventDefault();
65+
form.post(route('firewall.store', { server: serverId }), { onSuccess: () => onOpenChange(false) });
66+
};
67+
return (
68+
<Dialog open={open} onOpenChange={onOpenChange}>
69+
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
70+
{/* ... */}
71+
<Form id="firewall-rule-form" onSubmit={submit}>{/* fields */}</Form>
72+
<Button form="firewall-rule-form" type="submit" disabled={form.processing}>Save</Button>
73+
</DialogContent>
74+
</Dialog>
75+
);
76+
}
77+
```
78+
Then register it once in `registry.ts` (`firewallForm: FirewallRuleForm`). The consumer immediately gets `dialog.firewallForm.open(props)` with typed props.
79+
80+
**Where the dialog component file lives:** a dialog belongs to the module it serves — author it in that module's `components/` folder (e.g. the SSL activation dialog is `pages/server-ssls/components/activate-dialog.tsx`, the PHP ini dialog is `pages/php/components/ini-dialog.tsx`), and import it into `registry.ts` via its `@/pages/...` path. The folder already namespaces the file, so keep the filename short (`activate-dialog`, not `activate-server-ssl-dialog`). Only genuinely cross-cutting, generic dialogs (`confirmation-dialog`, `log-viewer-dialog`) and the registry infrastructure itself (`registry.ts`, `dialog-host.tsx`) live under `resources/js/components/dialogs/`.
81+
82+
**Rules & gotchas:**
83+
- **Form submit buttons** use `form="<form-id>" type="submit"` and the `onSubmit` handler calls `e.preventDefault()`. Do not wire submit via the button's `onClick` (a bare `onClick={submit}` with no `preventDefault` lets Enter fire a native form submission alongside the Inertia request).
84+
- **Always call `onOpenChange(false)` in `onSuccess`** to close after a successful request.
85+
- **Lifecycle:** `DialogHost` renders the component with `open` hard-coded `true` and **unmounts it on close** (it never re-renders with `open=false`). Each open is a fresh instance (keyed by `instanceId`), so `useForm` state resets automatically — don't add manual reset-on-open effects. But any `useEffect` that pushes state *outward* based on `open` (e.g. `useInputFocus`'s `setFocused`) **must return a cleanup**, because the component unmounts while `open` is still `true`:
86+
```tsx
87+
useEffect(() => {
88+
setFocused(open);
89+
return () => setFocused(false); // required — unmount happens with open===true
90+
}, [open, setFocused]);
91+
```
92+
- **Authorization:** the registry carries no authz. Every dialog's props must come from server-authorised sources (Inertia page props, API resources) — never URL params or other user-controlled input.
93+
1594
## Bootstrap Context (`useConfigs`)
1695

1796
- Shared catalogue data (provider lists, site types, GitHub App install state, public key text) is **not** in `page.props` — it lives in the Zustand `useBootstrapStore`, exposed via `useConfigs()` and `usePublicKeyText()` from `@/stores/bootstrap-store`.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ Vito has a specific architecture. Match these patterns exactly:
6767

6868
- Inertia pages in `resources/js/pages/`. React components in `resources/js/components/`. Functional components + hooks.
6969
- Forms: use the `useForm` helper, follow existing patterns. Navigation: `<Link>` or `router.visit()` — never raw `<a>` for internal routes.
70+
- **Dialogs**: all modals/sheets use the centralized registry (`resources/js/components/dialogs/registry.ts`) opened via `useDialog()``dialog.<key>.open(props)`, or `dialog.confirm.open({...})` for simple confirms. Never nest `<Dialog>`/`<DialogTrigger>` inside a `DropdownMenuItem`. **Read the "Dialogs" section of `.github/instructions/frontend.instructions.md` before adding or changing any dialog.**
7071
- Tailwind v4: `@import "tailwindcss"`, `@theme` for config, **`gap-*` over margins** for sibling spacing.
7172
- Use Shadcn components and semantic tokens (`text-foreground`, `bg-background`, `text-muted-foreground`). Avoid hard-coded colors and custom CSS.
7273
- **React hooks**: include ALL dependencies in `useEffect` / `useMemo` / `useCallback` arrays. Return cleanup for timers/subscriptions. Stale closures from missing deps are a recurring bug here.

app/Enums/DeploymentStatus.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
namespace App\Enums;
44

55
use App\Contracts\VitoEnum;
6+
use Forjed\InertiaTable\Contracts\HasTableDisplay;
67

7-
enum DeploymentStatus: string implements VitoEnum
8+
enum DeploymentStatus: string implements HasTableDisplay, VitoEnum
89
{
910
case DEPLOYING = 'deploying';
1011
case FINISHED = 'finished';

app/Http/Controllers/ApplicationController.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
use App\Exceptions\SourceControlIsNotConnected;
1313
use App\Exceptions\SSHError;
1414
use App\Helpers\EnvParser;
15-
use App\Http\Resources\DeploymentResource;
1615
use App\Http\Resources\DeploymentScriptResource;
1716
use App\Http\Resources\LoadBalancerServerResource;
1817
use App\Http\Resources\WorkerResource;
@@ -21,6 +20,7 @@
2120
use App\Models\Server;
2221
use App\Models\Site;
2322
use App\SiteTypes\AbstractProxiedSiteType;
23+
use App\Tables\DeploymentTable;
2424
use Illuminate\Http\JsonResponse;
2525
use Illuminate\Http\RedirectResponse;
2626
use Illuminate\Http\Request;
@@ -52,7 +52,7 @@ public function index(Server $server, Site $site): Response
5252
$bootstrapWorker = $type instanceof AbstractProxiedSiteType ? $type->bootstrapWorker() : null;
5353

5454
return Inertia::render('application/index', [
55-
'deployments' => DeploymentResource::collection($site->deployments()->latest()->simplePaginate(config('web.pagination_size'))),
55+
'deployments' => DeploymentTable::make($site->deployments())->paginate(),
5656
'deploymentScript' => new DeploymentScriptResource($deploymentScript),
5757
'buildScript' => $buildScript ? new DeploymentScriptResource($buildScript) : null,
5858
'preFlightScript' => $preFlightScript ? new DeploymentScriptResource($preFlightScript) : null,

app/Tables/DeploymentTable.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
namespace App\Tables;
4+
5+
use App\Http\Resources\ServerLogResource;
6+
use App\Models\Deployment;
7+
use Forjed\InertiaTable\Column;
8+
use Forjed\InertiaTable\Columns\ActionsColumn;
9+
use Forjed\InertiaTable\Columns\DateTimeColumn;
10+
use Forjed\InertiaTable\Columns\EnumColumn;
11+
use Forjed\InertiaTable\Columns\TextColumn;
12+
use Forjed\InertiaTable\Table;
13+
14+
class DeploymentTable extends Table
15+
{
16+
protected array $tableSettings = ['realtime' => 'deployment'];
17+
18+
protected string $defaultSort = '-created_at';
19+
20+
protected function query(): void
21+
{
22+
$this->perPage = config('web.pagination_size');
23+
$this->query->with('log', 'site');
24+
}
25+
26+
protected function columns(): array
27+
{
28+
return [
29+
TextColumn::make('id', 'ID')->sortable(),
30+
Column::make('commit', 'Commit'),
31+
DateTimeColumn::make('created_at', 'Deployed At')->sortable()->toLocal(),
32+
EnumColumn::make('status', 'Status')->sortable(),
33+
Column::make('release', 'Release'),
34+
Column::data('site_id'),
35+
Column::data('server_id', fn (Deployment $deployment) => $deployment->site->server_id),
36+
Column::data('active'),
37+
Column::data('commit_data'),
38+
Column::data('log', fn (Deployment $deployment) => $deployment->log ? ServerLogResource::make($deployment->log) : null),
39+
ActionsColumn::make(),
40+
];
41+
}
42+
}

app/Tables/Servers/FirewallRuleTable.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ protected function columns(): array
2222
{
2323
return [
2424
TextColumn::make('name', 'Name')->sortable(),
25-
TextColumn::make('type', 'Type')->uppercase()->sortable(),
25+
TextColumn::make('type', 'Type')->sortable(),
2626
TextColumn::make('source', 'Source')->fallback('any')->sortable(),
27-
TextColumn::make('protocol', 'Protocol')->uppercase()->sortable(),
27+
TextColumn::make('protocol', 'Protocol')->sortable(),
2828
TextColumn::make('port', 'Port')->sortable(),
2929
EnumColumn::make('status', 'Status')->sortable(),
3030
Column::data('id'),
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Button } from '@/components/ui/button';
2+
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
3+
import { useForm } from '@inertiajs/react';
4+
import type { VisitOptions } from '@inertiajs/core';
5+
import { LoaderCircleIcon } from 'lucide-react';
6+
import FormSuccessful from '@/components/form-successful';
7+
import InputError from '@/components/ui/input-error';
8+
9+
export type ConfirmationDialogProps = {
10+
open: boolean;
11+
onOpenChange: (open: boolean) => void;
12+
title: string;
13+
description?: string;
14+
confirmLabel?: string;
15+
variant?: 'default' | 'destructive';
16+
method: 'delete' | 'post' | 'patch' | 'put';
17+
url: string;
18+
data?: Record<string, string | number | boolean | null>;
19+
options?: VisitOptions;
20+
onSuccess?: () => void;
21+
};
22+
23+
export default function ConfirmationDialog({
24+
open,
25+
onOpenChange,
26+
title,
27+
description,
28+
confirmLabel = 'Confirm',
29+
variant = 'default',
30+
method,
31+
url,
32+
data,
33+
options,
34+
onSuccess,
35+
}: ConfirmationDialogProps) {
36+
const form = useForm<Record<string, string | number | boolean | null>>(data ?? {});
37+
38+
const submit = () => {
39+
const visitOptions: VisitOptions = {
40+
preserveScroll: true,
41+
...options,
42+
onSuccess: () => {
43+
onOpenChange(false);
44+
onSuccess?.();
45+
},
46+
};
47+
if (method === 'delete') {
48+
form.delete(url, visitOptions);
49+
} else if (method === 'post') {
50+
form.post(url, visitOptions);
51+
} else if (method === 'patch') {
52+
form.patch(url, visitOptions);
53+
} else {
54+
form.put(url, visitOptions);
55+
}
56+
};
57+
58+
const errors = Object.values(form.errors) as string[];
59+
60+
return (
61+
<Dialog open={open} onOpenChange={onOpenChange}>
62+
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
63+
<DialogHeader>
64+
<DialogTitle>{title}</DialogTitle>
65+
<DialogDescription className="sr-only">{description ?? title}</DialogDescription>
66+
</DialogHeader>
67+
<div className="space-y-2 p-4">
68+
{description && <p>{description}</p>}
69+
{errors.map((error) => (
70+
<InputError key={error} message={error} />
71+
))}
72+
</div>
73+
<DialogFooter>
74+
<DialogClose asChild>
75+
<Button variant="outline">Cancel</Button>
76+
</DialogClose>
77+
<Button variant={variant} disabled={form.processing} onClick={submit}>
78+
{form.processing && <LoaderCircleIcon className="animate-spin" />}
79+
<FormSuccessful successful={form.recentlySuccessful} />
80+
{confirmLabel}
81+
</Button>
82+
</DialogFooter>
83+
</DialogContent>
84+
</Dialog>
85+
);
86+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useEffect } from 'react';
2+
import type { ComponentType } from 'react';
3+
import { router } from '@inertiajs/react';
4+
import { useDialogStore } from '@/stores/dialog-store';
5+
import { dialogs, type DialogControlProps } from './registry';
6+
7+
export default function DialogHost() {
8+
const active = useDialogStore((s) => s.active);
9+
const instanceId = useDialogStore((s) => s.instanceId);
10+
11+
useEffect(() => {
12+
return router.on('navigate', () => useDialogStore.getState().close());
13+
}, []);
14+
15+
if (!active) {
16+
return null;
17+
}
18+
19+
const Component = dialogs[active.key] as ComponentType<typeof active.props & DialogControlProps> | undefined;
20+
21+
if (!Component) {
22+
return null;
23+
}
24+
25+
return (
26+
<Component key={`${active.key}:${instanceId}`} open onOpenChange={(o: boolean) => !o && useDialogStore.getState().close()} {...active.props} />
27+
);
28+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Button } from '@/components/ui/button';
2+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
3+
import LogOutput from '@/components/log-output';
4+
import { useLogContent } from '@/hooks/use-log-content';
5+
6+
type LogViewerDialogProps = {
7+
open: boolean;
8+
onOpenChange: (open: boolean) => void;
9+
serverId: number;
10+
logId: number;
11+
title: string;
12+
};
13+
14+
export default function LogViewerDialog({ open, onOpenChange, serverId, logId, title }: LogViewerDialogProps) {
15+
const { content, isLoading, error } = useLogContent({ serverId, logId, enabled: open });
16+
17+
return (
18+
<Dialog open={open} onOpenChange={onOpenChange}>
19+
<DialogContent className="sm:max-w-5xl" onCloseAutoFocus={(e) => e.preventDefault()}>
20+
<DialogHeader>
21+
<DialogTitle>{title}</DialogTitle>
22+
<DialogDescription className="sr-only">Log contents</DialogDescription>
23+
</DialogHeader>
24+
<LogOutput>
25+
<>
26+
{isLoading && 'Loading...'}
27+
{error && <div className="text-destructive">Error: {error}</div>}
28+
{content && !error && content}
29+
</>
30+
</LogOutput>
31+
<DialogFooter>
32+
<a href={route('logs.download', { server: serverId, log: logId })} target="_blank" rel="noopener noreferrer">
33+
<Button variant="outline">Download</Button>
34+
</a>
35+
</DialogFooter>
36+
</DialogContent>
37+
</Dialog>
38+
);
39+
}

0 commit comments

Comments
 (0)