fix(mobile): polish automations, dialogs, workspaces and post views

- automations: minimal back-only header on mobile for the workflow builder
  and detail tabs (extracted AutomationMobileBackHeader); open live
  automations on the metrics tab (drafts still open on workflow); full-width
  status filter + refresh on the invocations toolbar; use IconMenu2 for the
  mobile sidebar trigger
- dialogs: stack DialogFooter primary-on-top / cancel-at-bottom on mobile
- workspaces: bring the workspace picker cards into the neo-brutalist design
- posts: left-align the label filter content; wrap the post-view date/status
  header so a long status badge no longer squeezes the date; add hamburger
  clearance to the editor's mobile tab bar
This commit is contained in:
Paulo Castellano 2026-07-18 10:58:07 -03:00
parent e94f2cfe96
commit 2c52c90b6e
15 changed files with 163 additions and 82 deletions

View file

@ -231,7 +231,7 @@ ## Frontend (Vue/TypeScript)
## Dialogs
- In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col-reverse` on mobile and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens.
- In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col` on mobile (primary on top, cancel at the bottom) and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens.
- Match sibling dialogs in the same feature area before inventing a new footer layout.
## AI agents (`app/Ai/Agents`)

View file

@ -15,6 +15,7 @@
use App\Actions\Automation\Automation\UpdateAutomation;
use App\Actions\Automation\Run\RetryRunFromNode;
use App\Actions\Automation\Run\TestAutomation;
use App\Enums\Automation\Status;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\Automations\ActivateAutomationRequest;
use App\Http\Requests\App\Automations\InspectFeedRequest;
@ -77,7 +78,11 @@ public function show(Automation $automation): RedirectResponse
{
$this->authorize('view', $automation);
return redirect()->route('app.automations.workflow', $automation->id);
// A draft opens on the builder to be set up; a live automation (active
// or paused) opens on its metrics, where the user watches it run.
$tab = $automation->status === Status::Draft ? 'workflow' : 'metrics';
return redirect()->route("app.automations.{$tab}", $automation->id);
}
public function workflow(Automation $automation, GetAutomationEditorData $editorData): Response

View file

@ -3,6 +3,7 @@ import { Link } from '@inertiajs/vue3';
import { IconArrowLeft, IconCircleCheck, IconCircleDot, IconCircleX } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import AutomationMobileBackHeader from '@/components/automations/AutomationMobileBackHeader.vue';
import AutomationTabsNav from '@/components/automations/AutomationTabsNav.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -26,7 +27,9 @@ const statusConfig = (status: string) => {
<template>
<div class="flex-shrink-0">
<header class="flex items-center justify-between gap-4 border-b-2 border-foreground/10 bg-card py-2 pl-12 pr-4 md:pl-4">
<AutomationMobileBackHeader />
<header class="hidden items-center justify-between gap-4 border-b-2 border-foreground/10 bg-card px-4 py-2 lg:flex">
<div class="flex min-w-0 items-center gap-3">
<Link :href="automationsIndex.url()">
<Button variant="outline" size="icon-sm">

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { IconArrowLeft } from '@tabler/icons-vue';
import { Button } from '@/components/ui/button';
import { index as automationsIndex } from '@/routes/app/automations';
</script>
<template>
<header
data-testid="automation-mobile-back"
class="flex items-center border-b-2 border-foreground/10 bg-card py-3 pl-16 pr-4 md:pl-4 lg:hidden"
>
<Link :href="automationsIndex.url()" class="block w-full">
<Button variant="outline" class="w-full justify-start gap-2">
<IconArrowLeft class="size-4" />
{{ $t('common.back') }}
</Button>
</Link>
</header>
</template>

View file

@ -60,24 +60,26 @@ const clear = () => {
:aria-expanded="open"
class="w-full justify-between gap-2 font-normal sm:w-auto"
>
<IconTag class="size-4 shrink-0 opacity-60" />
<div class="flex min-w-0 items-center gap-2">
<IconTag class="size-4 shrink-0 opacity-60" />
<template v-if="selectedLabels.length === 0">
<span class="text-foreground/70">{{ trans('posts.filter_by_label') }}</span>
</template>
<template v-else>
<div class="flex flex-wrap items-center gap-1">
<LabelBadge
v-for="label in selectedLabels.slice(0, 3)"
:key="label.id"
:label="label"
/>
<span
v-if="selectedLabels.length > 3"
class="text-xs font-bold text-foreground/60"
>+{{ selectedLabels.length - 3 }}</span>
</div>
</template>
<template v-if="selectedLabels.length === 0">
<span class="text-foreground/70">{{ trans('posts.filter_by_label') }}</span>
</template>
<template v-else>
<div class="flex flex-wrap items-center gap-1">
<LabelBadge
v-for="label in selectedLabels.slice(0, 3)"
:key="label.id"
:label="label"
/>
<span
v-if="selectedLabels.length > 3"
class="text-xs font-bold text-foreground/60"
>+{{ selectedLabels.length - 3 }}</span>
</div>
</template>
</div>
<TooltipProvider v-if="selectedIds.length" :delay-duration="200">
<Tooltip>

View file

@ -27,7 +27,7 @@ const isTopBar = computed(() => props.status !== PostStatus.Scheduled);
<div
data-testid="editor-mobile-nav"
class="flex shrink-0 gap-1 overflow-x-auto border-b-2 border-foreground bg-card py-3 pr-2 lg:hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
:class="isTopBar ? 'pl-14' : 'pl-2'"
:class="isTopBar ? 'pl-16' : 'pl-2'"
>
<button
v-for="item in items"

View file

@ -8,7 +8,7 @@ const props = defineProps<{ class?: HTMLAttributes["class"] }>()
<template>
<div
data-slot="dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-start', props.class)"
:class="cn('flex flex-col gap-2 sm:flex-row sm:justify-start', props.class)"
>
<slot />
</div>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand } from "@tabler/icons-vue"
import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand, IconMenu2 } from "@tabler/icons-vue"
import { cn } from "@/lib/utils"
import { Button } from '@/components/ui/button'
import { useSidebar } from "./utils"
@ -21,7 +21,8 @@ const { isMobile, state, toggleSidebar } = useSidebar()
:class="cn('h-9 w-9', props.class)"
@click="toggleSidebar"
>
<IconLayoutSidebarLeftExpand v-if="isMobile || state === 'collapsed'" />
<IconMenu2 v-if="isMobile" />
<IconLayoutSidebarLeftExpand v-else-if="state === 'collapsed'" />
<IconLayoutSidebarLeftCollapse v-else />
<span class="sr-only">Toggle Sidebar</span>
</Button>

View file

@ -24,6 +24,7 @@ import '@vue-flow/controls/dist/style.css';
import AutomationConnectionLine from '@/components/automations/AutomationConnectionLine.vue';
import AutomationHeader from '@/components/automations/AutomationHeader.vue';
import AutomationMobileBackHeader from '@/components/automations/AutomationMobileBackHeader.vue';
import ConditionNodeConfig from '@/components/automations/config/ConditionNodeConfig.vue';
import DelayNodeConfig from '@/components/automations/config/DelayNodeConfig.vue';
import EndNodeConfig from '@/components/automations/config/EndNodeConfig.vue';
@ -450,7 +451,9 @@ const defaultEdgeOptions = {
<AppLayout full-width>
<div class="flex min-h-0 flex-1 flex-col bg-background">
<AutomationHeader :automation="automation" current="workflow">
<AutomationMobileBackHeader />
<AutomationHeader :automation="automation" current="workflow" class="hidden lg:block">
<template #actions>
<TooltipProvider>
<Tooltip>

View file

@ -20,6 +20,7 @@ import {
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import {
metrics as metricsAutomation,
store as storeAutomation,
workflow as workflowAutomation,
} from '@/routes/app/automations';
@ -56,6 +57,13 @@ const handleCreate = () => {
onFinish: () => { isCreating.value = false; },
});
};
// A draft opens on the builder to be set up; a live automation opens on its
// metrics, where the user watches it run.
const openAutomation = (automation: Automation) => {
const route = automation.status === 'draft' ? workflowAutomation : metricsAutomation;
router.visit(route.url(automation.id));
};
</script>
<template>
@ -101,7 +109,7 @@ const handleCreate = () => {
v-for="automation in automations.data"
:key="automation.id"
class="cursor-pointer"
@click="router.visit(workflowAutomation.url(automation.id))"
@click="openAutomation(automation)"
>
<TableCell class="font-medium">{{ automation.name }}</TableCell>
<TableCell>

View file

@ -169,64 +169,67 @@ const toggleExpand = async (invocation: Invocation) => {
<template>
<AutomationDetailLayout :automation="automation" current="invocations">
<div class="space-y-4 p-4">
<div class="flex flex-wrap items-center gap-2">
<Select v-model="statusFilter">
<SelectTrigger
dusk="invocations-status-filter"
class="w-44"
>
<SelectValue>{{ statusLabel }}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{
$t('automations.invocations.filter.all')
}}</SelectItem>
<SelectItem value="completed">{{
$t('automations.status_run.completed')
}}</SelectItem>
<SelectItem value="failed">{{
$t('automations.status_run.failed')
}}</SelectItem>
<SelectItem value="running">{{
$t('automations.status_run.running')
}}</SelectItem>
<SelectItem value="waiting">{{
$t('automations.status_run.waiting')
}}</SelectItem>
</SelectContent>
</Select>
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<div class="flex items-center gap-2 sm:contents">
<Select v-model="statusFilter">
<SelectTrigger
dusk="invocations-status-filter"
class="w-full sm:w-44"
>
<SelectValue>{{ statusLabel }}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{
$t('automations.invocations.filter.all')
}}</SelectItem>
<SelectItem value="completed">{{
$t('automations.status_run.completed')
}}</SelectItem>
<SelectItem value="failed">{{
$t('automations.status_run.failed')
}}</SelectItem>
<SelectItem value="running">{{
$t('automations.status_run.running')
}}</SelectItem>
<SelectItem value="waiting">{{
$t('automations.status_run.waiting')
}}</SelectItem>
</SelectContent>
</Select>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon"
class="shrink-0 sm:order-last"
:aria-label="
$t('automations.invocations.refresh')
"
:disabled="isRefreshing"
dusk="invocations-refresh"
@click="reload"
>
<IconRefresh
class="size-4"
:class="{ 'animate-spin': isRefreshing }"
/>
</Button>
</TooltipTrigger>
<TooltipContent>{{
$t('automations.invocations.refresh')
}}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Input
v-model="search"
:placeholder="
$t('automations.invocations.search_placeholder')
"
class="max-w-xs"
class="sm:max-w-xs"
dusk="invocations-search"
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon"
:aria-label="
$t('automations.invocations.refresh')
"
:disabled="isRefreshing"
dusk="invocations-refresh"
@click="reload"
>
<IconRefresh
class="size-4"
:class="{ 'animate-spin': isRefreshing }"
/>
</Button>
</TooltipTrigger>
<TooltipContent>{{
$t('automations.invocations.refresh')
}}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div class="relative">

View file

@ -131,7 +131,7 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {
</Button>
</Link>
</div>
<div class="flex items-start justify-between gap-3 md:items-center md:justify-end">
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 md:flex-nowrap md:justify-end">
<span class="flex items-start gap-1.5 text-sm font-medium text-foreground/70">
<IconCalendar class="mt-0.5 size-4 shrink-0 text-foreground/60" />
<span v-if="post.published_at">

View file

@ -41,14 +41,14 @@ const switchToWorkspace = (workspace: Workspace) => {
<div
v-for="workspace in workspaces"
:key="workspace.id"
class="flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-colors hover:bg-accent/50"
:class="workspace.id === currentWorkspaceId ? 'border-primary/50 bg-accent/30' : ''"
class="flex cursor-pointer items-center gap-3 rounded-xl border-2 border-foreground bg-card p-4 shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md"
:class="workspace.id === currentWorkspaceId ? 'bg-violet-100' : ''"
@click="switchToWorkspace(workspace)"
>
<Avatar
:src="workspace.logo_url"
:name="workspace.name"
class="size-10 shrink-0 rounded-lg"
class="size-10 shrink-0 rounded-lg border-2 border-foreground"
fallback-class="bg-muted text-muted-foreground"
/>
<div class="min-w-0 flex-1">

View file

@ -31,4 +31,25 @@
JS);
$page->assertVisible('@automation-mobile-notice');
// The desktop-only builder header (name, status, guide, save, tabs) is
// replaced by a minimal back-only header on a phone — nothing here is
// actionable, so only the back button and the floating hamburger remain.
$page->assertVisible('@automation-mobile-back');
});
test('the automation builder shows the full header on desktop, not the minimal one', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$automation = Automation::factory()->create(['workspace_id' => $workspace->id]);
$this->actingAs($user);
$page = visit(route('app.automations.workflow', $automation))->resize(1280, 900);
$page->assertMissing('@automation-mobile-back');
$page->assertMissing('@automation-mobile-notice');
});

View file

@ -17,12 +17,26 @@
$this->automation = Automation::factory()->for($this->workspace)->create();
});
it('redirects the bare automation URL to the workflow tab', function () {
it('redirects a draft automation URL to the workflow tab', function () {
$this->actingAs($this->user)
->get(route('app.automations.show', $this->automation->id))
->assertRedirect(route('app.automations.workflow', $this->automation->id));
});
it('redirects a live automation URL to the metrics tab', function () {
$active = Automation::factory()->for($this->workspace)->active()->create();
$this->actingAs($this->user)
->get(route('app.automations.show', $active->id))
->assertRedirect(route('app.automations.metrics', $active->id));
$paused = Automation::factory()->for($this->workspace)->paused()->create();
$this->actingAs($this->user)
->get(route('app.automations.show', $paused->id))
->assertRedirect(route('app.automations.metrics', $paused->id));
});
it('renders the workflow editor on the workflow tab', function () {
$this->actingAs($this->user)
->get(route('app.automations.workflow', $this->automation->id))