@object-ui/plugin-detail 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +8 -8
- package/CHANGELOG.md +20 -0
- package/dist/index.js +19953 -19684
- package/dist/index.umd.cjs +35 -26
- package/dist/plugin-detail.css +1 -1
- package/dist/src/DetailSection.d.ts.map +1 -1
- package/dist/src/DetailView.d.ts.map +1 -1
- package/dist/src/DetailView.stories.d.ts +37 -0
- package/dist/src/DetailView.stories.d.ts.map +1 -0
- package/package.json +8 -8
- package/src/DetailSection.tsx +113 -22
- package/src/DetailView.stories.tsx +258 -0
- package/src/DetailView.tsx +177 -42
package/src/DetailSection.tsx
CHANGED
|
@@ -7,8 +7,23 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import * as React from 'react';
|
|
10
|
-
import {
|
|
11
|
-
|
|
10
|
+
import {
|
|
11
|
+
cn,
|
|
12
|
+
Card,
|
|
13
|
+
CardHeader,
|
|
14
|
+
CardTitle,
|
|
15
|
+
CardContent,
|
|
16
|
+
Collapsible,
|
|
17
|
+
CollapsibleTrigger,
|
|
18
|
+
CollapsibleContent,
|
|
19
|
+
Badge,
|
|
20
|
+
Button,
|
|
21
|
+
Tooltip,
|
|
22
|
+
TooltipContent,
|
|
23
|
+
TooltipProvider,
|
|
24
|
+
TooltipTrigger,
|
|
25
|
+
} from '@object-ui/components';
|
|
26
|
+
import { ChevronDown, ChevronRight, Copy, Check } from 'lucide-react';
|
|
12
27
|
import { SchemaRenderer } from '@object-ui/react';
|
|
13
28
|
import type { DetailViewSection as DetailViewSectionType } from '@object-ui/types';
|
|
14
29
|
|
|
@@ -24,6 +39,15 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
|
|
|
24
39
|
className,
|
|
25
40
|
}) => {
|
|
26
41
|
const [isCollapsed, setIsCollapsed] = React.useState(section.defaultCollapsed ?? false);
|
|
42
|
+
const [copiedField, setCopiedField] = React.useState<string | null>(null);
|
|
43
|
+
|
|
44
|
+
const handleCopyField = React.useCallback((fieldName: string, value: any) => {
|
|
45
|
+
const textValue = value !== null && value !== undefined ? String(value) : '';
|
|
46
|
+
navigator.clipboard.writeText(textValue).then(() => {
|
|
47
|
+
setCopiedField(fieldName);
|
|
48
|
+
setTimeout(() => setCopiedField(null), 2000);
|
|
49
|
+
});
|
|
50
|
+
}, []);
|
|
27
51
|
|
|
28
52
|
const renderField = (field: any) => {
|
|
29
53
|
const value = data?.[field.name] ?? field.value;
|
|
@@ -41,14 +65,60 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
|
|
|
41
65
|
field.span === 5 ? 'col-span-5' :
|
|
42
66
|
field.span === 6 ? 'col-span-6' : '';
|
|
43
67
|
|
|
44
|
-
|
|
68
|
+
const displayValue = value !== null && value !== undefined ? String(value) : '-';
|
|
69
|
+
const canCopy = value !== null && value !== undefined && value !== '';
|
|
70
|
+
const isCopied = copiedField === field.name;
|
|
71
|
+
|
|
72
|
+
// Default field rendering with copy button and touch-friendly targets
|
|
45
73
|
return (
|
|
46
|
-
<div key={field.name} className={cn("space-y-1", spanClass)}>
|
|
47
|
-
<div className="text-
|
|
74
|
+
<div key={field.name} className={cn("space-y-1.5 group", spanClass)}>
|
|
75
|
+
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
48
76
|
{field.label || field.name}
|
|
49
77
|
</div>
|
|
50
|
-
<div
|
|
51
|
-
{
|
|
78
|
+
<div
|
|
79
|
+
className={cn(
|
|
80
|
+
"flex items-start justify-between gap-2 min-h-[44px] sm:min-h-0 rounded-md",
|
|
81
|
+
canCopy && "cursor-pointer active:bg-muted/60 transition-colors"
|
|
82
|
+
)}
|
|
83
|
+
onClick={canCopy ? () => handleCopyField(field.name, value) : undefined}
|
|
84
|
+
onKeyDown={canCopy ? (e: React.KeyboardEvent) => {
|
|
85
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
handleCopyField(field.name, value);
|
|
88
|
+
}
|
|
89
|
+
} : undefined}
|
|
90
|
+
role={canCopy ? "button" : undefined}
|
|
91
|
+
tabIndex={canCopy ? 0 : undefined}
|
|
92
|
+
>
|
|
93
|
+
<div className="text-sm flex-1 break-words py-1">
|
|
94
|
+
{displayValue}
|
|
95
|
+
</div>
|
|
96
|
+
{canCopy && (
|
|
97
|
+
<TooltipProvider>
|
|
98
|
+
<Tooltip>
|
|
99
|
+
<TooltipTrigger asChild>
|
|
100
|
+
<Button
|
|
101
|
+
variant="ghost"
|
|
102
|
+
size="icon"
|
|
103
|
+
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
|
104
|
+
onClick={(e) => {
|
|
105
|
+
e.stopPropagation();
|
|
106
|
+
handleCopyField(field.name, value);
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
{isCopied ? (
|
|
110
|
+
<Check className="h-3 w-3 text-green-600" />
|
|
111
|
+
) : (
|
|
112
|
+
<Copy className="h-3 w-3" />
|
|
113
|
+
)}
|
|
114
|
+
</Button>
|
|
115
|
+
</TooltipTrigger>
|
|
116
|
+
<TooltipContent>
|
|
117
|
+
{isCopied ? 'Copied!' : 'Copy to clipboard'}
|
|
118
|
+
</TooltipContent>
|
|
119
|
+
</Tooltip>
|
|
120
|
+
</TooltipProvider>
|
|
121
|
+
)}
|
|
52
122
|
</div>
|
|
53
123
|
</div>
|
|
54
124
|
);
|
|
@@ -57,11 +127,11 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
|
|
|
57
127
|
const content = (
|
|
58
128
|
<div
|
|
59
129
|
className={cn(
|
|
60
|
-
"grid gap-4",
|
|
130
|
+
"grid gap-3 sm:gap-4",
|
|
61
131
|
section.columns === 1 ? "grid-cols-1" :
|
|
62
|
-
section.columns === 2 ? "grid-cols-2" :
|
|
63
|
-
section.columns === 3 ? "grid-cols-3" :
|
|
64
|
-
"grid-cols-2 md:grid-cols-3"
|
|
132
|
+
section.columns === 2 ? "grid-cols-1 sm:grid-cols-2" :
|
|
133
|
+
section.columns === 3 ? "grid-cols-1 sm:grid-cols-2 md:grid-cols-3" :
|
|
134
|
+
"grid-cols-1 sm:grid-cols-2 md:grid-cols-3"
|
|
65
135
|
)}
|
|
66
136
|
>
|
|
67
137
|
{section.fields.map(renderField)}
|
|
@@ -70,19 +140,26 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
|
|
|
70
140
|
|
|
71
141
|
if (!section.collapsible) {
|
|
72
142
|
return (
|
|
73
|
-
<Card className={className}>
|
|
143
|
+
<Card className={cn(section.showBorder === false ? 'border-none shadow-none' : '', className)}>
|
|
74
144
|
{section.title && (
|
|
75
|
-
<CardHeader>
|
|
76
|
-
<CardTitle className="flex items-center
|
|
77
|
-
|
|
78
|
-
|
|
145
|
+
<CardHeader className={cn(section.headerColor && `bg-${section.headerColor}`)}>
|
|
146
|
+
<CardTitle className="flex items-center justify-between">
|
|
147
|
+
<div className="flex items-center gap-2">
|
|
148
|
+
{section.icon && <span className="text-muted-foreground">{section.icon}</span>}
|
|
149
|
+
<span>{section.title}</span>
|
|
150
|
+
{section.fields && (
|
|
151
|
+
<Badge variant="secondary" className="ml-2 text-xs">
|
|
152
|
+
{section.fields.length}
|
|
153
|
+
</Badge>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
79
156
|
</CardTitle>
|
|
80
157
|
{section.description && (
|
|
81
|
-
<p className="text-sm text-muted-foreground mt-1">{section.description}</p>
|
|
158
|
+
<p className="text-sm text-muted-foreground mt-1.5">{section.description}</p>
|
|
82
159
|
)}
|
|
83
160
|
</CardHeader>
|
|
84
161
|
)}
|
|
85
|
-
<CardContent>
|
|
162
|
+
<CardContent className="pt-4 sm:pt-6 px-3 sm:px-6">
|
|
86
163
|
{content}
|
|
87
164
|
</CardContent>
|
|
88
165
|
</Card>
|
|
@@ -97,21 +174,35 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
|
|
|
97
174
|
>
|
|
98
175
|
<Card>
|
|
99
176
|
<CollapsibleTrigger asChild>
|
|
100
|
-
<CardHeader className=
|
|
177
|
+
<CardHeader className={cn(
|
|
178
|
+
"cursor-pointer hover:bg-muted/50 transition-colors",
|
|
179
|
+
section.headerColor && `bg-${section.headerColor}`
|
|
180
|
+
)}>
|
|
101
181
|
<CardTitle className="flex items-center justify-between">
|
|
102
182
|
<div className="flex items-center gap-2">
|
|
103
183
|
{section.icon && <span className="text-muted-foreground">{section.icon}</span>}
|
|
104
184
|
<span>{section.title}</span>
|
|
185
|
+
{section.fields && (
|
|
186
|
+
<Badge variant="secondary" className="ml-2 text-xs">
|
|
187
|
+
{section.fields.length}
|
|
188
|
+
</Badge>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
<div className="flex items-center gap-2">
|
|
192
|
+
{isCollapsed ? (
|
|
193
|
+
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
194
|
+
) : (
|
|
195
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
196
|
+
)}
|
|
105
197
|
</div>
|
|
106
|
-
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
107
198
|
</CardTitle>
|
|
108
199
|
{section.description && !isCollapsed && (
|
|
109
|
-
<p className="text-sm text-muted-foreground mt-1">{section.description}</p>
|
|
200
|
+
<p className="text-sm text-muted-foreground mt-1.5">{section.description}</p>
|
|
110
201
|
)}
|
|
111
202
|
</CardHeader>
|
|
112
203
|
</CollapsibleTrigger>
|
|
113
204
|
<CollapsibleContent>
|
|
114
|
-
<CardContent>
|
|
205
|
+
<CardContent className="pt-4 sm:pt-6 px-3 sm:px-6">
|
|
115
206
|
{content}
|
|
116
207
|
</CardContent>
|
|
117
208
|
</CollapsibleContent>
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { SchemaRenderer } from '@object-ui/react';
|
|
3
|
+
import type { BaseSchema } from '@object-ui/types';
|
|
4
|
+
import { Building2, User } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Plugins/DetailView',
|
|
8
|
+
component: SchemaRenderer,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'padded',
|
|
11
|
+
docs: {
|
|
12
|
+
description: {
|
|
13
|
+
component: 'Enhanced detail view component with Airtable-inspired features: tooltips, functional menus, collapsible sections, and copy-to-clipboard.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
tags: ['autodocs'],
|
|
18
|
+
argTypes: {
|
|
19
|
+
schema: { table: { disable: true } },
|
|
20
|
+
},
|
|
21
|
+
} satisfies Meta<any>;
|
|
22
|
+
|
|
23
|
+
export default meta;
|
|
24
|
+
type Story = StoryObj<typeof meta>;
|
|
25
|
+
|
|
26
|
+
const renderStory = (args: any) => <SchemaRenderer schema={args as unknown as BaseSchema} />;
|
|
27
|
+
|
|
28
|
+
export const Default: Story = {
|
|
29
|
+
render: renderStory,
|
|
30
|
+
args: {
|
|
31
|
+
type: 'detail-view',
|
|
32
|
+
title: 'Employee Details',
|
|
33
|
+
data: {
|
|
34
|
+
name: 'Sarah Connor',
|
|
35
|
+
email: 'sarah.connor@example.com',
|
|
36
|
+
phone: '+1 (555) 867-5309',
|
|
37
|
+
department: 'Engineering',
|
|
38
|
+
role: 'Tech Lead',
|
|
39
|
+
location: 'Austin, TX',
|
|
40
|
+
},
|
|
41
|
+
fields: [
|
|
42
|
+
{ name: 'name', label: 'Full Name' },
|
|
43
|
+
{ name: 'email', label: 'Email' },
|
|
44
|
+
{ name: 'phone', label: 'Phone' },
|
|
45
|
+
{ name: 'department', label: 'Department' },
|
|
46
|
+
{ name: 'role', label: 'Role' },
|
|
47
|
+
{ name: 'location', label: 'Location' },
|
|
48
|
+
],
|
|
49
|
+
showBack: true,
|
|
50
|
+
showEdit: true,
|
|
51
|
+
showDelete: true,
|
|
52
|
+
} as any,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const WithSections: Story = {
|
|
56
|
+
render: renderStory,
|
|
57
|
+
args: {
|
|
58
|
+
type: 'detail-view',
|
|
59
|
+
title: 'Account: TechCorp Inc.',
|
|
60
|
+
data: {
|
|
61
|
+
name: 'TechCorp Inc.',
|
|
62
|
+
industry: 'Software',
|
|
63
|
+
website: 'https://techcorp.io',
|
|
64
|
+
employees: '200-500',
|
|
65
|
+
revenue: '$25M - $50M',
|
|
66
|
+
street: '456 Innovation Blvd',
|
|
67
|
+
city: 'Seattle',
|
|
68
|
+
state: 'WA',
|
|
69
|
+
zipcode: '98101',
|
|
70
|
+
country: 'USA',
|
|
71
|
+
},
|
|
72
|
+
sections: [
|
|
73
|
+
{
|
|
74
|
+
title: 'Company Info',
|
|
75
|
+
icon: '🏢',
|
|
76
|
+
fields: [
|
|
77
|
+
{ name: 'name', label: 'Company Name' },
|
|
78
|
+
{ name: 'industry', label: 'Industry' },
|
|
79
|
+
{ name: 'website', label: 'Website' },
|
|
80
|
+
{ name: 'employees', label: 'Employees' },
|
|
81
|
+
{ name: 'revenue', label: 'Annual Revenue' },
|
|
82
|
+
],
|
|
83
|
+
columns: 2,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
title: 'Address',
|
|
87
|
+
icon: '📍',
|
|
88
|
+
collapsible: true,
|
|
89
|
+
fields: [
|
|
90
|
+
{ name: 'street', label: 'Street' },
|
|
91
|
+
{ name: 'city', label: 'City' },
|
|
92
|
+
{ name: 'state', label: 'State' },
|
|
93
|
+
{ name: 'zipcode', label: 'Zip Code' },
|
|
94
|
+
{ name: 'country', label: 'Country' },
|
|
95
|
+
],
|
|
96
|
+
columns: 2,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
showBack: true,
|
|
100
|
+
showEdit: true,
|
|
101
|
+
showDelete: true,
|
|
102
|
+
} as any,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const WithRelatedLists: Story = {
|
|
106
|
+
render: renderStory,
|
|
107
|
+
args: {
|
|
108
|
+
type: 'detail-view',
|
|
109
|
+
title: 'Account: TechCorp Inc.',
|
|
110
|
+
data: {
|
|
111
|
+
name: 'TechCorp Inc.',
|
|
112
|
+
industry: 'Software',
|
|
113
|
+
},
|
|
114
|
+
fields: [
|
|
115
|
+
{ name: 'name', label: 'Account Name' },
|
|
116
|
+
{ name: 'industry', label: 'Industry' },
|
|
117
|
+
],
|
|
118
|
+
related: [
|
|
119
|
+
{
|
|
120
|
+
title: 'Contacts',
|
|
121
|
+
type: 'table',
|
|
122
|
+
data: [
|
|
123
|
+
{ id: '1', name: 'Mike Ross', email: 'mike@techcorp.io', title: 'VP Engineering' },
|
|
124
|
+
{ id: '2', name: 'Lisa Chen', email: 'lisa@techcorp.io', title: 'Product Manager' },
|
|
125
|
+
],
|
|
126
|
+
columns: ['name', 'email', 'title'],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
title: 'Opportunities',
|
|
130
|
+
type: 'table',
|
|
131
|
+
data: [
|
|
132
|
+
{ id: '1', name: 'Enterprise License', amount: '$120,000', stage: 'Negotiation' },
|
|
133
|
+
{ id: '2', name: 'Support Contract', amount: '$45,000', stage: 'Proposal' },
|
|
134
|
+
],
|
|
135
|
+
columns: ['name', 'amount', 'stage'],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
showBack: true,
|
|
139
|
+
showEdit: true,
|
|
140
|
+
showDelete: true,
|
|
141
|
+
} as any,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Enhanced view showcasing new Airtable-inspired features
|
|
146
|
+
*/
|
|
147
|
+
export const EnhancedAirtableStyle: Story = {
|
|
148
|
+
render: renderStory,
|
|
149
|
+
args: {
|
|
150
|
+
type: 'detail-view',
|
|
151
|
+
title: 'Sarah Johnson',
|
|
152
|
+
objectName: 'Contact',
|
|
153
|
+
resourceId: 'CNT-001',
|
|
154
|
+
showEdit: true,
|
|
155
|
+
showDelete: true,
|
|
156
|
+
showBack: true,
|
|
157
|
+
data: {
|
|
158
|
+
name: 'Sarah Johnson',
|
|
159
|
+
email: 'sarah.johnson@example.com',
|
|
160
|
+
phone: '+1 (555) 123-4567',
|
|
161
|
+
company: 'Acme Corporation',
|
|
162
|
+
title: 'Senior Product Manager',
|
|
163
|
+
department: 'Product',
|
|
164
|
+
location: 'San Francisco, CA',
|
|
165
|
+
website: 'https://example.com',
|
|
166
|
+
notes: 'Key decision maker for enterprise deals. Prefers email communication.',
|
|
167
|
+
linkedin: 'linkedin.com/in/sarahjohnson',
|
|
168
|
+
twitter: '@sarahjohnson',
|
|
169
|
+
status: 'Active',
|
|
170
|
+
lead_source: 'Website',
|
|
171
|
+
},
|
|
172
|
+
sections: [
|
|
173
|
+
{
|
|
174
|
+
title: 'Contact Information',
|
|
175
|
+
description: 'Primary contact details',
|
|
176
|
+
collapsible: false,
|
|
177
|
+
columns: 2,
|
|
178
|
+
fields: [
|
|
179
|
+
{ name: 'email', label: 'Email' },
|
|
180
|
+
{ name: 'phone', label: 'Phone' },
|
|
181
|
+
{ name: 'title', label: 'Job Title' },
|
|
182
|
+
{ name: 'department', label: 'Department' },
|
|
183
|
+
{ name: 'location', label: 'Location' },
|
|
184
|
+
{ name: 'website', label: 'Website' },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
title: 'Company Details',
|
|
189
|
+
collapsible: true,
|
|
190
|
+
defaultCollapsed: false,
|
|
191
|
+
columns: 2,
|
|
192
|
+
fields: [
|
|
193
|
+
{ name: 'company', label: 'Company Name' },
|
|
194
|
+
{ name: 'status', label: 'Status' },
|
|
195
|
+
{ name: 'lead_source', label: 'Lead Source' },
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
title: 'Social Media',
|
|
200
|
+
collapsible: true,
|
|
201
|
+
defaultCollapsed: true,
|
|
202
|
+
columns: 1,
|
|
203
|
+
fields: [
|
|
204
|
+
{ name: 'linkedin', label: 'LinkedIn' },
|
|
205
|
+
{ name: 'twitter', label: 'Twitter' },
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
} as any,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* View with field count badges in section headers
|
|
214
|
+
*/
|
|
215
|
+
export const WithFieldCounts: Story = {
|
|
216
|
+
render: renderStory,
|
|
217
|
+
args: {
|
|
218
|
+
type: 'detail-view',
|
|
219
|
+
title: 'Deal Overview',
|
|
220
|
+
objectName: 'Deal',
|
|
221
|
+
resourceId: 'DEAL-456',
|
|
222
|
+
showEdit: true,
|
|
223
|
+
data: {
|
|
224
|
+
name: 'Enterprise License',
|
|
225
|
+
amount: '$120,000',
|
|
226
|
+
stage: 'Negotiation',
|
|
227
|
+
probability: '75%',
|
|
228
|
+
close_date: '2024-03-31',
|
|
229
|
+
owner: 'Sarah Johnson',
|
|
230
|
+
account: 'TechCorp Inc.',
|
|
231
|
+
contact: 'Mike Ross',
|
|
232
|
+
created: '2024-01-15',
|
|
233
|
+
},
|
|
234
|
+
sections: [
|
|
235
|
+
{
|
|
236
|
+
title: 'Deal Information',
|
|
237
|
+
columns: 2,
|
|
238
|
+
fields: [
|
|
239
|
+
{ name: 'name', label: 'Deal Name', span: 2 },
|
|
240
|
+
{ name: 'amount', label: 'Amount' },
|
|
241
|
+
{ name: 'stage', label: 'Stage' },
|
|
242
|
+
{ name: 'probability', label: 'Win Probability' },
|
|
243
|
+
{ name: 'close_date', label: 'Expected Close' },
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
title: 'Related Records',
|
|
248
|
+
collapsible: true,
|
|
249
|
+
columns: 2,
|
|
250
|
+
fields: [
|
|
251
|
+
{ name: 'account', label: 'Account' },
|
|
252
|
+
{ name: 'contact', label: 'Primary Contact' },
|
|
253
|
+
{ name: 'owner', label: 'Deal Owner' },
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
} as any,
|
|
258
|
+
};
|