Skip to content

Commit fbcd8ee

Browse files
authored
refactor: Extract reusable components and composables to reduce duplication (#99)
* refactor: Extract reusable components and composables to reduce duplication Create shared infrastructure for CRUD operations across settings views: New utilities: - src/lib/api-utils.ts: API response unwrapping and error handling - src/lib/constants.ts: Centralized constants (categories, strategies, etc.) New composables: - useCrudState: Manages CRUD dialog/form state - useCrudOperations: Handles CRUD API calls with toast notifications - useSearch/useDeepSearch: Reusable search/filter logic - usePagination: Pagination state and helpers New shared components: - PageHeader: Header with icon, title, breadcrumbs, back link - SearchInput: Search input with clear button - DataTable: Generic table with loading/empty states and slots - PaginationControls: Pagination UI - CrudFormDialog: Dialog wrapper for create/edit forms - DeleteConfirmDialog: Confirmation dialog for deletions Store factory: - createCrudStore: Factory function for generating CRUD stores Refactored views with significant line reduction: - UsersView: 630 → 195 lines (69% reduction) - TeamsView: 637 → 263 lines (59% reduction) - CannedResponsesView: 385 → 162 lines (58% reduction) - APIKeysView: 376 → 165 lines (56% reduction) * chore: Remove useApiMocker from public exports * fix: Revert roles store to original implementation The createCrudStore factory pattern doesn't work well with nested store composition for complex stores like roles that have additional state (permissions) and computed properties (permissionGroups). * fix: Revert teams and users stores to original implementation The createCrudStore factory with nested store composition causes reactivity issues in Pinia. Reverting to original implementations fixes the e2e test failures. * chore: Remove unused createCrudStore factory The factory pattern doesn't work well with Pinia's reactivity system when composing stores. Nested store refs don't propagate changes properly to components. * refactor: Apply shared components to RolesView and WebhooksView - RolesView: 502 → 187 lines (63% reduction) - WebhooksView: 505 → 233 lines (54% reduction) Using PageHeader, SearchInput, DataTable, DeleteConfirmDialog. Custom dialogs kept for complex forms (PermissionMatrix, headers editor). * refactor: Apply shared components to CustomActionsView and FlowsView - CustomActionsView: 686 → 245 lines (64% reduction) - FlowsView: 729 → 285 lines (61% reduction) Using PageHeader, DataTable, DeleteConfirmDialog. FlowsView keeps card grid layout for visual flow previews. * refactor: Apply PageHeader to SSO, Chatbot, and Settings views These form-based settings views have limited refactoring potential since they don't use CRUD list patterns. Applied PageHeader component and consolidated imports for consistency. * refactor: Apply PageHeader and getErrorMessage to remaining views - AccountsView.vue: Use PageHeader with backLink/breadcrumbs (721→695) - AIContextsView.vue: Use PageHeader with backLink/breadcrumbs (530→507) - KeywordsView.vue: Use PageHeader, SearchInput component (523→497) - ChatbotFlowsView.vue: Use PageHeader with backLink/breadcrumbs (267→244) - CampaignsView.vue: Use PageHeader, getErrorMessage (2025→2012) - AgentTransfersView.vue: Use PageHeader, getErrorMessage - TemplatesView.vue: Use PageHeader, SearchInput, getErrorMessage All views now use consistent shared components and utilities. * refactor: Apply PageHeader to ChatbotView, AgentAnalyticsView, ProfileView - ChatbotView.vue: Use PageHeader with actions slot (294→292) - AgentAnalyticsView.vue: Use PageHeader with actions slot (721→718) - ProfileView.vue: Use PageHeader, getErrorMessage (177→173) All applicable views now use consistent PageHeader component.
1 parent bd298d0 commit fbcd8ee

36 files changed

+2434
-4060
lines changed
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<script setup lang="ts">
2+
import {
3+
Dialog,
4+
DialogContent,
5+
DialogDescription,
6+
DialogFooter,
7+
DialogHeader,
8+
DialogTitle,
9+
} from '@/components/ui/dialog'
10+
import { Button } from '@/components/ui/button'
11+
import { Loader2 } from 'lucide-vue-next'
12+
13+
const open = defineModel<boolean>('open', { default: false })
14+
15+
const props = withDefaults(defineProps<{
16+
title?: string
17+
editTitle?: string
18+
createTitle?: string
19+
description?: string
20+
editDescription?: string
21+
createDescription?: string
22+
isEditing?: boolean
23+
isSubmitting?: boolean
24+
submitLabel?: string
25+
editSubmitLabel?: string
26+
createSubmitLabel?: string
27+
cancelLabel?: string
28+
maxWidth?: string
29+
}>(), {
30+
title: '',
31+
editTitle: 'Edit Item',
32+
createTitle: 'Create Item',
33+
description: '',
34+
editDescription: 'Update the item details.',
35+
createDescription: 'Fill in the details to create a new item.',
36+
isEditing: false,
37+
isSubmitting: false,
38+
submitLabel: '',
39+
editSubmitLabel: 'Update',
40+
createSubmitLabel: 'Create',
41+
cancelLabel: 'Cancel',
42+
maxWidth: 'max-w-md',
43+
})
44+
45+
const emit = defineEmits<{
46+
submit: []
47+
cancel: []
48+
}>()
49+
50+
function handleSubmit() {
51+
emit('submit')
52+
}
53+
54+
function handleCancel() {
55+
open.value = false
56+
emit('cancel')
57+
}
58+
59+
const computedTitle = computed(() => {
60+
if (props.title) return props.title
61+
return props.isEditing ? props.editTitle : props.createTitle
62+
})
63+
64+
const computedDescription = computed(() => {
65+
if (props.description) return props.description
66+
return props.isEditing ? props.editDescription : props.createDescription
67+
})
68+
69+
const computedSubmitLabel = computed(() => {
70+
if (props.submitLabel) return props.submitLabel
71+
return props.isEditing ? props.editSubmitLabel : props.createSubmitLabel
72+
})
73+
</script>
74+
75+
<script lang="ts">
76+
import { computed } from 'vue'
77+
</script>
78+
79+
<template>
80+
<Dialog v-model:open="open">
81+
<DialogContent :class="maxWidth">
82+
<DialogHeader>
83+
<DialogTitle>{{ computedTitle }}</DialogTitle>
84+
<DialogDescription>{{ computedDescription }}</DialogDescription>
85+
</DialogHeader>
86+
87+
<div class="py-4">
88+
<slot />
89+
</div>
90+
91+
<DialogFooter>
92+
<Button variant="outline" size="sm" @click="handleCancel">
93+
{{ cancelLabel }}
94+
</Button>
95+
<Button size="sm" @click="handleSubmit" :disabled="isSubmitting">
96+
<Loader2 v-if="isSubmitting" class="h-4 w-4 mr-2 animate-spin" />
97+
{{ computedSubmitLabel }}
98+
</Button>
99+
</DialogFooter>
100+
</DialogContent>
101+
</Dialog>
102+
</template>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<script setup lang="ts" generic="T extends { id: string }">
2+
import {
3+
Table,
4+
TableBody,
5+
TableCell,
6+
TableHead,
7+
TableHeader,
8+
TableRow,
9+
} from '@/components/ui/table'
10+
import { Loader2 } from 'lucide-vue-next'
11+
import type { Component } from 'vue'
12+
13+
export interface Column<T> {
14+
key: string
15+
label: string
16+
width?: string
17+
align?: 'left' | 'center' | 'right'
18+
}
19+
20+
defineProps<{
21+
items: T[]
22+
columns: Column<T>[]
23+
isLoading?: boolean
24+
emptyIcon?: Component
25+
emptyTitle?: string
26+
emptyDescription?: string
27+
}>()
28+
29+
defineSlots<{
30+
[key: `cell-${string}`]: (props: { item: T; index: number }) => any
31+
empty: () => any
32+
'empty-action': () => any
33+
}>()
34+
</script>
35+
36+
<template>
37+
<Table>
38+
<TableHeader>
39+
<TableRow>
40+
<TableHead
41+
v-for="col in columns"
42+
:key="col.key"
43+
:class="[
44+
col.width,
45+
col.align === 'right' && 'text-right',
46+
col.align === 'center' && 'text-center',
47+
]"
48+
>
49+
{{ col.label }}
50+
</TableHead>
51+
</TableRow>
52+
</TableHeader>
53+
<TableBody>
54+
<!-- Loading State -->
55+
<TableRow v-if="isLoading">
56+
<TableCell :colspan="columns.length" class="h-24 text-center">
57+
<Loader2 class="h-6 w-6 animate-spin mx-auto" />
58+
</TableCell>
59+
</TableRow>
60+
61+
<!-- Empty State -->
62+
<TableRow v-else-if="items.length === 0">
63+
<TableCell :colspan="columns.length" class="h-24 text-center text-muted-foreground">
64+
<slot name="empty">
65+
<component v-if="emptyIcon" :is="emptyIcon" class="h-8 w-8 mx-auto mb-2 opacity-50" />
66+
<p v-if="emptyTitle">{{ emptyTitle }}</p>
67+
<p v-if="emptyDescription" class="text-sm">{{ emptyDescription }}</p>
68+
<div class="mt-3">
69+
<slot name="empty-action" />
70+
</div>
71+
</slot>
72+
</TableCell>
73+
</TableRow>
74+
75+
<!-- Data Rows -->
76+
<TableRow v-else v-for="(item, index) in items" :key="item.id">
77+
<TableCell
78+
v-for="col in columns"
79+
:key="col.key"
80+
:class="[
81+
col.align === 'right' && 'text-right',
82+
col.align === 'center' && 'text-center',
83+
]"
84+
>
85+
<slot :name="`cell-${col.key}`" :item="item" :index="index">
86+
{{ (item as any)[col.key] }}
87+
</slot>
88+
</TableCell>
89+
</TableRow>
90+
</TableBody>
91+
</Table>
92+
</template>
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<script setup lang="ts">
2+
import {
3+
AlertDialog,
4+
AlertDialogAction,
5+
AlertDialogCancel,
6+
AlertDialogContent,
7+
AlertDialogDescription,
8+
AlertDialogFooter,
9+
AlertDialogHeader,
10+
AlertDialogTitle,
11+
} from '@/components/ui/alert-dialog'
12+
13+
const open = defineModel<boolean>('open', { default: false })
14+
15+
const props = withDefaults(defineProps<{
16+
title?: string
17+
itemName?: string
18+
description?: string
19+
confirmLabel?: string
20+
cancelLabel?: string
21+
}>(), {
22+
title: 'Delete Item',
23+
confirmLabel: 'Delete',
24+
cancelLabel: 'Cancel',
25+
})
26+
27+
const emit = defineEmits<{
28+
confirm: []
29+
cancel: []
30+
}>()
31+
32+
function handleConfirm() {
33+
emit('confirm')
34+
}
35+
36+
function handleCancel() {
37+
open.value = false
38+
emit('cancel')
39+
}
40+
</script>
41+
42+
<template>
43+
<AlertDialog v-model:open="open">
44+
<AlertDialogContent>
45+
<AlertDialogHeader>
46+
<AlertDialogTitle>{{ title }}</AlertDialogTitle>
47+
<AlertDialogDescription>
48+
<slot name="description">
49+
<template v-if="description">{{ description }}</template>
50+
<template v-else-if="itemName">
51+
Are you sure you want to delete "{{ itemName }}"? This action cannot be undone.
52+
</template>
53+
<template v-else>
54+
Are you sure you want to delete this item? This action cannot be undone.
55+
</template>
56+
</slot>
57+
</AlertDialogDescription>
58+
</AlertDialogHeader>
59+
<AlertDialogFooter>
60+
<AlertDialogCancel @click="handleCancel">{{ cancelLabel }}</AlertDialogCancel>
61+
<AlertDialogAction
62+
@click="handleConfirm"
63+
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
64+
>
65+
{{ confirmLabel }}
66+
</AlertDialogAction>
67+
</AlertDialogFooter>
68+
</AlertDialogContent>
69+
</AlertDialog>
70+
</template>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<script setup lang="ts">
2+
import { Button } from '@/components/ui/button'
3+
import {
4+
Breadcrumb,
5+
BreadcrumbItem,
6+
BreadcrumbLink,
7+
BreadcrumbList,
8+
BreadcrumbPage,
9+
BreadcrumbSeparator,
10+
} from '@/components/ui/breadcrumb'
11+
import { ArrowLeft } from 'lucide-vue-next'
12+
import type { Component } from 'vue'
13+
14+
defineProps<{
15+
title: string
16+
description?: string
17+
icon?: Component
18+
iconGradient?: string
19+
backLink?: string
20+
breadcrumbs?: Array<{ label: string; href?: string }>
21+
}>()
22+
</script>
23+
24+
<template>
25+
<header class="border-b border-white/[0.08] light:border-gray-200 bg-[#0a0a0b]/95 light:bg-white/95 backdrop-blur">
26+
<div class="flex h-16 items-center px-6">
27+
<RouterLink v-if="backLink" :to="backLink">
28+
<Button variant="ghost" size="icon" class="mr-3">
29+
<ArrowLeft class="h-5 w-5" />
30+
</Button>
31+
</RouterLink>
32+
<div
33+
v-if="icon"
34+
class="h-8 w-8 rounded-lg flex items-center justify-center mr-3 shadow-lg"
35+
:class="iconGradient || 'bg-gradient-to-br from-blue-500 to-indigo-600 shadow-blue-500/20'"
36+
>
37+
<component :is="icon" class="h-4 w-4 text-white" />
38+
</div>
39+
<div class="flex-1">
40+
<h1 class="text-xl font-semibold text-white light:text-gray-900">{{ title }}</h1>
41+
<template v-if="breadcrumbs?.length">
42+
<Breadcrumb>
43+
<BreadcrumbList>
44+
<template v-for="(crumb, index) in breadcrumbs" :key="index">
45+
<BreadcrumbItem>
46+
<BreadcrumbLink v-if="crumb.href" :href="crumb.href">
47+
{{ crumb.label }}
48+
</BreadcrumbLink>
49+
<BreadcrumbPage v-else>{{ crumb.label }}</BreadcrumbPage>
50+
</BreadcrumbItem>
51+
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1" />
52+
</template>
53+
</BreadcrumbList>
54+
</Breadcrumb>
55+
</template>
56+
<p v-else-if="description" class="text-sm text-white/50 light:text-gray-500">
57+
{{ description }}
58+
</p>
59+
</div>
60+
<slot name="actions" />
61+
</div>
62+
</header>
63+
</template>

0 commit comments

Comments
 (0)