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:
parent
e94f2cfe96
commit
2c52c90b6e
15 changed files with 163 additions and 82 deletions
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue